Multi-Party Approval

Composition partially resolved 10 invariants regulated composes 3 atoms + 1 substrate substrate of 1
Derived card — generated from the corpus (status line, invariant headers, sibling formal-model files, Composes edges). The specification below is canonical. composition graph →
Table of contents
  1. Multi-Party Approval
    1. Summary
    2. Intent
    3. Composes
    4. Composition logic
      1. Composition state
      2. Configuration
      3. Scope vocabulary
      4. Logic confinement
      5. Primitive policies
      6. Action wiring
      7. The quorum evaluation rule
      8. Cascade-recall of trailing assignments on chain termination
    5. Composition-level invariants
    6. Examples
      1. Walkthrough — SOX-controlled journal entry, all-of-N quorum
      2. Happy path — FDA Part 11 batch release, M-of-N(2) quorum across three qualified persons
      3. Happy path — ICH E6 GCP protocol deviation, one-of-N quorum across a delegated approver pool
      4. Rejection path — all-of-N quorum, one approver rejects
      5. Rejection path — chain withdrawal by initiator
      6. Selected outcome runs
      7. Regulated adversarial scenarios
    7. Generation acceptance
      1. Audit gaps: what cannot be cleared from the chain store alone
    8. Non-goals and edge cases
    9. Terms
      1. Initiate Chain
      2. Approve Step
      3. Reject Step
      4. Withdraw Step
      5. Withdraw Chain
      6. Read Chain
      7. Trailing
      8. Chains Initiate
      9. Chains Withdraw
      10. Chains Read
      11. All Of N
      12. M Of N
      13. One Of N
      14. Permission Denied
      15. Recording Failure
    10. Standards references
    11. Status
    12. Ledger
    13. Decisions

Summary

Multi-Party Approval enforces an approval requiring several named approvers, over a single thing, in a way that cannot be bypassed and is fully reconstructable from the records.

The rule for how many approvers are needed is a quorum rule — all of them, a majority, or any one of them. It combines four patterns: the single-approver gate (Approval Step), a permission system controlling who may start, withdraw, or read an approval chain (Permissions), a task-tracker that puts each pending approval in the right approver’s queue (Assignment), and the tamper-evident audit record (Audit Trail) that stamps and seals every step.

The load-bearing addition is checking, after each individual decision, whether the quorum has now been met or has become impossible, and ending the chain accordingly — something none of the parts can do, because the single-gate pattern does not count approvals across steps and the others know nothing about quorum at all.

The check is deterministic: anyone with the records can recompute the chain’s outcome from the individual decisions and the quorum rule and confirm it. That is what makes the gate impossible to bypass — a chain cannot reach “approved” unless the required decisions are actually present in the records.

Once a chain ends, its outcome and its declared terms cannot change; corrections require a new chain. This is the mechanism behind dual-sign-off financial controls, pharmaceutical batch releases, electronic-signature chains, clinical-trial approvals, and change-control gates — any decision that must prove, from records alone, that a required set of named approvers actually decided before an action proceeded is a candidate for this composition.


Intent

Many regulated actions require more than one human approval before they may proceed. A SOX-controlled (Sarbanes-Oxley Act — US corporate financial-reporting law) journal entry above a materiality threshold requires the controller and the CFO (Chief Financial Officer). A pharmaceutical batch release under 21 CFR (Code of Federal Regulations — the codification of US federal agency rules) Part 211 requires the qualified person on duty plus the QA (Quality Assurance) director. A clinical protocol deviation under ICH E6 GCP (the International Council for Harmonisation’s E6 Good Clinical Practice guideline) requires the principal investigator plus, for substantive deviations, the IRB (Institutional Review Board — the committee that oversees research ethics) chair. A high-value engineering change order requires the engineering lead, the quality lead, and (when safety-critical) the safety officer. In each case the structure is the same: a set of named approvers, a quorum rule (all of them, a majority of them, any one of them), and a terminal decision that becomes the auditable evidence the control operated.

Approval Step is the per-gate primitive — one named approver, one subject, one scope, one decision. It deliberately does not know about chains: it does not count approvals, does not interpret quorum, and does not wire multiple gates together. Multi-Party Approval is the composition that does. The atom’s single-gate scope is preserved unchanged; the composition adds the chain identity, the quorum rule, the cross-step state evaluation, and the cascade (secondary effects triggered automatically by a primary event) behavior when a chain is withdrawn or quorum becomes unachievable.

The composition addresses what the constituent atoms cannot answer alone. Approval Step records each gate but never asks “is the chain done?” Permissions records who may initiate a chain but never asks “did the quorum’s named approvers actually decide?” Assignment binds work to actors but never asks “is the work part of a multi-actor gate?” Audit Trail records actions of consequence but never asks “do these actions constitute a complete approval chain under the named rule?” Stacked correctly, the four answer the auditor’s actual question in one structure: a chain identity that names the required approvers and rule, N Approval Step records under that chain identity, an Assignment record per pending step, attestations and event-log entries for every chain-level and step-level action, and a deterministic chain-state evaluation that any reader can reproduce from the records.

This is a composition, not a new primitive. The four constituent atoms (and the Audit Trail substrate) are unchanged. The composition is the wiring that makes their concepts coherent — one consolidated multi-party-approval surface rather than four parallel record stores the auditor has to correlate by hand.


Composes

  • Approval Step — provides the per-gate primitive: one Approval Step record per required approver, each carrying its own step_id, subject_ref, approver_ref, submitter_ref, scope, lifecycle (Pending → Approved Rejected Withdrawn), and Invariant 4 enforcement that only the named approver_ref may transition to Approved or Rejected. The composition maintains exactly one Approval Step store instance per chain store (one-to-one) and submits N steps under each chain.
  • Permissions — provides the authorization surface for chain-level actions: grant, revoke, permitted. The composition maintains exactly one Permissions instance scoped to the chain store. Every chain-level state-changing action (Initiate Chain, Withdraw Chain) and every chain-level read query is gated by a permitted check before reaching the chain store or its constituent stores.
  • Assignment — provides the in-tray binding: on chain initiation, one Assignment record per step (task_ref = step_id, assignee_ref = step.approver_ref) is created, so each pending approval carries exactly one Active responsibility record naming its approver. When a step reaches a terminal state (Approved, Rejected, or Withdrawn) the corresponding Assignment is recalled — its responsibility is discharged. What the binding does not supply is a by-assignee query: Assignment’s declared queries are active_for(task_ref) and history_for(task_ref), both keyed by task, so “what approval steps are sitting in my in-tray right now?” is not answerable from the atom’s own surface, and this composition does not absorb it — an assignee-keyed in-tray view is a Reverse Index (forthcoming) shaped lookup a deployment composes over the assignment store (or answers in its own workflow layer). The binding is the truth the view would be built from; the view itself is out of scope here.
  • Audit Trail — the regulated-audit substrate. Every chain-level action (Initiate Chain, Withdraw Chain) and every step-level decision (Approve Step, Reject Step, Withdraw Step) is recorded as one intent record_action call before it commits and one outcome call after (plus one per cascaded step transition) on the Audit Trail instance, each producing an Event Log entry, an Actor Identity attestation, a Retention Window record, and (per the cadence) a Tamper Evidence seal. The composition maintains exactly one Audit Trail instance configured with the host’s regulatory retention policy (SOX 7-year, FDA (US Food and Drug Administration) Part 11 predicate-rule, ICH E6 trial-master-file).

The Event Log and Actor Identity atoms named in the roadmap entry for this composition are reached transitively through Audit Trail; the composition does not maintain separate Event Log or Actor Identity instances of its own. Audit Trail is the regulated-audit composition that owns those constituents.


Composition logic

Composition state

The composition owns emergent state — the chain store and the cross-atom maps — that wires the constituent atoms into one queryable approval-chain surface. Every element carries its Contract classification per execution-contract.md §Composition state: all six elements are derived indexes within the audit horizon, and the truth they accelerate lives in the constituent stores plus the substrate audit log — past the horizon chain_store is truth-bearing under the durability obligation Configuration declares (chain_store_durability), because the chain’s fields lived only in payloads the substrate has destroyed and no constituent holds them — the composition records its chain-level truth as audit events through its own Audit Trail substrate (the Contract’s rule that a composition needing to record a multi-step sequence does so by composing Event Log, discharged here through the substrate’s record_action), and the rebuilds read it back. The payload requirement that makes the rebuilds total: every audit event this composition emits carries chain_id in its data except chain_initiation_intended, which cannot: it is written before the chain is allocated, and that is the point of it, and the chain-shape events — chain_initiated, and the recovery path’s chain_initiation_failed — additionally carry the full declared shape: subject_ref, scope, approver_set, quorum_rule, the ordered step_ids, the paired assignment_ids, and initiated_at (the same seam-injected reading step 4 stamped, which is what makes the rebuild byte-exact rather than approximated by the event’s own recorded_at); chain_initiation_failed additionally carries its disposition (which Initiate Chain step-8 case it records — a, b, or c) and the chain’s initiator_ref — this event is always composition-attributed, so the initiator cannot ride its actor_ref and must ride its data — and its step_ids / assignment_ids lists are the ids that actually landed; possibly-partial lists are the honest shape of a partial initiation. The chain-level terminal events carry the terminal facts: chain_resolved carries state, terminal_at, reason, and recalled_step_ids; chain_withdrawn carries terminal_at and reason. A composition-emitted event missing a required field is a conformance failure, not a formatting choice — the rebuilds below read exactly these fields. One bounded exception is named rather than hidden: a chain inside the Initiate Chain step-8 recovery window — constituent records committed (fully, or partially in cases (a)/(b)), no chain-shape event landed yet — is invisible to every rebuild, because the event that would carry its shape does not exist. For that window the chain record is truth-bearing (two further blind spots are carried as open findings and named where they arise: a chain_store rebuild landing inside the window, and the sweep’s initiation leg being unable to tell a crashed initiation from a live one) and carries a durability obligation — covering the id bindings, not just the declared shape: Initiate Chain appends each landed step_id and assignment_id to the record as it is minted (steps 5–6), so the record itself holds everything its recovery event must carry, and the record must persist until its chain_initiated or chain_initiation_failed event lands (the recovery discipline, below), after which the store is a pure derived index again. This is the same shape as the substrate’s own destruction-record window — a named, bounded, surfaced transient, never a standing truth-bearing store.

The elements:

  • chain_store — the set of chain records. Each record carries chain_id, subject_ref, scope, initiator_ref, approver_set (the N actor references the chain is bound to), quorum_rule (one of all-of-N, M-of-N(M), one-of-N), initiated_at, state (Pending Approved Rejected Withdrawn), and an optional audit_pending flag (default absent; set true only by the partial-failure recovery path in Initiate Chain step 8 — any of its three cases — surfaced to Read Chain as a top-level field on the chain record). Chain records are immutable (unchangeable once written) on every field except the three that transition, each at most once: state (Pending to one terminal value, never back), chain_terminal_at (unset to one timestamp), and audit_pending (set true at most once during recovery, cleared to absent when the chain’s audit record lands) — the same three Invariant 8 names. The audit_pending flag is not a state in the canonical chain state machine — a chain with audit_pending = true is still in Pending (or in a terminal state if it was subsequently withdrawn during recovery) — it is a recovery-status flag that read-only queries surface so the auditor sees the quarantine without reading the chain’s state as anomalous. Contract classification: derived index (outside the window named above). Rebuild procedure: enumerate the audit instance through its declared list-query surface — a sequence-range read with an open upper bound passes through the substrate to its Event Log unchanged — and filter in composition code to events whose action_ref is one of this composition’s own (the enumerate-and-filter move the substrate itself uses for its own rebuilds; a payload-predicate query is the Reverse Index pattern’s job, and this is not a query, it is a rebuild). For each chain_initiated (or chain_initiation_failed) event: the identity fields from its data per the payload requirement; initiator_ref split by event type — from the event’s own actor_ref for chain_initiated (a human-attributed event), from the payload’s initiator_ref for chain_initiation_failed (always composition-attributed, so its actor_ref is the service actor and never the initiator); state and chain_terminal_at from the chain’s terminal event where one exists (chain_resolved data state/terminal_at, or chain_withdrawn → Withdrawn with its terminal_at), Pending otherwise; audit_pending recomputed as absent for every rebuilt chain — a chain whose events landed has, by construction, left the recovery window. The rebuilt terminal state carries its own cross-check under Invariant 2’s terminal-path split: for a chain_resolved chain it must equal the quorum rule applied to the step records; for a chain_withdrawn chain it is verified by Invariant 2’s pre-withdrawal reconstruction instead — so a rebuild that disagrees with the applicable verification has found a conformance failure, not a rebuild bug. The usual three obligations attach — outside the action’s atomicity surface, rebuild-on-miss, no cross-constituent consistency claim.

    The rebuild is bounded by the audit instance’s horizon, and this entry needed the statement more than its neighbours did. The procedure enumerates the audit instance and filters in composition code to events whose action_ref is one of this composition’s own — and the payload it would naively filter on is destroyed in its entirety at the horizon. The filter is not defeated — it must change surface. action_ref survives on the attestation, reached through the destruction record’s (event_id, attestation_id) pair, so a purged event can still be identified as this composition’s; what cannot be recovered is the chain record it carried, which lived only in the destroyed data. So past the horizon the enumeration identifies the events and reconstructs none of the chains. Chains whose initiation events have been purged are not rebuildable, with no second source — the chain record’s fields are this composition’s own, held in no constituent store. The classification splits by retention state accordingly.

    This compounds with the additive rule above rather than sitting beside it. The additive rebuild exists because the store can hold what the events do not yet carry (an open recovery window). The horizon is the same asymmetry with the sign flipped — the store holds what the events no longer carry — and the additive rule covers both by construction, since retaining every surviving record is the correct response whether the event has not landed or has aged out. What the additive rule cannot do is reconstruct a chain whose record was also lost; that is what the chain_store_durability obligation in Configuration is for — the chain record past the horizon is the composition’s only copy, and an implementation that rebuilds chain_store by replacing it from the events (the derived-index reading) discards every post-horizon chain on the first rebuild.

    The rebuild is ADDITIVE over the surviving store, never a replacement of it, and this is a correctness requirement rather than an implementation preference. The classification above says derived index outside the window named above — and inside that window a chain record is the composition’s only truth-bearing record of the chain, because it exists precisely before its chain_initiated or chain_initiation_failed event lands. A rebuild that ran there and replaced the store would enumerate the events, correctly fail to find that chain, and reconstruct a store without it: the repair mechanism destroying the only record it was repairing. The two classifications do not conflict in the steady state and collide exactly at a crash, which is the moment a rebuild is most likely to be run. So the procedure is defined as a union: reconstruct from the events as above, then retain every surviving chain_store record the reconstruction did not produce, and classify each one by its initiated_at against the audit horizon: a record younger than the horizon whose chain-shape event has not landed is exactly the audit_pending quarantine the recovery discipline already defines, and the rebuild routes it there rather than dropping it; a record older than the horizon is the post-horizon truth-bearing class (Configuration, chain_store_durability) — its events were lawfully destroyed, nothing is owed, and it is retained as it stands, never quarantined and never driven through step 8’s recovery. Without that split every lawfully purged chain would read as a crashed initiation on the next restart, and the recovery would withdraw or re-emit chains whose audit was destroyed by policy. A record younger than the horizon that survives the rebuild and is not inside an open window is a different thing and must be surfaced as a finding, not silently retained; the two are told apart by the recovery discipline’s own criteria (step 8), not by the rebuild.

    Why this generalizes past this map, stated because the corpus keeps meeting it from both sides. “Rebuild from the authoritative record” silently assumes the authoritative record is both complete and current with respect to the index. Here it is not current — the index leads the events by a write window. In Forensic Recovery the same move fails because the authoritative record is not complete — a retention purge destroys the payload field its enumeration is keyed on, so the index outlives its own source. One assumption, falsified from opposite directions, and the same repair on each side: reconcile, never replace, and never let the repair mechanism outrank the record it is repairing (roadmap.md, methodology debt #19, the enumeration-anchoring class).

  • chain_to_steps — map from chain_id to the ordered list of step_ids submitted under that chain. The list is set at Initiate Chain and is immutable; reassignment or replacement of steps is not supported (a step that needs to be redone requires the chain to be withdrawn and a new chain initiated). The list’s order is the order in which approver_set was declared at Initiate Chain — equivalently, the submission order, since steps are submitted to Approval Step in declaration order. The order is diagnostic, not load-bearing: the canonical quorum rules (all-of-N, M-of-N, one-of-N) are order-independent in outcome, so reading the list back in any total order yields the same quorum evaluation. Composing patterns that do impose ordering (e.g., a Sequenced Approval Chain (forthcoming)) consume this list as an ordered sequence and add their own enforcement. Contract classification: derived index. Rebuild procedure: the ordered step_ids list carried in the chain’s chain_initiated (or chain_initiation_failed) event data, per the payload requirement — the binding lives nowhere in the Approval Step store, because submit takes no chain argument, which is exactly why the composition records it in the event it emits. And it is exactly why the audit horizon takes it: the binding exists in one place, so when that place is a payload the substrate destroys, the fact is gone rather than merely harder to reach. The traversal reads chain_initiated payloads; a purged event retains its event_id, sequence_number and recorded_at, and its attestation’s action_ref, actor_ref and attested_at through the destruction record’s pair — so past the horizon the event is still identifiable as a chain_initiated, and the step_ids list it carried is still gone, because that list lived only in the payload. Entries whose initiation event has been purged are therefore not rebuildable, with no second source available — Approval Step’s submit takes no chain argument, which this entry already notes and which forecloses the constituent-store fallback other patterns in this class can use. The classification splits by retention state accordingly. Same three obligations.
  • step_to_chain — inverse map from step_id to its chain_id, for fast traversal when a step-level event fires. Contract classification: derived index. Rebuild procedure: invert chain_to_steps — re-key each chain’s step_ids list by its members. Same three obligations.
  • step_to_assignment — map from step_id to the assignment_id of the in-tray binding for that step. Lets the auditor traverse from a step to its responsibility record. Contract classification: derived index. Rebuild procedure: pair each chain-shape event’s ordered step_ids with its paired assignment_ids, per the payload requirement (the two lists are written in the same declaration order by Initiate Chain steps 5–6). Same three obligations.
  • chain_terminal_at — for each chain in a terminal state, the timestamp at which the chain first satisfied the quorum rule (Approved) or became unsatisfiable (Rejected), or the timestamp the initiator withdrew the chain (Withdrawn). Set once when the chain leaves Pending; immutable thereafter. Contract classification: derived index. Rebuild procedure: the terminal_at field of the chain’s terminal event (chain_resolved or chain_withdrawn) — which carries the same seam-injected reading that stamped the store field, so the rebuild is exact. Same three obligations.
  • chain_to_events — map from chain_id to the event_ids of every audit event recorded for that chain (chain-level and step-level), in recording order. This is the declared chain→audit traversal — the surface an auditor uses to get from a chain to the audit events that prove its lifecycle, which Read Chain surfaces and Generation-acceptance checks 1 and 5 walk; without it, “walk the chain’s audit events” would require a payload-field query the substrate routes to Reverse Index. Populated as each record_action returns its event_id. Contract classification: derived index. Rebuild procedure: the same open-upper-bound enumerate-and-filter as chain_store’s rebuild, keyed by each event’s data chain_id (the payload requirement’s first clause exists for exactly this) — plus a second leg for chain_initiation_intended, which carries no chain_id and is keyed instead on the tuple (actor_ref, subject_ref, scope, approver_set, quorum_rule, intended_at) matched against the chain record’s (initiator_ref, subject_ref, scope, approver_set, quorum_rule, initiated_at), which are the same injected reading by construction (Logic confinement) and therefore an exact join wherever a chain record exists; an initiation intent with no matching chain record joins to nothing and is the residue Generation acceptance check 7 triages, not a rebuild failure. Both legs are bounded by the audit instance’s horizon, and the second leg’s tuple join is bounded twice over: it matches on payload fields (actor_ref, subject_ref, scope, approver_set, quorum_rule, intended_at) that a purge destroys together, so past the horizon an initiation intent is not merely unjoinable but unrecognizable as an intent. Entries for purged events are not rebuildable; the classification splits by retention state. This map is the declared chain→audit traversal — the surface an auditor uses to get from a chain to its events — so what the horizon costs here is that traversal itself, and the deployment obligation in Configuration is what holds it open.

Configuration

  • approver_set_minimum — the smallest valid approver_set size. Defaults to 1 (a single-gate chain is a degenerate but valid chain); deployments requiring genuine multi-party may configure ≥ 2. A chain initiated with |approver_set| < approver_set_minimum is rejected at Initiate Chain with invalid-request.
  • approver_set_uniqueness — whether the approver_set must contain pairwise-distinct approver_ref values. Defaults to true (the same actor cannot occupy two slots in the same chain); deployments where the same actor may legitimately appear twice (e.g., wearing two roles) may configure false. Even under false, the resulting chain submits one Approval Step per slot, each with its own step_id, and quorum is counted per step — an actor named in two slots must call Approve Step twice (once per step_id) to contribute two units to A. One decision does not auto-credit both slots; that would be the chain composition silently collapsing two gates into one and is a hidden mechanism the spec refuses. The auditor counts step records, not actors.
  • quorum_rule_allowed — the set of quorum rules the deployment permits. Defaults to {all-of-N, M-of-N, one-of-N}. A deployment may restrict to a subset; chains initiated with a quorum rule outside the allowed set are rejected at Initiate Chain with invalid-request.
  • chain_store_durability — the declaration that chain_store records are truth-bearing past the audit instance’s horizon and must be persisted with the durability of a primary store, never as a cache: a chain whose chain_initiated / chain_initiation_failed payload the substrate has destroyed survives in this record and nowhere else (Composition state — the horizon paragraphs; Invariant 9; Read Chain’s post-horizon answers; the Retention-horizon asymmetry edge case). The obligation is the same class as the step-8 recovery window’s, applied to the other end of the record’s life: within the horizon the record is a derived index over the events; past it the events are gone and the record is the truth. A deployment declares here which store carries it and that the store is never rebuilt by replacement. This entry is what the three passages that lean on “the deployment obligation in Configuration” name.
  • audit_trail_retention_policy — the policy reference the Audit Trail instance is configured with at deployment (Audit Trail’s record_action(action_ref, actor_ref, credential, data) takes no per-call retention argument). Typically a regulatory policy id (sox_7_year, fda_part_11_predicate_rule, ich_e6_tmf). The choice is deployment policy; the composition surfaces the configuration knob.

  • decision_completion_bound — the deployment-declared maximum duration of a state-changing invocation between its intent record and its outcome record, read against the seam-injected now the intent carries. It is the lower edge of the transition sweep: a constituent transition whose matching intent is younger than it may belong to an invocation still between its atom-level write and its step-5 record, and a re-emission fired at it would land a second outcome for one decision — the duplicate the store-against-events comparison cannot see, because the invocation has not written yet. The sweep additionally holds the per-chain serialization for every chain it touches; the bound guards the window an invocation releases the serialization inside. The upper edge is audit_trail_retention_policy’s horizon, which the initiation leg already carries (a chain older than it lacks its event because the substrate destroyed it — the truth-bearing class under chain_store_durability) and which every leg now carries. Default: none.
  • compensation_window and reconciliation_cadence — the deployment-declared duration within which an open marker of the recovery discipline must close or be escalated as an unresolved finding, and the interval at which the sweep runs after its mandatory run at restart; a cadence longer than the window makes the window unmeetable. Earlier revisions declared no cadence knob and stated the trigger as restart, and thereafter until each open marker closes; the window is what turns that into a bounded claim.
  • application_actor_ref and application_credential — the deployment-provisioned actor reference and credential the composition uses when emitting composition-internal Audit Trail entries that have no human-actor origin — three event types plus one flagged class: the chain_resolved event produced by the quorum evaluation rule firing, the cascade_completed follow-up entries the recovery discipline’s retries produce, the chain_initiation_failed record the Initiate Chain recovery path lands, and any recovery emission carrying recovery = true (the transition sweep’s re-emitted records, and the step-8 recovery withdrawals that run outside the failing invocation — retries run outside any human invocation, so the composition actor is their only honest attribution). The composition actor is a first-class registered actor in the Audit Trail’s underlying Actor Identity registry — not a special-cased nil — and its attestations verify under the same rules as human-actor attestations. The deployment is responsible for issuing, rotating, and retiring this credential under the same Permissions and Compromise Disclosure discipline applied to any privileged service identity. (Compromise Disclosure (forthcoming) is a composing pattern this library names but has not yet authored — Audit Trail’s Compromised credential mid-window edge case routes the same credential-compromise reinterpretation to it — and it is not a constituent of this composition; deployments operating before it lands satisfy the rotation/retirement discipline by retiring the credential in the Audit Trail’s Actor Identity registry — rotating the composition actor’s registered material so the leaked credential no longer verifies at record_action — plus operational runbook. No Permissions grant gates emission: the scope vocabulary declares three scopes, none of them an emit scope, and the substrate carries no Permissions layer, so the composition actor’s ability to emit rests on its registry credential alone, and revoking a grant would retire nothing.) An auditor querying the Audit Trail sees chain_resolved events attributed to the composition actor and can verify them against the registry’s public material exactly as for human-actor events. The records-alone forgery defense is Invariant 2 (quorum determinism): a forged chain_resolved event whose declared state does not match the deterministic outcome of the quorum rule on the chain’s step state vector is detectable by any reader independently recomputing the rule from the chain and step records. An attacker who compromises application_credential and emits a fake chain_resolved(state=Approved) against a chain whose step records show A < N (under all-of-N) or A < M (under M-of-N) produces a record that fails Invariant 2 verification — the forgery is structurally detectable without recourse to runbooks or credential-chain investigation. Forging the underlying step records themselves is foreclosed by Approval Step’s Invariant 4 (only the named approver_ref may decide; application_actor_ref does not satisfy this), so the attack surface for the composition credential is limited to chain-level events whose state claims must follow from the step records the composition credential cannot write. The distinguishing rule between human-emitted and composition-emitted audit events: three event types are always emitted with actor_ref = application_actor_refchain_resolved, cascade_completed, and chain_initiation_failed — and so is any event carrying recovery = true (a re-emission or recovery withdrawal, whatever its action_ref); outside those, chain_initiation_intended, chain_initiated, step_decision_intended, chain_withdrawal_intended, chain_withdrawn, step_approved, step_rejected, and step_withdrawn are emitted with the human actor’s reference — the three intent types always are, since an intent record exists precisely to consume a present human’s credential — including the in-invocation cascade-emitted step_withdrawn records, which carry the initiator’s reference (the human whose withdrawal caused the cascade, whose credential the invocation holds) with cascade = true marking their composition-driven origin. An auditor walks the rule directly from the actor_ref, action_ref, cascade, and recovery fields of each Audit Trail entry.

Scope vocabulary

Permissions treats action scopes as opaque. Multi-Party Approval defines the canonical scope vocabulary for its Permissions instance:

Scope Permits
Chains Initiate Call Initiate Chain to create a new approval chain
Chains Withdraw Call Withdraw Chain to withdraw a chain (the chain initiator’s act)
Chains Read Read chain records and their composed step/assignment/attestation surface

Step-level decisions (Approve Step, Reject Step, Withdraw Step) are not additionally permission-gated at the chain layer: Approval Step’s Invariant 4 (only the named approver_ref may transition Pending to Approved or Rejected) and Invariant 5 (only the named submitter_ref may transition Pending to Withdrawn) are the structural enforcement for who may decide each step. Adding a second permission check at the chain layer would be redundant and risks the two checks drifting out of sync. The chain composition relies on Approval Step’s enforcement and surfaces an unauthorized rejection from the underlying atom unchanged.

The vocabulary is deployment-configurable. A deployment that distinguishes “read your own chains” from “read any chain” introduces finer-grained scopes (chains:read:own, chains:read:any) and adjusts the wiring accordingly; the canonical vocabulary above is the minimum useful set.

Logic confinement

The clock is an injected input at the composition’s single I/O seam, never read inside a guard or a transition and never threaded through a caller signature. Per the Logic Confinement Principle (execution-contract.md), the host reads the clock once per invocation and injects now (clock_t) at the seam before the orchestration runs; the quorum evaluation and every chain-state transition are pure functions of the stored records plus that injected now. Because the clock enters at the seam rather than as a parameter, the action signatures below carry no now argument — the same discipline Retention Window pins for place_under_retention / purge. (Approval Step differs: its actions expose optional timestamp parameters — submitted_at? on submit, decided_at? / withdrawn_at? on the decisions — wall-clock-defaulted at its own seam when omitted. This composition omits them, so each step’s timestamp is stamped at Approval Step’s seam.)

This composition consumes now for exactly one purpose: stamping immutable timestamps on a committed write — initiated_at on Initiate Chain, chain_terminal_at when a chain leaves Pending (via the quorum evaluation rule firing or via Withdraw Chain), and intended_at on each action’s intent record. It evaluates no temporal guard: the quorum rule is a pure function of the step state vector (A, R, W, P) and the chain’s quorum_rule, with no clock term, and chain terminality is a state check rather than a timestamp comparison. Quorum determinism (Invariant 2) is therefore clock-independent — a reader recomputing the rule from the records reaches the same outcome regardless of the clock, which is what makes the deterministic-recomputation audit in Generation acceptance check 2 sound.

One injected now per invocation — for this composition’s own stamps. Within a single call, one injected reading serves every timestamp this layer writes (intended_at, and initiated_at or chain_terminal_at). An invocation’s intent event and its outcome event therefore carry the same instant by construction, and no check may order them by their stamps — the Audit Trail’s own sequence is the order source, as it already is for every other claim about record order here. It does not extend into the constituents: each constituent call is its own pipeline invocation with its own seam, and because this composition passes no timestamp arguments, a step’s decided_at is stamped at Approval Step’s seam from a different reading. Consequently chain_terminal_at and the triggering step’s decided_at are two readings, and under seam-to-seam skew the chain’s terminal stamp can fall marginally before the decision that caused it — which is why nothing load-bearing rests on comparing them (see Clock source for chain_terminal_at), and why ordering claims defer to the Audit Trail’s own sequence. A deployment wanting one reading across all seams in a request must supply it as a host obligation; the spec does not promise it. Ids (chain_id excepted — see below) are allocated by the constituents at their own seams: step_id by Approval Step, assignment_id by Assignment, event_id by Audit Trail. The chain_id allocated at Initiate Chain step 4 is the injected id_t, host-allocated at the seam, not minted inside the transition.

Primitive policies

The composition takes string-typed inputs at every action boundary. Each is validated at this layer or by a named constituent; nothing is normalized anywhere — no trimming, no case-folding, no Unicode normalization — and equality is opaque byte-identity throughout, which is what makes every comparison below mechanical: the Permissions scope match, Approval Step’s named-approver gate (Invariant 4), and the quorum rule’s step counting all compare bytes.

  • actor_ref — opaque reference to the calling actor. Must contain at least one non-whitespace character and fit the wired Audit Trail instance’s reference_length_cap (the substrate applies it to actor_ref at its own step 1 and refuses with invalid-request — a cap independent of the payload budget, and the one an over-long reference would otherwise reach only at the intent record, after the caller’s inputs were accepted); validated at this layer, before any constituent is called; a failure returns rejected(invalid-request) with nothing written. Byte-identity is the equality Permissions’ permitted and Approval Step’s exclusivity invariants apply, so QP_Lopez and qp_lopez are different actors.
  • credential — opaque credential material consumed only by the substrate’s record_action (which hands it to Actor Identity’s attest inside the substrate). The composition never inspects, normalizes, stores, or logs it, and it never enters any event payload. Its validity is judged only where it is consumed, which is why invalid-credential surfaces from the audit step rather than from an up-front check (Action wiring names where that lands on each action).
  • subject_ref and scope — opaque references to the thing under approval and its approval scope. Each must contain at least one non-whitespace character and fit its declared length cap — a deployment constant derived from the wired Audit Trail instance’s payload_cap less the composition’s event envelope, applied to subject_ref, scope, and each approver_ref so that the full constructed data of every event this composition emits fits the budget by construction (an uncapped reference could oversize a chain-shape event even with an in-budget reason); validated at this layer (Initiate Chain step 1), an oversized or empty value rejected(invalid-request) with nothing written; both are passed through unchanged to ApprovalStep.submit, which applies its own validation. The scope supplied here is the subject’s approval scope; the Permissions scopes the composition consults (Chains Initiate, Chains Withdraw, Chains Read) are composition-fixed vocabulary, never caller input.
  • approver_ref (each element of approver_set) — opaque actor references. Each must be non-empty and non-whitespace, each is capped per the same budget-derived rule as subject_ref / scope (the approver_set travels whole in every chain-shape event), and each must fit the substrate’s reference_length_cap, because every approver_ref becomes the actor_ref of that approver’s decision records — an approver reference that fits the payload budget but not the reference cap would be accepted at initiation and then refused at every decision it tried to make, leaving the chain unreachable under all-of-N; pairwise distinctness is governed by the approver_set_uniqueness knob; byte-identity is the equality Approval Step’s Invariant 4 gate applies at decision time.
  • quorum_rule — one of the enumerated tokens all-of-N, M-of-N(M), one-of-N, validated against quorum_rule_allowed; for M-of-N(M), M is an integer with 1 ≤ M ≤ |approver_set|. An unrecognized token or out-of-range M is rejected(invalid-request) at Initiate Chain step 1.
  • reason — free text, stored in the audit event data verbatim and in the constituent step record prefixed with the chain’s id ("chain:" + chain_id, then ": " and the caller’s text where supplied — Initiate Chain step 5), never otherwise interpreted. Optional at Initiate Chain and Approve Step; required at Reject Step (Approval Step’s Invariant 6 enforces a rejection reason), Withdraw Step, and Withdraw Chain. Two validations run at this layer, and their placement is load-bearing. First, where required, the reason must contain at least one non-whitespace character — the same rule the constituent applies (Approval Step refuses a whitespace-only reason on reject and withdraw as invalid-request), enforced here, before anything commits, because the constituent’s enforcement arrives too late on one path: Withdraw Chain sets the chain terminal at step 5 before its cascade calls ApprovalStep.withdraw, and a present-but-whitespace reason would have every cascade withdrawal deterministically refused — an unclosable cascade_partial no retry could ever land. With the check here, that arm is unreachable by construction for validated inputs, and the atom’s own rule is the backstop (a disagreement is a conformance fault). Second, the reason is size-checked against the deployment’s audit-payload budget — the wired Audit Trail instance’s configured payload_cap, less the envelope the composition’s event data adds — before any constituent is called; because the reference primitives carry their own budget-derived caps (above), this check covers the full constructed event data, not the reason alone, which is what makes the record_action invalid-request arm genuinely unreachable at the wiring steps that claim it. An oversized reason is rejected(invalid-request) with nothing written; without the check it would surface from record_action only after the step decision or chain writes had committed, converting a caller’s oversized input into a partial-state recovery case. This mirrors the substrate’s own size-check-before-attest ordering, for the same reason.
  • chain_id and step_id — opaque, system-allocated identifiers (chain_id is the seam-injected id_t of Initiate Chain step 4; step_id is allocated by Approval Step), supplied by callers to address existing records. Equality-only, no ordering semantics; an unknown id yields not-known from the addressed action; never normalized. For the budget arithmetic (the reason bullet’s constructed-data check), the deployment declares the maximum widths its wired constituents and seam allocate for the minted ids — chain_id, step_id, assignment_id, event_id — the substrate’s own attestation_id_width move, so an event carrying id lists (a chain-shape event’s step_ids and assignment_ids; a recovery record’s possibly-partial lists) is sizable at validation time rather than discovered oversized at the record step.

Action wiring

Chain-level actions follow this shape: this-layer primitive validation first (Primitive policies — nothing written on failure), Permissions check second, intent record, constituent atom calls, outcome audit record last. Withdraw Chain carries two declared steps between the Permissions check and its intent record, and the exception is real rather than cosmetic: its step-2 evaluate-first gate can terminate the chain by the quorum rule — setting the terminal, cascading withdrawals, recalling assignments and emitting chain_resolved — and return not-pending without the withdrawal ever happening, and its step-3 initiator check runs after that. Invariant 10 states why those writes are outside the authentication ordering and what residue they leave; a reader must take the exception from there and not from this sentence.

Step-level actions follow the same discipline with Permissions replaced by the atom’s own exclusivity gates: this-layer validation (step 0), chain/step resolution (step 1), intent record (step 2), the Approval Step call that decides (step 3, where the atom enforces approver/submitter exclusivity), the assignment recall (step 4), the outcome record (step 5), then chain-state re-evaluation (step 6) and the return (step 7).

The intent record is where the caller’s credential is verified, and its position is the point. Every action below takes a credential that the substrate validates against the actor registry inside record_action; before this discipline the only record_action in each action came after its constituent writes, so an unverified caller could create a chain of N Approval Steps and N Assignments, or commit an approval decision, and be refused only afterwards. Each state-changing action now opens with an AuditTrail.record_action naming what it is about to do, written before anything commits, and each outcome record carries intent_event_id back to it. Read Chain is outside the discipline: it takes no credential, records nothing, and changes nothing.

What this removes is as important as what it adds. Initiate Chain’s recovery contract existed in part to unwind a chain whose chain_initiated record was refused invalid-credential — a full cascade tearing down N submitted steps and N assignments, each withdrawal separately audited, for a caller who was never authenticated. That path is now unreachable for a bad credential: the refusal happens before the first constituent call that writes anything. (Permissions.permitted is itself a constituent call and still runs first, as Initiate Chain step 1 says; it commits nothing.) The recovery contract still governs storage and recording failures, and invalid-credential at an outcome record remains reachable only where the actor’s registration changed mid-invocation.

  • initiate_chain(actor_ref, credential, subject_ref, scope, approver_set, quorum_rule, reason?) → chain_id | rejected(permission-denied | invalid-request | invalid-credential | recording-failure) — the credential is the caller’s own, consumed first by step 3’s intent record — which is where it is verified — and again by step 7’s outcome record_action (Primitive policies); it is in the signature because the substrate’s contract requires it and nothing else can supply a human caller’s credential.
    1. Validate the primitives and the chain shape (Primitive policies): actor_ref, subject_ref, and scope non-empty, non-whitespace, and within their declared caps; |approver_set| ≥ approver_set_minimum; if approver_set_uniqueness is true, all elements of approver_set are pairwise distinct; quorum_rule is in quorum_rule_allowed; for M-of-N(M), 1 ≤ M ≤ |approver_set|; the constructed event data — the capped references, the sets and ids, and the reason where supplied — within the audit-payload budget, sizing the larger of the step-3 intent payload (the declared chain shape, no minted ids) and the step-7 outcome payload (the same shape plus the step_ids, assignment_ids and intent_event_id), since either can be the one that reaches the substrate. Any violation is invalid-request; no records are written. Validation runs before the Permissions read because Primitive policies places this layer’s checks before any constituent is called — and permitted is a constituent call.
    2. Permissions.permitted(actor_ref, chains:initiate) → if denied, return Permission Denied.
    3. Intent record. AuditTrail.record_action(action_ref=chain_initiation_intended, actor_ref, credential, data={subject_ref, scope, approver_set, quorum_rule, reason?, intended_at})event_id_intent, recorded in chain_to_events only once the chain exists (step 4); until then the id is held by the invocation. This record is where the substrate validates the caller’s credential against the actor registry for the supplied actor_ref, so no chain record, no Approval Step and no Assignment is created on an unverified claim (Authentication precedes commitment; Invariant 10). Nothing has committed, so every arm is a clean pre-state rejection and the caller may retry the whole action: invalid-credentialrejected(invalid-credential); recording-failure(step)Recording Failure; invalid-request is foreclosed for caller inputs by step 1’s caps (the reference cap included) and remains reachable only as a deployment fault — a mis-derived cap, or the substrate’s retention-configuration source, on which the intent event is already appended (Retry transience, partitioned) — alerted, never retried. None of this reaches the recovery contract, which exists to compensate constituent writes that already committed; here nothing has. The payload carries the declared chain shape and no chain_id, step_ids or assignment_ids — none is minted yet, and chain_initiated is what the rebuilds traverse; an intent event carrying a chain_id would put a chain into the trail that may never have been created.
    4. Allocate a fresh chain_id (the seam-injected id_t). Take the per-chain serialization for this chain_id and hold it through step 8 — the sweep’s initiation leg takes the same serialization, so a live initiation’s intermediate state (a chain record with no chain-shape event yet, which is exactly the discrepancy the leg acts on) is never observed by the leg while the invocation that will close it is still running; a crashed invocation releases it, and only then can the leg read the record as an open window. Record the chain in chain_store with state = Pending, initiated_at stamped from the seam-injected now (see Logic confinement), the supplied fields.
    5. For each approver_ref in approver_set, in declaration order: call ApprovalStep.submit(subject_ref, approver_ref, submitter_ref=actor_ref, scope, reason="chain:" + chain_id + (": " + reason if supplied))step_idthe step’s stored reason carries the chain_id, so a step that landed in the atom’s store but not yet in any chain’s list is attributable to exactly one invocation from the atom’s own record (case (a) below); step 1’s cap on reason is derived with the prefix included. Append step_id to chain_to_steps[chain_id]; record step_to_chain[step_id] = chain_id; append it to the truth-bearing chain record’s own landed-ids list (the window’s durability obligation covers these bindings — Composition state — so a recovery never has to scan a constituent store for them). Rejection mapping: invalid-requestrejected(invalid-request) (step 1’s validation makes this arm unreachable for well-formed wiring; reaching it after step 1 passed means the composition’s validation and the atom’s disagree — a conformance fault to fix, with the partial-state recovery of step 8a either way); storage-failure → step 8’s case (a).
    6. For each step_id just submitted: call Assignment.assign(task_ref=step_id, assignee_ref=step.approver_ref)assignment_id. Record step_to_assignment[step_id] = assignment_id; append the assignment_id to the truth-bearing chain record’s landed-ids list (the same durability rule as step 5). Rejection mapping: storage-failure → step 8’s case (b); already-assignedunreachable by construction — each task_ref is a freshly-allocated step_id no prior assignment can name; observing it means id reuse in a constituent, a deployment fault to alert on; invalid-request → as at step 5, a validation disagreement taking case (b)’s recovery. The in-tray binding is now the per-step responsibility record (Composes — the by-assignee view is deliberately not supplied here).
    7. Call AuditTrail.record_action(action_ref=chain_initiated, actor_ref, credential, data={intent_event_id, chain_id, subject_ref, scope, approver_set, quorum_rule, step_ids, assignment_ids, initiated_at, reason?}) — the data carries the full declared chain shape plus the ordered step and assignment bindings and the seam-stamped initiated_at, per the Composition-state payload requirement; steps 5–6 ran first precisely so their ids exist to be recorded. Record the returned event_id in chain_to_events[chain_id]. The Audit Trail produces the event-log entry, the actor attestation, the retention record, and (under per-event cadence) the tamper-evidence seal; retention is a property of the configured instance, not a per-call argument. Rejection mapping: invalid-credential → the caller’s credential is bad; the recovery path of step 8 runs (the constituent writes have committed) and the action returns rejected(invalid-credential) so the caller learns the true cause; recording-failure(step) → step 8’s case (c); invalid-request → foreclosed for caller inputs by step 1’s caps; reachable as a deployment fault — a mis-derived cap taking case (c)’s recovery path, or the substrate’s retention-configuration source, on which the event is already appended, nothing is owed, and only the alert is raised.
    8. If any of steps 4–7 fail after step 1 has validated, return the failing step’s mapped rejection — Recording Failure, except step 7’s invalid-credential, which returns its own code per that step’s mapping. Three specific partial-state cases the implementation must handle, in order of severity — under one recovery contract common to all three: every case sets audit_pending = true on the chain record, and every case closes the same way. Once Audit Trail accepts writes, the composition lands one full-shape chain_initiation_failed event (composition actor; the declared chain shape per the payload requirement, the step_ids and assignment_ids that actually landed — possibly-partial lists — a disposition field naming the case, a, b, c, or d (case d lands chain_initiated with recovery = true rather than chain_initiation_failed, since nothing failed but the record), and — where the recovery runs inside the failing invocation, which still holds the intent event’s id — the intent_event_id of that invocation’s chain_initiation_intended record; where the recovery runs from a later retry the field is absent, since that retry cannot know it), and the recovery’s own withdrawals are audited like any cascade: one step_withdrawn record (cascade = true) per recovery-withdrawn step — attributed to the initiator when the recovery runs inside the failing invocation, whose credential it still holds, or emitted by the composition actor with recovery = true when it runs from a later retry — and one terminal chain_withdrawn record for the chain — composition-attributed, carrying recovery = true, no intent_event_id (no withdrawal was ever intended; the invocation was an initiation), and the chain_initiation_failed event’s id as its provenance link. Generation acceptance check 7 excludes it by name for exactly that reason. The full-shape requirement is what keeps a case-(a)/(b) chain visible to the rebuilds at all: without a chain-shape event the rebuild drops the chain, its committed Approval Step records become orphans against Invariant 1, and an unaudited recovery withdrawal would leave the rebuilt state Pending against a stored Withdrawn. The cases: (a) a failure at step 5 (Approval Step submission for the k-th approver, with k−1 steps already submitted) leaves k−1 immutable Approval Step records that name a chain_id whose chain_to_steps[chain_id] is incomplete — and one more the landed-ids list cannot name: the atom mints the step_id and the composition appends it second, across two stores with no atomicity, so a crash between the k-th submit’s commit and its append leaves a Pending step in no chain’s list. The recovery reaches it by a bounded, sibling-safe scan: read the Approval Step store for Pending steps whose (subject_ref, submitter_ref, scope) equal this chain’s (subject_ref, initiator_ref, scope), whose stored reason carries this chain’s chain_id (the prefix step 5 writes — the atom’s read does not filter on reason, so the composition selects on it over the returned steps), and whose step_id appears in no chain record’s landed-ids list (the chain_id in the reason is what makes the scan sibling-safe: two chains one initiator opens concurrently over one subject and scope are serialized per chain_id, not against each other, so a sibling inside its own submit-before-append window has a Pending step in no list yet — matched by subject, submitter and scope alone, that step would be withdrawn by the wrong chain’s recovery), and withdraw each as part of the recovery cascade with its own audited step_withdrawn; the window is admitted in the chain→steps relation’s quantifier — the recovery path is to chain-withdraw the partial chain (cascading withdrawal through the k−1 submitted steps and the chain record itself, audited per the common contract) and surface Recording Failure to the caller. (b) a failure at step 6 (Assignment creation) leaves N submitted steps and j < N Assignments — the recovery path is the same (chain-withdraw, audited per the common contract), additionally requiring no-op handling for the missing Assignments — literally a map-miss skip: no assignment_id was ever returned or recorded for those steps, so there is nothing to call recall with, and the recovery cascade skips their recalls entirely (a not-known answer is not the mechanism; the call could never be dialed). (d) a quarantined chain that reached a lawful terminal before recovery ran — nothing gates decisions on audit_pending (approvers must be able to act on a chain whose only defect is a missing record), so a case-(c) chain can be driven to Approved, Rejected or Withdrawn by its named approvers first; withdrawing it would transition a terminal chain against Invariants 7 and 8, and the terminal-stable guard would otherwise leave the marker open forever. The recovery for this case is re-emission, not withdrawal: land chain_initiated reconstructed from the truth-bearing chain record (full declared shape, the landed-ids lists, initiated_at, recovery = true, no intent_event_id), close audit_pending against it, and leave the chain’s lawful terminal standing; the disposition vocabulary carries d beside a, b and c. (c) a failure at step 7 (AuditTrail.record_action for chain_initiated) leaves the chain, its steps, and its Assignments all in the constituent stores but no Audit Trail entry — this is the most serious case because Invariant 5 (audit completeness) is violated. The recovery path is to chain-withdraw the partial chain — after Withdraw Chain step 2’s evaluate-first gate, run under the per-chain serialization: a case-(c) chain accepts decisions while quarantined, so its routed step vector may already satisfy a firing arm whose evaluation was lost with the crash; the recovery evaluates first, and where an arm holds it takes case (d)’s path (re-emit chain_initiated with recovery = true, then the evaluation-and-cascade), never a withdrawal that would overturn a credential-verified approval under the initiator’s name — only a vector the rule maps to Pending proceeds to the recovery withdrawal — with the common contract’s chain_initiation_failed record and audited withdrawals landing once Audit Trail recovers; until then, the chain is in a quarantined state behind its audit_pending flag (not part of the canonical chain state machine; read-only queries surface it to the auditor). Deployments operating under regulators that do not tolerate any audit-pending state must serialize step 7 ahead of steps 4–6 (write-ahead audit logging) — a deployment-policy variant the canonical composition does not enforce but accommodates.
    9. Return chain_id.
  • approve_step(actor_ref, credential, chain_id, step_id, reason?) → approved | rejected(invalid-request | invalid-credential | not-known | not-pending | unauthorized | recording-failure) — the credential is the caller’s own, consumed first by step 2’s intent record — which is where it is verified — and again by step 5’s outcome record_action (Primitive policies).
    1. Validate the primitives at this layer (Primitive policies): actor_ref non-empty and non-whitespace; the constructed event data — the reason where supplied, plus the fixed fields the intent record (step 2) and the outcome record (step 5) carry — the check sizes the larger of the two payloads, since either can be the one that reaches the substrate — within the audit-payload budget. Any failure → rejected(invalid-request), nothing written.
    2. Validate that step_to_chain[step_id] == chain_id. If step_id is not part of the chain, return not-known. The chain’s state is consulted but does not gate the call: a chain already in a terminal state still accepts decisions on its trailing-Pending steps (the late-decision case named in Invariant 7 and in the Late decisions on a terminated chain edge case). The step’s own state — checked by Approval Step at step 3, the call that decides it — is the load-bearing gate: if the step is already in a terminal state (Approved, Rejected, or Withdrawn — e.g., it was cascade-withdrawn under a prior chain-Withdrawn-by-cascade transition), Approval Step’s not-pending propagates and the call is rejected. Whether the chain is terminal at the time of call is recorded as the trailing flag on both this invocation’s records — the intent record at step 2 and the outcome record at step 5 (see step 5 below).
    3. Intent record. AuditTrail.record_action(action_ref=step_decision_intended, actor_ref, credential, data={chain_id, step_id, decision: approve, reason?, trailing, intended_at})event_id_intent, recorded in chain_to_events[chain_id], written before the Approval Step call that commits. This action has no Permissions check by design — authorization here is Approval Step’s Invariant 4, which compares the supplied actor_ref against the step’s recorded approver_ref — so before this record both sides of that comparison were unverified strings and a caller who merely knew an approver’s reference could commit that approver’s decision. This record is where the reference is validated against the actor registry, which is what makes Invariant 1’s each step decided by its named approver a claim about a principal rather than about a value (Authentication precedes commitment; Invariant 10, and its binding half). Nothing has committed, so every arm is a clean pre-state rejection and the caller may retry the whole action: invalid-credentialrejected(invalid-credential); recording-failure(step)Recording Failure; invalid-request is foreclosed for caller inputs by step 0’s caps and the reference cap (Primitive policies) and remains reachable only as a deployment fault (a mis-derived cap, or the substrate’s retention-configuration source, on which the intent event is already appended — Retry transience, partitioned), alerted, never retried. None of this reaches the recovery contract, which exists to compensate constituent writes that already committed; here nothing has.
    4. Call ApprovalStep.approve(step_id, decided_by=actor_ref, reason?) → propagates invalid-request | not-known | not-pending | unauthorized | storage-failure unchanged (storage-failure is surfaced as Recording Failure at the composition boundary for caller-API uniformity). The atom’s Invariant 4 ensures actor_ref must match the step’s approver_ref.
    5. On atom-level success, call Assignment.recall(step_to_assignment[step_id]) → discharges the in-tray binding for the approver. If the assignment is already in Recalled state (trailing-decision case — the assignment was discharged at chain termination by the cascade-recall subsection), Assignment.recall returns not-active; the composition treats this as idempotent (submitting the same operation twice produces the same result as once) success and proceeds. The recall is required regardless of the resulting chain state — once decided, the step is no longer in-tray work. The two remaining arms are mapped, not left open: storage-failure → the composition proceeds to step 5 (the committed decision must be audited regardless), sets cascade_partial = true in that step-decision record’s data — the same marker class the chain-level terminal events carry — and the recovery discipline retries the recall until it lands, closing the marker with a cascade_completed entry referencing the chain; the case-(b) never-created assignment never reaches a recall at all — it is a map-miss skip (step_to_assignment has no entry, so no call is dialed) and the decision proceeds; a dialed recall answering not-known is an internal-consistency anomaly (the map named an assignment the store does not know) — alerted for store maintenance, the decision still proceeding.
    6. Call AuditTrail.record_action(action_ref=step_approved, actor_ref, credential, data={intent_event_id, acting_actor_ref=actor_ref, chain_id, step_id, reason, trailing, cascade: false})acting_actor_ref duplicates the human in the payload deliberately, because a re-emission under the recovery discipline carries the composition actor in actor_ref and the human would otherwise be lost (the same reasoning chain_initiation_failed applies to initiator_ref), recording the returned event_id in chain_to_events[chain_id], where trailing = true if the chain was already in a terminal state at step 1 (the late-decision case), and trailing = false otherwise; cascade is false on every caller-surface decision — true marks only the composition’s own cascade-emitted step withdrawals (the cascade-recall subsection). Rejection mapping for this call: invalid-credential → the caller’s credential is bad; the step decision has already committed (step 3, the ApprovalStep call — not the step-2 intent record, which commits nothing) and cannot be rolled back, so the partial state is the Cross-store consistency under failure case, surfaced as rejected(invalid-credential) so the caller learns the true cause; recording-failure(step)Recording Failure with the same recovery path; invalid-request → foreclosed for caller inputs by step 0’s caps; a deployment fault where observed (a mis-derived cap, or the retention-configuration source on which the event is already appended), taking the same recovery path only where nothing was appended. The Trailing flag is the audit-distinguishing signal that lets an auditor walking the Audit Trail tell a late decision from an on-chain decision from the records alone — without it, a step_approved event whose timestamp is after the chain’s chain_resolved event reads as a contradiction; with it, the audit interpretation is declared rather than inferred. One benign interleaving is admitted rather than denied: under the chain-scope serialization, a decision that serialized before the chain terminal can have its audit record land after the chain_resolved event, so a trailing = false record appearing after chain_resolved in sequence order is a lawful serialization artifact — the flag speaks to the chain’s state at the decision’s step-1 check, not to record order. If this record_action call fails after the atom-level step transition succeeded, the committed transition enters the recovery discipline’s audit_pending_transition window (the sweep re-emits the record); see Cross-store consistency under failure in Edge cases for the recovery path; the composition surfaces Recording Failure to the caller.
    7. If trailing = false at step 1 — re-evaluate the chain state per the Quorum evaluation rule. If the chain transitions to any terminal state (Approved, Rejected, or Withdrawn-by-cascade), set chain_store[chain_id].state and chain_terminal_at (from the seam-injected now for this invocation — see Logic confinement) first, before any cascade call runs — with the chain already terminal, any re-evaluation a cascade step transition might trigger is a no-op under the terminal-stable guard, which is what forecloses a spurious second chain_resolved — then cascade-recall any still-Active Assignments for the chain’s steps per the Cascade-recall of trailing assignments subsection, and call AuditTrail.record_action(action_ref=chain_resolved, actor_ref=application_actor_ref, credential=application_credential, data={chain_id, state, terminal_at, reason, recalled_step_ids}), recording the returned event_id in chain_to_events[chain_id] (terminal_at carries the same seam reading just stamped, per the Composition-state payload requirement). The recalled_step_ids field is present and lists every step_id whose Assignment was recalled by the cascade — empty list if none (i.e., all steps were already in a terminal state at the moment of chain transition), one or more entries otherwise. Under chain-Withdrawn-by-cascade, additionally call ApprovalStep.withdraw(step_id, withdrawn_by=initiator_ref, reason) for each still-Pending step (mirroring the Withdraw Chain cascade), each followed by its own step_withdrawn Audit Trail record with trailing = false and cascade = true. Attribution is keyed on one fact — whether the triggering invocation holds the initiator’s validated credential — not on an enumeration of trigger sites. Where it does (a Withdraw Step or Withdraw Chain invocation whose intent record verified the initiator’s credential), the cascade records are initiator-attributed. Where it does not — Withdraw Chain step 2’s evaluate-first gate firing a lost evaluation, the sweep’s evaluation leg, or an approver’s own Approve Step / Reject Step whose step-6 evaluation completes a lost withdrawal-driven evaluation (the approver’s credential is validated, but it is not the initiator’s, and Approval Step’s Invariant 5 admits only the initiator’s reference on a withdrawal) — the cascade records are composition-attributed with recovery = true, the only honest attestation available (Configuration’s distinguishing rule already reads recovery = true as composition-emitted). The key generalizes to any future trigger; a site-by-site list did not. The composition actor is the deployment-provisioned actor named in Configuration. If the chain_resolved record_action itself fails after chain_store has been transitioned, the committed terminal enters the recovery discipline’s audit_pending_transition window (the sweep re-emits the record); see Cross-store consistency under failure in Edge cases. If trailing = true at step 1 — chain re-evaluation is a no-op: the chain is already terminal (terminal-stable per the Quorum evaluation rule); no chain_resolved event is emitted; chain_store is unchanged. The step-level audit entry from step 5 is the sole outcome record of the trailing decision, with its trailing flag making the audit interpretation explicit.
    8. Return approved.
  • reject_step(actor_ref, credential, chain_id, step_id, reason) → rejected_outcome | rejected(invalid-request | invalid-credential | not-known | not-pending | unauthorized | recording-failure)
    1. As Approve Step step 0 (reason is required here — present, non-whitespace, and size-checked, per Primitive policies).
    2. As Approve Step step 1 — the chain’s terminal state does not gate the call; the step’s state (checked by Approval Step at step 3) is the load-bearing gate. The trailing flag is computed for the step-2 intent record and the step-5 outcome record.
    3. As Approve Step step 2 — the intent record, with decision: reject, written before the Approval Step call that commits and carrying the same reasoning: this action has no Permissions check, so the intent record is where the actor reference the atom’s exclusivity check compares is itself validated (Invariant 10).
    4. Call ApprovalStep.reject(step_id, decided_by=actor_ref, reason) → propagates the atom’s rejection taxonomy; reason is required (Approval Step’s Invariant 6 enforces it).
    5. As Approve Step step 4 (recall the in-tray assignment; not-active is idempotent success).
    6. As Approve Step step 5, with action_ref=step_rejected and the same trailing/cascade flag rules and rejection mapping.
    7. As Approve Step step 6 (chain re-evaluation only when trailing = false). Under all-of-N, a rejection makes quorum unreachable and the chain transitions to Rejected; under M-of-N or one-of-N, it depends on remaining capacity.
    8. Return rejected_outcome (Approval Step’s success token for reject).
  • withdraw_step(actor_ref, credential, chain_id, step_id, reason) → withdrawn | rejected(invalid-request | invalid-credential | not-known | not-pending | unauthorized | recording-failure)
    1. As Approve Step step 0 (reason required — present, non-whitespace — and size-checked).
    2. As Approve Step step 1 — the chain’s terminal state does not gate the call. The trailing flag is computed for the step-2 intent record and the step-5 outcome record. Note: under chain-Withdrawn-by-cascade, trailing steps were cascade-withdrawn already and will fail step 3 with not-pending; trailing-decision withdrawals therefore only occur when the chain is in Approved or Rejected (where trailing steps remain Pending).
    3. As Approve Step step 2 — the intent record, with decision: withdraw, written before the Approval Step call that commits and carrying the same reasoning: this action has no Permissions check, so the intent record is where the actor reference the atom’s exclusivity check compares is itself validated (Invariant 10).
    4. Call ApprovalStep.withdraw(step_id, withdrawn_by=actor_ref, reason) → propagates the atom’s rejection taxonomy; withdrawn_by must match submitter_ref (the chain initiator). Step-level withdrawal is the chain initiator correcting a single mis-submitted gate (wrong approver named, wrong scope) without retracting the whole chain — where quorum remains achievable: when the withdrawal itself makes quorum unreachable with no rejection present, the quorum rule’s R = 0 ∧ W ≥ 1 arm cascades the whole chain to Withdrawn at step 6.
    5. As Approve Step step 4 (recall the in-tray assignment).
    6. As Approve Step step 5, with action_ref=step_withdrawn and the same trailing/cascade flag rules and rejection mapping.
    7. As Approve Step step 6 (chain re-evaluation only when trailing = false). A withdrawn step is counted alongside rejected steps for quorum-unreachability purposes (see Quorum evaluation rule).
    8. Return withdrawn.
  • withdraw_chain(actor_ref, credential, chain_id, reason) → withdrawn | rejected(permission-denied | invalid-request | invalid-credential | not-known | not-pending | unauthorized | recording-failure) — the credential is the caller’s own, consumed by the audit records this action and its cascade emit (Primitive policies).
    1. Permissions.permitted(actor_ref, chains:withdraw) → if denied, return Permission Denied. (Before it, the step-0 primitive validation of Approve Step applies: actor_ref non-empty, reason required — present and non-whitespace, the check that keeps the step-6 cascade’s withdrawals from being deterministically refused over a committed terminal — and size-checked → invalid-request, nothing written.)
    2. Validate that chain_id is in chain_store (not-known otherwise) and in Pending state (not-pending otherwise). The Pending gate evaluates before it answers: with the chain stored Pending, evaluate the quorum rule against the current step vector under the per-chain serialization. If the vector satisfies a firing arm, the evaluation was lost by an earlier crash (the sweep’s evaluation leg’s case, met here first): run the evaluation-and-cascade exactly as Approve Step step 6 would — terminal set first, cascade, chain_resolved — and return not-pending; the chain terminates by the rule, never by a withdrawal racing a lost evaluation. Only a chain whose vector the rule maps to Pending proceeds to the withdrawal, which is exactly what makes Invariant 2(b)’s reconstruct-to-Pending verification sound on every chain_withdrawn chain.
    3. Validate that actor_ref == chain_store[chain_id].initiator_ref — only the chain initiator may withdraw the chain. Mismatch is unauthorized.
    4. Intent record. AuditTrail.record_action(action_ref=chain_withdrawal_intended, actor_ref, credential, data={chain_id, reason, intended_at})event_id_intent, recorded in chain_to_events[chain_id], written before the terminal write and the cascade it authorizes. This record is where the initiator’s credential is validated — step 3 compares actor_ref against the stored initiator_ref, and until this write that comparison ran against an unverified reference, so anyone who knew an initiator’s reference could withdraw their chain and cascade-withdraw every Pending step in it (Authentication precedes commitment; Invariant 10). Nothing has committed, so every arm is a clean pre-state rejection and the caller may retry the whole action: invalid-credentialrejected(invalid-credential); recording-failure(step)Recording Failure; invalid-request is foreclosed for caller inputs by the step-0 caps and the reference cap and remains reachable only as a deployment fault (a mis-derived cap, or the substrate’s retention-configuration source, on which the intent event is already appended — Retry transience, partitioned), alerted, never retried. None of this reaches the recovery contract, which exists to compensate constituent writes that already committed; here nothing has.

    Step 2’s lost-evaluation cascade is deliberately outside this ordering, and the reason is the rule’s own third answer. That gate may fire a full evaluation-and-cascade before this record is written — but it commits nothing on the caller’s authority: it fires the quorum rule against a step vector the recorded decisions already satisfied, it is attributed to the composition actor with recovery = true, and it would have fired identically for any caller or for the sweep. The transition relies on no actor’s authority, so there is no principal to authenticate for it. Moving the intent record ahead of step 2 would attach an unverified caller’s name to a recovery that is not theirs, and would record a withdrawal intent for every call that in fact only completed someone else’s lost evaluation. 5. Set the chain terminal first: chain_store[chain_id].state = Withdrawn, chain_terminal_at from the seam-injected now for this invocation (see Logic confinement). The ordering is load-bearing, not stylistic: the cascade below transitions steps, and each step transition is a point where a chain-state re-evaluation could fire — against a still-Pending chain, the quorum rule’s R = 0 ∧ W ≥ 1 case would read the cascade’s own withdrawals as a quorum event and emit a spurious second chain-terminal record. With the chain already Withdrawn, the terminal-stable guard (the Quorum evaluation rule) makes any such re-evaluation a no-op by construction. 6. For each step_id in chain_to_steps[chain_id] whose underlying Approval Step is still Pending, in declaration order: call ApprovalStep.withdraw(step_id, withdrawn_by=initiator_ref, reason) → discards the Pending gate; record it — one AuditTrail.record_action(action_ref=step_withdrawn, actor_ref, credential, data={chain_id, step_id, reason, trailing: false, cascade: true}) per cascaded step, its event_id into chain_to_events[chain_id] — a cascade withdrawal is a step transition of consequence and is audited like one, which is what Generation-acceptance check 5’s one-event-per-transition claim requires; then Assignment.recall(step_to_assignment[step_id]) discharges the in-tray binding (not-active is idempotent success; a never-created assignment is a map-miss skip — no id exists to dial — and a dialed recall answering not-known is a conformance anomaly to alert on, the cascade proceeding either way). Steps already in a terminal state are not affected by chain-level withdrawal — their records are immutable. Partial-failure recovery during this cascade follows the same rule as cascade-recall on quorum-fire termination (see Cascade-recall of trailing assignments on chain termination): the composition proceeds through the remainder of the cascade on individual call failure — the per-step record_action calls included: a cascaded step_withdrawn record that fails to land (recording-failure, or invalid-credential should the substrate refuse the initiator’s credential mid-cascade) is a failed cascade call like any other, the step’s committed withdrawal standing — reflects the actual progress in the chain-level record below (cascade_partial = true when one or more calls failed), and the recovery discipline retries the failed calls with follow-up cascade_completed entries (a retried record’s cascade_completed entry is that step transition’s audit record, per Invariant 5’s retry carve-in). 7. Call AuditTrail.record_action(action_ref=chain_withdrawn, actor_ref, credential, data={intent_event_id, acting_actor_ref=actor_ref, chain_id, reason, terminal_at, cascade_partial?}), recording the event_id in chain_to_events[chain_id]terminal_at carrying the step-5 seam reading per the payload requirement. Rejection mapping as at Approve Step step 5: invalid-credential surfaces as rejected(invalid-credential) over the already-committed cascade (the Cross-store consistency recovery), recording-failure(step) as Recording Failure, invalid-request as the deployment’s payload-budget fault. 8. Return withdrawn.

  • read_chain(actor_ref, query) → ordered_sequence_of_chains | rejected(permission-denied | invalid-query) — a pure projection: no state change, no audit event, and no credential (nothing here records).
    1. Permissions.permitted(actor_ref, chains:read) → if denied, return Permission Denied. That is the canonical behavior, with no deployment fork: an existence-hiding empty result would make denied and no matching chains indistinguishable to the caller, and a deployment wanting that posture builds it in its own calling layer, outside this contract.
    2. Query the chain store on the supported filter axes: chain_id, subject_ref, scope, initiator_ref, state, time ranges on initiated_at or chain_terminal_at (using the {after: <timestamp>, before: <timestamp>} sub-key form consistent with Approval Step’s read). The malformed-query rules are Approval Step’s read rules, adopted by reference rather than restated: a blank string-axis value, a state token outside the four chain states, a time range with end before start, or an unrecognized filter key is invalid-query — rejected rather than silently ignored, for the same reason the atom gives. Empty result for a well-formed query that matches no chains; not a rejection.
    3. For each chain in the result set, the response carries the chain record’s fields (including audit_pending when set — the auditor sees the quarantine flag without needing a separate query), plus the derived view: the ordered list of step records (via chain_to_steps joined against the Approval Step store), each step’s assignment record, Active or Recalled (via step_to_assignment joined against the Assignment store) — every step, not only Pending ones, which is what surfaces the decision-path recall-failure residue Invariant 4’s liveness arm admits: an Active assignment on a terminally-decided step, the chain’s terminal status, and the chain’s audit event_ids from chain_to_events — the declared chain→audit traversal (Composition state). An auditor verifying the chain’s audit surface takes each event_id through the substrate’s own two-call asymmetry: AuditTrail.read_record(event_id) returns the joined audit record naming the covering seal’s range, and AuditTrail.verify_record(event_id, original_event_payload) — the two-argument contract, with the caller re-presenting the record set the covering seal commits to, exactly as that composition’s Invariant 7 requires — returns the integrity outcome. This query surfaces the ids and leaves the verification calls to the auditor; it does not proxy them.
    4. The result ordering is declared, not left open: chains are returned in ascending initiated_at order, tie-broken by ascending byte order of chain_id — a total, deterministic order over any result set, so two readers of the same store paginate identically. (initiated_at alone is not total: two chains initiated in the same clock instant, or on two nodes with skewed clocks, can share a stamp; the id tiebreak restores totality without implying any cross-node ordering claim.)

The quorum evaluation rule

The composition’s load-bearing wiring decision: chain state is a deterministic function of the constituent step states under the chain’s named quorum rule, re-evaluated at each step-level transition — owned at this layer, stored nowhere as independent truth.

Principle. The chain’s outcome must be recomputable by any reader from the step records and the declared rule — the auditor’s question is “did the required approvers actually decide?”, and only an outcome that is a pure function of their recorded decisions answers it from records alone. The composition is the only layer that can own the evaluation: Approval Step knows nothing about chains, and Permissions, Assignment, and Audit Trail know nothing about quorum.

Likely objection. Why not let the calling workflow count approvals — it already reads the steps and knows its own business rule — or push the quorum into Approval Step itself, where the decisions live?

Mechanism that resolves it. A calling-system count is exactly the unauditable form: the outcome would live in a system whose logic no spec governs, so “the chain approved” would be that system’s assertion, not a recomputable fact — the bypass this composition exists to foreclose. And Approval Step cannot hold it without ceasing to be the freestanding single-gate atom its own EOS boundary defends: counting across steps requires chain identity, which the atom deliberately does not know. So the rule lives here, stated as a pure function with named variables, and the chain’s stored state is never independent truth — it is the memoized value of this function, cross-checkable against the step records at any time (Invariant 2), which is what makes a forged terminal state structurally detectable.

Result. Bypass-resistance from the records alone: a chain cannot read Approved unless the quorum-named decisions are actually present in the Approval Step store, every reader reaches the same verdict, and neither the calling system nor a compromised composition credential can assert an outcome the step records do not support.

For a chain with approver_set of size N, let:

Only routed transitions count. A step transition enters the vector only if it was decided through this composition — evidenced by a step_decision_intended event for that chain_id and step_id whose actor_ref equals the Approval Step record’s decided_by (or withdrawn_by), or by the composition’s own cascade or recovery record for it. Approval Step takes no credential and its exclusivity check is a byte comparison, so a transition written directly to that store carries no principal behind it; counting it would let an unauthenticated write move quorum. Such a step is an out-of-band transition: it is counted as Pending for the rule (the chain cannot terminate on it), flagged as a conformance alert, excluded from every sweep re-emission, and surfaced by Read Chain. The only lawful resolution is the initiator withdrawing the chain and initiating afresh — the atom’s terminal absorption means the step itself cannot be re-decided.

  • A = the count of step_ids in chain_to_steps[chain_id] whose underlying Approval Step state is Approved through this composition.
  • R = the count of step_ids whose state is Rejected.
  • W = the count of step_ids whose state is Withdrawn.
  • P = the count of step_ids whose state is Pending. A + R + W + P = N.

Withdrawn and Rejected step states are both counted toward quorum unreachability, but they carry distinct audit signals — a rejection is a deliberate negative decision by the named approver; a withdrawal is the chain initiator’s retraction of a single mis-submitted gate (wrong approver named, wrong scope) without negative decision content. The chain’s terminal state distinguishes the two:

Under quorum rule All Of N:

  • If A == N, the chain transitions to Approved.
  • If R ≥ 1, the chain transitions to Rejected with reason "quorum unreachable: all-of-N requires every approval; step S was rejected". Rejection takes priority over withdrawal: if a step is both rejected and (after a later step withdrawal) “withdrawal-blocked,” the rejection is the load-bearing audit signal.
  • If R == 0 ∧ W ≥ 1, the chain transitions to Withdrawn with reason "chain withdrawn by cascade: step S was withdrawn by the initiator; all-of-N quorum is no longer reachable without a fresh chain". The remaining Pending steps are cascade-withdrawn through Approval Step (mirroring the Withdraw Chain action’s cascade), each Assignment recalled (see Cascade-recall of trailing assignments below), and an Audit Trail event emitted by the composition actor.
  • Otherwise the chain remains Pending.

Under quorum rule M Of N (which includes One Of N as M=1):

  • If A ≥ M, the chain transitions to Approved.
  • If (N - R - W) < M ∧ R ≥ 1 — i.e., fewer than M steps remain that are either already Approved or still capable of becoming Approved, and at least one of the unreachable-quorum-causing transitions was a rejection — the chain transitions to Rejected with reason "quorum unreachable: M-of-N requires M approvals; only K remain achievable; rejections present".
  • If (N - R - W) < M ∧ R == 0 ∧ W ≥ 1 — quorum unreachable solely because of withdrawals, no rejections — the chain transitions to Withdrawn with the cascade behavior named above.
  • Otherwise the chain remains Pending.

Cascade-recall of trailing assignments on chain termination

When the quorum evaluation rule fires a chain transition to any terminal state (Approved, Rejected, or Withdrawn-by-cascade) — including via the cascade above — the composition recalls every Assignment whose underlying step is still in Pending, then records the chain transition to the Audit Trail. The trailing-step treatment differs by terminal state, by design:

  • Under chain-Approved or chain-Rejected — trailing-Pending steps remain in Approval Step’s Pending state (the per-step Invariant 3 — terminal absorption — applies only to steps, not to the chain), but their in-tray binding is discharged. The work is moot from the chain’s perspective; the named approver’s responsibility ends with chain termination, not with their own decision. A late decision by the named approver is permitted (Invariant 7) and is recorded with trailing = true in the Audit Trail entry.
  • Under chain-Withdrawn-by-cascade — trailing-Pending steps are also step-Withdrawn through Approval Step (per the Quorum evaluation rule’s cascade for the R = 0 ∧ W ≥ 1 case), mirroring Withdraw Chain. Once cascade-withdrawn, the step is in a terminal state, and a subsequent Approve Step or Reject Step call on it fails at the atom level with not-pending. Late decisions are therefore only possible against chain-Approved or chain-Rejected chains, not against chain-Withdrawn chains — outside an open partial-cascade window: a step whose cascade withdrawal initially failed remains Pending on the Withdrawn chain until the retry lands, a decision landing in that window is lawful (recorded trailing = true) and supersedes the withdrawal, and the retry then closes as superseded_by_decision rather than pretending it can still withdraw. The asymmetry is intentional and audit-defensible: chain-Withdrawn names a deliberate retraction of the chain as a whole, so trailing gates are released; chain-Approved and chain-Rejected name quorum outcomes that do not foreclose the named approver’s right to record a decision for the audit trail.

The chain’s state and chain_terminal_at are set before the first cascade call runs — on both paths, the quorum-rule firing (Approve Step step 6) and Withdraw Chain step 5 — so every cascade-triggered step transition happens against an already-terminal chain and any re-evaluation it might fire is a no-op under the terminal-stable guard below; a cascade that ran against a still-Pending chain would read its own withdrawals as a quorum event and emit a spurious second chain-terminal record. Each cascade withdrawal’s reason is declared, not left open: the quorum rule’s generated reason string for the firing arm (or, on the Withdraw Chain path, the initiator’s own validated reason) — generated strings are non-whitespace by construction, so Approval Step’s Invariant 6 can never refuse a cascade withdrawal over its reason; the step-8 recovery’s withdrawals likewise carry the fixed generated reason "initiation-failed recovery (disposition a|b|c)". The cascade-recall is then one Assignment.recall(assignment_id) call per still-Active assignment in chain_to_steps[chain_id], in declaration order. Under chain-Withdrawn-by-cascade, each recall is preceded by one ApprovalStep.withdraw(step_id, withdrawn_by=initiator_ref, reason) call against the still-Pending step, each with its own step_withdrawn audit record carrying trailing = false and cascade = true, attributed per the credential-keyed rule stated at Approve Step step 6 (initiator-attributed only when the triggering invocation holds the initiator’s validated credential; composition-attributed with recovery = true otherwise — the evaluate-first gate, the sweep’s evaluation leg, or another approver’s decision completing a lost evaluation). After all recalls (and step-withdraws, if applicable) complete, one AuditTrail.record_action(action_ref=chain_resolved, actor_ref=application_actor_ref, credential=application_credential, data={chain_id, state, terminal_at, reason, recalled_step_ids}) call is emitted, its event_id recorded in chain_to_events[chain_id]. The recalled_step_ids field is always present (not optional): it carries the list of step_ids whose Assignments were recalled by this cascade, in the order recalls were performed; the list is empty ([]) when the chain transitioned with no trailing-Active assignments (i.e., all N steps reached a terminal state in lockstep with the triggering decision — the canonical all-of-N happy path on the N-th approval). Always-present rather than omitted-when-empty preserves a single audit-data schema across all chain_resolved events.

Every arm of the cascade’s constituent calls is landed, in-invocation as well as on retry. ApprovalStep.withdraw on a cascaded step: ok → the step’s step_withdrawn record and its recall follow; not-pending in-invocation — the step was decided at its own step 3 while this cascade held the chain-level mutex (decisions serialize against the chain at their step-6 evaluation, not at their commit, the benign interleaving Approve Step step 5 admits) — is the same supersession the retried arm closes with: the step’s decision record is the audit truth, the cascade writes the cascade_completed(step_id, superseded_by_decision = true) entry in-line, and the step counts as decided in Invariant 2(b)’s reconstruction (a reading that silently skipped it would make check 2(b) convict a lawful history); invalid-request — Approval Step’s temporal rule, a withdrawn_at below the step’s submitted_at under cross-seam skew — is a retryable arm: the step stays Pending, cascade_partial = true, and the retry runs with a later reading; not-known → the map named a step the store does not know, a conformance anomaly alerted for store maintenance, the cascade proceeding; unauthorized → unreachable by construction (withdrawn_by is the initiator, who is every step’s submitter_ref), a conformance fault if observed. Assignment.recall: ok; not-active → idempotent success; not-known → the same anomaly; storage-failure → retryable. Partial-failure recovery during cascade. If one of the cascade calls fails on a retryable arm — an Assignment.recall returns storage-failure, under chain-Withdrawn-by-cascade an ApprovalStep.withdraw returns storage-failure or the temporal invalid-request, or a cascade step’s own step_withdrawn record_action fails to land over a committed withdrawal — the composition has executed some of the cascade calls successfully and some not. The recovery rule: the composition proceeds through the remainder of the cascade (does not abort on first failure), then records the chain transition to the Audit Trail with the partial outcome reflected: the recalled_step_ids lists only the assignments actually recalled, and a cascade_partial = true flag is included in the chain_resolved data when one or more cascade calls failed. The chain’s terminal state is still set in chain_store (the chain has structurally terminated; the cascade is housekeeping), and the still-Active trailing Assignments (or, under Withdrawn-by-cascade, still-Pending trailing steps) are surfaced via Read Chain so the auditor and operator see the partial state. The implementation is responsible for retrying the failed cascade calls until each closes — and one deterministic arm closes without succeeding: a retried cascade withdrawal answered not-pending has been overtaken (the still-Pending step was lawfully decided by its named approver inside the open window — step 1 gates on the step’s state, not the chain’s), retrying it cannot ever succeed, and the retry closes the marker instead with a cascade_completed entry recording the supersession (step_id, superseded_by_decision = true) — the step’s own decision record is the audit truth, and the closure entry is what keeps the marker from standing forever. Each subsequent retry call that succeeds emits its own follow-up Audit Trail entry (action_ref=cascade_completed, emitted with actor_ref = application_actor_ref — a retry runs outside any human invocation, so the composition actor is its only honest attribution) referencing the original chain-level terminal event — chain_resolved, or chain_withdrawn on the Withdraw Chain path — by chain_id. For a retried cascade step-withdrawal (or a retried step record), the cascade_completed entry is that step transition’s audit record: it carries the step_id it closed, and no initiator-attributed step_withdrawn record is — or could honestly be — emitted for it, because the initiator’s credential belonged to the original invocation; Invariant 5 counts these entries accordingly. The composition returns approved (or the analogous success token) to the caller even when the cascade was partial — the load-bearing transition (step decision and chain re-evaluation) succeeded; the cascade is recovery-amenable.

The recovery discipline, named as one surface. Four markers — three introduced separately above and in Initiate Chain step 8, one (the transition sweep) declared here — are one discipline and are read together; naming it here is what keeps it a declared reconciliation surface rather than an implicit state machine an implementer discovers:

  • audit_pending (a chain-record flag) — set by Initiate Chain’s step-8 recovery, in all three cases, whenever constituent writes committed but no chain-shape audit event landed; closed when the chain_initiation_failed record lands (emitted by the composition actor, carrying the full chain shape, the possibly-partial landed-id lists, and the case disposition per the payload requirement, so the quarantined chain becomes rebuildable the moment the flag clears — and the recovery withdrawal it precedes is itself audited, per step 8’s common contract). While set, the chain record itself is the truth-bearer for exactly that window (Composition state names the durability obligation). The sweep opens this marker too: its initiation leg — under the per-chain serialization Initiate Chain holds from step 4 through step 8, so it never reads a live initiation as a crashed one — compares chain_store against the chain-shape events — every chain record lacking one whose initiated_at is inside the audit horizon is the flag-less crash form of the same window (a record older than the horizon lacks its event because the substrate destroyed it, is the truth-bearing class Configuration’s chain_store_durability covers, and is never read as an open marker) (a crash inside Initiate Chain before step 8 could run), read as an open initiation marker by the discrepancy-is-the-marker rule and driven through step 8’s common recovery contract: the full-shape chain_initiation_failed with its disposition derived from what actually landed — the id lists read off the truth-bearing chain record itself, which carries every landed step_id and assignment_id appended as it was minted (Initiate Chain steps 5–6’s durability rule), never from a constituent scan: a sibling chain on the same subject_ref is a permitted state (Concurrent chains on the same subject), and a subject-keyed read could collect its steps and destroy a healthy chain — then the audited recovery withdrawal (composition actor, recovery = true). Sweep-driven recovery is the recovery path, which is what makes Invariant 8’s set-only-by-the-recovery-path clause satisfiable after a crash that died before setting the flag.
  • cascade_partial (a field on the chain-level terminal event — or on a step-decision event whose secondary Assignment.recall failed, Approve Step step 4’s mapped arm; one marker class, two carrier sites) — set true when one or more secondary calls failed; closed when every initially-failed call has a matching cascade_completed entry referencing the chain.
  • cascade_completed (the closure records) — one per retried-and-succeeded cascade call, emitted by the composition actor; for a retried step-withdrawal or step record, the entry is that transition’s audit record (the carve-in above).
  • audit_pending_transition (the transition sweep) — covers the committed transition whose own audit record failed: a step decision whose step-5 record_action failed after the atom-level write, or a chain terminal set in chain_store whose chain_resolved or chain_withdrawn record failed. No stored flag can carry this marker at failure time — the failed call is exactly the record that did not land — so the marker is the discrepancy itself: the recovery sweep compares the Approval Step store’s transitions and chain_store’s terminals against chain_to_events, and every transition with no matching event is an open marker — and a transition is matched only by an event whose action_ref is the corresponding outcome reference (step_approved / step_rejected / step_withdrawn / chain_resolved / chain_withdrawn); an intent event never matches a transition, or a lost outcome record would read as audited and the marker would close without ever opening, living entirely on records already written, kept in no second store. Re-emission is conditional on provenance, and the condition is what keeps the sweep from laundering. The sweep re-emits a step transition’s record only where a step_decision_intended event exists for that chain_id and step_id whose actor_ref equals the constituent record’s decided_by / withdrawn_by (or, for a cascade withdrawal, where the chain’s terminal or its chain_withdrawal_intended event authorizes it). A committed transition with no such intent event is an out-of-band transition — written to the Approval Step store around this composition — and it is never re-audited: the sweep raises a conformance alert naming the step, excludes it from the evaluation leg’s step vector (the quorum rule counts it as Pending — The quorum evaluation rule), and leaves it for the initiator to withdraw the chain around. Re-emitting it would turn an unauthenticated write into a composition-attested decision that then drives the chain to Approved. Closed when the sweep re-emits the missing record_action — same action_ref and data the original call would have carried, except that intent_event_id is replaced by intent_event_candidates — every unmatched step_decision_intended (or chain_withdrawal_intended) event for that chain_id and step_id whose actor_ref equals the constituent record’s actor, listed in log order; the sweep names the set rather than choosing, and intent_event_id alone where the set is a singleton — while acting_actor_ref is carried through from the constituent record’s decided_by / withdrawn_by, with two admitted inferences, both carried by recovery = true: the trailing flag reflects the chain’s state at the lost decision’s own step-1 check, which some crash interleavings leave unreconstructable, so the re-emitted record’s trailing is the sweep’s best-effort derivation from the chain’s terminal stamps; and a re-emitted chain_resolved’s always-present recalled_step_ids cannot know which recalls the original cascade performed before dying, so it lists the recalls the sweep itself performed at closure plus the chain’s steps already Recalled with a recall stamp at or after chain_terminal_at — the best-effort set, marked as such by the same flag — emitted by the composition actor with recovery = true marking the after-the-fact emission (the original caller’s credential belonged to the failed invocation; the composition actor is the retry’s only honest attribution, the same rule as cascade_completed) — and the discrepancy vanishes. The sweep’s closure duty goes beyond re-emission — it re-runs the skipped logic, under the per-chain serialization: for every committed decision transition it re-audits, it also re-runs the decision’s secondary steps before closing — the step-4 assignment recall where the decided step’s assignment is still Active, and the step-6 chain evaluation. And because a crash can skip the logic even where every record landed, two further detector legs make the evaluation and recall restart-idempotent: the evaluation leg — a chain still Pending whose current step vector satisfies a firing arm of the quorum rule is a lost evaluation (a crash between a decision’s commit and its step 6), and the sweep runs the evaluation-and-cascade exactly as step 6 would (terminal set first, cascade, chain_resolved under the composition actor); the recall leg — a step holding an Active assignment while the step or its chain is terminal, with no open cascade_partial marker, is a lost recall (a decision whose step-4 recall died, or a cascade that died half-run after the chain’s terminal was set), closed by recalling it and landing the closure as a cascade_completed entry; on the Withdraw Chain path the same leg also withdraws a still-Pending step on a Withdrawn chain, since the cascade owed it a withdrawal. A quorum-satisfied chain can therefore never sit Pending forever: no future decision call is needed to reach step 6, because the sweep reaches it.

Leg order is fixed, not left to the implementer: the transition sweep’s evaluation leg runs before the initiation leg on every pass, and the initiation leg’s recovery withdrawal itself runs Withdraw Chain step 2’s evaluate-first gate under the per-chain serialization (Initiate Chain step 8, case (c)) — so a quarantined chain carrying a lost evaluation is fired by the rule, never withdrawn by the recovery. The trigger is implementation-owned and stated: retries — and the transition sweep — run at restart, and thereafter until each open marker closes — on reconciliation_cadence (Configuration), between two edges — no transition whose matching intent is younger than decision_completion_bound is re-emitted for, and no chain past the audit horizon is — with every closure that commits constituent state or emits a state-transition record (a recall, a step withdrawal, a chain_resolved, a re-emitted decision) preceded by a chain.recovery_intended record under the composition actor naming the chain, the leg, and the candidates it pairs to, so the trail shows the act was occasioned by the sweep and not by a direct call; the open state is never silent in the interim: Read Chain surfaces audit_pending and the still-Active trailing assignments directly, and the auditor’s closure procedure is Generation acceptance’s fifth audit-gap entry (match every failure to its cascade_completed). This is the substrate’s reconciliation shape one layer up — detect from the records, compensate through the same audit surface, close on a marker the records carry — without a second store: every marker lives on a record or event the composition already writes.

Retry transience, partitioned. A retry obligation is well-formed only when the retried call’s rejection arms are split into transient and deterministic, with a landing for every deterministic arm — a loop over an arm that rejects the same way every time is not a recovery. The discipline’s retries and re-emissions loop on the transient arms only, and there are three, not one: the substrate’s recording-failure(step) on every record; the constituents’ storage-failure on the cascade’s withdraw and recall calls; and Approval Step’s temporal invalid-request on a cascade withdrawal (a withdrawn_at below submitted_at under cross-seam skew — deterministic until the clock advances, transient after it), retried with a later reading. Every other arm is deterministic and landed, never looped on: the cascade’s not-pending closes as a supersession, its not-known and unauthorized are conformance anomalies alerted once. The substrate’s invalid-request is foreclosed for caller inputs by the budget-derived caps and the reference cap (Primitive policies), recovery records included; its retention-configuration source — Audit Trail routing its Retention Window invalid-policy / policy-not-found onto this arm — is not caller-controlled and remains reachable as a deployment fault, on which the event is already appended and nothing is owed (the substrate’s own reconciliation places the retention); a cap-source invalid-request after step 1 passed is the payload-budget/instance-cap disagreement, alerted, never retried. invalid-credential on a retry cannot be a caller’s fault: every re-emission is composition-attributed (application_actor_ref / application_credential), so the arm means the deployment’s own service credential is bad — a pageable operational fault that stands as the open marker’s alert until the deployment rotates the credential, not an unreachable state and not an infinite loop.

If a trailing step’s named approver later decides on the step (a late decision per Invariant 7 and the corresponding Edge case), the step-level Approve Step/Reject Step call still recalls “the” assignment; but the assignment is already in Recalled state (or never created in a partial-cascade case where the assignment recall hadn’t completed before the late decision arrived — in which case Assignment.recall returns ok on first call here). The composition treats not-active from Assignment.recall as idempotent success — the assignment was lawfully discharged at chain termination — and proceeds. The decision action’s own Audit Trail entry (with trailing = true) is the record of the trailing decision; the chain’s state and chain_terminal_at are unchanged.

The rule is terminal-stable: once a chain reaches a terminal state via this rule, subsequent step transitions (e.g., a still-Pending step is later approved by its named approver after the chain has already become Approved) do not alter the chain’s state. The chain’s terminal status is set once and is immutable. The trailing step transitions are still recorded in the Approval Step store and the Audit Trail (with trailing = true), but the chain’s state is unchanged. Implementations must serialize the chain-state re-evaluation on a given chain_id (per Concurrent step decisions on the same chain in Edge cases); a bug or race that re-fires the rule against an already-terminal chain MUST be a no-op at the chain-state level — re-evaluation reads chain_store[chain_id].state first and returns immediately if the chain is not in Pending. An implementation that emits a second chain_resolved event with a different state from the first is an Invariant 7 conformance failure observable from the records (two chain_resolved events for one chain_id, with different declared states); the audit defense against this race is structural — the duplicate is detectable.

The rule is also order-independent in outcome but order-sensitive in timing: for any final distribution of step states (A, R, W, P=0), the chain’s terminal state is determined by the rule above regardless of the order in which step transitions occurred. The chain_terminal_at timestamp captures the first moment the rule fired against this chain; later transitions to terminal states on still-Pending steps do not advance chain_terminal_at. Two concurrent triggering decisions whose serialization choice would yield different first-firing moments are serialized per the chain-scope mutex; the spec does not promise which decision serializes first, only that the chain-state re-evaluation is single-threaded per chain_id and that whichever serializes first wins the chain_terminal_at slot. For deployments where the precise first-firing moment must be adversarially defensible (against a claim that the recorded chain_terminal_at was delayed or batched), the Audit Trail’s per-event receipt timestamps on the triggering step event and the resulting chain_resolved event are the audit pair an auditor compares.


Composition-level invariants

These invariants (conditions that must always hold) emerge from the composition. None belongs to a single constituent atom; each requires two or more constituents working together to hold.

The composition’s three structural relations. Per spec-format.md §Structural-relation invariant templates, a declared relation carries its cardinality and its modality:

  • chain → steps — one-to-many, exactly |approver_set| per chain, mandatory on both sides — at quiescence with no open Initiate Chain recovery marker, the submit-before-append window included. Every chain has exactly one step per declared approver slot, fixed at Initiate Chain and never extended or replaced; every step submitted through this composition belongs to exactly one chain (the quantifier: steps written to the Approval Step store around this composition are not covered — this composition declares exactly one way in). One window is admitted: a step minted by submit whose append to the chain’s list was lost to a crash is chain-less until the recovery’s sibling-safe scan withdraws it (Initiate Chain step 8, case a). A step-8 case-(a) partial inside its audit_pending window has fewer steps than slots until the audited recovery withdrawal closes it — the declared bounded exception, not a violation. Invariant 1 states the referential-integrity and orphan-freedom forms under the same condition.
  • step → assignment — one-to-one, mandatory at creation. Every step gets exactly one in-tray Assignment at Initiate Chain step 6; the assignment is later recalled (its record persisting in Recalled), never replaced — the composition performs no reassign. The Initiate Chain step-8 recovery cases are the declared bounded exception (a case-(b) partial leaves steps briefly assignment-less, closed by the chain-withdraw recovery); Invariant 4 carries the conditional structure.
  • chain → audit events — one-to-many, at least one per chain, mandatory on the event side. Every chain carries at least its chain_initiated (or, for a quarantined chain, eventually its chain_initiation_failed) event, plus one event per step-level transition and per chain-level terminal; every chain-related audit event names an existing chain in its data chain_id (Invariant 5’s no-dangling-reference direction). The traversal is chain_to_events (Composition state).

The inverse directions are read through the derived indexes, classified in Composition state; a lost index entry is a rebuild trigger, never a relation violation.

  • Invariant 1 — Chain completeness. For every chain_id in chain_store, chain_to_steps[chain_id] is a non-empty ordered list of step_ids, each of which exists as a record in the Approval Step store. Every step is bound to the chain by step_to_chain[step_id] = chain_id. No chain has fewer than approver_set_minimum steps; no step is orphaned from its chain. The claim is at quiescence with no open Initiate Chain recovery marker: a step-8 partial inside its audit_pending window can hold fewer steps than slots (case a) or an empty-so-far binding, and its committed steps are kept from orphanhood by the recovery contract — the full-shape chain_initiation_failed event names them, and the audited recovery withdrawal closes the window. A shortfall with no open marker is a violation — where “open marker” means the audit_pending flag or the sweep-read discrepancy (a chain record with no chain-shape event), so a flag-less crash counts as an open window, never as a violation-in-waiting.

  • Invariant 2 — Quorum determinism, split by terminal path. The chain’s terminal state is verifiable from the records, with the verification form depending on how the chain terminated — the two paths are distinguished by the chain’s terminal audit event, and the split is load-bearing, not a refinement. (a) Quorum-fired chains (terminal event chain_resolved — including the cascade path’s data.state = Withdrawn): the stored state is the deterministic outcome of applying the chain’s quorum_rule to the state vector (A, R, W, P) of its constituent steps at the firing, as named in the Quorum evaluation rule; because the rule’s terminal states are absorbing and trailing decisions never alter the vector’s terminal reading of the rule’s firing arms, any reader recomputing the rule over the final step records reaches the stored state. (b) Initiator-withdrawn chains (terminal event chain_withdrawn): the stored state is Withdrawn by initiator action, not by the rule — Withdraw Chain step 5 sets it directly while the rule still read Pending, and the step-6 cascade then withdraws every still-Pending step, so the final vector is not the input the chain terminated on. Naive recomputation over the final vector is unsound here: a chain initiator-withdrawn while a survivable rejection stood (M-of-N with R ≥ 1 and quorum still achievable) has a final vector satisfying (N − R − W) < M ∧ R ≥ 1, which the rule maps to Rejected — a lawful history that must not read as a conformance failure. The sound verification: reconstruct the pre-withdrawal vector by counting back as Pending the chain’s cascaded step_withdrawn records (cascade = true), the retry-closed withdrawals, and the cascade_completed entries marked superseded_by_decision — a step whose cascade withdrawal was overtaken by a late decision was Pending when the initiator acted, whatever it reads now, confirm the rule yields Pending on that vector — the chain was still open when the initiator acted, quorum neither met nor unreachable, which is exactly what Withdraw Chain step 2’s Pending gate enforced — a gate that evaluates the rule before accepting, so a chain carrying a lost evaluation terminates by the rule there rather than slipping through as a withdrawal — and confirm the stored state is Withdrawn with the chain_withdrawn event present. A mismatch under the applicable path’s verification is a conformance failure. An auditor needing the path distinction reads the terminal event’s action_ref; an auditor running the determinism check needs it too — that is what the split means.

  • Invariant 3 — Permission enforcement (over a verified principal for the state-changing actions; over a reference for the read). No actor performs Initiate Chain or Withdraw Chain without a permitted result from the Permissions instance for the corresponding scope and a credential validated by the action’s intent record before anything commits (Invariant 10) — the grant and the principal are both established. Read Chain is gated by permitted(actor_ref, chains:read) over an unverified actor_ref: the read takes no credential and records nothing, so nothing in this composition establishes that the caller is the reference it presents, and the disclosure of a chain’s approver set, decisions, deciders and reasons is authorized to a reference, not a principal. This composition performs no reader authentication; a deployment that needs it binds actor_ref to an authenticated caller in its calling layer, and the Audit gaps entry says so. Existence-hiding is not offered either (a denied is itself informative). A denied result short-circuits every action before any further constituent is invoked — the permitted check is itself a constituent read, and it is the only one that runs before denial. Step-level decision actions are enforced by Approval Step’s Invariant 4 (named-approver exclusivity) and Invariant 5 (submitter-only withdraw) directly; the chain composition adds no separate permission check for step-level decisions.

  • Invariant 4 — Assignment coverage during pendency, with cascade-on-terminal (safety + liveness at quiescence). Safety: Initiate Chain creates exactly one Active Assignment per submitted step (task_ref = step_id, assignee_ref = step.approver_ref) before it returns, and every recall the composition performs is paired with the step or chain transition that discharges the responsibility — no path recalls an assignment whose step is live in a live chain. The claim at quiescence: with no invocation in flight and no recovery marker open (no audit_pending chain, no unmatched cascade_partial), every step in Pending state in a chain itself in Pending has exactly one Active Assignment, and no step has an Active Assignment when either the step or its chain is terminal. Assignments are recalled in two cases: (a) when the step transitions to a terminal state via Approve Step, Reject Step, Withdraw Step, or the Withdraw Chain cascade, in the same composition-level transition; (b) when the chain transitions terminal via the quorum evaluation rule with steps still Pending, every still-Active Assignment for the chain’s steps by cascade. Liveness: the three reachable windows the spec itself describes are bounded, surfaced transients, not violations — a partial cascade leaves trailing Active Assignments on a terminal chain until the recovery discipline’s retries close them (cascade_partial open, Read Chain surfacing the residue, each closure a cascade_completed record), an Initiate Chain step-8 partial leaves steps briefly without assignments until the chain-withdraw recovery lands, and a decision-path recall failure (Approve Step step 4’s mapped storage-failure arm) leaves one Active Assignment on a terminally-decided step until the retry closes its cascade_partial marker. Stating the invariant as a static always-both-sides claim would be falsified by exactly those paths; the conditional form is the honest one, and the recovery discipline is what makes the quiescent claim reachable from every failure the spec admits. Late decisions on trailing-Pending steps recall the (already-Recalled) Assignment idempotently: the second Assignment.recall returns not-active, which the composition treats as no-op success — the assignment was lawfully discharged at chain termination.

  • Invariant 5 — Audit completeness. Every chain-level action (Initiate Chain, Withdraw Chain) and every step-level action (Approve Step, Reject Step, Withdraw Step) produces one intent AuditTrail.record_action call and exactly one outcome call, plus exactly one record per cascade-performed step transition, and so does every step transition the composition’s own cascades perform: each cascade-withdrawn step gets its own step_withdrawn record with cascade = true, on both cascade paths (the quorum rule’s Withdrawn-by-cascade and Withdraw Chain step 6) — a step transition without a record would be a decision of consequence invisible to the trail. One retry carve-in: for a cascade call that initially failed and was closed by the recovery discipline, the closing cascade_completed entry — composition actor, carrying the step_id it closed — is that transition’s audit record; no initiator-attributed step_withdrawn is emitted for it, because the initiator’s credential belonged to the failed invocation, and demanding one would demand a record no honest implementation can produce. And one admitted window: the invariant’s exactly-one claim holds at quiescence with no open recovery marker — a committed transition whose record_action failed is the audit_pending_transition window, opened by the recovery sweep’s store-vs-chain_to_events comparison and closed by the re-emitted record (composition actor, recovery = true) plus the re-run of the skipped recall and evaluation (the sweep’s closure duty); it is a surfaced transient, not a silent violation, and the closure duty is what makes the quiescent claims of Invariants 2, 4, and 5 reachable from a crash at any point inside a decision invocation. Additionally, every chain-state transition emitted by the Quorum evaluation rule (a chain_resolved event) produces its own record_action, the recovery discipline’s records (chain_initiation_failed, cascade_completed) are themselves audit events under the composition actor, and trailing-decision step events produced after their chain has terminated are recorded with trailing = true to distinguish them from on-chain decisions. The complete chain lifecycle is reconstructible from the Audit Trail records (forward) and the chain plus step records (reverse); no chain-level event is recorded only in the chain store without a corresponding Audit Trail entry, and no chain-related Audit Trail entry refers to a chain or step that does not exist. The invariant inherits Audit Trail’s atomicity surface. record_action is treated as atomic from this composition’s perspective, but Audit Trail’s Partial attestation on step failure edge case (Actor Identity.attest succeeds but EventLog.append fails) can produce an orphan attestation without a corresponding event-log entry — a violation of “one Event Log entry, one attestation, one retention record per call” propagated from the substrate. The composition does not re-derive the substrate’s recovery; it inherits Audit Trail’s orphan-resolution discipline (flagging, compensating record, manual investigation) and treats any unresolved orphan as a gap in the chain’s audit surface. The composition’s role is to surface the gap (the implementation must alert on orphan attestations referencing this composition’s action_ref values) rather than to police it; Invariant 5 holds modulo Audit Trail’s own atomicity contract.

  • Invariant 6 — Constituent invariants preserved. All invariants of every constituent atom hold over its respective instance. Approval Step’s invariants (submission immutability, membership exclusivity, terminal absorption, approver exclusivity, submitter exclusivity, decision attribution completeness, temporal ordering, submission attribution completeness, concurrent step independence, step store durability) hold per step. Permissions’ invariants hold over the chain-store-scoped Permissions instance. Assignment’s invariants hold over the composition’s Assignment instance. Audit Trail’s composition-level invariants hold over its instance.

  • Invariant 7 — Chain terminal absorption. Once a chain is in Approved, Rejected, or Withdrawn, the chain’s state does not transition further regardless of subsequent step-level events. A Pending step that is later decided after its chain has terminated produces a step-level record and an Audit Trail entry, but does not alter chain_store[chain_id].state or chain_terminal_at. This is the chain-level counterpart to Approval Step’s Invariant 3 (terminal absorption on steps).

  • Invariant 8 — Chain immutability of declared fields. For every chain in chain_store, the fields chain_id, subject_ref, scope, initiator_ref, approver_set, quorum_rule, and initiated_at are immutable from the moment Initiate Chain returns. Only state, chain_terminal_at, and audit_pending may transition, each at most once in each direction it has: state from Pending to one terminal value; chain_terminal_at from unset to one timestamp; audit_pending set true at most once by the recovery path — Initiate Chain step 8’s in-invocation arm, or the sweep’s initiation leg when the invocation died before setting it, both being the same path — and cleared to absent when the chain’s audit record lands — the same three fields Composition state names mutable, so the two sections agree by construction. A chain cannot be re-targeted to a different subject, scope, approver set, or quorum rule; corrections require chain-level withdrawal and a new Initiate Chain call.

  • Invariant 9 — Chain reconstructibility, within the audit horizon (an investigator can reconstruct the full chain history from records alone — the chain-layer counterpart of the substrate’s own forensic completability invariant, named distinctly so the two are never conflated in a cross-reference). For any chain_id, the composition’s query surface returns: the chain record (subject, scope, initiator, approver set, quorum rule, state, timestamps); the ordered list of step records with each step’s decision, decider, decision timestamp, and reason; the Assignment records for each step (Active or Recalled); and the chain’s audit event_ids (chain_to_events), through which the substrate returns the verified attestation for every chain-level and step-level action. An external auditor can reconstruct the full chain lifecycle from the records alone without recourse to source code, runbooks, or developer narration — for events within the audit instance’s horizon; past it a chain is verifiable only to the depth its surviving attestations and its truth-bearing chain and step records carry (Retention-horizon asymmetry), and checks 1, 2 and 5 run over that horizon.

  • Invariant 10 — Authentication precedes commitment at the five state-changing actions. At Initiate Chain, Approve Step, Reject Step, Withdraw Step and Withdraw Chain — every action that accepts a credential and acts on a named actor’s authority — no constituent call that commits, and no chain_store write, is reached on any path before that actor’s credential has validated against the actor registry’s public material for the supplied actor_ref. Each opens with an intent record whose AuditTrail.record_action performs that validation: chain_initiation_intended before the chain record and its N submissions and N assignments; step_decision_intended before the Approval Step decision; chain_withdrawal_intended before the terminal write and its cascade. invalid-credential is a pre-state refusal with nothing committed and the action retryable in whole; each outcome record carries intent_event_id back for an exact per-invocation join. Read Chain is outside the invariant — it takes no credential, records nothing, and changes nothing.

    Three paths are outside the quantifier, and the carve-out is narrower than it first looks. Withdraw Chain step 2’s lost-evaluation cascade may fire a full evaluation-and-cascade before the intent record, the recovery sweep’s transitions run under no caller at all, and an approver’s own decision can complete a lost withdrawal-driven evaluation at its step 6 — under a credential that is validated but is not the initiator’s, whose reference the cascade’s withdrawals must carry. What is true of all three is precise: they authenticate no principal, and they commit no decision the recorded step states did not already determine — the quorum rule fires against a step vector the recorded decisions already satisfied, and it would fire identically for any caller or for the sweep. What is not true is that they commit nothing on a named actor’s authority, and the difference must be stated rather than glossed.

    The residue: Approval Step’s record names an actor who did not act. All three paths call ApprovalStep.withdraw(step_id, withdrawn_by = initiator_ref, reason), and Approval Step’s Invariant 5 admits that call only when the reference matches the step’s submitter_ref — so the withdrawal is made on the initiator’s authority, and the atom’s immutable record permanently states that the initiator withdrew the step, at a moment when the initiator has neither authenticated nor been established as the caller (and, on the sweep’s path, is not present at all). The same holds for Initiate Chain’s recovery withdrawals. Approval Step exposes no composition-actor withdrawal surface, so there is no honest alternative available at the constituent. The disambiguator is records-alone and an auditor must use it: the paired step_withdrawn audit record is composition-attributed and carries recovery = true, which is what distinguishes a cascade the initiator did not perform from one they did. A reader of the Approval Step record alone will draw the wrong conclusion; the two records must be read together. This is carried in Audit gaps as well.

    Read Chain is excluded because it commits nothing — and that exclusion says nothing about its authorization gate. Permissions.permitted(actor_ref, chains:read) consults an actor reference that carries no credential, exactly as the state-changing actions’ gates did before this discipline; the read therefore discloses a chain’s full approver set, decisions, deciders and reasons to whatever authentication the calling layer performs, and this composition performs none. That is a real gap, and Invariant 3 now states it as the read’s actual guarantee — a grant checked over a reference — rather than leaving it to be inferred from an exclusion clause about commitment. A credential on the read was considered: with no audit event the substrate’s verification surface is not reached, and adding a read meta-event to reach it is the posture Propagate Consent Revocation Downstream takes for regulated consent records; this composition does not take it, because its read is the auditor’s own high-volume surface and every read would then write. The gap is the calling layer’s to close, and it is named, not assumed away.

    The binding half, which is where this composition’s load-bearing claim rested. Invariant 1 and the quorum rule turn on each step having been decided by its named approver, and the three step-decision actions carry no Permissions check by design — authorization is Approval Step’s Invariant 4, which compares the supplied actor_ref against the step’s recorded approver_ref. Both sides of that comparison were unverified strings, so a caller who merely knew an approver’s reference could commit that approver’s decision and move the chain’s quorum. Withdraw Chain’s initiator check (step 3) had the same shape against initiator_ref. The intent record does not change either comparison; it establishes that the caller is the actor being compared, before anything commits, which is what makes those claims statements about principals rather than about values — for decisions routed through this composition. The scope matters: Approval Step takes no credential and its exclusivity check is a byte comparison, so a transition written directly to that store, around this composition, carries no such guarantee, and the intent-event join is precisely what distinguishes the two. A committed transition with no matching step_decision_intended event is an out-of-band transition, and the recovery sweep’s treatment of it is carried as an open finding.

    What a validation does not establish. It establishes that material matching the actor’s registered verifier was presented at that instant. It does not establish that the presenter is that actor (a stolen credential validates), that the presentation is bound to a channel or session, or that it cannot be replayed — so the disputed-approval boundary is unchanged in kind and moved only in time. It establishes nothing about whether the named approver was the right approver, which remains an audit gap the chain store cannot close. Rests on: Audit Trail’s record_action and the Actor Identity attestation reached through it. Defended in-line: the ordering is visible in all five actions’ step lists, and Generation acceptance check 7 tests it from the records alone.

Quorum determinism and chain completeness together give the bypass-resistance property — a chain cannot reach Approved without the quorum-named decisions actually present in the Approval Step store, and a reader can verify this from the records. Permission enforcement and audit completeness together give the non-repudiability property — every chain-level action is attribution-stamped, retention-bounded, and tamper-evident through the Audit Trail substrate. Chain terminal absorption and chain immutability together give the finality property — once a chain terminates, its outcome is fixed; later corrections require new chains, not retroactive edits to old ones.


Examples

Walkthrough — SOX-controlled journal entry, all-of-N quorum

A multinational bank’s general-ledger system uses Multi-Party Approval to gate posting of journal entries above the $5M materiality threshold. The deployment configures approver_set_minimum = 2, approver_set_uniqueness = true, quorum_rule_allowed = {all-of-N}, audit_trail_retention_policy = sox_7_year.

  1. A controller prepares a journal entry. Journal entry JE-2026-0441 posts a $12M intercompany transfer. Under the deployment’s business rules, materiality of this size requires the regional controller, the CFO, and the CEO (Chief Executive Officer). The controller calls initiate_chain(actor_ref=controller_morgan, credential=morgan_credential, subject_ref="je-2026-0441", scope="financial:journal-entry:post:materiality-tier-3", approver_set=[finance_director_chen, cfo_park, ceo_walsh], quorum_rule="all-of-N", reason="$12M intercompany transfer per Q1 close").
  2. The composition validates and writes. Validation passes (three pairwise-distinct approvers, all-of-N is allowed, the constructed payload in budget) and Permissions returns permitted (controller_morgan holds chains:initiate). Then — before anything is created — the composition records chain_initiation_intendedev_init_int, which is where morgan_credential is checked against the actor registry; had it not validated, the call would have returned rejected(invalid-credential) with no chain, no Approval Steps and no Assignments, and no teardown cascade to run. The payload names the declared chain shape and no chain_id, since none is minted yet. Only then does the composition allocate chain-2026-0441, write the chain record in Pending, submits three Approval Steps (step-001, step-002, step-003), creates three Assignments (one per approver’s in-tray), and records the chain_initiated event carrying intent_event_id: ev_init_int. Returns chain_id = chain-2026-0441.
  3. The CFO approves first. approve_step(actor_ref=cfo_park, credential=park_credential, chain_id=chain-2026-0441, step_id=step-002, reason="Reviewed Q1 close package; transfer is in-policy") → the composition records step_decision_intendedev_dec_int_02 first, which is where park_credential is validated — this action has no Permissions check, so that record is the only thing standing between an asserted actor_ref and a committed approval in the CFO’s name; then Step 002 transitions to Approved; the Assignment is recalled; step_approved records carrying intent_event_id: ev_dec_int_02. Quorum evaluation: A=1, R=0, W=0, P=2; all-of-N requires A == N(=3); not satisfied; no quorum failure (no rejections or withdrawals); chain stays Pending.
  4. The controller’s regional finance director approves second. approve_step(actor_ref=finance_director_chen, credential=chen_credential, chain_id=chain-2026-0441, step_id=step-001, reason="Mapping verified; consolidation rules applied correctly") → its own step_decision_intended record validates chen_credential, then the approval commits and step_approved carries the join. A=2, R=0, W=0, P=1; still not at quorum.
  5. The CEO approves third. approve_step(actor_ref=ceo_walsh, credential=walsh_credential, chain_id=chain-2026-0441, step_id=step-003, reason="Reviewed and authorized")approved. A=3, R=0, W=0, P=0; A == N; chain transitions to Approved; chain_terminal_at is set; the audit trail records the chain_resolved event. The composing workflow system releases JE-2026-0441 for posting.
  6. Three years later, a SOX §404 audit. The auditor queries read_chain({subject_ref: "je-2026-0441"}). The result is one chain in Approved with three step records, nine attestations, nine retention records under the seven-year policy — one initiation intent, one chain_initiated, three decision intents, three step_approved, and one chain_resolved, each record_action producing one attestation and one retention record, and a Tamper Evidence seal covering the relevant range. verify_record on each Audit Trail event returns verified. The auditor confirms (a) Invariant 4 was enforced on each step (decided_by matched approver_ref), (b) Invariant 2 holds (chain state matches the deterministic quorum evaluation), and (c) no chain-state edit occurred after chain_terminal_at. Control evidence is complete from the records alone.

Every invocation in the runs below follows the same order — validation, any Permissions check, the intent record that verifies the caller, the constituent writes, the outcome record carrying intent_event_id — and the narrations elide it except where the example turns on it. An implementation that elided it would fail Generation acceptance check 7. Withdraw Chain is the one exception: its evaluate-first gate and its initiator check both run before its intent record, for the reasons Invariant 10 gives.

Happy path — FDA Part 11 batch release, M-of-N(2) quorum across three qualified persons

A pharmaceutical manufacturer’s batch release system requires any two of three Qualified Persons (QPs — the designated batch-release authorities under EU (European Union) and FDA manufacturing rules) to approve a batch release under 21 CFR Part 211. The deployment uses quorum_rule = M-of-N(2). A batch BR-2026-0412 is ready for release. The QA manager initiates: initiate_chain(actor_ref=qa_manager, credential=qa_manager_credential, subject_ref="br-2026-0412", scope="pharma:batch-release:bulk", approver_set=[qp_santos, qp_lopez, qp_kim], quorum_rule="M-of-N(2)")chain-2026-0412. QP Santos approves first (A=1); QP Lopez approves second (A=2); A ≥ M; the chain transitions to Approved without needing QP Kim’s decision. QP Kim’s step remains Pending unless Kim later decides — the chain has terminated, but the step’s submission record is immutable (Approval Step’s Invariant 1) and the trailing decision, if Kim later approves, is recorded in the audit trail without altering the chain state (Invariant 7). The released batch carries the chain id as its control evidence; an FDA inspector queries the chain and confirms the two named QPs decided affirmatively under their respective Actor Identity attestations.

Happy path — ICH E6 GCP protocol deviation, one-of-N quorum across a delegated approver pool

A clinical trial protocol deviation at a multi-site study can be approved by any one of the site principal investigators (PIs) on call. The trial coordinator initiates: initiate_chain(actor_ref=coordinator_lee, credential=lee_credential, subject_ref="dev-2026-1057", scope="clinical-trial:protocol-deviation:non-substantive", approver_set=[pi_chen, pi_okafor, pi_müller, pi_singh], quorum_rule="one-of-N"). PI Okafor approves: A=1, M=1; chain Approved. The other three steps remain Pending in the audit trail. If a fifth approval is needed later (e.g., for a substantive deviation that requires escalation), a new chain is initiated with the appropriate quorum and approver set; the original chain is not modified.

Rejection path — all-of-N quorum, one approver rejects

In the SOX walkthrough above, suppose the CEO finds the entry suspicious and rejects: reject_step(actor_ref=ceo_walsh, credential=walsh_credential, chain_id=chain-2026-0441, step_id=step-003, reason="Counterparty not on approved-affiliates list; refer back to finance team for review")rejected_outcome. Quorum evaluation: R = 1 ≥ 1; under all-of-N the chain transitions to Rejected with reason "quorum unreachable: all-of-N requires every approval; step step-003 was rejected". chain_terminal_at is set; the audit trail records the chain resolution. JE-2026-0441 is not released for posting; the composing workflow routes the entry back to the controller, who must initiate a new chain for the corrected entry (a fresh chain_id, fresh step ids — no editing of the rejected chain’s records).

Rejection path — chain withdrawal by initiator

The controller submitting JE-2026-0441 discovers a clerical error in the entry before any approver has decided: the chain was opened against the wrong subject. The controller calls withdraw_chain(actor_ref=controller_morgan, credential=morgan_credential, chain_id=chain-2026-0441, reason="Wrong journal entry id; superseded by new chain on JE-2026-0441-revised"). The chain transitions to Withdrawn; each of the three still-Pending steps cascades to Approval Step’s Withdrawn state; each Assignment is recalled; the audit trail records the chain withdrawal and the three step withdrawals. A new chain is initiated on the corrected entry.

Selected outcome runs

Three one-paragraph runs over branches the walkthroughs above do not reach — chosen because each exercises a surface the composition’s defenses turn on: the withdrawal-driven cascade, the trailing-decision discipline, and the partial-cascade recovery.

Chain-Withdrawn-by-cascade — a step withdrawal makes quorum unreachable with no rejection. A three-approver M-of-N(2) engineering change chain has one approval in (A=1) when the initiator discovers the second gate names the wrong reviewer and calls withdraw_step(actor_ref=initiator_ross, credential=ross_credential, chain_id=chain-ec-118, step_id=step-b, reason="Wrong reviewer named; superseding chain to follow"), then realizes the third gate is mis-scoped too and withdraws it the same way. On the second withdrawal the rule reads A=1, R=0, W=2: (N−R−W)=1 < M=2 ∧ R=0 ∧ W≥1 — quorum unreachable purely by withdrawal — so the chain transitions to Withdrawn, not Rejected: no approver made a negative decision, and the terminal state says so. The state is set first; there are no still-Pending steps left to cascade-withdraw (recalled_step_ids = [] on the chain_resolved event, whose data.state = Withdrawn marks this as the cascade path, distinct from a chain_withdrawn event’s initiator path); the one approval already recorded stays immutable.

A trailing decision — the third Qualified Person approves after the chain closed. Continuing the batch-release example: two days after chain-2026-0412 reached Approved on the second approval, QP Kim reviews the batch anyway and calls approve_step(actor_ref=qp_kim, credential=kim_credential, chain_id=chain-2026-0412, step_id=step-kim, reason="Independent review complete; concur"). Step 1 finds the chain terminal — the call is not refused (the step’s own Pending state is the gate) — and computes trailing = true. The atom records the approval, the already-Recalled assignment’s recall returns not-active (idempotent success), and the audit event lands as step_approved with trailing = true, cascade = false. Step 6 is a no-op: no re-evaluation, no second chain_resolved, chain_store untouched. An auditor later walking the trail sees a step_approved after the chain’s chain_resolved and reads the flag, not a contradiction.

A partial cascade and its recovery, closed from the records. An all-of-N chain of four is rejected by its second approver (R=1 → chain Rejected). The cascade must recall three trailing assignments; the Assignment store refuses the third with storage-failure. The composition proceeds (no abort), sets nothing back, and emits chain_resolved with data.state = Rejected, recalled_step_ids = [step-1, step-3], and cascade_partial = true; the caller still receives their rejected_outcome — the load-bearing transition succeeded. Read Chain now shows a terminal chain with one still-Active assignment: the surfaced residue. At the next restart the recovery discipline retries, the recall lands, and a cascade_completed event (composition actor) referencing the chain closes the marker. The auditor’s closure procedure — match the failure to its cascade_completed — is Generation acceptance’s fifth audit-gap entry, run here on a one-element list.

Regulated adversarial scenarios

Three adversarial reads the composition must survive in regulated contexts:

  • Regulator audit — SOX §404 control evidence query, “show me every chain that approved a material journal entry in Q1 with full attribution.” The auditor queries read_chain({scope: "financial:journal-entry:post:materiality-tier-3", state: Approved, initiated_at: {after: "2026-01-01T00:00:00Z", before: "2026-03-31T23:59:59Z"}}). Every chain in the result set carries its approver set, its quorum rule, its constituent step records (with decided_by matched to approver_ref by Approval Step’s Invariant 4), and its Audit Trail attestations under the seven-year retention. The auditor independently computes the expected chain state from the step records and the quorum rule and confirms Invariant 2 holds: no chain reached Approved without the quorum-named decisions actually present. The auditor also queries read_chain({scope: "...:tier-3", state: Pending, initiated_at: {after: ..., before: ...}}) to verify no chain was left unresolved — a non-empty result would identify a stalled material chain, a control gap the auditor would surface. The covered entity has documentable, auditable control evidence with no recourse to developer testimony.

  • Disputed approval — FDA Part 11 electronic signature challenge against a chain participant. An FDA investigator reviewing batch BR-2026-0412 challenges the authenticity of QP Lopez’s approval: the actor claims they did not approve the batch. The investigator queries read_chain({subject_ref: "br-2026-0412"}) and retrieves the chain plus its step records. Step step-lopez-0412 shows decided_by: "qp_lopez", decided_at: "2026-04-15T16:04:00Z", decision_reason: "Specification limits met; COA (Certificate of Analysis) reviewed". Approval Step’s Invariant 4 guarantees that the value qp_lopez was presented and matched approver_ref at decision time — that atom takes no credential, and its identity model is byte equality on the reference as supplied, so the record alone does not establish who presented it. What does is the step_decision_intended record that preceded the decision and the Actor Identity attestation on it: the principal Lopez presented material matching the registry’s public material for that reference before the step could be decided (Invariant 10). The bound is the honest one — a stolen credential validates — so the records establish that Lopez’s credential was used, not that Lopez was at the keyboard. The Audit Trail’s Actor Identity attestation for the corresponding step_approved event is then verified against qp_lopez’s registered public material at the attestation’s own attested_at — the substrate’s stamp, not Approval Step’s decided_at, which is another seam’s reading. The denied-approval claim cannot be sustained against the structural record without claiming credential compromise; that reinterpretation is the Compromise Disclosure (forthcoming) composing pattern’s responsibility (Configuration glosses its status), not the chain composition’s.

  • Breach or incident forensics — unauthorized chain initiation investigation. During a security incident review, the incident response team needs to determine whether any chains were initiated by actors who should not have held chains:initiate during a window of suspected privilege escalation (2026-05-01T00:00:00Z through 2026-05-03T23:59:59Z). The team queries read_chain({initiated_at: {after: ..., before: ...}}) and, for each chain in the window, walks the Audit Trail back to the chain_initiated event and verifies its Actor Identity attestation. The team also queries the Permissions store for grants of chains:initiate active during the same window; any initiator whose grant was not active at initiated_at is a finding — judged under the deployment’s operating skew: the grant stamps and initiated_at are two seams’ readings, so the audit condemns only violations wider than that skew and reads boundary-width discrepancies as inconclusive (Invariant 3’s check-time enforcement is the gate; this audit reads its evidence trail). The chain composition’s records faithfully document every initiation and outcome; the cross-store verification — initiator attestation vs. Permissions grant state at the initiation timestamp — is the audit operation that surfaces unauthorized chains.


Generation acceptance

A derived implementation of Multi-Party Approval is acceptable — in the regulator-acceptance sense — when an external auditor, given the composition’s emergent state plus the constituent stores, can do all of the following without recourse to source code, runbooks, or developer narration:

  1. Reconstruct any chain’s full lifecycle (within the audit horizon). From chain_id: the chain record (subject, scope, initiator, approver set, quorum rule, state, timestamps); the ordered list of step records with each step’s decision, decider, decision timestamp, and reason; the Assignment records for each step; and — walking chain_to_events, the declared chain→audit traversal — the substrate’s verification outcome for every chain-level and step-level event, obtained through its two-call asymmetry (read_record(event_id) for the covering seal’s range, then the two-argument verify_record(event_id, original_event_payload) with that range re-presented). Invariants 1 and 9 are the structural guarantee; this check verifies the reconstruction is complete and consistent.

  2. Verify quorum determinism over every terminal chain — by terminal path, within the audit horizon. For every chain whose terminal event is chain_resolved (the quorum-fired path, its cascade-Withdrawn case included): independently compute the expected state from the constituent step state vector (A, R, W, P) and the chain’s quorum_rule per the Quorum evaluation rule, and confirm it matches chain_store[chain_id].state. For every chain whose terminal event is chain_withdrawn (the initiator path): reconstruct the pre-withdrawal vector by counting back as Pending the chain’s cascaded step_withdrawn records (cascade = true), retry closures, and superseded_by_decision closures alike, confirm the rule yields Pending on that vector, and confirm the stored state is Withdrawn. Invariant 2 is the contract; this check verifies it holds across the chain store under the terminal-path split the invariant declares — running the recomputation form against an initiator-withdrawn chain is a check error, not a finding.

  3. Verify chain completeness and immutability — at quiescence. The check runs with no open Initiate Chain recovery marker (no audit_pending chain). For every chain: chain_to_steps[chain_id] is non-empty; |chain_to_steps[chain_id]| == |chain.approver_set| ≥ approver_set_minimum; each step_id exists in the Approval Step store and has step_to_chain[step_id] = chain_id; the chain’s declared fields (subject, scope, initiator, approver set, quorum rule, initiated_at) are unchanged across snapshots. A recovery-closed chain — terminal event chain_withdrawn following a chain_initiation_failed whose disposition names its case — verifies against the possibly-partial step_ids list that event carries, not against |approver_set|: the partial shape is what the recovery honestly recorded. Invariants 1 and 8 are the contract.

  4. Verify assignment coverage during pendency and recall on transition — at quiescence. The check runs with no invocation in flight and no recovery marker open (no audit_pending chain, no cascade_partial without its matching cascade_completed series — the fifth audit-gap entry is how the marker state is read): for every step in Pending state in a Pending chain, exactly one Active Assignment exists with task_ref = step_id and assignee_ref = step.approver_ref; for every step whose step or chain is terminal, any Assignment for that step is in Recalled (not Active). A trailing Active assignment found with an open cascade_partial is the surfaced recovery residue Invariant 4’s liveness arm admits, not a finding; the same assignment with the markers closed is a finding. Invariant 4 is the contract; this check verifies the in-tray binding tracks the step lifecycle under the invariant’s own conditional structure.

  5. Verify audit completeness — at quiescence, within the audit horizon. The check runs with no open recovery marker; its store-transitions-vs-chain_to_events comparison is itself how the audit_pending_transition marker state is read, so an unmatched transition found here is either an open marker (a surfaced transient awaiting the sweep’s re-emission) or, with the markers closed, a finding. For every chain-level and step-level state transition recorded in the chain store and the Approval Step store — the composition’s cascade-driven step withdrawals included, identifiable by their cascade = true flag — exactly one corresponding record_action event exists in the Audit Trail, where Invariant 5’s retry carve-in counts a cascade_completed entry as the record of the transition it closed, and a sweep re-emission (recovery = true) as the record of the transition it re-audited. The reverse direction also holds: every chain-related action in the Audit Trail corresponds to a chain or step record, and every event’s data carries the chain_id the payload requirement obliges — except chain_initiation_intended, which by construction cannot and is joined by the initiation tuple instead (Composition state) — which is what makes the enumeration mechanical. The auditor also confirms the attribution rule: chain_resolved, cascade_completed, chain_initiation_failed, and every recovery = true emission carry the composition actor’s reference, and nothing else does. No chain or step transition is invisible to the Audit Trail; no Audit Trail entry refers to a chain or step that does not exist. Invariant 5 is the contract.

  6. Verify chain terminal absorption. Identify chains whose chain_terminal_at is set and whose constituent steps include at least one still-Pending step (the trailing-decision case). Confirm that chain_store[chain_id].state and chain_terminal_at are unchanged in subsequent snapshots, regardless of any trailing step decisions. Invariant 7 is the contract.

  7. Verify authentication precedence — at quiescence. For every chain_initiated, step_approved, step_rejected, step_withdrawn with cascade: false, and chain_withdrawn event, take its data.intent_event_id, resolve it to the matching intent event (chain_initiation_intended / step_decision_intended / chain_withdrawal_intended), and confirm that event precedes it in the Audit Trail’s own sequence and names the same actor_ref. Because the substrate validates the caller’s credential against the actor registry inside every record_action, the intent event is the records-alone proof that the actor was authenticated before the chain was created, the decision committed, or the chain withdrawn — which makes Invariant 10 verifiable rather than asserted, and anchors Invariant 3’s permission claims and Invariant 1’s named-approver claim to a verified principal. Read the order from the sequence, never from the stamps: an invocation’s intended_at and its outcome stamp are one injected reading and are equal by construction (Logic confinement). Join by intent_event_id, never by {actor_ref, chain_id, step_id} — and never by {actor_ref, subject_ref, scope} at Initiate Chain, since one initiator opening two chains over the same subject and scope is ordinary use.

Four event classes are outside this check, each for a stated reason. Cascade-emitted step_withdrawn records (cascade: true) carry no intent_event_id: they are the composition’s own side effects of a chain terminal, authorized by the chain_withdrawal_intended event or by the quorum rule’s firing rather than by a caller, and check 5 already binds each of them to its chain transition. chain_resolved is emitted under the composition actor by the quorum rule, which no caller authorizes. The Initiate Chain recovery contract’s terminal chain_withdrawn record is excluded by name: no withdrawal was ever intended — the invocation was an initiation — so it is composition-attributed, carries recovery = true and no intent_event_id, and carries its chain_initiation_failed event id as its provenance link instead; an auditor who does not exclude it will report a false conformance failure on every recovery-closed chain. And every other record carrying recovery = true was re-emitted outside its original invocation under the composition actor.

Re-emitted records cannot always supply a single intent_event_id, and the check must not pretend otherwise. The sweep detects a lost outcome by comparing constituent transitions against chain_to_events; nothing in that comparison identifies which invocation’s intent event belonged to the lost call, and a step can be named by more than one invocation. A re-emitted record therefore carries intent_event_candidates — every unmatched intent for that step from that actor — in place of the field (intent_event_id where the set is a singleton), and the acting human rides in data as acting_actor_ref — the same reasoning chain_initiation_failed already applies when it carries initiator_ref because the initiator cannot ride a composition-attributed actor_ref. For a re-emitted record, verify that every candidate it names is an intent event naming that acting_actor_ref for that chain_id and (for decisions) that step_id, that each precedes the re-emitted record, and that a chain.recovery_intended record naming the same candidates precedes it; that is a weaker claim than the exact join where the set is not a singleton, and the check must report it as such.

An intent event with no outcome event is not a failure, and the expected population is large: every pre-state refusal arm leaves one, and those arms are the ordinary ones (a bad credential, a transient substrate fault, a validation disagreement). Triage by action, and note that the two kinds differ in what they can resolve. A step_decision_intended or chain_withdrawal_intended event carries the chain_id (and, for decisions, the step_id), so the triage reads that step’s or chain’s state — joining by intent_event_id, never by step_id, since a chain’s step can be named by more than one invocation and only one of them can have decided it. A committed transition with no outcome record is the audit_pending_transition window the recovery discipline already owns, closed by the sweep’s re-emission; an undecided step or a still-Pending chain means the constituent refused and nothing is owed. A chain_initiation_intended event carries no chain_id, because none was minted when it was written — but it is not unjoinable. The tuple (actor_ref, subject_ref, scope, approver_set, quorum_rule, intended_at) matches the chain record’s (initiator_ref, subject_ref, scope, approver_set, quorum_rule, initiated_at) exactly, because those two stamps are the same injected reading by construction (Logic confinement); that is the same join the chain_to_events rebuild uses for this event type. The join has one failure mode and it is the interesting one: no chain record was ever written, which means the invocation was refused between the intent record and the chain-store write, or crashed there. In that case, and only that case, fall back to the open-initiation markers the discrepancy-is-the-marker rule maintains. Do not join on {actor_ref, subject_ref, scope} alone — one initiator opening two chains over one subject and scope is ordinary use. Never write a compensating outcome record for an intent whose effect you cannot find: on this pattern that would mean fabricating a decision or a chain terminal that never happened.

Audit gaps: what cannot be cleared from the chain store alone

The seven checks above cover the composition-level invariants the composition enforces internally, with two stated exceptions carried as open findings: Invariant 3 (permission enforcement) and Invariant 6 (constituent invariants preserved) have no dedicated check. Five audit questions arise around this composition that cannot be answered from the chain store and Approval Step store alone — they require either the Audit Trail substrate (which is part of the composition’s records — clearable, but requires cross-store traversal) or external evidence (truly out-of-scope). The distinction matters: a Round 3 reviewer asked whether “what cannot be cleared from these records alone” was honest given that the composition’s records include Audit Trail. The honest answer: the headings below split the two cases. They are named here so the audit boundary is explicit.

  • Whether a reader of a chain was who its actor_ref says. Read Chain checks chains:read over an unverified reference (Invariant 3): the composition can show that a permitted reference read a chain, never that the principal behind it did. Reader authentication is the calling layer’s, and its evidence — a session, a channel binding, a gateway log — is outside these records.

  • Whether the deployment’s approver-set policy was correctly applied. The composition records the approver_set declared at Initiate Chain and enforces structural validity (size, uniqueness). It does not — and cannot — verify that the declared approver set was the correct set for the subject under the deployment’s regulatory policy. “Was this $12M journal entry required to be approved by exactly these three actors?” is a calling-system policy question; the chain records the answer the calling system declared. Verification of the policy mapping (transaction type → required approver set) is parallel to Approval Step’s named out-of-scope on which approvals are required for a given subject and Selective Disclosure’s Invariant 5 (calling-system integration obligation). Auditors verify this by reading the calling system’s policy declaration alongside the chain records.

  • Whether each named approver held the standing authorization to approve the scope at initiation time. The composition does not check that approver_ref is permitted to approve scope at initiated_at. The named approver could be policy-unauthorized (an intern named for a controller-only gate) and the chain would still be structurally accepted here. Auditors verify approver appropriateness by cross-referencing each step’s approver_ref and scope against the deployment’s standing-authorization registry — typically a separate Permissions instance scoped to scope-approval grants — at the chain’s initiated_at (a cross-seam comparison, judged under the deployment’s operating skew). Named in Edge cases as a calling-system obligation; the chain composition’s records are necessary but not sufficient for this audit question.

  • Whether the initiator’s chains:initiate grant was appropriate at initiation time. The composition enforces that the grant existed and was Active at the Permissions.permitted check (Invariant 3). It does not verify that the grant was correctly issued in the first place — that the actor who issued the grant held the meta-authority to do so, that the grant scope matched the deployment’s policy for the subject type, or that segregation-of-duties policies were honored at grant time. Grant appropriateness is handled at the composing-Permissions / governance layer; this composition’s audit surface is grant existence and use.

  • Whether the deployment under partial-cascade recovery has fully reconciled. When a cascade-recall partially fails (see Cascade-recall of trailing assignments on chain termination), the chain-level terminal event (chain_resolved, or chain_withdrawn on the initiator path) carries cascade_partial = true and the still-Active trailing Assignments are surfaced via Read Chain. The composition records that the cascade was partial; verification that the implementation has subsequently completed the recovery requires reading the follow-up cascade_completed Audit Trail entries (one per retried cascade call) and confirming every initially-failed call is matched by a later cascade_completed referencing the original chain-level terminal event. An auditor reads both the original terminal event and the cascade_completed series from Audit Trail to clear the recovery; the chain store alone does not surface recovery progress beyond the presence or absence of still-Active trailing Assignments. This is the cleanest case of a non-substrate audit question: the records contain the answer; the auditor must traverse both chain_store and Audit Trail to assemble it. Named here so the audit boundary is explicit rather than implicit.


Non-goals and edge cases

What this composition does not cover:

  • Quorum rules beyond the three named. all-of-N, M-of-N, and one-of-N cover the canonical multi-party-approval patterns. Weighted-voting quorums (where approvers carry different vote weights), conditional quorums (M-of-N where the M must include specific actors), and sequenced quorums (where approver order is enforced — A must approve before B) are not supported in the canonical composition. Each is a richer quorum semantics that belongs to a higher-order composition; the canonical chain treats approvers as equal voters and decisions as order-independent in outcome.

  • Sequenced (ordered) approval chains. Some regulated processes require approvals in a specific order (e.g., the safety officer must approve before the engineering lead). The canonical composition does not enforce ordering — all steps are submitted at Initiate Chain and become Pending in parallel. A Sequenced Approval Chain composing pattern (forthcoming — named here and in Composition state, not yet on the roadmap) would extend this composition with an explicit ordering constraint and a step-not-yet-eligible rejection on out-of-order approvals.

  • Delegation. As named in Approval Step’s Edge cases, delegation — binding a different actor to step in for the named approver — is not a property of the per-gate atom. It is also not a property of this composition. A deployment requiring delegation composes a separate Delegation pattern (forthcoming) that intercepts Approve Step / Reject Step and re-authorizes the call when a delegation record permits the delegate to act on behalf of the named approver. The chain composition does not absorb the delegation policy.

  • Segregation of duties. As named in Approval Step’s Edge cases, segregation of duties (SoD — the discipline that the submitter cannot also be an approver; an approver cannot be on the second gate in a sequenced chain) is enforced at the policy layer. The chain composition does not enforce it. The composing Permissions pattern is the layer that rejects Initiate Chain calls whose (initiator_ref, approver_set) pairing violates a declared SoD policy. The composition’s approver_set_uniqueness config addresses only the pairwise-distinct case within the chain; broader SoD policies are external.

  • Step-level rejection causing chain-level Rejected vs. Withdrawn. Under all-of-N, a single step rejection makes quorum unreachable and the chain transitions to Rejected (not Withdrawn). Withdrawal is reserved for the initiator’s deliberate retraction of the whole chain. The two terminal states carry different audit semantics: Rejected names a quorum failure attributable to specific approver decisions; Withdrawn names a chain retracted before reaching quorum determination. Conflating them would lose audit information.

  • Re-submission of a step under the same chain. Once a step is in a terminal state (any of Approved, Rejected, Withdrawn), it cannot be re-opened or re-submitted under the same chain. The chain’s chain_to_steps list is fixed at Initiate Chain. A redo requires a new chain. This mirrors Approval Step’s Invariant 3 (terminal absorption) at the chain layer.

  • Late decisions on a terminated chain. A step’s named approver may approve or reject after the chain has already reached a terminal state via the quorum rule (e.g., under M-of-N(2) of 3, the third approver decides after the first two already satisfied the quorum). The atom-level decision is recorded (Approval Step’s Invariant 4 still gates who may decide), the Assignment is recalled, and the Audit Trail captures the event. The chain’s state does not change (Invariant 7). The trailing decision is preserved as audit evidence but is not part of the chain’s terminal disposition.

  • Concurrent step decisions on the same chain. Two approvers concurrently calling Approve Step for distinct steps of the same chain are processed independently by their respective Approval Step records. Each step’s transition is atomic at the atom level; chain-state re-evaluation after each step transition may race. Implementations must serialize the chain-state re-evaluation step (step 6 of Approve Step / Reject Step / Withdraw Step) on a given chain_id to ensure the re-evaluation reads a consistent step state vector. Concurrent Withdraw Chain and step-level decisions on the same chain must also be serialized — the implementation is responsible for the chain-scope mutex; the spec assumes it.

  • Audit Trail composition with Legal Hold. When a chain’s subject is under a Legal Hold, the Audit Trail’s purge_event cascade is suspended for the chain’s events. The chain composition does not interact with Legal Hold directly; the suspension happens at the Audit Trail layer. The chain’s records persist through any retention horizon while a hold is active.

  • Audit Trail records of failed chain initiations. This is no longer the whole truth, and the boundary has moved. Since the authentication-precedence discipline, a record_action is emitted for committed chain actions, for their recovery records, and for every attempt that reaches its action’s intent step — so the audit surface is committed actions plus intents that got that far. Where the boundary falls differs by action and is stated rather than left to inference. Refusals that land before the intent record leave nothing: Initiate Chain’s primitive validation and permission denial; Withdraw Chain’s permission denial, unknown chain, not-pending, and initiator mismatch. Refusals that land after it leave a step_decision_intended record: every Approval Step refusal at the three step-decision actions, unauthorized included — so a caller who authenticates as themselves and then attempts a decision on a step they do not own leaves a permanent, sealed record of the attempt in that chain’s audit surface. That is a deliberate consequence of putting the credential check before the commit, not an oversight, and an auditor must read an intent event as an attempt that was authenticated, never as an act that happened. The remaining pre-intent arms are what — a successful Initiate Chain’s chain_initiated, or the step-8 recovery’s chain_initiation_failed and audited withdrawals. A call rejected at the Permissions check (Permission Denied) or at structural validation (invalid-request) leaves no Audit Trail entry by default. High-assurance deployments where failed initiation attempts are themselves auditable compose a Failed-Attempt Log pattern (forthcoming — the same pattern Audit Trail’s own *Failed attribution attempts edge case names); the canonical composition’s audit surface is committed chain actions plus intents that reached their action’s intent step, never pre-intent attempts. This parallels Audit Trail’s *Failed attribution attempts edge case.

  • Clock source for chain_terminal_at. The composition sets chain_terminal_at at the moment the quorum evaluation rule transitions the chain to a terminal state, stamped from the injected clock_t supplied at the composition’s single I/O seam (see Logic confinement); no action reads a wall clock internally and no signature carries a now parameter. “The receiving node’s wall clock” names the deployment source of that reading, not an internal call. Clock quality — honesty, monotonicity, skew relative to other nodes — remains a deployment matter: skew across distributed composition nodes can still produce a chain_terminal_at earlier than the triggering step’s decided_at or withdrawn_at, since the step timestamp is stamped at Approval Step’s own seam from that deployment’s clock. Nothing load-bearing rests on the comparison — the quorum rule carries no clock term, so Invariant 2’s deterministic recomputation is unaffected by skew — but for deployments under strict clock-discipline requirements a Trusted Timestamping composing pattern (forthcoming — the one Audit Trail’s externally-clearable clock check names) is the resolution.

  • Cross-store consistency under failure. Initiate Chain writes to four constituent stores in sequence (chain, Approval Step ×N, Assignment ×N, Audit Trail). A failure mid-sequence leaves partial state. The writes are ordered, never atomic — the substrate’s append cannot be withdrawn and the Approval Step submissions are immutable — and the ordering is what makes a partial recoverable: the compensating record the recovery discipline produces closes the one partial each ordering leaves. The simplest recovery for a partial Initiate Chain failure is chain-level withdrawal of the partially-submitted chain — the Approval Step records that were submitted are immutable, but they can be withdrawn through the cascade. Cross-store consistency on Approve Step / Reject Step / Withdraw Step is tighter: the Approval Step transition is the load-bearing write; subsequent Assignment recall and Audit Trail recording are recovery-amenable through the orphan-state edge case in Audit Trail — and the recording half’s failure arms are all mapped at the wiring step, invalid-credential included: a caller whose credential the substrate refuses learns the true cause while the committed step decision stands as the partial state this edge case owns. The implementation owns the transactional boundary; the recovery discipline (the cascade-recall subsection) is the declared closure surface for the markers these paths set — a committed transition whose audit record failed entering the audit_pending_transition sweep window in particular, with the sweep’s recall and evaluation legs closing what a crash skipped even where every record landed.

  • Chain deadline and expiry. A Pending chain has no deadline: it remains Pending indefinitely until a decision path or a withdrawal terminates it, and this is a declared non-goal, not an omission. The composition evaluates no temporal guard by design (Logic confinement — the quorum rule carries no clock term, which is what keeps Invariant 2’s recomputation clock-independent), so an expiry that fired inside this composition would break the one property the composition exists to guarantee. The honest realization of a business deadline is external and already fits the declared surface: a composing scheduler or Deadline Escalation pattern (forthcoming) watches initiated_at through Read Chain and calls Withdraw Chain (or escalates to a human who does) when its policy elapses — the expiry is then an ordinary, attributed withdrawal in the records, not a clock-driven state the quorum rule would have to know about.

  • Approver notification. Binding a pending step into the approver’s in-tray (Assignment) is not telling the approver it is there. The composition emits no notification on chain initiation, step decision, or chain termination — deliberately: delivery is its own concept with its own state (channels, preferences, retries), owned by Notification and fanned out per recipient by Notification Fanout. A deployment wanting approvers told of new or moot work composes those patterns over this composition’s surface; the in-tray binding here is the truth such a notifier would announce, exactly as the by-assignee view (Composes) is the truth a Reverse Index (forthcoming) shaped lookup would serve.

  • Concurrent chains on the same subject. A subject may have multiple concurrent chains — for example, a financial transaction may require approvals under both a SOX chain and a separate AML (Anti-Money Laundering) chain. The composition does not restrict this; each chain has its own chain_id and its own quorum evaluation. A composing system that requires “all chains on subject X must reach Approved before X may proceed” is a higher-order policy not enforced by this composition.

  • Approver authorization at initiation is a calling-system obligation. The composition validates that the initiator holds chains:initiate (Invariant 3) but does not validate that each approver_ref in the declared approver_set holds the standing authorization to approve the named scope. A chain that names intern_xu as the approver for a financial:journal-entry:post:materiality-tier-3 gate is structurally accepted here; whether the intern is policy-authorized to hold that gate is a calling-system / Permissions check the calling system must run before calling Initiate Chain. Auditors verify approver appropriateness by cross-referencing each step’s approver_ref against the deployment’s standing-authorization registry (typically a Permissions instance scoped to scope-approval grants) at the chain’s initiated_at timestamp — a cross-seam comparison, judged under the deployment’s operating skew. This parallels Approval Step’s named out-of-scope on segregation of duties and is named in Generation acceptance’s Audit gaps.

  • Step-withdraw vs. chain-withdraw authority asymmetry. Step-level withdrawal (Withdraw Step) is governed by Approval Step’s Invariant 5 (submitter-only) — only the chain initiator may step-withdraw, regardless of whether they hold chains:withdraw. Chain-level withdrawal (Withdraw Chain) requires the actor to (a) be the chain initiator and (b) hold chains:withdraw. The asymmetry is intentional: a chain initiator who has lost the chains:withdraw permission post-initiation may still step-withdraw individual gates (correcting submission errors at the gate granularity, which is a property of the submitter role they earned at initiation) but cannot retract the chain as a whole (which requires the standing chain-level authority). Deployments that want both surfaces to require chains:withdraw add the check at the composition boundary in front of Withdraw Step; the canonical composition relies on the atom-level submitter check.

  • Retention-horizon asymmetry between substrate and constituents. Audit Trail enforces a retention policy on its events; after purge_event fires on an event past its retention_until, verify_record on the corresponding event_id returns failed-verification(purged). The Approval Step store and the chain store have no retention policy of their own — Approval Step’s Invariant 10 (step store durability) keeps step records present indefinitely, and the chain record’s presence past the horizon is the truth-bearing half of chain_store’s classification (Composition state), under the durability obligation that split declares — Invariant 8 governs the record’s field immutability, not its durability, and an earlier revision cited it for the wrong property. The consequence: a chain whose Audit Trail retention has elapsed remains queryable through Read Chain (returning chain and step records, attestation-status notwithstanding), but verify_record on its underlying chain-level and step-level events returns failed-verification(purged). The audit surface is consistent but staggered: the structural records (who decided what, when, under which approver set) persist; the cryptographic attribution chain has been lawfully destroyed. Auditors querying post-retention see the chain lifecycle and the Retention Window records in Purged state — sufficient for Audit Trail’s honest representation of destruction invariant; insufficient for any audit obligation that requires re-verifiable signatures past the retention horizon. Deployments needing perpetual signature re-verifiability configure a longer Audit Trail retention; the composition surfaces the trade-off rather than resolving it.

  • Late-decision audit interpretation. A step_approved, step_rejected, or step_withdrawn event whose data carries trailing = true was recorded after the chain itself had terminated (Invariant 7 / cascade-recall subsection). An auditor walking the Audit Trail in timestamp order may see a step_approved event with timestamp greater than the chain’s chain_resolved event; without the trailing flag, this read as a contradiction (decision after chain Rejected?); with the flag, the audit interpretation is declared — the step’s named approver decided after chain termination, the chain state did not change, the audit trail records the decision for completeness. The trailing flag is the records-alone disambiguator. (One interleaving is benign, not contradictory: a decision that serialized before the terminal but whose record landed after it appears as trailing = false after chain_resolved — the flag reflects the chain’s state when the decision was accepted, not record order.) Auditors reading chains under M-of-N quorum where trailing decisions are routine should filter or sort on trailing to separate on-chain decisions from after-the-fact recordings; the chain’s terminal disposition is read from its chain_resolved event alone, not from the union of step_approved events.


Terms

The canonical concepts this spec refers to. Each [Term] marker in the prose above links to its term entry here. A term entry states what the concept is, in plain English, plus its Kind — one of five: Type (a thing or category), Operation (a behavior), Member (a value of an enumerated Type), or, for a named datum, Field (a datum a Type carries — what does it carry?) or Parameter (a value an Operation needs — what does it need?). A term entry also names the Type it is a Member of / Field of, the Operation it is a Parameter of, and its Role where the domain assigns one. A term entry carries one Projection line — the concept’s single canonical lowering token, the one place the concrete name stays visible on the page — for every Field, Parameter, and pinned/wire Member. Everything else about casing (each target’s snake / camel / pascal / const / wire form) is derived from that one token by tools/harness/term-adapter.mjs, never hand-written. This is a composition, so its own concepts are: the six actions it exposes — the chain intake (Initiate Chain), the three step decisions (Approve Step, Reject Step, Withdraw Step), the chain withdrawal (Withdraw Chain), and the read (Read Chain); the Trailing flag that distinguishes a late decision from an on-chain one in the audit record; the three-scope authorization vocabulary it defines for its Permissions instance (Chains Initiate, Chains Withdraw, Chains Read); the three quorum rules it names (All Of N, M Of N, One Of N); and the Permission Denied / Recording Failure rejections it surfaces. Its load-bearing guarantees — the quorum evaluation rule (chain state is a deterministic function of the step states) and full records-alone auditability (Invariant 1 through 10) — are structural properties, not data. Its emergent state (chain_store, chain_to_steps, step_to_chain, step_to_assignment, chain_terminal_at, chain_to_events) — all derived indexes per Composition state — wires the constituents into one approval-chain surface, left as backticked tokens; the chain lifecycle states (Pending → Approved Rejected Withdrawn) overload Approval Step’s own step states, so they are left uncarded rather than ambiguously carded. The chain- and step-level audit event types (chain_initiated, chain_resolved, chain_withdrawn, step_approved, step_rejected, step_withdrawn, and the recovery records chain_initiation_failed and cascade_completed) stay backticked as wire values, as do the audit-data flags and fields beside Trailing (cascade, cascade_partial, recovery, disposition) and the recovery markers audit_pending and audit_pending_transition, as do the constituent calls and their outcomes — Approval Step’s submit / approve / reject / withdraw / read, Permissions’ permitted / grant / revoke, Assignment’s assign / recall, Audit Trail’s record_action / verify_record — the relayed constituent tokens (chain_id, step_id, assignment_id, subject_ref, scope, approver_ref, approver_set, quorum_rule, actor_ref), the parameterized M-of-N(M), the generic/relayed rejections (invalid-request, invalid-credential, not-known, not-pending, unauthorized, invalid-query), the deployment configuration knobs (approver_set_minimum, approver_set_uniqueness, quorum_rule_allowed, audit_trail_retention_policy, application_actor_ref, application_credential), and concrete example ids. Constituent atom and substrate names remain the existing full links to ../atoms/* and ./audit-trail.md; constituent operations stay backticked qualified calls, not cross-page links (the decided convention). (annotation.md Terms registry; representational only — it changes no guarantee, invariant, or behavior of the composition above.)

Initiate Chain

The composition action that creates an approval chain over one subject and scope: it validates the chain shape against the deployment’s quorum-rule and approver-set knobs, records chain_initiation_intended — the write that verifies the initiator’s credential, before anything is created — then submits one Approval Step per named approver, binds each to the approver’s in-tray, and audits the chain (chain_initiated). Gated by Chains Initiate. Returns the chain_id; the chain is now Pending.

Kind: Operation

Approve Step

The composition action wrapping one approver’s Approval Step approval: it records step_decision_intended first — the write that verifies the caller’s credential, and the only thing standing between an asserted actor reference and a committed approval, since this action has no Permissions check — then on success recalls the in-tray binding, audits the decision (step_approved, tagged with Trailing), and — unless the decision is trailing — re-evaluates the quorum rule, terminating the chain if quorum is now met.

Kind: Operation

Reject Step

The composition action wrapping one approver’s Approval Step rejection: it records step_decision_intended first (verifying the caller’s credential), then on success recalls the in-tray binding, audits the decision (step_rejected) and re-evaluates the quorum rule, which may transition the chain to Rejected when quorum becomes unreachable.

Kind: Operation

Withdraw Step

The composition action by which the chain initiator withdraws a single mis-submitted gate (wrong approver or scope) through Approval Step — recording step_decision_intended first, which verifies the initiator’s credential before the withdrawal commits, without retracting the whole chain where quorum remains achievable (a withdrawal that alone makes quorum unreachable cascades the chain to Withdrawn) — audited (step_withdrawn) and counted toward quorum-unreachability alongside rejections.

Kind: Operation

Withdraw Chain

The composition action by which the chain initiator retracts the whole chain while still Pending. Its Pending gate evaluates the quorum rule before it answers, so a chain whose lost evaluation has left it stored-Pending terminates by the rule and the call returns not-pending rather than withdrawing. Otherwise: it checks the initiator, records chain_withdrawal_intended (verifying the initiator’s credential), sets the chain terminal, cascade-withdraws every still-Pending step — auditing each (step_withdrawn, cascade = true) — recalls their in-tray bindings, and audits the withdrawal (chain_withdrawn). Gated by Chains Withdraw.

Kind: Operation

Read Chain

The read-only query returning chain records and their composed step / assignment / attestation surface — the chain’s audit event_ids from chain_to_events included, and the audit_pending quarantine flag when set — in declared order (ascending initiated_at, tie-broken by chain_id). Gated by Chains Read; produces no audit event and takes no credential.

Kind: Operation

Trailing

The flag the composition records on every step-decision audit event: true when the decision lands on a step whose chain had already reached a terminal state (a permitted late decision), false otherwise. It is the audit-distinguishing signal that lets an auditor tell a late decision from an on-chain one from the records alone — without it a step_approved event after the chain’s chain_resolved reads as a contradiction. Its sibling flag cascade (backticked, not carded) marks the composition’s own cascade-emitted step withdrawals; the two never overlap — a cascade record is part of the termination (trailing = false), a trailing record comes after it (cascade = false).

Kind: Field Field of: the step-decision audit event Role: the late-decision audit flag Projection: trailing

Chains Initiate

The scope permitting Initiate Chain — create a new approval chain.

Kind: Member Member of: the chain scope vocabulary Role: Scope Projection: chains:initiate

Chains Withdraw

The scope permitting Withdraw Chain — withdraw a chain (the chain initiator’s act).

Kind: Member Member of: the chain scope vocabulary Role: Scope Projection: chains:withdraw

Chains Read

The scope permitting Read Chain — read chain records and their composed step, assignment, and attestation surface.

Kind: Member Member of: the chain scope vocabulary Role: Scope Projection: chains:read

All Of N

The quorum rule requiring every named approver to approve: the chain is Approved when A == N, and Rejected the moment any step is rejected.

Kind: Member Member of: the quorum rule Role: Quorum rule Projection: all-of-N

M Of N

The quorum rule requiring any M of the N named approvers (with 1 ≤ M ≤ N): the chain is Approved when A ≥ M, and Rejected once fewer than M steps remain achievable with a rejection present.

Kind: Member Member of: the quorum rule Role: Quorum rule Projection: M-of-N

One Of N

The quorum rule requiring any single approver (the M = 1 case of M Of N): the first approval Approves the chain.

Kind: Member Member of: the quorum rule Role: Quorum rule Projection: one-of-N

Permission Denied

The composition’s rejection when the acting actor lacks the required chain scope at the Permissions check in Initiate Chain, Withdraw Chain, or Read Chain. (Step decisions are not chain-layer permission-gated — Approval Step’s own approver and submitter exclusivity is the enforcement.)

Kind: Member Member of: the chain rejection Role: Rejection Projection: permission-denied

Recording Failure

The composition’s uniform rejection for a constituent storage-failure or an Audit Trail record_action failure surfaced at the composition boundary — the failure the partial-state recovery paths and the audit_pending quarantine address. invalid-credential is not folded into it: a credential the substrate refuses surfaces under its own code, over whatever committed constituent state the recovery paths own.

Kind: Member Member of: the chain rejection Role: Rejection Projection: recording-failure


Standards references

This composition is the structural form of what every multi-actor regulatory approval regime requires:

  • Sarbanes-Oxley §404 (15 U.S.C. (United States Code) §7262) — Internal control over financial reporting. Material financial actions require multi-actor approval; SOX auditors query the approval chain to confirm the control existed and operated. The composition is the structural form. Composes with Audit Trail’s SOX §802 retention obligation (7 years).
  • FDA 21 CFR Part 211 (Current Good Manufacturing Practice for Finished Pharmaceuticals) — batch release requires authorization by a Qualified Person (QP) or equivalent designated authority. Multi-QP releases under M-of-N(M) quorum are the canonical composition for high-value batches and for batches requiring QA-plus-QP dual signoff.
  • FDA 21 CFR Part 11 (Electronic Records; Electronic Signatures) — each approval in a chain is an electronic signature event. Part 11 §11.50 requires signatures be attributable; §11.70 requires they be linked to records to prevent removal, substitution, or falsification. The composition’s Audit Trail substrate (with Actor Identity providing the cryptographic binding and Tamper Evidence providing the linking) satisfies both.
  • ICH E6(R3) Good Clinical Practice — Guideline. Sections 4–5 require documented approvals at multiple points in the trial lifecycle, often by multiple parties (investigator, sponsor, IRB). One-of-N over a delegated PI pool is a recurring composition shape for non-substantive deviations; M-of-N is the form for substantive deviations requiring both PI and sponsor approval.
  • ISO 9001:2015 §8.5.1 (Control of production and service provision) and §7.5.3 (Control of documented information) — the International Organization for Standardization’s quality-management standard; controlled changes require approval by named authorities. Multi-party chains map to ISO 9001 documented-procedure approval requirements.
  • ISO 13485:2016 §7.3 (Design and development) — medical device design changes require multi-disciplinary approval (design lead, quality, regulatory affairs, often clinical). M-of-N quorum is the structural form.
  • SOC 2 (System and Organization Controls 2 — the Trust Services Criteria attestation framework; Control Activities, Common Criteria CC2.1, CC8.1) — changes to data, infrastructure, or business processes require documented multi-party approval; SOC 2 audits query the approval chain as control evidence. The composition is one structural form.
  • NIST (National Institute of Standards and Technology — US federal standards body) SP 800-53 Rev. 5 (AC-5 Separation of Duties; CM-3 Configuration Change Control) — multi-actor approval is the operational form of separation of duties for change control. The composition is the structural form.
  • PCI DSS (Payment Card Industry Data Security Standard) Requirement 6.5.3 (Production data must not be used for testing or development) and 6.4.5 (Change control procedures) — production changes require documented multi-party approval.

It inherits from:

  • Daniel Jackson, The Essence of Software — the composition discipline: the per-gate atom is freestanding; the chain is the composing pattern that wires N gates under a quorum rule.
  • Approval chain literature in change-control and regulatory engineering — every major change-management framework (ITIL (Information Technology Infrastructure Library) change advisory boards, FDA design-control reviews, SOC change-control gates) names multi-party approval as the structural form; the composition is the formal version.

Status

partially resolved — see the Ledger.

Ledger

status: partially resolved
formal: pending — vote yes 2026-06-03, but no model file exists on disk; the vote entry's "model present" is unevidenced
last gate: 2026-08-29 — second gate after closure, fresh reader — 7 foundational (all since closed), 14 refining, 6 rhetorical

open:
- 2026-08-26-e · refining · Invariant 1; chain-to-steps relation; check 3 · convict the recovery discipline's own terminal state (a recovery-closed case-(a) chain has fewer steps than slots; at k=1 the list is empty); check 3 carries the carve-out, the invariant does not → carry it on the invariant
- 2026-08-26-f · refining · retry-partition paragraph; retried `withdraw` arms · says the discipline loops on exactly one arm while `recall` and `withdraw` each loop on their own `storage-failure`; `not-known` and `unauthorized` foreclosed but unsaid; the temporal `invalid-request` sub-arm not foreclosed and rejects identically on every retry → state the foreclosures and land the temporal arm
- 2026-08-26-g · refining · `chain_resolved` payload · required `reason` has no declared value on either Approved arm → declare it
- 2026-08-26-h · refining · rebuild procedures · the enumerate-and-filter move is imported without the substrate's instance-capability-requirement declaration, so the non-conforming-instance arm has no landing → declare the requirement
- 2026-08-26-i · refining · assignment join · declared through `step_to_assignment` against a store offering only `active_for` and `history_for` → declare the traversal as `history_for(step_id)`
- 2026-08-26-j · refining · Examples, SOX walkthrough · counts three attestations where the wiring emits five → recount
- 2026-08-26-k · refining · `chain_terminal_at` · "first moment the rule fired" is false for the sweep's evaluation leg and the evaluate-first gate, which stamp their own reading → restate
- 2026-08-26-l · refining · quorum rule identity · `A + R + W + P = N` is violated by a recovery-closed partial chain → condition it
- 2026-08-26-m · refining · recovery discipline · the composition-layer extraction gate is never run on it though `audit_pending` is new state the constituents lack → run the gate and state the verdict
- 2026-08-26-n · refining · dialed `Assignment.recall` · a `not-known` on retry produces no `cascade_completed` and leaves `cascade_partial` open forever → land it
- 2026-08-26-o · refining · quorum rule · the Essence of Software initialism undefined at first use → gloss
- 2026-08-26-p · refining · Generation acceptance checks 3 and 6 · snapshot-difference checks where the payload requirement supports the records-alone one-observation form → restate
- 2026-08-26-q · rhetorical · Intent · "four constituent atoms (and the Audit Trail substrate)" reads as five against Composes' four → reconcile
- 2026-08-26-r · rhetorical · `chain_id` · "equality-only, no ordering semantics" against [Read Chain] step 4's byte-order tiebreak → reconcile
- 2026-08-26-s · rhetorical · Examples, one-of-N · places Pending steps "in the audit trail" and omits their cascade recall → correct
- 2026-08-26-t · rhetorical · Examples · three mutually exclusive terminal outcomes narrated over one `chain-2026-0441` without a hedge → hedge
- 2026-08-27-e · refining · Generation acceptance · Invariants 3 and 6 have no dedicated check → add them
- 2026-08-27-f · refining · first use · *EOS* used without expansion → gloss
- 2026-08-27-g · refining · Invariant 2(b); check 2 · call the `chain_withdrawn` population "the initiator path" where recovery-closed chains terminate there with no initiator act → rename
- 2026-08-27-h · rhetorical · Intent · the illustration rearranges the walkthrough's roles → align
- 2026-08-27-i · rhetorical · Examples, regulator audit · calls indefinite pendency a control gap where the deadline edge case declares it a lawful non-goal → align
- 2026-08-29-a · refining · Quorum rule, vector definitions · only `A` says "through this composition"; `P` is defined as state = Pending while the routed rule counts out-of-band terminal steps as Pending, so `A + R + W + P = N` is false as written → define all four over routed transitions with `P = N − A − R − W`
- 2026-08-29-b · refining · Invariant 2(a); check 2 · "recompute over the final step records" omits the routed filter; an out-of-band step makes the recomputation disagree by design → state that the check uses the routed vector (intent-event join) and what an unjoinable transition contributes
- 2026-08-29-c · refining · Quorum rule, "Only routed transitions count" · accepts "the composition's own recovery record" as provenance, so a forged sweep re-emission under a compromised `application_credential` launders an out-of-band atom write → require a `step_decision_intended` join for decisions; recovery records evidence only cascade withdrawals
- 2026-08-29-d · refining · Routed rule at the audit horizon · a Pending chain whose earlier decisions' intent events were purged has those decisions demoted to Pending and can never terminate → add a horizon clause (attestation-surviving `actor_ref`/`action_ref` as fallback evidence, or bound pendency to retention)
- 2026-08-29-e · refining · `chain_to_events` second leg; check 7 tuple join · the tuple is not unique for two same-shape chains by one initiator in one clock instant → carry a seam-injected invocation nonce on the intent event and echo it on the chain record
- 2026-08-29-f · refining · check 5, reverse direction · "every chain-related action in the trail corresponds to a chain or step record" is contradicted by check 7's expected orphan intents → exclude intent events from the reverse direction
- 2026-08-29-g · refining · [Approve Step] step 6; cascade-recall · `chain_resolved` data `reason` is unspecified for the Approved arm → define the Approved reason string or make the field conditional
- 2026-08-29-h · refining · Quorum rule vs [Read Chain] step 3 · out-of-band transitions are "surfaced by [Read Chain]" but the response names no such field → add a per-step `out_of_band` flag and say how it is derived
- 2026-08-29-i · refining · Composition state vs [Initiate Chain] step 4 · the initiation leg's crashed-vs-live blindness is carried as an "open finding" while step 4 says the serialization resolves it → delete the stale clause or state what remains open
- 2026-08-29-j · refining · Edge case, Audit Trail with Legal Hold · Audit Trail's hold is per event and conditional on composing Legal Hold; who maps a subject-level hold onto the chain's `event_id`s is undeclared → name it a deployment obligation over `chain_to_events`
- 2026-08-29-k · refining · `disposition` vocabulary · Composition state says `a`, `b`, or `c`; step 8 adds `d`; the recovery bullet says "all three cases"; the recovery reason string omits `d` → align every mention to the four-case vocabulary
- 2026-08-29-l · refining · [Approve Step] step 3 · the atom's temporal `invalid-request` (wall-clock `decided_at` < `submitted_at` under cross-seam skew) reaches a well-formed caller with no statement that it is transient → name it and advise retry
- 2026-08-29-m · refining · Composition state, `step_to_assignment` · "not rebuildable past the horizon" ignores `Assignment.history_for(task_ref = step_id)`, which reconstructs the pairing from the Assignment store → cite the fallback
- 2026-08-29-n · refining · Quorum rule, Mechanism · "EOS" used without being spelled out on this page → gloss at first use
- 2026-08-29-o · rhetorical · [Initiate Chain] step 8 · cases listed (a), (b), (d), (c) "in order of severity" → reorder or drop the claim
- 2026-08-29-p · rhetorical · Quorum rule, M-of-N Rejected reason · "only K remain achievable" never defines K → write `N − R − W`
- 2026-08-29-q · rhetorical · [Withdraw Chain] step 1 · refers to "the step-0 primitive validation of [Approve Step]" but its own list has no step 0 → add a step 0 line
- 2026-08-29-r · rhetorical · Composition state · `chain_terminal_at` is both a standalone element and a field of the chain record → keep one
- 2026-08-29-s · rhetorical · Quorum rule · the bold "Only routed transitions count" paragraph sits between "let:" and its bullet list → move it after the definitions
- 2026-08-29-t · rhetorical · [Initiate Chain] step 8 · "every case closes the same way … lands one `chain_initiation_failed`" is contradicted by case (d) → reword to "cases (a)–(c)"
- 2026-08-29-u · refining · formal · the model's sweep carries no lower edge and no recovery record → extend it

Decisions

Directional changes only — the turns a future reader must know the pattern took, and why. Everything smaller lives in the commit that made it: git log -- compositions/multi-party-approval.md.

  • 2026-08-29 — The sweep is bounded below as well as above, names candidate intents rather than declaring them unrecoverable, and records its intent before it commits. Chose: decision_completion_bound as the transition sweep’s lower edge (the horizon it already carried as its upper), compensation_window and reconciliation_cadence declared; intent_event_candidates on every re-emitted record in place of intent_event_id_unrecoverable = true; a chain.recovery_intended record before every closure that recalls, withdraws, resolves, or re-emits; the cross-store edge case’s “atomically or” restated as ordered. Over: a sweep whose lower edge was the store-against-events comparison alone, and a flag that said the pairing was unknowable when the records name the candidates. Because: the comparison cannot see an invocation that has not written yet, so a sweep inside the bound lands a second outcome for one decision; the candidates are on the trail and naming them is what the pairing rule asks for; and a recall or withdrawal the sweep performs with no record of its own is indistinguishable from a direct call (the frozen rules of 2026-08-29 — A reconciliation is bounded at both ends, Intents pair with outcomes, Recovery commits under a declared service identity).
  • 2026-08-29 — Recovery is horizon-bounded, evaluate-first, and keyed on the chain’s own id. Chose: the initiation leg and the additive rebuild’s quarantine apply only inside the audit horizon, with post-horizon chain records declared truth-bearing under chain_store_durability; every recovery withdrawal runs the evaluate-first gate and the evaluation leg runs before the initiation leg; each step’s stored reason carries its chain_id so the case-(a) scan is exact; every cascade arm is landed with the in-invocation not-pending closing as a supersession. Over: an unbounded initiation leg, a recovery that withdrew without evaluating, a scan keyed on subject and initiator, and a cascade whose arms were enumerated only for storage-failure. Because: the unbounded leg withdrew every lawfully purged chain at each restart; the ungated recovery overturned credential-verified approvals in the initiator’s name; the subject-keyed scan withdrew a concurrently-initiating sibling’s step; and the unenumerated arms gave check 2(b) two opposite verdicts on one history.
  • 2026-08-26 — Invariant 4 is safety plus liveness at quiescence, not a static always-claim. Chose: the assignment-cascade claim holds at quiescence conditioned on no open recovery marker, with the partial-cascade and step-7 windows named as bounded surfaced transients; check 4 audits under the same structure. Over: the static “always both sides” statement. Because: the partial-cascade and trailing-decision paths the spec itself describes reach and violate the static form.
  • 2026-08-26 — Recovery emissions are composition-attributed and the retry loop runs only on transient failure. Chose: every record emitted outside the original human invocation attests under application_actor_ref with the human in data and recovery = true; invalid-request foreclosed by budget-derived caps; one vocabulary shared with Execute Gated Workflow. Over: re-presenting human credentials on retry, or looping on every rejection. Because: a human credential is unavailable to a recovery path, and a deterministic rejection retried forever is a stall.