Rule Cascade
Playbooks

Multi-level inheritance and overrides

An enterprise or organisation baseline, rulesets per business unit, application and feature that extend it, and rules targeted down to a single field - with the parent deciding what a child may change.

Two separate questions place a rule:

  • Who owns it is the scope of the ruleset it is written in: an ordered list of levels, for example enterprise, organization, business unit, application, feature. A ruleset extends exactly one parent one level up, so the levels form a chain.
  • What it is about is the rule's target: an entity, optionally narrowed to a page, screen, section, component and field. A field is a target, not a level: the most specific ruleset in the chain holds the field-level rules.

Rules cascade down the levels; the organization locks its blocked-country list and the business unit may only tighten the transfer limit

The example is the pair in examples/contracts: the organisation baseline acme.org.base@1.2.0 and the feature ruleset acme.payments.transfer that extends it.

Steps

Write the baseline at the highest level that owns the rule

acme-org-base.ruleset.yaml
metadata:
  id: acme.org.base
  version: 1.2.0

scope:
  - { level: enterprise, id: acme-group }
  - { level: organization, id: acme }

params:
  blockedCountries:
    type: stringList
    default: [KP, IR]
    overridePolicy: locked              # no child may change it
  maxTransferAmount:
    type: number
    default: 50000
    overridePolicy: tighten-only        # a child may only make it stricter ...
    tightenDirection: lower             # ... which for this parameter means lower

rules:
  - id: org.transfer.blocked-country
    target: { entity: Transfer, field: /beneficiary/country }
    enforcement: server
    overridePolicy: locked
    # ...
The parent marks itA child may
lockedChange nothing
tighten-only (the default for rules)Raise a rule's severity, require an acknowledgement, withdraw an acceptance; move a numeric parameter in the stricter direction
open (the default for parameters)Change it freely

Extend it one level down and state what changes

payments-transfer.ruleset.yaml
scope:
  - { level: enterprise, id: acme-group }
  - { level: organization, id: acme }
  - { level: businessUnit, id: retail-banking }
  - { level: application, id: payments-hub }
  - { level: feature, id: transfers }

extends:
  - { ruleset: acme.org.base, version: ^1.2.0 }

overrides:
  params:
    maxTransferAmount: 25000              # the organisation allows 50000; retail tightens it
  rules:
    - rule: org.transfer.memo-recommended
      set: { severity: warning }          # raised from info
      reason: Retail operations needs memos for dispute handling.

Every rule override needs a reason (without one the schema refuses the document, guideline 2.5). Pin extends[].checksum when an upgrade of the parent within the version range must be a deliberate decision.

Add the field-level rules in the most specific ruleset

rules:
  - id: transfer.swift.required-international
    kind: validation
    target: { entity: Transfer, component: beneficiary-panel, field: /beneficiary/swiftCode }
    operations: [create, update]
    when: { op: eq, args: [ { var: data.type }, international ] }
    # ...

A rule that should hold for every field of one meaning targets a data type instead (target: { type: Money }, with fields bound in entities.<Name>.fieldTypes); see the cookbook.

Check the chain

check resolves the parent from the *.ruleset.* files in the same directory and enforces every policy:

rule-cascade check examples/contracts/*.ruleset.yaml

Loosening a tighten-only parameter (maxTransferAmount: 60000) fails the load:

payments-transfer.ruleset.yaml: LOAD FAILED
  PARAM_LOOSENED: acme.payments.transfer: param maxTransferAmount may only move lower

So does touching a locked rule (set: { severity: warning } on org.transfer.blocked-country):

payments-transfer.ruleset.yaml: LOAD FAILED
  RULE_LOCKED: org.transfer.blocked-country acme.payments.transfer: rule org.transfer.blocked-country is locked by acme.org.base@1.2.0

Read the result: one flat ruleset, every finding says where it came from

The compiler resolves the chain into one flat ruleset; a bundle has no hierarchy left, so depth costs nothing per request. A finding's source names the level that defined the rule, and its message uses the effective parameter:

{ "rule": "org.transfer.amount-limit", "code": "ORG-TRF-002",
  "message": "Amount exceeds the single-transfer limit of 25000.",
  "source": "acme.org.base@1.2.0" }

The limit reads 25000, not the organisation's 50000, because retail tightened it one level down.

Release parents with care

A new parent version inside the child's range (^1.2.0) changes every child's checksum at its next compile. Run check on every child in the parent's pull request, and follow ship a rule change for each.

Done when each level below the top has its own ruleset that extends exactly one parent, every rule override has a reason, check passes for the whole chain, and an attempt to loosen a tighten-only parameter or change a locked rule fails with PARAM_LOOSENED or RULE_LOCKED.

The design is ADR 0004; the normative rules are in specification section 5 and guideline section 2.

Source: site/content/docs/playbooks/multi-level-inheritance.mdx

On this page