Forensic Recovery
Table of contents
Summary
Forensic Recovery makes soft deletion forensically complete. Every delete, restore, and purge action against a record is attributed (the acting actor verified via credential), tamper-evident (the Audit Trail substrate seals each event), and retention-governed (the audit events are placed under retention).
The composition’s headline emergent guarantee is full ordered lifecycle history recoverability: a single forensic read action, Recover History, reconstructs the complete delete/restore/purge history of any record from the Audit Trail event stream — including prior epochs that Soft Delete’s current-state-only summary overwrites — proving, from the records alone, that no record was purged without an auditable record naming who purged it, when, and under what stated authority.
Neither Soft Delete (no tamper-evidence, current-state attribution only) nor Audit Trail (no lifecycle-continuity model) provides this guarantee alone; the composition’s lifecycle-transition ⇒ audit-event binding is what makes it emergent.
Intent
A record’s destruction lifecycle carries two distinct obligations that neither Soft Delete nor an audit substrate satisfies alone. The first is lifecycle faithfulness: every step of the Active → Deleted → Purged path — including every restore that returns a record to Active — is individually attributed to a named, verifiable actor, placed in an immutable event stream in the order it occurred, and sealed against post-hoc modification. The second is full-history recoverability: given any record_id, an investigator must be able to reconstruct the complete ordered sequence of what happened to that record — all the deletes, all the restores, the final purge — not just a snapshot of the most recent state.
Soft Delete provides a lifecycle state machine and current-state attribution: who most recently deleted the record, who most recently restored it, and who purged it. This is a deliberate design choice — Soft Delete is a freestanding (specifiable without naming any other pattern) atom, and retaining a full ordered history would absorb the Event Log concept, breaking that freestanding status. The atom’s Behavior section is explicit: “The atom retains only the most recent attribution in each category; the full cycle history requires Event Log composition.” The atom’s Edge cases name the gap precisely: “full history of all cycles requires composition with Event Log.” The composition is exactly where that gap is filled.
Audit Trail provides attribution, tamper-evidence, and retention governance for events it is explicitly told about, but it does not know what a deletion lifecycle is. It does not know that a record_action labeled record.soft_deleted must be followed (eventually) by either record.restored or record.purged for the same record_id. It cannot reconstruct the lifecycle of a specific record across multiple events without a binding structure that names which events belong to which record’s lifecycle. The composition provides that binding via its emergent composition state — record_to_events, the ordered map from record_id to the sequence of Audit Trail event_ids for that record’s lifecycle transitions.
The load-bearing emergent guarantee this composition exists to enforce is: no record reaches Purged without an attributed, tamper-evident Audit Trail event naming who purged it, when, and under what stated reason — and the full ordered history of every prior lifecycle transition is recoverable from the same event stream. The Recover History action is the forensic read surface: it builds the record’s reconciled transition sequence from the substrate’s lifecycle events and the record_to_events index (step 4), returns every transition in order, and calls AuditTrail.verify_record on each event to produce a verified, seal-confirmed, retention-governed history with no gaps.
One boundary this composition does not cross is critical to name in Intent: this composition does not gate purge eligibility. It does not check whether a Legal Hold is active, whether the Retention Window has elapsed, or whether any other governance condition permits destruction. That gate belongs to Defensible Retention, and absorbing it here would duplicate Defensible Retention’s hold-blocks-purge logic — the defining emergent invariant of a separate composition. This composition is the forensic attribution composition: it records every lifecycle transition faithfully, makes each attributed and tamper-evident, and makes the full history recoverable. Whether a given purge was permissible — whether the actor had authority, whether the hold check was clear — is the composing peer’s (Defensible Retention’s) question. An auditor using this composition can determine what happened and to whom, and can verify the records’ integrity; whether what happened was authorized requires Defensible Retention’s records (the hold-check audit events) and external evidence (the Permissions registry). This composition names this boundary explicitly in each relevant section rather than silently absorbing a responsibility that belongs elsewhere.
This is a composition, not a new primitive. Soft Delete and Audit Trail are unchanged. The composition is the wiring that makes them coherent as a single forensic-deletion surface. It introduces two emergent actions — Recover History and the three lifecycle-wrapping actions Delete Record, Restore Record, Purge Record — that belong to neither constituent alone and exist only because the two are wired together. Recover History in particular belongs to neither alone: Soft Delete can return the current-state summary, but it cannot return the full ordered history because it discards prior epochs; Audit Trail can return all events matching a query, but it cannot identify which events constitute a deletion lifecycle for a specific record without record_to_events. The composition is the layer that answers: what is the complete attributed, sealed, historically-ordered deletion lifecycle of this record?
Composes
-
Soft Delete — the recoverable-destruction lifecycle. Supplies the Active → Deleted → Purged state machine (with the Deleted → Active restoration path), the current-state attribution fields (
deleted_by,restored_by,purged_by), and the structural requirement that a record pass through Deleted before Purge. The composition callssoft_delete,restore,purge, andread. Soft Delete’s current-state attribution and lifecycle enforcement are the subject-scoped foundation; the composition does not re-derive the three-state machine but adds full-history recoverability, tamper-evidence, and attribution verification to each transition via the Audit Trail substrate wiring. -
Audit Trail — the regulated-audit substrate. Every lifecycle action the composition exposes records here as
AuditTrail.record_actioncalls — consumed at the substrate’s declared contract,record_action(action_ref, actor_ref, credential, data) → event_id | rejected(invalid-credential | invalid-request | recording-failure(step)), the(step)payload read at every outcome record (the uniform rejection-mapping rule) — producing an Event Log entry (append-only, totally ordered), an Actor Identity attestation (binding the acting actor’sactor_refto a verified credential), a Retention Window record (for the audit event), and a Tamper Evidence seal per the configured cadence. The composition maintains exactly one Audit Trail instance configured with the host’s regulatory retention policy for the lifecycle audit events. Actor Identity, Tamper Evidence, Retention Window (for audit events), and Event Log are reached transitively through Audit Trail — the composition does not maintain separate instances of those four atoms at this layer. The Audit Trail’srecord_actionis the mechanism by which each lifecycle transition receives an attributed, sealed, retention-governed audit event, closing the gap Soft Delete’s multiple-cycle attribution Edge case named: “full history of all cycles requires composition with Event Log” and “Actor Identity provides cryptographic attestation.” Instance capability requirement — the lifecycle-event enumeration. Every records-alone claim on this page that quantifies over this composition’s lifecycle events — therecord_to_eventsrebuild (Composition state), Recover History steps 4 and 6, the outcome-position read-back (Uniformrecord_actionrejection-mapping rule), the orphan pre-check every lifecycle action runs at its step 2, the reconciliation scan (Cross-store consistency under partial failure), and Generation acceptance checks 1, 4 and 5 — is a selection byaction_refand by a payload field (data.record_id,data.intent_event_id), and the substrate does not serve that shape: Event Log’s Reverse lookup / indexing edge case routes lookup by payload field to a separate Reverse Index pattern (forthcoming), and Audit Trail’s own Action wiring says a query by payload field “does not pass through … and this composition does not absorb it.” The route this composition declares is therefore the substrate’s pass-through list read by sequence-number range — Event Log’sread, passed through unchanged, a declared query shape — over a range with an open upper bound, with every selection made in composition code on the returned events’action_refand payload fields, exactly as the substrate’s owncompensated_attestationsrebuild enumerates and filters. The wired Audit Trail instance must expose that open-upper-bound sequence-range read (the same requirement the substrate places on its own Event Log), andinvalid-queryon any of these paths is a deployment-configuration fault, alerted on rather than surfaced as an outcome of this composition’s contract. Each user names its lower edge: the rebuild, the scan and the acceptance checks read from the start of the log; the read-back and the pre-check read from a stated position. Id-addressed reads go throughAuditTrail.read_record(event_id), which the substrate does declare. A deployment that composes Reverse Index over the audit log may use it to accelerate the selection; the enumeration is the declared route and the one an auditor reproduces. The route’s totality is bounded byaudit_trail_retention_policy: a purged event’sdatais destroyed in its entirety, so a selection on a payload field cannot see it — the reason Composition state splitsrecord_to_eventsat the horizon and Recover History reconciles rather than re-reads.
The Event Log, Actor Identity, Retention Window (for audit events), and Tamper Evidence atoms are reached transitively through Audit Trail; the composition does not maintain separate instances of those atoms at this layer. This follows the Compositions of compositions convention (see spec-format.md §Compositions of compositions): naming Audit Trail as the substrate is what satisfies the Actor Identity, Tamper Evidence, and Retention Window requirement — they are reached transitively, not maintained as separate instances at this composition layer.
Composition logic
Composition state
The composition owns one piece of emergent state that wires the two constituents into a forensically complete, records-alone-defensible lifecycle-history surface:
-
record_to_events— map fromrecord_idto the ordered list of{event_id, intent_position}pairs for that record’s lifecycle transitions — the Audit Trailevent_idof each outcome event and the Event Logsequence_numberof the intent event that outcome names throughintent_event_id— ordered byintent_position, which is transition occurrence order. The key is the intent’s position, not the outcome’s, and the choice is load-bearing: the intent record is written inside the per-record critical section before the transition commits and is never re-emitted, so under the whole-action serialization (Edge cases — Concurrent lifecycle actions) intent order is commit order, structurally and without a timestamp; an outcome event’s own position is not, because a compensated outcome lands whenever the compensation lands — after a later transition’s outcome, if a restore ran while a delete’s audit write was being retried — and a replay ordered by outcome position would then show the record restored before it was deleted. The compensating event carries the originalintent_event_idunchanged, so the key survives compensation. This is the forensic backbone: the complete ordered, attributed, sealed lifecycle history that Soft Delete’s current-state summary cannot provide. Each entry in the list corresponds to exactly one successful Delete Record, Restore Record, or Purge Record call — one lifecycle transition, one Audit Trail event, one binding. Contract classification: split by retention state (execution-contract.md§Composition state; first classified derived index 2026-06-11, closing part of the Invariant-4 coverage-gap finding, and split 2026-08-27 when that classification was found to be false past the audit horizon — the rebuild reads a payload field the purge destroys).- Entries whose lifecycle event still retains its payload — derived index, reconstructible as the procedure below describes.
- Entries whose lifecycle event has been purged — extraction-pending, carrying truth no constituent replays. Their loss is data loss, not a rebuild trigger, and the durability obligation follows from that: this half of the map must be durably persisted with the same seriousness as a truth-bearing store, because nothing regenerates it — Configuration’s
index_durabilityis the entry that spends it.
Why the split is forced rather than fastidious. The rebuild filters on
action_refand groups bydata.record_id, and the substrate’s purge destroys the Event Logdatafield in its entirety — taking the payload’s own copies ofaction_refandrecord_idwith it, and leavingevent_idandsequence_number. So past the horizon neither rebuild key is readable. Meanwhile this composition’s retention asymmetry edge case requires those very events to stay in the list, reported asattestation_verification = failed-verification(purged), which is possible only if the map holds anevent_idno rebuild can regenerate. The two statements cannot both be true of one classification, and the honest resolution is that the map is required to outlive the payloads it was said to be derived from.The fact is already captured, which is what makes this cheap. Audit Trail faced the same loss for its
event_to_attestationand had to capture the pair into the destruction record before the delegation runs, because nothing else held it. Here theevent_idis written into the list when the transition commits, long before any purge — so what was missing was never a mechanism, only a classification that stopped implying the fact was disposable. Where the index already holds the fact before the destruction, the treatment is a durability obligation rather than a capture mechanism. The named eventual home is the same Erasure Tombstone (forthcoming) that carries Audit Trail’s purged-entry pair; until it lands, the obligation is stated here rather than normalized away.For the derived-index half, every fact in the map is reconstructible from constituent stores — the named rebuild procedure enumerates the audit log through the declared route (Composes — the substrate’s pass-through sequence-range read from the start of the log with an open upper bound), keeps in composition code the events whose
action_refis one ofrecord.soft_deleted/record.restored/record.purged, groups them bydata.record_id(every this composition lifecyclerecord_actioncarriesrecord_idin its data payload), and orders each group by the position of each outcome’s intent event —data.intent_event_idresolved to itssequence_numberthroughAuditTrail.read_record— in the Event Log’s total order (Event Log Invariant 3), which under the whole-action serialization is exactly transition-commit order; the outcomes’ own positions are not used, because a compensated outcome’s position is the compensation’s time, not the transition’s. The Contract’s obligations follow: the map sits outside the two truth-bearing writes — the binding obligation (Invariant 4 — ordered, never atomic) binds the Soft Delete transition and the audit event, and the list-append, occurring only after both writes succeed, is evidence that obligation was met, never a peer write whose failure the compensation protocol must handle; a missing or lost entry is a rebuild trigger, not data loss — reads consult the index with rebuild-on-miss semantics, and a transition that still lacks an audit event after rebuild is the real orphan (Invariant 4’s surfaced finding); and the map claims no cross-constituent transactional consistency. The list is insert-only under a fixed key: entries are never removed, and each is placed by itsintent_position, so a late-landing compensation takes the place its intent already fixed rather than the end of the list — the one “reorder” the list admits is not one, since the order key was settled before the transition committed. The purged half keepsintent_positionwith theevent_id(the intent event’s payload is destroyed at the same horizon, but its sequence number is what the pair recorded), which is what lets apayload-purgedentry keep its place in the replay. A committed transition whose rebuild yields no audit event is the partial-failure orphan surfaced per the Cross-store consistency under partial failure edge case; anevent_idin any list that does not carry adata.record_idpointing back to the same record is a structural finding (Invariant 4’s inverse orphan — unreachable through this composition’s wiring). -
enumeration_high_water— the highestsequence_numberthis instance has read back through the declared route (Composes): theintent_positionof the last intent it read back and the position of the last outcome it read back. It is the lower edge of the outcome-position and intent-position read-backs (Uniformrecord_actionrejection-mapping rule), so that a read-back is a short tail read rather than a full enumeration in the healthy case. Contract classification: derived index. Rebuild procedure: the maximumintent_positionover everyrecord_to_eventslist, or zero where the map is empty. A lost or stale value costs a read-back exactly one thing: the read from a stale mark comes back without the event and falls back to the route from the start of the log, which is total for a live payload — so a miss is observable and never writes a wrong id. Outside the two truth-bearing writes; never consulted by the scan or by any acceptance check, which read from the start of the log.
The Soft Delete store state (lifecycle records, current attribution, state for each record_id) and the Audit Trail substrate state (event_to_attestation, event_to_retention, seal_coverage, sealed_through) are owned by their respective constituent instances. The composition does not duplicate them; it indexes into them via record_to_events.
Configuration
-
audit_trail_retention_policy— the policy reference (or content-derived policy selector) configured on this composition’s single Audit Trail instance, governing the lifetime of the lifecycle audit events (therecord_actionentries in the Event Log, not the Soft Delete lifecycle records themselves). This is set once on the Audit Trail instance; Audit Trail’srecord_actiontakes no per-call retention argument — every this composition lifecycle-audit event inherits this configured policy. The audit record should persist at least as long as the Soft Delete lifecycle record it describes, and typically longer for regulatory defensibility after the lifecycle record’s last transition. For deployments under GDPR (EU General Data Protection Regulation) Article 17 erasure obligations, the configured policy must account for the meta-question of retaining the audit record of an erasure (see Right-to-erasure vs. retention of audit events in Edge cases). For HIPAA (Health Insurance Portability and Accountability Act) §164.312(b) audit-controls deployments, the policy must encode the applicable minimum audit-record retention period. -
seal_cadence— inherited from the Audit Trail substrate’s configuration (per-event,interval-based, oron-demand). For regulated deletion-lifecycle deployments, per-event or tight interval-based cadences are recommended: every lifecycle event should be covered by a seal as promptly as possible because the unsealed tail is the window during which tampering is structurally undetectable at verification time. GDPR Article 17 erasure proof, HIPAA §164.310(d)(2) disposal records, and FRCP (Federal Rules of Civil Procedure) Rule 37(e) spoliation-defense audit trails all benefit from a narrow unsealed-tail window. The deployer configuresseal_cadenceon the Audit Trail instance; this composition does not impose an override. The cadence also sets the shape of every verification presentation. Recover History presents, per lifecycle event, the payloads of the event’s whole covering range (the substrate’s rule — Audit Trail Invariant 7), so under an interval or on-demand cadence one event’s check re-presents every seal-mate — intent records and unrelated actions included — and a lawful purge of any seal-mate leaves the survivorsunverifiable(partially-purged-coverage)permanently absent a composed Seal Lifecycle pattern; per-event cadence is the recommended wiring for a lifecycle-history instance, under which each presentation is a singleton and each purge isolates to its own event. An interval cadence remains admissible where the deployment accepts the range-shaped presentation and that standing answer; the presentation is range-shaped under every cadence. -
recovery_identity— a deployment-provisionedactor_ref+credentialpair under which every write the reconciliation scan makes is attested — therecord.recovery_intendedrecord it writes before compensating, and the compensating outcome event itself — and under which an in-invocation compensation re-attests when the original actor’s credential is deterministically rejected (invalid-credentialat the outcome record — see the uniform rejection-mapping rule and the Cross-store consistency under partial failure edge case). The scan cannot attest as the original actor, whose credential is never persisted; and retrying the original call cannot land a deterministic rejection. Events so attested carryrecovery = trueand name the originalactor_refindata, keeping attribution honest — the record shows which operational identity attested the compensation and which actor performed the lifecycle action. Mirrors the composition-actor convention established in Multi-Party Approval’s Configuration. A deployment that wires a credential pre-check above this composition (per the uniform mapping rule) may never exercise this identity; it must still be declared wherever the deterministic-rejection arm is reachable. (Added 2026-06-11 with the Invariant 4 safety + liveness restatement — the liveness arm’s deterministic-rejection leg rests on this declared capability.) transition_completion_bound— the deployment-declared maximum duration of a lifecycle action between its intent record and its outcome record — itsoutcome_retry_attemptsincluded — read against the seam-injectednow. It is the invocation’s terminus: an invocation holds the record’s critical section across its outcome retries and has yielded by the bound (Cross-store consistency under partial failure), so the scan’s lower edge and the invocation’s last possible write are the same instant and the two never both land an outcome for one transition (§A compensator is exclusive). It is the lower edge of the reconciliation scan: an intent younger than it may belong to an invocation still between its Soft Delete write and its outcome append, and a compensation fired at it would land a second outcome event for one transition — the duplicate Invariant 4’s bijection forbids. The upper edge isaudit_trail_retention_policy’s horizon: past it the intent event is destroyed, the transition’srecord_to_eventsentry is the extraction-pending half (Composition state), and the scan reads it there and re-emits nothing — a Soft Delete state whose events have aged out is the retention asymmetry edge case, never an orphan. The scan reads both edges against anowthe host injects at the scan’s own seam — the scan is a composition process with no caller, and its reading is declared here rather than borrowed from a constituent. Default: none — an instance with no bound set does not start.outcome_retry_attempts— the number of times an invocation re-attempts its outcomerecord_actionon a transientrecording-failure(step-2 | step-3)before it returnsrejected(recording-failure(outcome(finding))), releases the record’s critical section and yields the orphan to the scan. The terminus is counted rather than timed because an invocation holds one seam-injectednowand cannot watch a clock; the deployment sets the count so that every attempt completes insidetransition_completion_bound— the setting rule that ties the counted terminus to the timed bound. It is the invocation’s half of the one-writer-per-transition rule (partial-failure edge case). Default: none.record_serialization— an instance capability requirement: the host-supplied mutual exclusion on arecord_idunder which the whole-action critical section of Concurrent lifecycle actions runs — the orphan pre-check, the intent record, the Soft Delete transition, the outcome record and its retries, and the index insert — and which the reconciliation scan takes for every record it compensates. Soft Delete serializes its own transitions on arecord_id(its Concurrency edge case) and declares nothing wider; the multi-call section is this composition’s own obligation on the deployment, named here rather than attributed to the constituent (§Capability provenance, the multi-call-section tell). Its semantics are stated, not left to the host: a section is released on the invocation’s return or death; where the host implements it as a lease, the lease is exactlytransition_completion_boundlong — at least the bound, so no live invocation is preempted inside itsoutcome_retry_attempts, and no longer, so the time a stalled-but-alive holder can keep the scan off a record is bounded by the bound — and its expiry is the invocation’s terminus: an invocation whose lease has expired has yielded, and everything after the bound belongs to the scan (§A compensator is exclusive). Every write an invocation makes after its first is made only while it holds the section: an invocation that finds its section lost re-takes it before its pre-check, adopts an outcome the scan has landed as its own (proceed as landed), and otherwise returnsrejected(recording-failure(outcome(finding)))— it makes no write it cannot take the section for and no write past the bound. The hold time is why the liveness inequality’s first term is the bound and not a separate lease term (compensation_window). Default: none — deployment-required.compensation_windowandreconciliation_cadence— the deployment-declared duration within which an orphan transition must have its outcome event landed or be escalated as an unresolved finding, and the interval at which the reconciliation scan runs in addition to its mandatory run at restart. The inequality that makes the window meetable istransition_completion_bound + reconciliation_cadence + outcome_write_latency < compensation_window, strictly — an orphan created attis invisible to the scan untilt + transition_completion_bound, the next run is at most a cadence later, and the compensating write lands a latency after that; a cadence no longer than the window is satisfied by a deployment that breaches on every orphan. The first term already bounds the hold time a stalled-but-alive invocation can impose on the scan, becauserecord_serialization’s lease is exactly the bound long; no separate lease term is needed. The three terms are checked at instance start, and a deployment whose knobs fail the inequality refuses to start rather than starting to breach (§Liveness is arithmetic).outcome_write_latency— the deployment’s disclosed upper bound on oneAuditTrail.record_actionlanding — attestation, append and retention placement across the substrate’s three stores — the third term of the inequality above. Default: none — deployment-required, since the latency is observed at the deployment and this composition cannot derive it. Invariant 4’s liveness arm and Generation acceptance check 4’s reverse direction spend the window.intent_candidates_cap— the mostintent_event_candidatesa compensating outcome event may name. Step 1 of every lifecycle action sizes the largest record the act can write against the substrate’spayload_cap, and the compensation payload is the outcome’s fields plusrecovery,acting_actor_refand the candidate set; this cap is what keeps that payload inside the envelope the step sizes (§An outcome is sized before the intent). An orphan whose candidate set exceeds the cap is escalated as an unresolved finding rather than compensated. Default: none.index_durability— the durability the deployment owes therecord_to_eventsstore, as an ordering: at least as durable as the Soft Delete store it keys, because the extraction-pending half — entries whose events have been purged — is the only carrier of those transitions’ existence (Composition state), and a lost entry there is data loss.permissions_scope_prefix— optional. When a Permissions composing pattern is wired above this composition, this prefix defines the scope names expected by that pattern:forensic:delete,forensic:restore,forensic:purge,forensic:read. The composition does not enforce Permissions directly; the deployment wires Permissions checks at the calling layer using these scope names. Whenpermissions_scope_prefixis absent, no Permissions gate is applied (the composition records whoever calls it — having first verified their credential at the intent record, Invariant 6; eligibility and authority remain externally clearable). See Access control in Edge cases for the treatment of authority vs. execution.
Primitive policies
The composition takes string-typed inputs at its action boundaries; each is validated either at this layer or by a constituent.
record_id— opaque byte-identity as established in Soft Delete’s identity model. Must contain at least one non-whitespace character (Soft Delete’s requirement). The composition validates before any constituent call; empty or whitespace-only →rejected(invalid-request). The composition does not case-fold, normalize, or trim;record_idequality is opaque byte-identity.actor_ref— opaque reference to the actor performing the lifecycle action. Must contain at least one non-whitespace character. Validated at this composition layer before any constituent call; empty or whitespace-only →rejected(invalid-request). The sameactor_refvalue is passed asactor_reftoAuditTrail.record_actionand as the relevant attribution field (deleted_by,restored_by,purged_by) in the constituentSoftDeletecall.credential— opaque credential material consumed only byAuditTrail.record_action(specifically by Actor Identity inside the substrate), which validates it against the actor registry’s public material for the suppliedactor_ref. The composition does not inspect it. That validation is reached before anything commits, because every state-changing action opens with an intent record (Authentication precedes commitment; Invariant 6) — so a credential that does not verify refuses the act, and this composition’s claim to record an actor with a valid credential is true of the act rather than merely of the recording that followed it. The Actor Identity surface is reached only transitively, insiderecord_action— the composition composes no Credential or Actor Identity instance of its own — which is exactly why the position of that call is load-bearing rather than incidental. See Uniformrecord_actionrejection-mapping rule below.reason— required on Purge Record (any non-empty string with at least one non-whitespace character, per Soft Delete’s requirement onpurge); optional on Delete Record and Restore Record. Empty or whitespace-onlyreasonon Purge Record →rejected(invalid-request)before any constituent call. When present on Delete Record or Restore Record, passed through to the respective Soft Delete action; no additional composition-layer validation.event_id— opaque, system-generated by Audit Trail’s Event Log. Returned in action results; stored as elements inrecord_to_eventslists. Byte-identity equality as a map value and list element; never normalized.
Uniform record_action rejection-mapping rule. For every AuditTrail.record_action call in the action wiring below, the substrate’s rejection taxonomy (invalid-credential | invalid-request | recording-failure(step)) is mapped by the call’s position relative to the constituent write, and every lifecycle action has exactly two such calls: an intent record before it and an outcome record after it (Authentication precedes commitment).
Before either record — the envelope is sized. Step 1 of every lifecycle action constructs the largest record the act can write — not the intent, which is the smallest: the outcome payload {record_id, reason, recorded_at, intent_event_id}, and the compensation payload a reconciliation would write for it, which carries the outcome’s fields plus recovery = true, acting_actor_ref, and up to intent_candidates_cap candidate ids, with record.recovery_intended in front of it — and sizes each, with the longer of the caller’s actor_ref and recovery_identity’s (the compensation is attested under the latter), the transition’s action_ref under the substrate’s reference_length_cap, and the attestation_id at the substrate’s declared width, against the substrate’s payload_cap. Any of them over the cap → rejected(invalid-request) with nothing written (§An outcome is sized before the intent). An over-long reason is the reachable case and it is refused here, before the intent, never discovered over a committed transition.
At the intent record — nothing has committed, so every arm but one is a clean pre-state rejection. invalid-credential returns rejected(invalid-credential): the caller’s credential did not validate against the actor registry for the actor_ref they claimed, and the composition refuses the lifecycle change. recording-failure(step-2 | step-3) returns rejected(recording-failure(intent)) and is the genuinely retryable arm: nothing committed, so the caller may retry the whole action. recording-failure(step-4) is not: the intent event is appended and attested, only its retention placement failed — the invocation reads its id back through the declared route (Composes — the pass-through sequence-range read from enumeration_high_water (Composition state) to the open end, keeping in composition code the record.*_intended event for this record_id whose intended_at is this invocation’s now and whose actor_ref is the caller’s; a read that comes back without it falls back to the route from the start of the log), takes it as event_id_intent, and proceeds to the transition, naming the event in the result’s unretained field — a retry of the whole action here would write a second intent for one act. invalid-request has three sources: the substrate’s own retention-configuration fault, with the intent appended — the same read-back finds it and it proceeds as step-4 — and, after step 1 has sized the envelope, two with nothing appended: the substrate’s cap disagreement (Audit Trail’s invalid-payload at its step 3, a deployment fault its own Configuration forbids) and Actor Identity’s own invalid-request at attest, which Audit Trail propagates from its step 2 with nothing recorded. For both the read-back finds nothing and the action returns rejected(invalid-request), pageable, never retried, since a retry re-sends the identical payload.
At the outcome record — the constituent write has committed, so no arm can refuse the act, only report it — and two arms report that it succeeded. The invocation holds the record’s critical section throughout (record_serialization; Concurrent lifecycle actions). recording-failure(step-4) means the outcome event is appended and attested, only its retention placement failed: the invocation reads the event’s id back through the declared route (Composes) — the pass-through sequence-range read from enumeration_high_water (Composition state) to the open end, keeping in composition code the event whose action_ref is this transition’s and whose data.intent_event_id is event_id_intent; a read that comes back without it (a replica behind the append) falls back to the route from the start of the log, which is total for a live payload — inserts it into record_to_events, and returns success naming the event in the result’s unretained field, the hard alert the deployment pages on — never a retry, which would append a second outcome for one transition. invalid-request at this position has one source that appends once step 1 has sized the envelope: the substrate’s own retention-configuration fault, with the event appended — the same read-back finds it and it lands as step-4. A read-back that finds nothing after the fallback is one of two faults with nothing appended — the substrate’s cap disagreement (its invalid-payload at its step 3, a deployment fault its own Configuration forbids) or Actor Identity’s own invalid-request at attest, propagated from the substrate’s step 2 — which this position cannot tell apart and treats alike: rejected(recording-failure(outcome(finding))) with cause = invalid-request, the orphan surfaced and escalated at once — the scan’s compensation cannot land until the deployment repairs the fault, and Invariant 4’s liveness arm names that carve-out. recording-failure(step-2 | step-3) is the transient arm: the invocation re-attempts the outcome write under the section, for at most outcome_retry_attempts (Configuration) — its terminus, set to complete inside transition_completion_bound — and, if none lands, returns rejected(recording-failure(outcome(finding))) with cause = step-2 or step-3, releases the section, and yields the orphan to the scan; a later success is the scan’s, never the invocation’s (§A compensator is exclusive). invalid-credential means the credential was revoked between the intent and the outcome and repetition cannot land it: the invocation writes a record.recovery_intended record and re-emits the outcome under recovery_identity with data.recovery = true and data.acting_actor_ref naming the original actor (Configuration) — inside the invocation, under the section; if that write fails in turn it lands as the arm above with cause = invalid-credential, and the scan finishes it. Every write at this position is pre-checked under the section: before any outcome append — a first attempt, a retry, a re-attestation, or an invocation resuming after a stall — the invocation re-reads the route for an outcome naming its event_id_intent and, finding one, adopts it as its own (proceed as landed) rather than appending beside it; an invocation that finds it no longer holds the section (its lease expired while it stalled — record_serialization) has passed its terminus: it re-takes the section before the pre-check, adopts an outcome the scan has landed, and where the pre-check finds nothing returns rejected(recording-failure(outcome(finding))) rather than writing — everything after the bound is the scan’s. The position rides the exported code, recording-failure(intent | outcome(finding)) on every lifecycle signature: intent tells the caller nothing committed and the whole action may be retried; outcome(finding) tells the caller the transition exists — the finding is {record_id, transition, intent_event_id, cause}, the orphan surfaced in the same outcome that reports it — and a retry of the action is refused orphan-pending until the scan binds it (§A composition’s own rejection arm carries the retry bit).
invalid-credential and invalid-request are therefore both listed as returnable codes in the lifecycle action signatures — reachable, cleanly, at the intent record — where before the intent record existed they were unreachable as pre-state rejections and every credential failure had to masquerade as a recording failure over a completed act. The resulting orphan — a Soft Delete lifecycle record with no corresponding Audit Trail event and no record_to_events entry — is surfaced per the Cross-store consistency under partial failure edge case, and a record with a pending orphan admits no new transition: every lifecycle action’s step 2 runs the orphan pre-check under the record’s section before it writes its intent, and refuses rejected(orphan-pending(intent_event_id)) while one stands, because a later transition would overwrite the attribution fields the pairing reads — Soft Delete keeps only the most recent of each kind — and the orphan would become unpairable for good (§Intents pair with outcomes; the partial-failure edge case states the pairing rule once, for the pre-check and the scan alike). Deployments requiring credential pre-validation before the Soft Delete write wire an Actor Identity pre-check above this composition (a composing peer).
No primitive is case-sensitivity-normalized at the composition layer; deployments wanting normalization wire it at the calling layer before invoking composition actions.
Action wiring
The composition exposes three orchestrating lifecycle actions, one emergent forensic read action, and one read passthrough. Every successful lifecycle-state-changing action records in Audit Trail; Recover History and the read passthrough produce no Audit Trail event — they are pure reads that change no state. All record_to_events list-appends occur only after both the Soft Delete write and the Audit Trail write succeed — the population of the list is the evidence that the binding obligation was met (Invariant 4 — ordered, never atomic). Every lifecycle action runs inside the record’s critical section (record_serialization) from its step 2 to its return, and returns {record_id, event_id, unretained?} — unretained naming the intent or outcome event whose retention placement the substrate reported failed (recording-failure(step-4)), the hard alert delivered in the result rather than only to a dashboard.
The full rejection taxonomy for each action enumerates every reason the action can fail; constituent rejections are either propagated (with the same name), renamed (with the mapping noted), or surfaced as a new code at the composition’s boundary.
delete_record
delete_record(
actor_ref,
record_id,
credential,
reason?
) →
{record_id, event_id, unretained?}
| rejected(
invalid-request
| invalid-credential
| already-deleted
| already-purged
| orphan-pending(intent_event_id)
| recording-failure(intent | outcome(finding))
)
Soft-deletes the record and immediately binds the transition to an attributed Audit Trail event.
Steps:
- Validate
actor_refandrecord_idper Primitive policies. Empty or whitespace-only →rejected(invalid-request). Size the envelope — the outcome payload and the compensation payload this act could need, per the uniform mapping rule’s sizing paragraph — against the substrate’spayload_cap; over the cap →rejected(invalid-request). Stop on either, with nothing written. - Enter the record’s critical section (
record_serialization; Concurrent lifecycle actions) and run the orphan pre-check (the pairing rule of the Cross-store consistency under partial failure edge case): read the record’s current attribution throughSoftDelete.read({record_id}), enumerate thisrecord_id’s intent and outcome events through the declared route (Composes) from the position after the record’s last bound intent (record_to_events[record_id]’s lastintent_position, rebuild-on-miss; the start of the log where the record has no entry), and if an intent no outcome names pairs to a current attribution field no outcome accounts for, a committed transition is owed its outcome event — returnrejected(orphan-pending(intent_event_id)), release the section, and stop; an unmatched intent that pairs to nothing is an attempt and does not block. Then the intent record:AuditTrail.record_action(action_ref=record.soft_delete_intended, actor_ref, credential, data={record_id, reason, intended_at=now})→event_id_intent. Arms per the uniform mapping rule’s intent position:invalid-credential→rejected(invalid-credential);recording-failure(step-2 | step-3)→rejected(recording-failure(intent)), retryable as a whole action;recording-failure(step-4)and the retention-sourceinvalid-request→ the intent is appended, read back through the route, taken asevent_id_intent, named inunretained, and the action proceeds;invalid-requestwith nothing appended →rejected(invalid-request). Every rejecting arm releases the section. SoftDelete.soft_delete(record_id, deleted_by=actor_ref, reason?, deleted_at=now)→deleted. Map constituent rejections:invalid-request→rejected(invalid-request);already-deleted→rejected(already-deleted);already-purged→rejected(already-purged);storage-failure→rejected(recording-failure(intent))— Soft Delete leaves the record in its prior state, so nothing committed and the whole action may be retried. Stop on any, releasing the section; the intent record stands as the record of the attempt.AuditTrail.record_action(action_ref=record.soft_deleted, actor_ref, credential, data={record_id, reason, recorded_at=now, intent_event_id=event_id_intent})→event_id, pre-checked under the section as the uniform mapping rule’s outcome position requires. The acting actor’s credential attests the deletion — the same credential step 2 already verified before anything committed. Arms per that position:recording-failure(step-4), and the retention-sourceinvalid-requestwhose read-back finds the event → appended; take its id, name it inunretained, and proceed to step 5.recording-failure(step-2 | step-3)→ re-attempt under the section for at mostoutcome_retry_attempts;invalid-credential→ re-emit underrecovery_identitybehind arecord.recovery_intendedrecord, withdata.recovery = trueanddata.acting_actor_ref = actor_ref. Where no outcome lands (the Soft Delete write landed in step 3 but the audit write did not): returnrejected(recording-failure(outcome(finding)))withfinding = {record_id, soft_delete, event_id_intent, cause}, release the section, and yield; the orphan (Soft Delete lifecycle record with no Audit Trail outcome event, norecord_to_eventsentry) is surfaced in that outcome and bound by the scan per the Cross-store consistency under partial failure edge case. Stop on any arm that rejects.- Insert
{event_id, intent_position}intorecord_to_events[record_id]at its intent position (creating the list if this is the record’s first entry), whereintent_positionis thesequence_numberofevent_id_intentread back throughAuditTrail.read_record— the occurrence key (Composition state). - Release the section and return
{record_id, event_id, unretained?}.
restore_record
restore_record(
actor_ref,
record_id,
credential,
reason?
) →
{record_id, event_id, unretained?}
| rejected(
invalid-request
| invalid-credential
| not-known
| not-deleted
| already-purged
| orphan-pending(intent_event_id)
| recording-failure(intent | outcome(finding))
)
Restores a Deleted record to Active and binds the restore transition to an attributed Audit Trail event.
Steps:
- Validate
actor_refandrecord_idper Primitive policies. Empty or whitespace-only →rejected(invalid-request). Size the envelope per the uniform mapping rule’s sizing paragraph; over the cap →rejected(invalid-request). Stop on either, with nothing written. - Enter the record’s critical section (
record_serialization) and run the orphan pre-check exactly as Delete Record step 2 does —SoftDelete.read({record_id})for the current attribution, the declared route from the position after the record’s last bound intent for its unmatched intents, the pairing rule of the partial-failure edge case; a committed transition owed its outcome →rejected(orphan-pending(intent_event_id)), section released, stop. Then the intent record:AuditTrail.record_action(action_ref=record.restore_intended, actor_ref, credential, data={record_id, reason, intended_at=now})→event_id_intent, with the intent-position arms of the uniform mapping rule:invalid-credential→rejected(invalid-credential);recording-failure(step-2 | step-3)→rejected(recording-failure(intent)), retryable as a whole action;step-4and the retention-sourceinvalid-request→ appended, read back, taken asevent_id_intent, named inunretained, proceed;invalid-requestwith nothing appended →rejected(invalid-request). Every rejecting arm releases the section. SoftDelete.restore(record_id, restored_by=actor_ref, reason?, restored_at=now)→restored. Map:invalid-request→rejected(invalid-request);not-known→rejected(not-known);not-deleted→rejected(not-deleted);already-purged→rejected(already-purged);storage-failure→rejected(recording-failure(intent))(the record stays Deleted; nothing committed). Stop on any, releasing the section.AuditTrail.record_action(action_ref=record.restored, actor_ref, credential, data={record_id, reason, recorded_at=now, intent_event_id=event_id_intent})→event_id, pre-checked under the section. Arms per the uniform mapping rule’s outcome position:step-4and the retention-sourceinvalid-request→ appended, read back, named inunretained, proceed to step 5;step-2 | step-3→ re-attempt for at mostoutcome_retry_attempts;invalid-credential→ re-emit underrecovery_identitybehindrecord.recovery_intended. Where no outcome lands, returnrejected(recording-failure(outcome(finding)))withfinding = {record_id, restore, event_id_intent, cause}, release the section, and yield; the orphan (Soft Delete record restored with no corresponding attributed outcome event, norecord_to_eventsentry) is surfaced in that outcome and bound by the scan per the Cross-store consistency under partial failure edge case. Stop on any arm that rejects.- Insert
{event_id, intent_position}intorecord_to_events[record_id]at its intent position —intent_positionthesequence_numberofevent_id_intent, read back throughAuditTrail.read_record(Composition state). - Release the section and return
{record_id, event_id, unretained?}.
purge_record
purge_record(
actor_ref,
record_id,
credential,
reason
) →
{record_id, event_id, unretained?}
| rejected(
invalid-request
| invalid-credential
| not-known
| not-deleted
| orphan-pending(intent_event_id)
| recording-failure(intent | outcome(finding))
)
Permanently destroys the record and binds the purge transition to an attributed Audit Trail event. reason is required (not optional) — a purge without a stated justification is an attributed destruction with no record of the authority under which it was executed, defeating the forensic guarantee. This action does NOT check purge eligibility. It does not consult Legal Hold, Retention Window, or any other governance gate before executing the purge. Whether a purge was permissible at the time it was called — whether a Legal Hold was active, whether the Retention Window had elapsed, whether the actor was authorized — belongs to Defensible Retention (for the hold and retention gate) and a Permissions composing pattern (for the authorization gate). This composition records the purge faithfully; Defensible Retention gates it. Absorbing the eligibility gate here would duplicate Defensible Retention’s hold-blocks-purge invariant.
Steps:
- Validate
actor_ref,record_id, andreasonper Primitive policies. Empty or whitespace-onlyactor_ref,record_id, orreason→rejected(invalid-request). Size the envelope per the uniform mapping rule’s sizing paragraph — the requiredreasonis the field that can reach the cap, and it is refused here rather than over a destroyed record; over the cap →rejected(invalid-request). Stop on either, with nothing written. - Enter the record’s critical section (
record_serialization) and run the orphan pre-check exactly as Delete Record step 2 does; a committed transition owed its outcome →rejected(orphan-pending(intent_event_id)), section released, stop — a purge is never committed on top of an unbound delete. Then the intent record:AuditTrail.record_action(action_ref=record.purge_intended, actor_ref, credential, data={record_id, reason, intended_at=now})→event_id_intent. This stands immediately before the irreversible step 3, and it is where the caller’s credential is verified: the substrate validates it against the actor registry’s public material foractor_refinsiderecord_action, soinvalid-credentialreturnsrejected(invalid-credential)with nothing destroyed — a pre-state refusal rather than a fault discovered over a destroyed record.recording-failure(step-2 | step-3)returnsrejected(recording-failure(intent)), again with nothing destroyed — the genuine-retry case;step-4and the retention-sourceinvalid-request→ the intent is appended, read back, taken asevent_id_intent, named inunretained, and the action proceeds;invalid-requestwith nothing appended →rejected(invalid-request), nothing destroyed, never retried. Every rejecting arm releases the section. (Authentication precedes commitment; the uniform mapping rule.) SoftDelete.purge(record_id, purged_by=actor_ref, reason, purged_at=now)→purged. Map:invalid-request→rejected(invalid-request);not-known→rejected(not-known);not-deleted→rejected(not-deleted)(Soft Delete enforces the Deleted → Purged requirement; Active → Purged direct path is prohibited by Soft Delete Invariant 4);storage-failure→rejected(recording-failure(intent))(the record stays Deleted; nothing destroyed). Stop on any, releasing the section.AuditTrail.record_action(action_ref=record.purged, actor_ref, credential, data={record_id, reason, recorded_at=now, intent_event_id=event_id_intent})→event_id, pre-checked under the section. The actor’s credential is bound here too — the audit record names who purged the record, when, and under what stated reason — and it carries the step-2 intent event’s id, so the pair is joinable in one read rather than by matching payloads. Arms per the uniform mapping rule’s outcome position:step-4and the retention-sourceinvalid-request→ appended, read back, named inunretained, proceed to step 5;step-2 | step-3→ re-attempt for at mostoutcome_retry_attempts;invalid-credential→ re-emit underrecovery_identitybehindrecord.recovery_intended. Where no outcome lands, returnrejected(recording-failure(outcome(finding)))withfinding = {record_id, purge, event_id_intent, cause}and release the section. This is the most consequential partial-failure case: the Soft Delete purge is irreversible (Purged is terminal per Soft Delete Invariant 3); the record is destroyed and carries no outcome event — though it now carries the step-2 intent event, which is what makes the destruction visible to the scan rather than invisible, and which names the actor whose credential was verified before it happened. Surfaced in thefindingand per the Cross-store consistency under partial failure edge case as a high-priority finding; the scan binds it. Stop on failure.- Insert
{event_id, intent_position}intorecord_to_events[record_id]at its intent position —intent_positionthesequence_numberofevent_id_intent, read back throughAuditTrail.read_record(Composition state). - Release the section and return
{record_id, event_id, unretained?}.
recover_history
recover_history(
actor_ref,
record_id,
original_event_payloads
) →
lifecycle_history
| rejected(
invalid-request
| not-known
)
The emergent forensic read action. Reconstructs the complete, ordered, attributed, tamper-verified delete/restore/purge history of record_id from the record_to_events list and the Audit Trail substrate. original_event_payloads is a map keyed by the audit log’s sequence_number to the byte-exact payload Event Log holds at that position — keyed that way because the record set a seal commits to is a sequence range, not one event: AuditTrail.verify_record takes the payloads of every event in the covering seal’s coverage in ascending sequence_number order (Audit Trail Invariant 7), read_record names that range for each event, and under the interval-based and on-demand cadences the range spans intent records and unrelated actions, so a one-payload-per-event presentation would return failed-verification(seal-record-set-mismatch) for every lifecycle event, indistinguishable from tampering. Under per-event cadence each range is a singleton and the map holds one payload per event — the special case, not the rule. It is a required argument because tamper-evidence verification is self-contained only given the originating records (Audit Trail Invariant 7 — verification asymmetry preserved). Surfacing the payloads at the signature, rather than having the composition fetch them internally, is intentional: the verifier must present the record set, not trust the host system to supply it. Unmatched intent events for the record are returned in the result’s own attempts field — a separate named residue alongside the ordered events history, never interleaved into it: they are attempts, not transitions, and conflating the two would corrupt the lifecycle replay that Invariant 3 rests on. They are forensically material precisely because they are attempts (an authenticated actor began a destruction that did not complete, or completed without recording), and Generation acceptance check 5 is where they are adjudicated against the Soft Delete store. For an entry whose covering range is not wholly present in the map, the per-event attestation_verification is reported as unverifiable(payload-not-supplied(missing)), naming the absent sequence numbers, rather than failing — partial verification (attribution and retention for the entries whose ranges are supplied) is still useful, and the affected entries are named in the result. This mirrors Chain of Custody’s verify_custody(chain_id, original_event_payloads) design.
Neither constituent alone can answer this query: Soft Delete returns only the current-state attribution summary, discarding prior epochs on each new soft_delete or restore; Audit Trail stores every event but does not know which events constitute a deletion lifecycle for a specific record_id. The composition is the layer that holds record_to_events, the binding that answers: what is the full ordered history of this record’s deletion lifecycle, and is each step attributed, sealed, and within its retention horizon?
The returned Lifecycle History contains:
record_id— the identifier of the record being inspected.current_state— the record’s current Soft Delete state (Active,Deleted, orPurged), read fromSoftDelete.read({record_id}).current_summary— the current-state attribution fromSoftDelete.read({record_id}):deleted_by,deleted_at,deletion_reason(if present), plusrestored_by,restored_at,restoration_reason(if the record has been restored), pluspurged_by,purge_reason,purged_at(if Purged). This is the summary Soft Delete provides; theeventssequence below is the full ordered history it cannot provide.attempts— the unmatched-intent residue: intent events for thisrecord_idthat no outcome event names through itsintent_event_id. Each entry carries the intentevent_id, itsaction_ref(record.soft_delete_intended/record.restore_intended/record.purge_intended), itsactor_ref— authenticated, by construction, since the substrate validated that credential to write it — itsintended_at, and itsreason. These are attempts, not transitions: an authenticated actor began a lifecycle change that did not complete, or completed without recording. They are deliberately kept out ofeventsbecause interleaving them would corrupt the lifecycle replay Invariant 3 rests on, and they are adjudicated against the Soft Delete store by Generation acceptance check 5. The list is empty for a record whose every intent completed and recorded.events— an ordered list, in transition occurrence order, of per-event verification records. The order is the Event Log’s total order over the outcomes’ intent events — each outcome’sintent_position(Composition state) — over the reconciled sequence step 4 builds, never the outcomes’ own positions: a compensated outcome lands at the compensation’s time, and an order read off outcome positions would replay a delete whose audit write was retried after the restore that followed it. The index’s order agrees with the intent order for every entry the index holds; the distinction between index and enumeration matters only because either may hold what the other lacks. Each record contains:sequence_position— the 1-based index of this event in the reconciled transition sequence (1 = first transition ever recorded for this record). Derived from the reconciliation rather than from the index’s list, and deliberately: a list that has lost an element renumbers everything after it, so the positions would stay contiguous and a reader would have no way to see that a transition was missing. Contiguous positions are evidence of completeness only when the sequence they number is the reconciled one.provenance— how this entry was reached:both(in the substrate enumeration and the index),index-gap-repaired(found by the enumeration, absent from the index — the index is updated),payload-purged(in the index, unreachable by the payload-keyed enumeration becauseread_recordreports its retention statePurged), orenumeration-miss(in the index, live, and absent from the enumeration until it was re-run — a read replica behind the append). The field exists so a reader can tell a repaired index from a lawfully-purged payload from a lagging read, which the verdict alone conflates.event_id— the Audit Trailevent_idfor this transition.action_ref— the transition type (record.soft_deleted,record.restored, orrecord.purged).actor_ref— from the Audit Trail event’sactor_reffield — the actor whose credential was bound for this record’s write.acting_actor_ref— the human actor who performed the transition. On a clean binding this equalsactor_ref. On a recovered binding it does not: the compensating event is attested underrecovery_identity, soactor_refnames the recovery identity andacting_actor_refis read from the original actor carried in the event’s payload. Surfacing both is what keeps Invariant 1’s and Invariant 2’s attribution claims true on the compensation path rather than only on the clean one.recovery—truewhen this binding was produced by the compensation path (the event carriesdata.recovery = true; the payload fields a compensating event carries aredata.recovery,data.acting_actor_ref, anddata.intent_event_idordata.intent_event_candidates— declared here because every check that reads them names them),falseotherwise. This is where Invariant 4’s promise that recovered bindings stay distinguishable from clean ones is actually delivered to a reader.recorded_at— from the Audit Trail event’srecorded_atfield — the timestamp of the event as recorded in the Event Log.reason— from the Audit Trail event’sdata.reasonfield (present onrecord.purgedalways; present onrecord.soft_deletedandrecord.restoredwhen supplied at call time).- Attestation Verification — the result of
AuditTrail.verify_record(event_id, <presentation>), where the presentation is the covering range’s payloads assembled fromoriginal_event_payloadsin ascendingsequence_numberorder (step 5b; Audit Trail Invariant 7 requires the covering record set re-presented, and the caller supplies it via the argument). Every outcome the substrate’s contract can return has a landing here, and each is placed in the verdict table below:verified;failed-verification(purged)(lawful destruction — the substrate answers it at its step 1, before it reads a presentation, and step 5b relays it without consulting the map);failed-verification(attestation-…)(the attestation failed);failed-verification(seal-…)(seal-proof-invalid,seal-record-set-mismatch,seal-not-known— the seal failed);failed-verification(unsealed)(the event is in the unsealed tail underunsealed_tail_mode = strict— not yet sealed, resolved by the next cadence firing);not-known(the audit event is unknown — a binding-gap);unverifiable(payload-not-supplied(missing))(this composition’s own — the map lacks one or more members of the event’s covering range, so the seal check cannot run);unverifiable(partially-purged-coverage)(a seal-mate was lawfully purged, so the covering record set can no longer be re-presented — the substrate’s standing answer for the survivors under an interval cadence, which is why Configuration recommendsper-event); andunverifiable(attestation-registry-unavailable)/unverifiable(seal-mechanism-verification-unavailable)(a verifying surface was down — transient, retried when it returns). Any of them may additionally carry the substrate’s(compensation-window)qualifier — the event’s retention placement is still owed inside the substrate’s own window — which is a separate channel, never a reason, and is reported throughretention_staterather than folded into the verification result. retention_state— the retention state of the audit event asAuditTrail.read_record(event_id)reports it (step 5a — the declared surface, never the substrate’s internalevent_to_retention):Retained,Purged,unresolved (compensation window)(the substrate’s own status for an appended event whose retention placement has not landed), orunknown(if theevent_idis not bound).
- Overall Verdict — a summary verdict over the reconciled sequence step 4 builds (never over the index’s list alone): History Complete when every transition in the reconciled sequence has a binding, the sequence passes step 7’s path-legality test, and every
attestation_verificationreturnsverifiedorfailed-verification(purged)— the latter is lawfully destroyed and counted as consistent; otherwise History Incomplete naming each class that applies. The classes, and what each is: the failure classes — a defect in the lifecycle record or its evidence — arebinding-gap(a transition with no event, or an event the substrate returnsnot-knownfor),attestation-failed(failed-verification(attestation-…)),seal-failed(failed-verification(seal-…)),unsealed(failed-verification(unsealed)understrict— the event is real and its seal is owed at the next cadence firing), andout-of-order(the ordered sequence is not a path Soft Delete’s lifecycle could have committed; step 7); the incomplete-verification classes — verification could not be performed, which is not a defect in the record — arepayload-not-supplied(unverifiable(payload-not-supplied(missing))),partially-purged-coverage(unverifiable(partially-purged-coverage)— standing, and resolvable only by a composed Seal Lifecycle pattern the substrate names), andavailability(unverifiable(attestation-registry-unavailable | seal-mechanism-verification-unavailable)— transient, re-run when the surface returns). Every class blocks History Complete: a history is proven only when every step was verified, and could not be verified is not verified. Three reconciliation outcomes are reported but are not classes, and keeping them out of that list is the point:index-gap-repaired(the substrate enumeration found a transition the index had lost — the derived index’s own rebuild trigger, now closed),payload-purged(the index holds a transition the payload-keyed enumeration can no longer reach because itsdatais past the retention horizon),enumeration-miss(a live transition the enumeration returned only on its re-run), andretention-pending(an event whoseretention_stateisunresolved (compensation window)— its placement is owed by the substrate’s own liveness arm, the same fact the lifecycle action already reported inunretained). None is a defect in the lifecycle record; all are facts a forensic reader needs, and reporting the first as abinding-gapwould be the misdiagnosis Audit Trail names — an index gap read as a compliance finding.
Steps:
- Validate
actor_refandrecord_idper Primitive policies. Empty or whitespace-only →rejected(invalid-request). Stop. - Look up
record_idin Soft Delete’s store viaSoftDelete.read({record_id}). If no lifecycle record exists →rejected(not-known). Stop. - Read
current_stateandcurrent_summaryfrom theSoftDelete.readresult. - Build the transition sequence by reconciling the substrate against the index — never by reading the index alone. Enumerate the substrate’s lifecycle events for this
record_idthrough the declared route (Composes — the pass-through sequence-range read from the start of the log with an open upper bound, keeping in composition code the events whoseaction_refis one ofrecord.soft_deleted/record.restored/record.purgedand whosedata.record_idis this record’s), ordered by each outcome’sintent_position(itsintent_event_idresolved throughread_record) — which is exactly the declaredrecord_to_eventsrebuild procedure (Composition state), and the same declared traversal step 6 already uses for the residue. Then look upevent_list = record_to_events[record_id]and reconcile the two.
Why this is a reconciliation and not simply an anchored read, which is the interesting part of this action. The class treatment for a derived index is anchor in the authoritative record and use the index as a cache, because rebuild-on-miss is a keyed-lookup contract and a read that does not perform a keyed lookup has no miss to observe. Reading the list — as this step previously did — honors the contract at the granularity of the key and violates it at the granularity of the element: a list that has lost an element is not a miss, so the lookup succeeds, the rebuild never fires, and the history returns short while claiming to be complete. But here the cache can outlive its source. The enumeration keys on data.record_id, a payload field the Audit Trail retention purge destroys in its entirety at the horizon, so past that point the substrate enumeration returns fewer transitions than the cached list, whose event_ids still resolve to events. Replacing the list with the enumeration would therefore shorten exactly the histories a forensic reader most needs. So both directions are computed and both are informative:
- In the enumeration, not in the list → an index gap. The index is a derived index and this is precisely the rebuild trigger its Contract names; the entry is added, the transition is included, and the divergence is reported as
index-gap-repairedrather than as a defect in the lifecycle record. - In the list, not in the enumeration → not an index gap, and which of two things it is is decided by the read, never presumed: call
AuditTrail.read_record(event_id)and branch on theretention_stateit returns.Purged→ the payload is past the retention horizon (or the cascade destroyed it), so it can no longer be found by a payload-keyed enumeration; the transition is included and reported aspayload-purged— a first-class forensic fact (this transition happened; its payload is lawfully gone), not an error, and the same distinction Audit Trail Invariant 8 draws between purged and missing. Otherwise the event is live and the enumeration missed it: re-run the route once from the start of the log; if the event’sdata.record_idis this record’s, the miss was a read replica behind the append — the transition is included and reported asenumeration-miss, not a class; if itsdata.record_idis another record’s or absent while the event isRetained, the index entry points at an event that does not point back — Invariant 4’s inverse orphan, a structural finding the verdict carries asbinding-gap. - In neither → the record has no recorded lifecycle at this composition’s layer: for a record the Soft Delete store shows as Deleted or Purged that is the orphan Invariant 4 names — under compensation, or a finding — and the verdict carries
binding-gap. An absent list is a miss like any other: a crash between an outcome write and the index insert leaves the event in the log and the list short or missing, and the enumeration repairs it on this read; only a transition the enumeration cannot find either is a gap.
The enumeration’s own bound is named here rather than assumed away: the rebuild reads exactly the fields the purge destroys, so the anchor is bounded by audit_trail_retention_policy and the reconciliation above is what keeps that bound from silently truncating the answer.
- For each
event_idin the reconciled sequence, ordered byintent_position(apayload-purgedentry’s key is theintent_positionthe index kept): a. CallAuditTrail.read_record(event_id)— the substrate’s declared consolidated read surface, the only surface this composition reads event metadata through — and take the event’saction_ref,actor_ref,recorded_at,data.reason(absent for a purged event), its ownsequence_numbern, its covering seal’s range[lo .. hi](or unsealed tail), and itsretention_state(Retained,Purged, orunresolved (compensation window)) from the returned audit record. The composition never reads the substrate’sevent_to_retentionor any other internal map directly. Branch on the retention state before anything the caller supplied is examined. If it isPurged, the substrate’s own verdict comes first and is the only one: callAuditTrail.verify_record(event_id, <whatever members of the range the map holds, possibly none>)— the substrate reads retention state at its step 1 and answersfailed-verification(purged)before it reads a presentation — record that, and skip step 5b: a lawfully destroyed payload is not a payload the caller failed to supply, and a membership check run ahead of the constituent’s purged answer would report every purged transition aspayload-not-suppliedand make History Complete unreachable for any record past its first purge (§Lawful destruction is answered before absence). The branch is taken on the state this read returns, never on the entry’s provenance — step 4 classifiedpayload-purgedfrom the same read, so the two agree, and an entry whose state reads otherwise is verified as the live event it is. b. (Retainedandunresolved (compensation window)events only.) Assemble the presentationoriginal_event_payloads[lo], …, original_event_payloads[hi]in ascending order (for an unsealed-tail event, the singleton atn). If any member is absent from the map, recordattestation_verification = unverifiable(payload-not-supplied(missing = <the absent sequence numbers>))and continue. Otherwise callAuditTrail.verify_record(event_id, <presentation>)— Audit Trail Invariant 7 requires the covering record set re-presented byte-exact; the caller supplies it, and the composition never fetches, reorders or normalizes it. Record the verification result, relaying the substrate’s arm as-is — the availability-class reasons, the standingpartially-purged-coverage, and the(compensation-window)qualifier alike, each with the landing the field’s definition above gives it. - Collect the
attemptsresidue: enumerate this composition’s intent events forrecord_idthrough the declared route (Composes — the same enumeration step 4 issued, keeping in composition code therecord.*_intendedevents whosedata.record_idis this record’s), and drop every one whoseevent_idappears as thedata.intent_event_id, or among thedata.intent_event_candidates, of an event in the reconciled sequence — not the index’s list, which may be shorter. What remains is the residue defined above. (This enumeration reads intent events by theirdata.record_idand inherits the route’s horizon bound — see Composition state.) - Compute
overall_verdictfrom the collected results per the class table above — including the path-legality test over the ordered sequence: the first transition isrecord.soft_deleted;record.soft_deletedis followed only byrecord.restoredorrecord.purged;record.restoredonly byrecord.soft_deleted;record.purgedby nothing; and the last transition agrees withcurrent_state(Deletedafter a delete,Activeafter a restore,Purgedafter a purge). A sequence that fails it carries theout-of-orderfailure class: the records are all present and each may verify, but they do not replay as a lifecycle Soft Delete could have committed — an ordering defect (a compensation landed under a broken serialization, or an index insert took the wrong key), neverhistory-complete. - Return
lifecycle_historywith all fields populated.
Note on original_event_payloads at Recover History. The asymmetry is surfaced at the signature: original_event_payloads is a required argument, not an internally-fetched value (Audit Trail Invariant 7 — verification asymmetry preserved: the verifier must present the original record set, not trust the host system to fetch it). For interactive forensic investigations, the investigator retrieves payloads from cold storage or the event log — through Event Log’s declared sequence-range read where the log is still online, keyed by the positions read_record names — and supplies the full map. For operational monitoring, the system supplies the payloads from its own store. A caller who supplies a partial map receives a partial history: events whose covering range is incomplete report unverifiable(payload-not-supplied(missing)) and the verdict names them, while attribution and retention for the supplied events are still verified.
read (passthrough)
read(record_id, query?) →
lifecycle_record
| rejected(invalid-request | not-known | invalid-query)
Passes directly to SoftDelete.read({record_id, ...query}) without modification. No Audit Trail event is recorded — this is a pure read that changes no state. Returns the current Soft Delete lifecycle record for record_id. Does not return the full history (use Recover History for that); returns only the current-state summary Soft Delete provides. Propagates invalid-query from Soft Delete unchanged.
The load-bearing wiring decision — lifecycle-transition ⇒ audit-event binding (and emergent full-history recoverability)
The composition’s structural reason to exist: every Soft Delete lifecycle action (soft_delete, restore, purge) emits, ordered after it and never atomically with it, an Audit Trail record_action that (a) names the record_id in its data payload, (b) attributes the acting actor via credential (Actor Identity through Audit Trail), and (c) is thereby sealed and placed under retention. The binding — this transition’s event — is inserted into record_to_events[record_id] at its intent position.
Principle. A defensible deletion record requires that every delete/restore/purge be attributed to a verified actor, tamper-evidently sealed, and that the complete lifecycle history of any record be reconstructable — not just the current-state summary. No single atom satisfies all three. Soft Delete satisfies none of them fully: its attribution fields are immutable by specification but not cryptographically, it explicitly defers full history to Event Log composition, and it explicitly defers tamper-evidence to Tamper Evidence. Audit Trail satisfies attribution, tamper-evidence, and retention but does not know which events belong to which record’s deletion lifecycle, so it cannot reconstruct the lifecycle without the record_to_events binding.
Likely objection. “Soft Delete already records who deleted and who purged — why compose? The lifecycle record carries full attribution.”
Mechanism that resolves it. Soft Delete keeps only the most-recent attribution per category and is immutable only by specification (not cryptographically). The atom’s own Behavior section states: “The atom retains only the most recent attribution in each category; the full cycle history requires Event Log composition.” Its Edge cases state: “full history of all cycles requires composition with Event Log.” Its Composition notes state: “Actor Identity provides cryptographic attestation that those references are real, credentialed actors.” The composition fills each of these gaps simultaneously via the Audit Trail substrate: the Event Log retains every transition in order (not just the most recent); Tamper Evidence seals them cryptographically (not just by specification); Actor Identity verifies the actor (not just records an opaque reference). The record_to_events binding makes all three available per transition and per record.
Result. A records-alone-defensible, tamper-evident, fully-recoverable deletion lifecycle that neither constituent provides alone. An investigator holding record_to_events plus the Soft Delete store plus the Audit Trail substrate can answer, for any record_id, the regulator’s questions — who deleted it, who restored it, who purged it, in what order, is the record intact, was it kept long enough — from the records alone, without developer narration, without source code, without runbooks.
Composition-level invariants
These invariants (conditions that must always hold) emerge from the composition. None belongs to a single constituent; each requires both Soft Delete and the Audit Trail substrate working together to hold.
-
Invariant 1 — Lifecycle attribution coverage. Every
soft_delete,restore, andpurgetransition recorded in the Soft Delete store has exactly one corresponding Audit Trail event (viarecord_to_events) whose verified attestation binds the acting actor’sactor_refto a verified credential, and whoseaction_refmatches the transition type (record.soft_deleted,record.restored,record.purgedrespectively). No lifecycle transition exists without an attributable, attribution-verified audit record. Rests on: Soft Delete Invariants 1 and 8 (deletion attribution immutability and completeness), Soft Delete Invariant 5 (purge attribution completeness), and Audit Trail Invariant 1 (attribution coverage — every Audit Trail event has a verified attestation). Established by the outcome record and the index insert at the intent’s position (steps 4 and 5) in each lifecycle action wiring. -
Invariant 2 — Purge accountability. No record reaches Purged without an Audit Trail event naming
actor_ref(who purged),recorded_at(when), anddata.reason(stated authority/justification), sealed via Tamper Evidence. An anonymous purge, a missing reason, or an unsealed purge event is a conformance failure. This is the composition’s headline regulated invariant — the property no downstream auditor, regulator, or court should be able to discover has been violated without the records surfacing it. Rests on: Soft Delete Invariant 5 (purge attribution completeness:purged_byandpurge_reasonrequired), Soft Delete Invariant 4 (purge requires prior deletion), and Audit Trail Invariant 3 (integrity coverage — every event covered by a seal, modulo the unsealed-tail window bounded byseal_cadence). -
Invariant 3 — Forensic completeness / full-history recoverability.
recover_history(record_id)reconstructs the complete ordered delete/restore/purge history of any record, including prior deletion epochs that Soft Delete’s current-state summary overwrote. Completeness rests on the reconciliation, not on the index, and this is a capability-provenance dependency rather than an implementation note:record_to_eventsis a derived index whose rebuild-on-miss contract covers a keyed lookup, and reading the list under a key is a shape that contract does not cover — an element lost from the list is not a miss, so the lookup succeeds and a truncated history would return indistinguishable from a complete one. Recover History step 4 therefore builds the sequence by reconciling the substrate’s lifecycle-event enumeration against the index in both directions, because each can hold what the other has lost: the enumeration finds transitions the index dropped, and the index holds transitions whose payloads are past the retention horizon and which a payload-keyed enumeration can no longer reach. This invariant is a claim about the reconciled sequence. For every event in the reconciled sequence whose covering range is supplied,attestation_verification = verifiedconfirms attribution and seal integrity. The complete ordered history is the emergent guarantee neither constituent provides alone: Soft Delete keeps only the current-state summary; Audit Trail’s Event Log keeps every event but cannot associate them with a lifecycle withoutrecord_to_events. Rests on: Event Log Invariant 3 (total order preserved) applied to the intent events — the occurrence key every outcome carries asintent_event_id, which is what makes the reconciled sequence orderable at all and keeps a late-landing compensation in its place (Composition state; the whole-action serialization of Concurrent lifecycle actions), with theout-of-orderfailure class as the verdict when the ordered sequence is not a legal lifecycle path; Event Log Invariant 2 (event immutability); Audit Trail Invariant 7 (verification asymmetry preserved — the verifier supplies original payloads); Audit Trail Invariant 8 (purged distinguished from missing), which is what lets the reconciliation reportpayload-purgedas a fact rather than a gap; the declared route for the lifecycle-event enumeration (Composes — the substrate’s pass-through open-upper-bound sequence-range read, an instance capability requirement on the wired Audit Trail, with theaction_refanddata.record_idselection made in composition code; the substrate declares no read by payload field and routes it to Reverse Index) — the same traversal therecord_to_eventsrebuild procedure and the step-6 residue enumeration use, and the declared source for the anchor’s existence; the append-only discipline ofrecord_to_events; and, for the anchor’s totality, the deployment’saudit_trail_retention_policy— the enumeration reads a payload field the purge destroys, which is the reason the reconciliation runs in both directions rather than replacing the index. -
Invariant 4 — Binding bijection / no dangling partial (safety + liveness). A one-to-one binding between lifecycle transitions committed through this composition and Audit Trail lifecycle events: every such transition has exactly one corresponding
record_actionevent whosedata.record_idpoints back to it, and vice versa. The two truth-bearing writes (the Soft Delete transition and the audit event; therecord_to_eventsmap is a derived index outside this surface — see Composition state) are ordered, never atomic — the Soft Delete transition first, the outcome append second; the substrate’s append cannot be withdrawn and Soft Delete’s transitions are not always reversible (Purged is terminal per Soft Delete Invariant 3), so no transaction spans them and none is claimed, and the failure path of the Cross-store consistency under partial failure edge case is what closes the one partial the ordering leaves. That orphan state — a committed transition with no audit event — is reachable under the prescribed design, durably, until compensation lands. For a purge transition this is Invariant 2’s worst case: a destroyed record awaiting its attributed audit event. The honest claim therefore splits:- Safety — no unsurfaced orphan. At all times, every orphan is detectable from the records alone (enumerate the unmatched intents through the declared route — Composes — and pair each against the Soft Delete record it names, the partial-failure edge case’s rule). Surfacing has two mandatory legs: a partial failure that returns surfaces the orphan as the
findingpayload of the same outcome that returnsrejected(recording-failure(outcome(finding))); a partial failure that cannot return — a process crash between the two truth-bearing writes — is caught by the mandated reconciliation scan (the same orphan enumeration, run at restart and onreconciliation_cadence, betweentransition_completion_boundand the audit horizon; see the partial-failure edge case), so no orphan survives unsurfaced past the scan bound. The intent record sharpens this leg rather than replacing it: the crashed invocation is no longer inferred solely from a state-versus-trail difference, because it left a record of its own naming therecord_id, the actor whose credential was verified, and the transition it was about to make — so the scan reads what was attempted from the trail instead of reconstructing it, and an orphan is distinguishable from a bypass without consulting anything outside the Audit Trail. Never a quiet inconsistency. And an orphan stays pairable while it stands: every lifecycle action’s step 2 refusesorphan-pendingunder the record’s section while a committed transition is owed its outcome, so no later transition overwrites the attribution fields the pairing reads — Soft Delete keeps only the most recent of each kind — and an orphan is always the most recent transition of its kind for its record. A lifecycle audit event naming arecord_idwith no Soft Delete lifecycle record (the inverse orphan) is unreachable through this composition’s wiring. Recovered bindings remain distinguishable from clean ones via therecoverymarker on the compensating event. - Liveness — every orphan is bound within
compensation_window. The mandated compensation (retry the failedAuditTrail.record_action— inside the invocation under the actor’s credential for at mostoutcome_retry_attempts, holding the record’s section; by the scan underrecovery_identitybehind arecord.recovery_intendedrecord thereafter, under the same section; never both: one writer per transition, the pre-check re-read under the section by whichever writes — partial-failure edge case) restores the bijection: an orphan is a surfaced transient under compensation, never a steady state of a conforming implementation. Formally: Orphan(t) ↝ Bound(t) under weak fairness on the compensation — the invocation’s bounded retry and, past its terminus, the scan’s runs onreconciliation_cadence; the eventuality lives in the scan’s obligation, not in an unbounded retry; the formal model carries its enabledness half (the compensating action is enabled in exactly the orphan configuration, so no orphan state is a dead end). The retry discharges transient failures (recording-failure); a deterministic rejection of the audit write (invalid-credential/invalid-request— first detectable after the Soft Delete write, per the uniform rejection-mapping rule) cannot land by repetition, so there the compensation re-attests under the deployment’s declaredrecovery_identity(see Configuration and the partial-failure edge case) — the eventuality holds across both failure classes, by retry on one and by recovery-attestation on the other. One carve-out, stated: an outcome the substrate refusesinvalid-requestwith nothing appended after step 1 has sized the envelope is the substrate’s own cap disagreement or Actor Identity’s owninvalid-requestatattest— deployment faults — and no compensation can land until the deployment repairs it; the orphan is surfaced and escalated at once, and is bound within the window only in a deployment that has repaired the fault inside it, which is what the records can support.
This is the load-bearing claim and the formal-model subject — the model covers both arms, the clean ordered pair and the compensated partial failure; it mirrors Audit Trail Invariant 4 (cascade-on-purge atomicity) at the lifecycle-transition-creation boundary, and Invariant 2’s purge-accountability coverage rides the same predicates. A violation — an unsurfaced transition orphan, or an orphan outside compensation — is a high-priority finding surfaced per the partial-failure edge case.
Precondition — this composition is the sole write path to its Soft Delete instance. The composition maintains exactly one Soft Delete instance and is the only writer of it; every
soft_delete/restore/purgeagainst that instance flows through a lifecycle action of this composition and therefore appends torecord_to_eventsand emits an Audit Trail event before the action returns. A direct Soft Delete write that bypasses this composition would create a transition with no audit event and is outside the composition’s guarantee (and a violation of this invariant). Because Soft Delete retains only current-state attribution — it does not store its own transition history — the authoritative record of the full transition sequence isrecord_to_eventsplus the Audit Trail Event Log, not the Soft Delete store. The bijection’s forward direction (everyrecord_to_eventsevent is a real, back-pointing Audit Trail event) is independently auditable from the records; the reverse direction (every committed transition has an event) holds by construction of the sole write path and is corroborated for the current state bySoftDelete.read, since a re-enumeration of prior-epoch transitions from Soft Delete alone is impossible by design. - Safety — no unsurfaced orphan. At all times, every orphan is detectable from the records alone (enumerate the unmatched intents through the declared route — Composes — and pair each against the Soft Delete record it names, the partial-failure edge case’s rule). Surfacing has two mandatory legs: a partial failure that returns surfaces the orphan as the
-
Invariant 5 — Constituent invariants preserved. Soft Delete Invariant 1 through 8 hold over the Soft Delete store instance; Audit Trail Invariant 1 through 8 hold over the Audit Trail substrate instance, and transitively all Event Log, Actor Identity, Retention Window, and Tamper Evidence invariants hold over their constituent instances within the substrate. The composition does not weaken or override any constituent invariant.
-
Invariant 6 — Authentication precedes destruction and commitment. No
SoftDeletecall that commits a lifecycle transition is reached on any path before this composition’s caller has presented a credential that validates against the actor registry’s public material for the suppliedactor_ref. The intent record is the mechanism: it is anAuditTrail.record_actioncall, the substrate validates the credential inside it, and it stands beforesoft_delete,restore, and — the load-bearing case —purge. A record is therefore never destroyed on an unverified actor’s asserted authority, and this composition’s repeated claim to record an actor with a valid credential is exact rather than approximate:invalid-credentialis a pre-state refusal with nothing destroyed. What this does and does not establish: a successful validation 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 — and it establishes nothing whatever about authority, which this composition deliberately leaves to a composing Permissions pattern (Access control). Rests on Audit Trail’srecord_actionand the Actor Identity attestation reached through it. Defended in-line: the ordering is visible in each action’s step list, and Generation acceptance check 5 tests it from the records alone.
Examples
Walkthrough — GDPR Article 17 erasure under supervisory authority scrutiny
A healthcare SaaS (Software as a Service) platform uses this composition to govern the deletion lifecycle of patient profile records. Configuration: audit_trail_retention_policy = hipaa_6yr_audit (encoding a 6-year audit-record retention minimum per HIPAA §164.312(b)), seal_cadence = per-event.
-
Patient requests erasure. DSAR (Data Subject Access Request — a request by an individual to see, correct, or erase the personal data an organization holds about them) workflow calls
delete_record(actor_ref="dsar_service", record_id="profile-4491", credential=<dsar_credential>, reason="GDPR Art. 17 erasure request — ticket DSR-2026-0441")→{record_id="profile-4491", event_id="ev_5001"}. Step 2 enters the record’s section, finds no pending orphan, and writes the intent recordrecord.soft_delete_intended→ev_5000at sequence position 5000; Soft Delete transitionsprofile-4491to Deleted;AuditTrail.record_action(record.soft_deleted, actor_ref="dsar_service", ...)→ev_5001;record_to_events["profile-4491"] = [{event_id="ev_5001", intent_position=5000}]. The deletion is attributed and sealed. -
Erasure confirmed and purge executed. After confirming no Legal Hold blocks the purge (that check is the responsibility of a composing Defensible Retention instance in this deployment), the workflow calls
purge_record(actor_ref="dsar_service", record_id="profile-4491", credential=<dsar_credential>, reason="GDPR Art. 17 erasure confirmed — no blocking hold — ticket DSR-2026-0441")→{record_id="profile-4491", event_id="ev_5003"}. The intent recordrecord.purge_intended→ev_5002at position 5002; Soft Delete transitionsprofile-4491to Purged;AuditTrail.record_action(record.purged, actor_ref="dsar_service", ...)→ev_5003;record_to_events["profile-4491"] = [{event_id="ev_5001", intent_position=5000}, {event_id="ev_5003", intent_position=5002}]. -
GDPR supervisory authority audit. A Data Protection Authority (DPA) auditor asks: “Prove the erasure of profile-4491 was performed, attributed, tamper-evident, and the full lifecycle is recoverable.” The system calls
recover_history(actor_ref="dpa_auditor", record_id="profile-4491", original_event_payloads={5001: <payload_1>, 5003: <payload_2>})— the map is keyed by sequence position, and underper-eventcadence each covering range is the singleton at the event’s own position.current_state = Purged.attempts = [].events:[{sequence_position=1, provenance=both, event_id="ev_5001", action_ref=record.soft_deleted, actor_ref="dsar_service", acting_actor_ref="dsar_service", recovery=false, recorded_at=T1, reason="GDPR Art. 17 erasure request…", attestation_verification=verified, retention_state=Retained}, {sequence_position=2, provenance=both, event_id="ev_5003", action_ref=record.purged, actor_ref="dsar_service", acting_actor_ref="dsar_service", recovery=false, recorded_at=T2, reason="GDPR Art. 17 erasure confirmed…", attestation_verification=verified, retention_state=Retained}].overall_verdict = history-complete. The DPA auditor sees: (a) who deleted the record and why; (b) who purged it, when, and under what stated reason; (c) both events are tamper-evidently sealed; (d) both are under active retention. Invariant 1 through 3 are the structural guarantees behind each field. No developer narration required.
Multi-epoch lifecycle — delete, restore, re-delete, purge
A content moderation system uses this composition for post lifecycle management. A post is deleted, then reinstated on appeal, then deleted and purged after the appeal window closes. Soft Delete retains only the most recent deletion attribution; this composition retains the full ordered history.
delete_record(actor_ref="mod_jones", record_id="post-8821", credential=..., reason="Policy violation — review pending")→ev_6001(its intentev_6000at position 6000).record_to_events["post-8821"] = [{ev_6001, 6000}].restore_record(actor_ref="appeals_team", record_id="post-8821", credential=..., reason="Appeal upheld — reinstatement")→ev_6003(intent at 6002).record_to_events["post-8821"] = [{ev_6001, 6000}, {ev_6003, 6002}].delete_record(actor_ref="mod_chen", record_id="post-8821", credential=..., reason="Policy violation — appeal exhausted")→ev_6005(intent at 6004).record_to_events["post-8821"] = [{ev_6001, 6000}, {ev_6003, 6002}, {ev_6005, 6004}]. At this point Soft Delete’sdeleted_by = "mod_chen", overwriting"mod_jones"— the prior epoch is gone from Soft Delete’s current-state summary.purge_record(actor_ref="retention_service", record_id="post-8821", credential=..., reason="90-day post-appeal purge policy")→ev_6007(intent at 6006).record_to_events["post-8821"] = [{ev_6001, 6000}, {ev_6003, 6002}, {ev_6005, 6004}, {ev_6007, 6006}].
recover_history(record_id="post-8821", ...) returns all four events in order, including ev_6001 (the original moderation deletion by "mod_jones") that Soft Delete’s current-state summary no longer carries. Invariant 3 (forensic completeness) is the structural guarantee: the full ordered history is recoverable from the composition’s records even after Soft Delete’s current-state attribution has been overwritten by a subsequent epoch.
Rejection path — purge of an Active record
An automated purge job targets a record that was never soft-deleted: purge_record(actor_ref="purge_job", record_id="doc-0099", credential=..., reason="scheduled purge"). Step 2 enters the record’s section, finds no pending orphan, and writes the intent record record.purge_intended — the job’s credential validates, so the attempt is authenticated and durable. Step 3 calls SoftDelete.purge("doc-0099", ...) → rejected(not-deleted) (per Soft Delete Invariant 4 — Active → Purged direct path is prohibited). The composition returns rejected(not-deleted). No Soft Delete state is written; no outcome event is recorded; record_to_events is unchanged. Invariant 4 (binding bijection) is preserved: no transition, no outcome event, no entry — the binding bijection is over transitions and outcome events, and an intent record is neither. The intent record stands, and this is the point: an automated job attempting to destroy a record it had no business destroying is exactly what a forensic trail should retain. It surfaces in Recover History’s attempts residue and is adjudicated by Generation acceptance check 5 against a Soft Delete store that shows the record was never deleted — an attempted destruction that the composition refused. Before the intent record existed, this attempt left no trace anywhere.
Rejection path — Delete Record on an already-Deleted record
delete_record(actor_ref="admin", record_id="profile-7723", credential=..., reason="duplicate delete") where the record is already Deleted. Step 2 finds no pending orphan (the record’s current deletion is bound) and writes the intent record record.soft_delete_intended. Step 3 calls SoftDelete.soft_delete("profile-7723", ...) → rejected(already-deleted). The composition returns rejected(already-deleted). No state is written; no outcome event is recorded. The intent record stands and appears in Recover History’s attempts residue — here the benign reading, a duplicate request against a record already in the state the caller wanted, which check 5 resolves against the Soft Delete store.
Regulated adversarial scenarios
Three scenarios the composition must survive in regulated contexts:
Regulator audit — GDPR/HIPAA: prove a data subject’s erasure was performed, attributed, tamper-evident, and the full lifecycle is recoverable.
A GDPR supervisory authority or HIPAA Office for Civil Rights (OCR) investigator queries recover_history(record_id="profile-4491", ...) with original event payloads. The returned lifecycle_history:
current_state = Purged— the record is destroyed, consistent with the erasure claim.- For each event in the reconciled sequence for
profile-4491:attestation_verification = verifiedby Invariant 1 (lifecycle attribution coverage) — each transition has an Audit Trail event whose Actor Identity attestation bindsactor_refto a credential. The Tamper Evidence seal (via Audit Trail Invariant 3) confirms no event was rewritten after the fact. - Every event’s
retention_state = Retained— the audit events are under active retention per the configured policy (Audit Trail Invariant 2 — retention coverage). overall_verdict = history-complete. The regulator’s question — who deleted the record, who purged it, was the record tampered with, and is the audit trail being kept? — is answered from the records alone. Invariant 1 through 3 are the structural basis for each answer. No developer narration is required.
Disputed erasure — data subject claims their record was not erased, or was erased without their request.
A data subject or their representative challenges the system: “I never requested erasure — who deleted my record and why?” or “I requested erasure months ago and nothing was done.”
Claim (a) — unauthorized erasure: Recover History returns the full event list, including the earliest record.soft_deleted event with actor_ref and data.reason. Invariant 1 (lifecycle attribution coverage) guarantees every transition is attributed. If the reason field on the first record.soft_deleted event does not reference a DSAR ticket or an authorized process, the attribution is in the record — the actor who performed the deletion is named. The data subject’s claim that it was unauthorized is an external-clearable question (whether the actor had authorization is a Permissions/governance matter, not a records matter), but the records name who did it.
Claim (b) — erasure not performed: SoftDelete.read({record_id}) returns the current lifecycle record. If the record is in Deleted or Active state rather than Purged, the erasure was not completed; the reconciled sequence Recover History returns shows every transition taken, and the absence of a record.purged event in that sequence is the structural evidence. The composition records what happened, not what should have happened; the absence of a purge event is the honest answer that the erasure was not completed.
In both cases the records answer from Invariants 1 and 3; the challenge cannot be sustained without claiming the records were fabricated, at which point Audit Trail Invariant 3 (tamper-evident seal) and attestation_verification = verified from each event are the structural rebuttal.
Breach or incident investigation — reconstruct every delete/restore/purge in an anomaly window and detect tampering via seal verification.
An incident responder suspects records were purged by an unauthorized actor during an anomaly window (02:00–04:00 UTC on a given date). The responder reads the window through the substrate’s pass-through wall-time range read — a declared query shape on Event Log’s read, which passes through Audit Trail unchanged — and keeps, in the responder’s own code, the events whose action_ref = record.purged, since a selection by action_ref is not a query the substrate serves (Composes, the declared route). For each found event_id:
- The
actor_reffield names who executed the purge. Anactor_refoutside the authorized purge-actor set is an immediate finding (Invariant 2 — purge accountability requires the purge be attributed; it does not guarantee the actor was authorized, which is externally clearable via Defensible Retention / Permissions, but it names the actor structurally). AuditTrail.verify_record(event_id, <the covering range's payloads, as read_record names the range>) → verifiedconfirms the event has not been tampered with since it was sealed. Afailed-verification(seal-proof-invalid)result is a finding that the record was altered after sealing.record_to_events[record_id]can be read for each affectedrecord_idto reconstruct the full lifecycle context around the anomaly purge: was the record deleted just before the purge (consistent with a rapid delete + purge sequence), or was it in a long-standing Deleted state (consistent with a scheduled purge)?
An unexpected actor_ref on a record.purged event, or a failed-verification on any event in the window, is a forensic finding. The seal cadence governs the window’s resolution: a tighter cadence narrows the range of events that could have been tampered with between seal checkpoints. Invariant 1 through 4 are the structural basis for the investigation; the composition’s records answer the investigation’s questions from the records alone.
Generation acceptance
A derived implementation of this composition is acceptable — in the regulator-acceptance sense — when an external auditor, given the composition’s emergent state (record_to_events) plus the Soft Delete store and the Audit Trail substrate stores, can do all of the following without recourse to source code, runbooks, or developer narration.
Record checks
These checks are answerable by reading the composition’s records (including the Audit Trail substrate and the Soft Delete store):
-
Every lifecycle transition has a verified attribution. Enumerate the records from the substrate’s lifecycle events through the declared route (Composes — the open-upper-bound sequence-range read from the start of the log, the
action_refanddata.record_idselection made in the auditor’s own code) — not fromrecord_to_events— and reconcile each against the index as Recover History step 4 does. An auditor who quantified over the index would be reading a derived index at a shape its rebuild-on-miss contract does not cover, so a lostrecord_idkey or a lost list element would present as a record or transition that never existed, and the check would pass over exactly what it exists to verify. For everyrecord_idso found, and for everyevent_idin its reconciled sequence, callAuditTrail.verify_record(event_id, <the covering range's payloads, as read_record names the range>)and confirm it returnsverified(orfailed-verification(purged)for events whose retention has lawfully elapsed — distinguished from missing per Audit Trail Invariant 8). An entry inrecord_to_eventswith no corresponding Audit Trail event, or whoseattestation_verificationreturnsfailed-verification(attestation-...), is a conformance failure. Invariant 1 is the contract. -
No purge lacks an attributed, tamper-evident audit event. For every Purged record in the Soft Delete store (identified by
SoftDelete.read({state: Purged})), confirm the record’s reconciled sequence — the substrate’s lifecycle events through the declared route, reconciled against the index as Recover History step 4 does, never the index list alone — contains arecord.purgedevent, and thatAuditTrail.verify_recordreturnsverifiedfor that event. Confirm the event’s ownactor_ref— or, for an event carryingdata.recovery = true, itsdata.acting_actor_ref— and itsdata.reasoneach contain at least one non-whitespace character. A Purged record with norecord.purgedevent in its reconciled sequence, or whose purge event has an emptyreason, is a conformance failure. Invariant 2 is the contract. -
Full history is recoverable and replays in order. For a sample of
record_ids, callrecover_history(record_id, original_event_payloads)with every covering range supplied. Confirmoverall_verdict = history-complete. Test the output against sources it was not built from — never againstrecord_to_events, which step 4 reconciled it with, so agreement with the index proves nothing: (i) every transition the substrate enumeration finds for the record appears inevents, and everypayload-purgedentry resolves throughread_recordto an event whose attestation carries a lifecycleaction_refand whose retention record isPurged; (ii) the sequence is ordered byintent_positionascending, and each entry’sintent_event_id(where its payload is readable) resolves to an intent event at exactly that position; (iii) the sequence passes the path-legality test of step 7 and its last transition agrees withSoftDelete.read({record_id})’scurrent_state; and (iv) for records that have had multiple delete/restore cycles, the intermediate delete and restore events appear — events Soft Delete’s current-state summary does not retain. A history that passes (i)–(iv) is complete by evidence outside the index; one that agrees with the index and fails any of them is the loop the earlier form of this check closed on its own input. Invariant 3 is the contract. -
Binding bijection is complete. Forward direction (fully auditable): for every
record_idinrecord_to_events, everyevent_idin the list is present in the Audit Trail substrate (not a dangling reference) and carries adata.record_idpointing back to the same record. This direction verifies the index against its source and can only find dangling references — it cannot find omissions, by construction, and an auditor must not read a clean forward pass as evidence of completeness. The direction that finds omissions is the reconciliation: enumerate the substrate’s lifecycle events through the declared route (Composes), select bydata.record_idin the auditor’s own code, and confirm every transition so found appears in the index. One that does not is an index gap the rebuild closes; one the index holds but the enumeration cannot reach is apayload-purgedentry (Audit Trail Invariant 8), not a defect. Running only the forward direction is the shape an earlier sweep of this pattern found and named. Then confirm one writer per transition: in the same enumeration, exactly one outcome event names eachintent_event_id(or names it among itsintent_event_candidates); two isbinding-duplicate— a second writer, foreclosed by the one-writer rule of the partial-failure edge case — and a conformance failure. Reverse direction: every committed lifecycle transition has an event — this holds by construction (this composition is the sole write path to its Soft Delete instance; each action appends its event before returning) and is corroborated for the current state by confirming thatSoftDelete.read({record_id})’s most-recent transition matches the last relevantrecord.soft_deleted/record.restored/record.purgedevent in the record’s reconciled sequence — the log through the declared route, reconciled against the index — never the index list alone. The reverse direction runs between two edges (Configuration,transition_completion_bound): a current Soft Delete state whose matching intent is younger than the bound may belong to an invocation still between its writes and is inconclusive, not an orphan; one whose events lie past the audit horizon is the retention asymmetry case, answered by the index’s extraction-pending half and never by a compensation. For an orphan inside the edges, confirm either a compensating outcome event carryingdata.recovery = true, attested underrecovery_identity, naming the original actor asdata.acting_actor_ref, carrying the originaldata.intent_event_id(ordata.intent_event_candidates, at mostintent_candidates_capof them), and preceded by arecord.recovery_intendedrecord, landed withincompensation_windowof the transition’s stamp, or an open finding naming therecord_id; neither present is the failure. Confirm the window is meetable at all by reading Configuration’s three terms and checkingtransition_completion_bound + reconciliation_cadence + outcome_write_latency < compensation_windowstrictly — a check that reads one knob confirms nothing (§Liveness is arithmetic). And confirm the gate held: for every record, no outcome event’s intent lies between a committed-but-unbound transition’s intent and its compensating outcome — a transition committed on top of a pending orphan is a conformance failure, since it overwrote the attribution the pairing needed. Soft Delete does not retain prior-epoch transitions, sorecord_to_eventsplus the Audit Trail Event Log — not the Soft Delete store — is the authoritative transition history for the reverse-direction check. Anevent_idinrecord_to_eventsthe Audit Trail substrate does not recognize, a back-pointer mismatch, or a current-state transition with no matching tail event is a conformance failure. Invariant 4 is the contract. -
Authentication precedes the transition. For every lifecycle event in the trail (
record.soft_deleted,record.restored,record.purged— obtained through the declared route, Composes), confirm an intent event of the matching kind (record.soft_delete_intended,record.restore_intended,record.purge_intended) precedes it in the Audit Trail’s own sequence, carrying the samerecord_idand the sameactor_ref. The join is exact rather than inferred on all three: every outcome event carriesintent_event_idnaming the intent event that authenticated it. Because the substrate validates the caller’s credential against the actor registry inside everyrecord_action, the named intent event is the records-alone proof that the actor was authenticated before the transition committed — this is what makes Invariant 6 verifiable rather than asserted, and it is what entitles the composition to say it records an actor with a valid credential. An outcome event whoseintent_event_idnames no event, or names one under a differentactor_ref, is a conformance failure: the transition either bypassed this composition’s surface or committed before its authentication. One carve-out, and it is the compensation path: an outcome event carryingrecovery = trueis attested underrecovery_identityrather than the acting human, so its ownactor_refdeliberately differs from its intent event’s — the check compares the intent event’sactor_refagainst the original actor the recovery event names asdata.acting_actor_ref, not against its attesting actor. Without that carve-out this check would condemn every event the composition’s own deterministic-rejection compensation produces. An intent event with no outcome event is not a failure — and for this composition it is forensically material rather than mere bookkeeping. It names an attempt: a lifecycle change an authenticated actor began and that did not complete, or completed without recording. Enumerate them against the Soft Delete store — where the state moved, the owed outcome event is the orphan the reconciliation scan compensates (Invariant 4); where it did not, the intent stands as the record of an attempted deletion, restore, or destruction that did not happen. A regulated deployment reading this trail can therefore distinguish nothing was attempted from something was attempted and refused or lost, which the pre-intent-record design could not express at all. -
Constituent Generation acceptance bars. Verify each constituent’s own Generation acceptance bar over its respective store: Soft Delete’s six checks (lifecycle record retention, purge attribution completeness, two-step purge path, terminal absorption, multi-cycle coherence, deletion attribution completeness) and Audit Trail’s six checks (all four audit questions answerable, all eight composition-level invariants verifiable, each constituent atom’s GA bar satisfied, forensic window boundable, honest destruction distinguishable, composing patterns identifiable). The composition’s invariants depend on the correctness of the constituents’ invariants.
External checks
These audit questions arise around this composition but cannot be answered from the composition’s records alone:
- Whether the purge was eligible at the time it was executed. This composition records that the purge was performed, who performed it, when, and under what stated reason (Invariant 2). It does not record whether a Legal Hold was active at the time, whether the Retention Window had elapsed, or whether any other eligibility condition was satisfied. Answering the eligibility question requires Defensible Retention’s records — specifically its Purge Record Audit Trail events carrying
hold_check_result: empty(the hold gate passed) and its Retention Window records. This composition is the forensic-attribution composition; Defensible Retention is the eligibility-gate composition. A deployment composing this composition + Defensible Retention gets both the attributed, sealed lifecycle history and the defensible-eligibility evidence. - Whether the acting actor was authorized to perform the lifecycle action. This composition records
actor_refand verifies the credential via Audit Trail’s Actor Identity. It does not verify thatactor_refwas authorized under the deployment’s organizational policy to delete, restore, or purge. Authorization requires a Permissions instance scoped toforensic:delete,forensic:restore,forensic:purge(or the deployment’s equivalent scopes) — a composing peer. This composition records that an actor with a valid credential performed the action — and the claim is exact, because the credential is verified at the intent record before the lifecycle transition commits (Invariant 6), not merely at the audit write that follows it; whether that actor was the right actor is the Permissions question. - Whether the record’s content was actually destroyed. This composition records that
SoftDelete.purgereturnedpurgedand the audit event was committed. Whether the host system’s content deletion was executed — whether the bytes were actually erased from storage — is the host system’s obligation, per Soft Delete’s Edge case “Content destruction is handled by the host system.” this composition records the lifecycle transition; content-destruction assurance requires the host system’s own attestation or a media-sanitization audit (NIST SP 800-88 — US National Institute of Standards and Technology guidelines for storage media sanitization).
Non-goals and edge cases
-
Purge eligibility gate. Whether a Deleted record is eligible for purge — whether a Legal Hold blocks it, whether the Retention Window has elapsed — is not enforced by this composition. The composition records a purge when called; it does not gate the call. Purge gate enforcement belongs to Defensible Retention, which wires Legal Hold + Retention Window + Audit Trail into the hold-blocks-purge gate. This composition is the forensic-attribution composition; Defensible Retention is the eligibility-gate composition. A deployment requiring both the attributed lifecycle history and the hold-blocks-purge gate composes this composition + Defensible Retention; neither absorbs the other’s concept.
-
Content destruction is handled by the host system. Inherited from Soft Delete. The composition’s Purge Record action delegates to
SoftDelete.purge, which signals thepurgedoutcome; the host system is responsible for executing the content deletion against its own storage layer. This composition records the lifecycle transition; it does not implement or verify content destruction. -
Cross-store consistency under partial failure. Every this composition lifecycle action writes its intent record first, then the Soft Delete transition, then the outcome record, then inserts into
record_to_eventsat the intent’s position (a derived index outside the two truth-bearing writes — see Composition state). A failure after the Soft Delete write but before the Audit Trail write produces an orphan lifecycle transition: a Soft Delete state change with no Audit Trail event. Soft Delete’s transitions are not always reversible (Purged is terminal per Soft Delete Invariant 3), so synchronous rollback is not universally available. The implementation must (a) retry the failedAuditTrail.record_actioninside the invocation — under the record’s critical section, for at mostoutcome_retry_attempts(Configuration), the counted terminus the deployment sets to complete insidetransition_completion_bound— and, when the attempts are spent, returnrejected(recording-failure(outcome(finding))), release the section and yield the orphan to the scan; (b) surface the orphan as thefindingpayload of that same outcome, and to the compliance dashboard; and (c) once the compensating Audit Trail event lands, insert it intorecord_to_eventsat its intent position — the compensating event carries the originalintent_event_idunchanged, so its place in the replay is the one its intent fixed, not the end of the list — and mark it withrecovery = trueso an auditor can distinguish a clean lifecycle transition from a recovered one. Two legs complete the protocol (added 2026-06-11 with Invariant 4’s safety + liveness restatement; bounded, keyed and attributed 2026-08-29): the reconciliation scan — the orphan enumeration (the unmatched intents, read through the declared route — Composes — and each paired against the Soft Delete record it names by the rule below), run at restart and onreconciliation_cadence, between two edges: it examines no intent younger thantransition_completion_bound(a younger one may belong to an invocation still between its writes, and a compensation fired at it would append a second outcome for one transition) and none past the audit horizon (there the intent is destroyed and the transition’srecord_to_eventsentry is the extraction-pending half — read, never re-emitted; a Soft Delete state whose events aged out is the retention asymmetry case, not an orphan). It pairs by the records: every lifecycle action passes its injectednowto Soft Delete explicitly asdeleted_at/restored_at/purged_atand stamps the same reading as the intent’sintended_at, and names the actor asdeleted_by/restored_by/purged_by, so the pairing rule — one rule, read by the scan and by every lifecycle action’s step-2 pre-check — is: an intent no outcome names (throughdata.intent_event_idordata.intent_event_candidates) is the intent of a committed transition exactly when the Soft Delete record’s current attribution field of its kind (deleted_at/deleted_byfor a delete intent,restored_at/restored_byfor a restore,purged_at/purged_byfor a purge) equals itsintended_atand itsactor_ref, and no outcome event already accounts for that field (an outcome naming an intent with the same kind and stamp binds it, and a same-stamp unmatched intent beside a bound one is a refused attempt, not an orphan); where the clock’s resolution admits more than one unmatched intent pairing to one field, the compensating event carriesintent_event_candidates— at mostintent_candidates_capof them, past which the orphan is escalated rather than compensated — and takes its place at the earliest candidate’s position, and the record says so. The rule is exact only because a record with a pending orphan admits no new transition (theorphan-pendingrefusal): Soft Delete keeps the most recent attribution of each kind and nothing older, so a later transition committed on top of an unbound one would overwrite the very field the pairing reads and leave the orphan unpairable, classified as a refused attempt, and the replay out of order. The gate is what makes the stamp a key rather than a hint. One writer per transition: the scan takes the record’s critical section (record_serialization) for every record it compensates and holds it across its pre-check and its compensating write; the pre-check — is this intent still unmatched? — is re-read under the section, never before it; a record whose section the scan cannot take (an invocation still inside itsoutcome_retry_attempts) is skipped until the scan’s next run rather than raced; and two scan runs — restart and cadence, one node or two — serialize on the same section, so the second finds the first’s outcome and writes nothing (§A compensator is exclusive). An invocation past its attempts has yielded; an invocation resuming after a stall whose lease has expired re-takes the section before the outcome position’s pre-check, adopts an outcome it finds as its own, and writes nothing otherwise — its lease was its terminus (record_serialization). Every write the scan makes is attested underrecovery_identity— the actor’s credential is not in hand — and is preceded by arecord.recovery_intendedrecord naming therecord_id, the transition, and the intent (or candidates) it pairs to, so the trail shows the compensation was occasioned by the scan and not by a direct call. What the compensating event carries —record_id,reason, the transition,data.acting_actor_ref,data.intent_event_id(or the candidates) — is re-derived from the intent event’s payload and the Soft Delete record, never remembered. The samerecord.recovery_intendedrecord precedes an in-invocation re-attestation underrecovery_identity(theinvalid-credentialarm), so Generation acceptance check 4’s preceded by a recovery record holds for everyrecovery = trueevent, whichever writer landed it. The scan catches the crash-orphan, a process death between the two truth-bearing writes that leaves no returning outcome to surface the finding, bounding how long any orphan can remain unsurfaced; and the deterministic-rejection arm — where the audit write’s failure is deterministic rather than transient (invalid-credential/invalid-request, per the uniform rejection-mapping rule), retrying the same call cannot land it, so the compensatingrecord_actionis re-attested under the deployment’s declaredrecovery_identity(see Configuration), still markedrecovery = true, naming the original actor asdata.acting_actor_refand carrying the originalintent_event_id— never a fresh intent, which would move the transition’s place in the replay and make the recovery identity its authenticated principal. Theinvalid-requesthalf of this arm is narrower than the token: after step 1 has sized the envelope — outcome and compensation alike, against the substrate’spayload_cap— the onlyinvalid-requests that leave the event unappended are the substrate’s own cap disagreement and Actor Identity’s owninvalid-requestatattest, both deployment faults; that orphan is surfaced and escalated at once and bound when the deployment has repaired the fault (Invariant 4’s carve-out), never by re-sending the identical payload. The Purge Record partial failure is the most consequential: a Purged record with no attributedrecord.purgedevent is a direct violation of Invariant 2 — visible the whole time it awaits compensation, per Invariant 4’s safety arm. Deployments under GDPR Article 17, HIPAA §164.310(d)(2), or FRCP Rule 37(e) exposure must treat any orphan purge transition as a hard alerting condition. -
Access control. Who may Delete Record, Restore Record, Purge Record, or Recover History is not defined by this composition. That is the obligation of a composing Permissions pattern. The composition takes
actor_refandcredentialat every action boundary; the deployment wires Permissions checks at the calling layer using the scopesforensic:delete,forensic:restore,forensic:purge, andforensic:read. Purge in particular should be a restricted action in any deployment handling regulated records. This composition records whoever calls it with a valid credential — authenticity is gated, at the intent record, before any lifecycle transition commits (Invariant 6); authority is not gated at all. The distinction is the whole content of this entry: an actor who cannot present a credential that validates against theactor_refthey claim gets nothing done here, while an actor who can, but should not, is the Permissions question this composition leaves open. -
Recover History original-payload asymmetry. The caller is responsible for supplying
original_event_payloadsto Recover History. The composition does not internally fetch payloads from the Audit Trail substrate on the caller’s behalf (Audit Trail Invariant 7 — verification asymmetry preserved: the verifier must present the record set, not trust the host system to supply it). For operational monitoring, the system supplies payloads from its own operational store. For forensic investigations, the investigator retrieves payloads from cold storage or the audit event log and supplies the full map. A caller who supplies a partial map receives a partial history: entries whose covering range is not wholly present reportunverifiable(payload-not-supplied(missing)), naming the absent sequence numbers, and the verdict names them. This is not a failure of the composition; it is an incomplete verification input. The pattern mirrorsverify_custodyin Chain of Custody. -
Right-to-erasure vs. retention of the audit events themselves — the meta-question. A data subject invoking GDPR Article 17 erasure requests destruction of their personal data. This composition deletes and purges the underlying record. The Audit Trail events recording the deletion lifecycle are themselves records that contain
actor_ref,reason, andrecorded_at— fields that may (depending on their content) themselves constitute personal data. Whether the audit record of an erasure can be erased is the meta-question: GDPR Article 17(3)(b) permits retaining data when processing is necessary for compliance with a legal obligation, and audit records of erasure decisions are typically retained to demonstrate compliance. However, the specific content of the audit events — beyond the structuralaction_ref,actor_ref(if opaque), andrecorded_at— requires legal counsel to assess. This composition does not adjudicate this; the Audit Trail substrate’s own Edge case Erasure Coordination governs the retention of audit events under competing GDPR pressures. This composition inherits that edge case. -
Concurrent lifecycle actions on the same
record_id. Two callers simultaneously attempting Delete Record or Purge Record on the samerecord_idmust be serialized. Soft Delete’s own Concurrency edge case governs: the first write wins; the second observes the updated state and either receivesalready-deleted,already-purged, ornot-deleteddepending on the outcome of the first. This composition propagates Soft Delete’s serialization requirement and widens it to the whole action: the critical section on a givenrecord_idspans the orphan pre-check, the intent record, the Soft Delete transition, the outcome record and its retries to their terminus, and the index insert — not the transition alone. The widened section is not Soft Delete’s — the atom serializes its own transitions and declares nothing wider — but the deployment’s, declared as therecord_serializationinstance capability requirement (Configuration), which the reconciliation scan takes as well. The widening is what makes the intent order the commit order (Composition state — the occurrence key): a section scoped to the transition lets another action’s intent and outcome interleave between this one’s transition and its outcome write, so the audit writes commit out of the order the transitions did and no key in the records recovers it. A compensation for a failed outcome write runs later, under the same section, taken by the scan for the record it compensates: the section governs how many writers an outcome can have — one — and the intent it carries governs where the outcome sits in the replay, fixed before the transition committed rather than by when the compensation lands. -
Recover History coverage of records with prior (pre-this composition) lifecycle transitions. If a
record_idhas lifecycle transitions in Soft Delete’s store that predate the deployment of this composition (i.e., records soft-deleted before this composition’srecord_to_eventstracking was in place), Recover History will return the current-state summary fromSoftDelete.readbut theeventslist will be incomplete — it will contain only the transitions recorded after this composition was deployed. Theoverall_verdictwill behistory-incomplete(binding-gap)for those pre-this composition transitions. Deployments migrating existing Soft Delete stores into this composition are responsible for backfillingrecord_to_eventsand corresponding Audit Trail events for pre-existing transitions, or for documenting the coverage boundary explicitly in their compliance posture. -
Soft Delete store vs. Audit Trail event retention asymmetry. This composition’s
audit_trail_retention_policygoverns the lifetime of the lifecycle audit events (therecord_actionentries in the Audit Trail Event Log). Soft Delete’s lifecycle records persist indefinitely by the atom’s own discipline (Soft Delete Invariant 7 — lifecycle record durability: the lifecycle record is never removed from the atom’s store once created). When a lifecycle audit event reaches its retention end and is lawfully purged via the Audit Trail cascade, the corresponding Soft Delete lifecycle record persists, and Recover History reports that event’sattestation_verification = failed-verification(purged)— lawful destruction of the attribution and seal, honestly distinguished from missing (Audit Trail Invariant 8 — honest representation of destruction). Defensible disposal of the Soft Delete lifecycle records themselves — destroying the lifecycle records, not just their attributed audit events — is a separate composing concept: a Defensible Retention instance applied to the Soft Delete store directly. This composition does not absorb full-lifecycle-record disposal; it governs the attributed audit layer. -
Auditing the forensic-query itself. Recover History and
readare pure reads; they record no Audit Trail event. For high-assurance deployments that must also account for who queried the history and when — an access-audit over the forensic-query surface — an access-logging composing pattern wraps this composition’s read surface. This composition’s own audit surface is committed lifecycle actions, not lifecycle queries. This mirrors Audit Trail’s own Failed attribution attempts edge case and Chain of Custody’s Auditing the custody-proof query itself edge case. -
Batch lifecycle operations. Delete Record, Restore Record, and Purge Record each operate on one
record_id. Bulk deletion (all records matching a filter) is a composing-layer operation. Atomic bulk deletion — where all records in a set are deleted or none are — requires a transaction wrapper in the composing layer.
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 three lifecycle actions it wraps (Delete Record, Restore Record, Purge Record) and the emergent forensic read (Recover History); the structure that read returns (Lifecycle History) with its summary Overall Verdict and per-event Attestation Verification; and the two verdict values (History Complete, History Incomplete). Its load-bearing guarantee — every lifecycle transition binds to an attributed, sealed, retention-governed Audit Trail event, and the complete ordered history of any record is recoverable from the records alone (the lifecycle-transition ⇒ audit-event binding, Invariant 4) — is a structural property, not a datum. Its emergent state (record_to_events) is a single derived index over the Audit Trail substrate, left as a backticked token; the record lifecycle states (Active → Deleted → Purged) are the Soft Delete constituent’s, not carded here. The lifecycle audit event types (record.soft_deleted, record.restored, record.purged) and the per-event verification values (verified, failed-verification(reason), not-known, unverifiable(payload-not-supplied), and the verdict classes binding-gap, attestation-failed, seal-failed, unsealed, out-of-order, payload-not-supplied, partially-purged-coverage, availability) stay backticked as wire values, as do the read passthrough (read), the constituent calls and their outcomes — Soft Delete’s soft_delete / restore / purge / read (and its Active / Deleted / Purged states and deleted_by / restored_by / purged_by attribution), Audit Trail’s record_action / read_record / verify_record and its pass-through sequence-range read — the relayed constituent tokens (record_id, event_id, actor_ref, credential, reason, recorded_at), the generic/relayed rejections (invalid-request, not-known, not-deleted, already-deleted, already-purged, orphan-pending, recording-failure(intent | outcome(finding)), invalid-query), the composition-introduced output fields left uncarded (events, attempts, sequence_position, provenance, current_state, current_summary, retention_state, unretained), the deployment configuration knobs (audit_trail_retention_policy, seal_cadence, recovery_identity, transition_completion_bound, outcome_retry_attempts, record_serialization, compensation_window, reconciliation_cadence, intent_candidates_cap, index_durability, permissions_scope_prefix), 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.)
Delete Record
The composition action that soft-deletes a record through Soft Delete and, ordered after that write, binds the transition to an attributed, sealed Audit Trail event (record.soft_deleted) inserted into record_to_events at its intent position. Refused orphan-pending while a committed transition of the record is still owed its audit event. Returns {record_id, event_id, unretained?}.
Kind: Operation
Restore Record
The composition action that restores a Deleted record to Active through Soft Delete and binds the restore transition to an attributed Audit Trail event (record.restored). Refused orphan-pending while a committed transition of the record is still owed its audit event. Returns {record_id, event_id, unretained?}.
Kind: Operation
Purge Record
The composition action that permanently destroys a record through Soft Delete and binds the purge to an attributed Audit Trail event (record.purged) naming who purged it, when, and under what stated reason. It does not check purge eligibility — Legal Hold and retention gating belong to Defensible Retention; this composition records the purge faithfully. reason is required. Refused orphan-pending while a committed transition of the record is still owed its audit event. Returns {record_id, event_id, unretained?}.
Kind: Operation
Recover History
The composition’s emergent forensic read: reconstruct the complete, ordered, attributed, tamper-verified delete/restore/purge history of a record from record_to_events and the Audit Trail substrate — including prior epochs Soft Delete’s current-state summary overwrites. Returns a Lifecycle History; the caller must present the original event payloads (Audit Trail’s verification asymmetry). No audit event is produced — it changes no state.
Kind: Operation
Lifecycle History
The structure Recover History returns: the record’s current Soft Delete state and attribution summary, an ordered per-event verification list, and a summary Overall Verdict. The records-alone answer to who deleted, restored, and purged this record, in what order, and is each step attributed, sealed, and within its retention horizon? — which neither constituent provides alone.
Kind: Type Role: the reconstructed lifecycle-history structure
Overall Verdict
The summary field of a Lifecycle History, computed over the reconciled sequence: History Complete when every transition has a binding, the sequence replays as a legal lifecycle path, and every verification passes (or is lawful destruction), else History Incomplete naming each class that applies — the failure classes (binding-gap, attestation-failed, seal-failed, unsealed, out-of-order) and the incomplete-verification classes (payload-not-supplied, partially-purged-coverage, availability).
Kind: Field Field of: the lifecycle history Role: the summary verdict Projection: overall_verdict
Attestation Verification
The per-event field of a Lifecycle History: the result of re-verifying each transition’s Audit Trail seal — verified, failed-verification(reason) (the substrate’s purged, attestation-…, seal-…, or unsealed), not-known, or unverifiable(reason) (this composition’s payload-not-supplied(missing) when the caller did not supply the event’s covering range, or the substrate’s partially-purged-coverage, attestation-registry-unavailable, seal-mechanism-verification-unavailable), optionally qualified (compensation-window). For a purged event the substrate’s own answer comes first and the caller’s map is not consulted.
Kind: Field Field of: the per-event verification record Role: the per-event seal-verification result Projection: attestation_verification
History Complete
The Overall Verdict value when every transition in the reconciled sequence has a binding, the sequence is a legal lifecycle path, and every Attestation Verification returns verified (or a lawful failed-verification(purged)) — the record’s full lifecycle is proven from the records alone. A retention-pending, index-gap-repaired or payload-purged outcome is reported beside it and does not block it.
Kind: Member Member of: the overall verdict Role: Verdict Projection: history-complete
History Incomplete
The Overall Verdict value when at least one class applies (a binding gap, a failed attestation, a seal failure, an unsealed tail event, a sequence that does not replay as a legal lifecycle path, a not-supplied payload, partially-purged seal coverage, or a verifying surface that was unavailable) — the reconstruction is not fully self-proving, and the verdict names why.
Kind: Member Member of: the overall verdict Role: Verdict Projection: history-incomplete
Standards references
This composition is the structural form of the forensic-deletion-lifecycle requirement across its canonical regulated domains:
-
GDPR Article 17 (Right to erasure / Right to be forgotten — EU General Data Protection Regulation) — the data subject’s right to request destruction of personal data. Invariant 2 (purge accountability) and Invariant 3 (forensic completeness) together provide the structural erasure proof: not only is the destruction recorded and attributed, but the full lifecycle leading to the erasure is recoverable. Article 5(1)(e) (storage limitation) is satisfied by the attributed, time-bounded purge record; Article 5(1)(f) (integrity and confidentiality) is supported by the Tamper Evidence sealing via the Audit Trail substrate.
-
HIPAA §164.310(d)(2)(i) (Disposal — Health Insurance Portability and Accountability Act) — covered entities must implement policies for the final disposition of electronic Protected Health Information (PHI). This composition’s Purge Record +
record.purgedaudit event is the disposal-attribution record required by this provision; Recover History is the audit surface demonstrating that disposal was attributed and complete. -
HIPAA §164.312(b) (Audit controls) — electronic information systems must record and examine activity. The lifecycle audit events produced by this composition (one per Delete Record, Restore Record, and Purge Record call) are the audit-control records for the deletion lifecycle; Recover History is the examination surface. The Audit Trail substrate’s attribution, retention, and tamper-evidence satisfy the integrity and non-repudiation aspects of the audit-controls requirement.
-
FRCP Rule 37(e) (Federal Rules of Civil Procedure — preservation duty for electronically stored information, spoliation) — failure to preserve ESI (Electronically Stored Information) when litigation is reasonably anticipated can result in sanctions. A Purge Record call executed while a Legal Hold is active is the spoliation exposure; this composition’s
record.purgedaudit event (withactor_ref,reason, andrecorded_at) is the structural record of the destruction. Whether the purge was permissible under the legal duty is Defensible Retention’s records question; whether the purge happened and who executed it is this composition’s records answer. The sealed, attributed, retained audit event is the evidence-grade record FRCP Rule 37(e) sanctions hearings require. -
SOX §802 (Sarbanes-Oxley Act — 18 U.S.C. §1519, criminal obstruction of justice for records destruction subject to federal investigation) — this composition’s purge accountability invariant (Invariant 2) is the structural defense: no purge occurs without an attributed, sealed, retained audit event naming the actor and the stated reason. Whether a legal hold was active at purge time is Defensible Retention’s question; whether the purge was attributed is this composition’s guarantee.
-
ISO 15489-1 (Records management — International Organization for Standardization) — Section 9.7 (suspension of disposition) and Section 9.9 (destruction of records). The Deleted → Purged path in this composition aligns with ISO 15489’s deliberate-authorization requirement for records destruction; the attributed audit trail satisfies the accountability requirement for destruction decisions.
-
NIST SP 800-88 (Guidelines for Media Sanitization — US National Institute of Standards and Technology) — the
purge_reasonfield in therecord.purgedaudit event documents the stated authority for destruction; the media-sanitization mechanism itself is the host system’s obligation (see Content destruction is handled by the host system in Edge cases).
This composition inherits the broader standards compliance of its constituents:
-
Through Audit Trail (and its transitive atoms): SOX §802 record retention, HIPAA §164.312(b) audit controls, PCI DSS (Payment Card Industry Data Security Standard) Requirement 10, 21 CFR Part 11 (US Code of Federal Regulations — electronic records and signatures in regulated industries), SEC (US Securities and Exchange Commission) Rule 17a-4, ISO/IEC 27001 §A.12.4 (logging and monitoring), GDPR Articles 30 and 32, and the full Audit Trail standards inheritance. Deployments composing this composition for regulated-record-lifecycle purposes receive these as the substrate’s contribution; they are framed as inherited, not as this composition’s own primary standards anchors.
-
Through Soft Delete: GDPR Article 17, GDPR Article 5(1)(e), HIPAA §164.310(d)(2)(i), HIPAA §164.312(b), FRCP Rule 37(e), SOX §802, ISO 15489-1, and NIST SP 800-88 at the lifecycle-state and attribution layer. This composition lifts these to the full attributed+sealed+full-history-recoverable form those standards actually require but that Soft Delete alone cannot satisfy.
Status
partially resolved — see the Ledger.
Ledger
status: partially resolved
formal: pending — re-derivation, 2026-08-30: forensic-recovery.tla + 1 twin verified 2026-06-11 over a single-writer invocation with no terminus and no orphan gate; re-derive over the invocation and the scan as two processes over one transition, the bound as the yield point, and the `orphan-pending` refusal on a later transition (2026-08-29-a, 2026-08-30-h)
last gate: 2026-08-30 — third gate, fresh reader, under the frozen rules — 7 foundational and 10 refining corrected in-round, 4 refining (one the formal re-derivation) and 3 rhetorical routed (3 refining and 1 rhetorical duplicating lines already open); closure check 2026-08-30 — 5 new defects and 10 consistency items corrected in-round; 2026-08-26 authentication-precedence gate — 4 foundational routed (all since closed), 1 foundational and 6 refining corrected in-round, 13 refining (3 since closed) and 3 rhetorical routed
open:
- 2026-08-26-e · refining · Generation acceptance check 6 · cites the substrate's bar as six checks where it has eight, omitting the two that bear on the retention split → cite eight
- 2026-08-26-h · refining · `recorded_at` · names two different values (the payload stamp and Event Log's seam stamp) and the auditor-facing example does not say which → name both distinctly
- 2026-08-26-i · refining · Invariant 1 · reads unconditionally where Invariant 4 admits the durable orphan, and glosses Audit Trail Invariant 1 as verification where it is coverage → qualify and correct the gloss
- 2026-08-26-j · refining · Invariant 2 · forbids an unsealed purge event its own *Rests on:* admits under permitted cadences → reconcile
- 2026-08-26-k · refining · `read` passthrough · declares two rejections the constituent cannot produce and promises a single record where it returns a sequence → match the contract
- 2026-08-26-l · refining · [Recover History] signature; Examples · requires an `actor_ref` nothing consumes; two examples omit it → consume it or drop it
- 2026-08-26-m · refining · Clock semantics · never named anywhere; `intended_at` has no policy → add the clock policy
- 2026-08-26-o · refining · Standards references · "UTC" undefined at first use → gloss
- 2026-08-26-p · refining · Invariant 4 · a formal model is asserted twice and named as a verification surface nowhere → name it
- 2026-08-26-q · rhetorical · Rests on citations · Audit Trail Invariant 4 cited as "atomicity" where its text says coordination and declines that reading → cite correctly
- 2026-08-26-r · rhetorical · throughout · "this composition" used as a proper noun, an incomplete find-and-replace → name the pattern
- 2026-08-26-s · rhetorical · Composition state, `record_to_events` bullet · classification, rebuild, three obligations, append-only discipline and two orphan definitions in one ~400-word sentence → split
- 2026-08-29-a · refining · formal · the model's compensation action carries no identity, no recovery record, and no age bound → extend the model with the bounded scan under `recovery_identity`
- 2026-08-30-a · refining · *Right-to-erasure vs. retention of audit events* · cites an Audit Trail edge case *Erasure Coordination* that does not exist; the substrate's is *Right-to-be-forgotten vs. retention obligation* → cite it
- 2026-08-30-b · refining · *pre-composition transitions* edge case vs step 7 · the edge case promises `binding-gap` for transitions that predate the composition while the path-legality test yields `out-of-order` for the same sequence → pick one and say which
- 2026-08-30-d · refining · lifecycle steps 3 · the composition's `now` is passed to Soft Delete as `deleted_at` / `restored_at` / `purged_at` and Soft Delete future-bounds a caller-supplied stamp against its own clock, so a skew makes `invalid-request` reachable after the intent → name the assumption, or a `clock_offset_allowance` (with 2026-08-26-m)
- 2026-08-30-e · rhetorical · normative body · dated review annotations ("*Added 2026-06-11 …*", "bounded, keyed and attributed 2026-08-29", "first classified … 2026-06-11") sit in Configuration, Composition state and the partial-failure edge case → move to the Ledger and Decisions
- 2026-08-30-f · rhetorical · Terms preamble · "a single derived index" contradicts Composition state's split classification → "split-classified"
- 2026-08-30-g · rhetorical · Intent · "two emergent actions" then lists four → "four"
- 2026-08-30-h · refining · formal · the model has one writer per transition and no gate: it cannot exhibit the invocation and the scan both landing an outcome, nor a later transition overwriting a pending orphan's attribution → extend it with the scan as a second process over one transition, the bound as the yield point, and the `orphan-pending` refusal
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/forensic-recovery.md.
- 2026-08-30 — One writer per transition, a record with a pending orphan admits no new transition, the purged answer first, the enumeration route declared, the envelope sized before the intent, the retry bit carried. Chose: an in-invocation retry bounded by
outcome_retry_attemptsinside the record’s critical section, a scan that takes the same section (record_serialization, a declared instance capability) for every record it compensates and re-reads its pre-check under it, and an outcome step that adopts an outcome it finds rather than appending beside it; anorphan-pendingrefusal at step 2 of every lifecycle action while a committed transition is owed its outcome event, so the stamp-and-actor pairing against Soft Delete’s current attribution stays exact — the atom keeps only the most recent of each kind, and a later transition would overwrite the field the pairing reads; Recover History branching on retention state before any membership check, so a purged transition landsfailed-verification(purged)and History Complete stays reachable after a purge; every substrate verification outcome placed in a verdict class, withretention-lapseddropped as a class nothing produced; the lifecycle-event enumeration declared as the substrate’s pass-through open-upper-bound sequence-range read with the selection made in composition code, Reverse Index an optimization; the outcome and compensation envelopes sized at step 1 againstpayload_capwithintent_candidates_capbounding the compensation;recording-failure(intent | outcome(finding))on every lifecycle signature with the orphan in thefindingandunretainedin the result; the liveness inequality written out withoutcome_write_latency(declared) and checked at instance start; and, from the closure check the same day, the section’s lease semantics stated — exactlytransition_completion_boundlong, expiry the invocation’s terminus, re-taken before any later pre-check and never written past — so the hold time a stalled holder can impose on the scan is the inequality’s first term,enumeration_high_waterdeclared as the read-back’s derived lower edge, Actor Identity’s owninvalid-requestlanded at both positions, the envelope sized with the longer of the two attesting actors, and every remaining “atomic” and “walks the index” phrasing brought to ordered and reconciled sequence. Over: “retry until it lands” beside a scan starting at the bound; a pairing by a stamp any later transition could overwrite; a membership check ahead of the constituent’s purged short-circuit; a read “bydata.record_id” the substrate routes to a forthcoming pattern; a foreclosed-by-construction argument made about the intent and not the outcome; a bare token on both sides of the commit with the finding in prose only; a section attributed to the host with no lease length, whose stalled holder could block the scan for as long as it liked. Because: two compensators over one transition land two outcomes the seal then protects; a lawfully destroyed payload is not one the caller failed to supply; a capability the constituent declines is an undeclared dependency however often the page calls it declared; an unbounded set is an input, not a construction; a caller who cannot tellintentfromoutcomere-runs a committed act; and a lease longer than the bound is a hold time the inequality never counted — while the gate is what keeps an orphan pairable, since the composition does not get to weaken Soft Delete’s latest-only attribution by paraphrase (the frozen rules of 2026-08-30 — A compensator is exclusive, Lawful destruction is answered before absence, An outcome is sized before the intent, Liveness is arithmetic, A stamp from another seam never decides a write alone (swept: the pairing passes the composition’s own reading through, so equality is by construction; the skew on Soft Delete’s future-bound routed as 2026-08-30-d), A composition’s own rejection arm carries the retry bit, and Capability provenance frozen — with §Intents pair with outcomes and §A transcribed rejection arm’s fourth tell). - 2026-06-11 — Invariant 4’s compensated arm is in the model, not idealized away. Chose: re-derive the model over sequential sub-writes with compensation. Over: the original model, which committed three sub-writes as one atomic action. Because: Soft Delete writes first and Purged is terminal, so rollback is not universally available and the compensated path is the design, not an exception.
- 2026-08-27 — The replay is ordered by the intent event’s position, not the outcome’s. Chose:
record_to_eventsentries carryintent_position, the Event Log sequence number of the intent event each outcome names; the list, the rebuild and Recover History order by it; the per-record critical section spans the whole action; and an ordered sequence that is not a legal lifecycle path carries theout-of-orderfailure class. Over: ordering by the outcome events’ own positions, or by a timestamp the compensation preserves. Because: a compensated outcome lands when the compensation lands, so outcome order replays a retried delete after the restore that followed it; the intent record is written before the transition inside the serialized section and never re-emitted, so its position is commit order structurally, without a clock. - 2026-08-29 — The scan is bounded at both edges, pairs by the injected reading, and writes as the recovery identity behind a recovery record; the outcome’s step decides its landing. Chose:
transition_completion_boundbelow and the audit horizon above for the reconciliation scan; pairing of an orphan transition to its intent byintended_at = deleted_at / restored_at / purged_atand the attribution field, candidates named where undecidable; every scan write underrecovery_identitybehindrecord.recovery_intended;recording-failure(step-4)and the retention-sourceinvalid-requestat an outcome record read back and treated as landed;compensation_window,reconciliation_cadenceandindex_durabilitydeclared; Invariant 4’s “committed atomically or” restated as ordered. Over: an unbounded scan re-emitting under an unstated identity, and a uniform “all three arms surface as recording-failure” at the outcome. Because: an unbounded scan appends a second outcome beside an in-flight invocation’s and re-emits lawful destruction as orphans; the actor’s credential is never persisted; and the substrate’s step-4 arm means the event exists (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, A transcribed rejection arm keeps its payload). - 2026-08-27 — Recover History reconciles the index against the enumeration in both directions. Chose: report a transition the index lost as
index-gap-repairedand one the enumeration can no longer reach aspayload-purged, neither a failure class. Over: replacing the index list with the substrate enumeration. Because: the enumeration keys on a payload field the retention purge destroys while the index’sevent_ids still resolve, so naive anchoring would have shortened exactly the histories a forensic reader most needs.