Decision records
ADR 0116 — Attaching an outcome to a past decision of a finished execution
- Status: Accepted — implemented (2026-10-07) — design approved after 4 review rounds; see “Implementation notes” for what the code does beyond the text
- Date: 2026-10-05
- Closes: R11 (agentic-readiness audit, kernel appendix §2i)
- Builds on: ADR 0030, ADR 0078–0080
(decisions carry
policy.propensity;@loom/learningevaluates off-policy), C.1 execution authz, K9.4 fencing. - Placement: a small runtime method, not a syscall and not a store. No ABI change; no new event
kind. The one schema-visible part is two additive optional payload fields on the existing
OUTCOME_RECORDED, inside the shared Wave 2 EVENT_SCHEMA 1.23.0. - Revision: round 1 (Opus review of PR #495).
recordOutcomeis terminal-only and takes no fence (a fence would kill a live run); retention is measured from the terminal event, not the last event (the first draft’s claim that a late outcome “does not refresh retention” was false); andguardUserspaceEmitrefusesrecordedBy/late, which were forgeable by anyctx.emitcaller.
Context
Rewards arrive late: a user’s thumbs-up days after the conversation, a business KPI at month end.
OUTCOME_RECORDED { key, success, score, decisionId } links a reward to a decision, but the only
writers are ctx.emit and EffectContext.emit of a live run (analytics’ OutcomeSink is
{ emit }); resume refuses a non-suspended execution, and a terminal one is reopenable only via
resumeAs, a code-migration verb. So a reward has no first-class home, and learning needs rewards:
off-policy evaluation of a policy trained on propensity-logged decisions is useless if the reward never
joins.
Decision — evaluated, and the answer is mostly “not 1P”
Two 1P options exist, and each fails on something the kernel alone holds.
- A side table keyed
(executionId, decisionId). It is a new durable store: a new SPI, a conformance kit, and three backends (SQLite, Postgres, in-memory), plus a join in every consumer of outcomes (trajectories, analytics, read models) becausefold(log)no longer contains the reward. That is far more surface than an append, and it forks the truth about a trajectory in two. - A 1P function that appends to the terminal log through
EventStore. It works mechanically (theOUTCOME_RECORDEDhandler is already pure audit and status-neutral) but it bypasses everything the runtime owns:ExecutionAuthorizer(who may attach rewards: a reward steers policy learning, so an unauthenticated writer is a poisoning channel), fencing, tenant scoping and the denial audit trail. It would be a 1P package holding a security boundary it cannot enforce.
So the verdict is a single runtime method:
runtime.recordOutcome(executionId, {
decisionId, key, success?, score?, idempotencyKey?
}, options?: { principal?, system? }): Promise<{ appended: boolean }>
It is not an ExecutionContext member (no ABI), it is not a state change, and it adds no event kind.
D1 — Semantics
- Authorized as a new
ExecutionOp"annotate"(an additive union member;executionAccessPolicymaps it with the default rule"owner"(amendment, Q2: a reward steers policy learning, so only the recorded owner writes one by default; a reward pipeline opts in withrules: { annotate: "tenant" }); the conformance kit gains the op), judged against the recorded owner (ADR 0111 D5). Undersecurity.stricta call without a principal is refused. - Terminal executions only (
completed,failed,cancelled, and ADR 0109’scontinued: the decision lives in that generation’s log). A running or parked execution is refused with a typedExecutionNotTerminalError: a live run usesctx.emit(anOutcomeSink), which is the existing path. The first draft allowed all states and acquired the fence first, andacquireFencesupersedes the current holder, so a reward arriving for a live run would have killed it. Not acquiring the fence is safe because a terminal execution has no live holder. - Append with a sequence compare-and-set, no fence. The runtime reads the head, folds, validates and
appends at
head + 1(the store’sDuplicateSequenceErroris the CAS); on a collision (a concurrentrecordOutcome, or the best-effort unfencedEXECUTION_DENIEDaudit append the runtime itself makes) it re-reads and retries a bounded number of times, so two identical concurrent calls resolve to one append and one{ appended: false }. Nothing a fenced writer owns is disturbed. - The
decisionIdmust exist in the fold’s_decisions(an outcome for a decision that never happened is refused: reward poisoning by invented ids).(decisionId, key)is idempotent: a second identical call returns{ appended: false }; a differentscorefor the same pair is refused, not overwritten (the log is append-only; a correction is a newkey). - The event is the existing
OUTCOME_RECORDED, with two additive optional fields,recordedBy(the caller’s actor, or"system") andlate: true. They are forgeable unless the guard says otherwise:OUTCOME_RECORDEDis inUSERSPACE_KERNEL_EVENTS(userspaceEmit.ts), so any flow’sctx.emitcould stamprecordedBy: "someone-else"orlate: trueand a learning pipeline that trusts them would be fooled.guardUserspaceEmittherefore refuses anOUTCOME_RECORDEDcarryingrecordedByorlatefrom userspace; onlyrecordOutcomewrites them (it appends through the runtime’s own path, which does not pass the guard). The fold keeps appending to_outcomes; as of W2-0 (EVENT_SCHEMA 1.23.0) the handler copies the two fields onto the entry (recordedBywhen a string,latewhentrue) — only from an envelope withmetadata.kernel === true, which therecordOutcomewriter stamps, so a log the guard did not protect cannot make a pipeline trust a forged field. - It does not change status and does not touch
_failedWhileParked. Retention is measured from the terminal event.LogLifecycletoday computes the end of an execution asmax(lastEvent.timestamp, state.updatedAt)(LogLifecycle.ts:320), so a late outcome appended days after the terminal event would move the end forward and postpone reclamation; that is an unintended retention extension an outcome writer could use to pin a log forever. The change in this ADR’s PR: the end is the timestamp of the newest terminal event (found by scanning the tail backwards, asorchestration’ssettledoes), falling back to the old formula only when the terminal event was compacted away. (Superseded by the amendment below: compaction never removes a terminal execution’s terminal event, and the learning evidence is read from the folded state, round 3. The first draft’s claim thatOUTCOME_RECORDED“is a pinned kind for compaction (ADR 0106)” was wrong:assertPinnedEventTypesrefuses kernel kinds, so nothing pinned it and compaction folds it like any other.)
D2 — What stays 1P
The reward pipeline: collecting feedback, mapping it to (executionId, decisionId), batching.
@loom/analytics ships attachOutcomes(runtime, rewards[]) as a thin loop with retry. Validation of
propensity range (the audit’s second R11 point) is a separate tightening of ctx.decide, listed in
the design document’s open questions, not bundled here.
Replay and migration
No new kind. Existing logs fold identically; a log with late outcomes appended after
EXECUTION_COMPLETED folds the same status (the handler is status-neutral) and a snapshot older than
the tail is extended by folding the tail, as for any late event.
Tests
Attach to a completed execution → _outcomes has it, status unchanged, late and recordedBy
present; a running or parked execution is refused and its holder’s next append still succeeds (the
negative: with a fence it would not); two concurrent identical calls append once; a userspace
ctx.emit of OUTCOME_RECORDED { recordedBy } / { late: true } is refused by the guard; a late
outcome does not move the retention end (an execution terminal for longer than terminalTtlMs is still
reclaimed after an outcome is attached); unauthorized principal refused, no event appended (negative: log untouched); unknown
decisionId refused; duplicate idempotent; conflicting score refused; strict + no principal refused;
compaction folds it (a late one stays above the terminal event until the next compaction);
@loom/learning trajectories picks the late reward up with no join.
Rejected
- Side table and unauthorized 1P append: see above.
- Reopening the execution (
resumeAs): a code-migration verb that would also re-drive the flow.
Implementation notes (2026-10-07)
- Frozen at W2-0, unchanged.
OUTCOME_RECORDED.{recordedBy, late}, the stamp-gated fold and theguardUserspaceEmitrefusal were reserved at W2-0; EVENT_SCHEMA stays 1.23.0 and KERNEL_ABI 1.3.0. R11 adds the writer (WorkflowRuntime.recordOutcome), the"annotate"op, three typed errors (ExecutionNotTerminalError,OutcomeRefusedError,OutcomeContendedError) and the retention-clock change. - No
idempotencyKey. The frozen payload has no field for one, and(decisionId, key)is already the idempotency identity (D1), so the option the first draft listed is dropped rather than accepted and ignored. The result gainssequence(where the outcome sits; absent on{ appended: false }). - Order of checks. Not-found (judged first for an anonymous strict caller, as
senddoes) → authorizeannotateagainst the recorded owner → shape (decisionId,keyof at most 256 characters, finitescore, booleansuccess, at least one of them) → then, inside the CAS loop, terminal → decision exists →(decisionId, key)already present (same outcome:{ appended: false }; a different one:OutcomeRefusedError). A denial appends nothing (likesend, unlike a deniedresume): a finished execution’s log is evidence and a denied writer leaves it untouched; theonExecutionDeniedcallback fires. - A fenced execution. (Superseded by the amendment below: the round-0 design re-appended under the
store’s current token taken from
StaleFenceError; that adoption path is removed.) continuedis accepted (R1b, ADR 0109, amends the original refusal): the decision lives in that generation’s own log, so the outcome is attached to the id the caller named, never to the head of the chain; acontinuedexecution is terminal for the retention clock (a kernel-stampedEXECUTION_CONTINUED).ExecutionNotTerminalErrorstill refuses a running or parked execution.recordedByis"system"for the runtime’s own grant, otherwise what the principal record would write for the caller: the codecrefwhen one handles the chain, else the verbatim actor ("anonymous"with none); refused inrefmode with no codec, as a start is. The delegation token is never read. Undersecurity.crypto,defineConfigregistersOUTCOME_RECORDED.recordedBy(events) andvariables._outcomes[].recordedBy(snapshots) with the crypto stores, so the actor is shredded with the subject like the start’s identity subset. An actor literally named"system"is indistinguishable from the grant inrecordedBy; the field is provenance, not authorization.- Retention.
LogLifecyclenow measures the end from the newest terminal event, scanning the tail backwards. (The residual named here, theupdatedAtfallback after compaction removed the terminal event, is closed by the amendment below.) - D2.
attachOutcomes(runtime, rewards, { retries?, options? })in@loom/analytics: a structuralOutcomeAttacher(no dependency on the runtime), retrying only errors that sayretryable, reporting{ appended, duplicates, failed }. - Learning. No change to
@loom/learning:trajectoriesreads the log, so a late outcome is adirectreward exactly like an in-run one, the logged propensity is untouched, and the terminal outcome stays the execution’s own result (a test pins the trajectories, the SNIPS estimate and the logged value equal to the on-time run). - Not done here.
propensityrange validation (design §7 question 8) stays a separate PR.
Review round 1 (2026-10-07)
- Sequence after compaction. (Round 1 wrote at
max(stored head, folded sequence) + 1; round 3 replaced that with the next slot, refusing a log compacted below its checkpoint by a pre-0116 kernel. See round 3, P3 1.) - Score bound.
|score| <= 1e9and finite, inrecordOutcomeAND in a flow’s ownOUTCOME_RECORDEDemit (guardUserspaceEmit): a finite1.7e308otherwise sums toInfinityintrajectories, making the liftNaN, andNaN < minLiftis false.@loom/learning’sevaluatePolicynow throws when its reward accumulators are non-finite, andpromotionGatefails on a non-finitelift/estimatedValue/loggedValue/effectiveSampleSize. - Per-execution cap. At most 1024 late outcomes per execution (
OutcomeRefusedError); the duplicate check and the fold are O(n), so the cap bounds the cost of a call. - Reward pipelines use their own keys. The idempotency key is
(decisionId, key)across in-run and late outcomes: an in-run outcome under the same pair makes a later late one a duplicate (same value) or a conflict (different value). A reward pipeline should namespace its keys (for examplecsat:,thumbs:) apart from the flow’s own. - Existence oracle. Like
sendandresume, an authorized caller learns whether an execution exists and whether it is terminal; an unauthorized caller is denied before either is revealed.
Amendment — review round 2 (2026-10-07; user decisions Q1–Q4)
Q1 — the conditional append: EventStore.appendIfUnchanged (EVENT_STORE_SPI 2.1.0 → 2.2.0)
Round 0 wrote the late outcome with a plain append, adopting the store’s current fence token from a
StaleFenceError. Two defects followed. (1) A retention sweep that deleted the log between the fold and
the append left an orphan: an OUTCOME_RECORDED at sequence n+1 with no sequence 0. (2) A holder that
had acquired a fence but not yet appended (ADR 0104 resumeAs on a failed execution) lost its slot or its
run to the late outcome. Both are time-of-check/time-of-use gaps that no amount of retrying in the runtime
closes; they need one atomic step in the store.
appendIfUnchanged?(executionId, event: EventEnvelope | null, expect?: { head: number; fence: number; anchor: string }):
Promise<{ appended: true } | { appended: false; reason: "moved" | "gone" | "observed"; head: number | null; fence: number; anchor: string | null }>;
Atomically: append event iff (a) the execution EXISTS (its sequence-0 event is stored), (b) the stored head
is exactly expect.head, (c) the fence token is exactly expect.fence (0 = never fenced), (d) the creation
marker is exactly expect.anchor, and the event is in slot expect.head + 1 (round 3, below). Otherwise
nothing is written and the current tip is reported (moved, or gone with head: null). event === null is
a pure observation. It never presents, bumps or adopts a token: the caller observed the token, the store
proves it has not moved. recordOutcome observes (head, token), folds, validates, then appends conditionally;
moved re-observes and re-judges within the existing bound (OutcomeContendedError, retryable), gone is
“no such execution” with nothing written. A store without the method makes recordOutcome refuse with a
clear error (OutcomeRefusedError naming the method); there is no unsafe fallback to a plain append.
Why the token is in the condition. A token that moved since the observation means a holder acquired after the
caller looked; the write is refused (moved, retried after a fresh observation) rather than placed inside the
holder’s range. The token rule is a CONDITION on the write, not a policy about the execution: round 3 dropped the
first version’s extra rule (“the terminal event must have been written under the current token”), which blocked
late outcomes for good after any post-terminal acquire (an at-least-once redispatch’s LocalWorkerRuntime.allocate,
TimerService, recoverAbandoned, a dead holder) while telling the caller to retry. The one legitimate
post-terminal writer, a REOPEN, is protected on its own side (round 3, below).
Locks, per backend (the same row ADR 0118’s acquireFenceIf and answering appends use):
- Memory: one synchronous step after the tick (existence, head, token, commit).
- SQLite: one
BEGIN IMMEDIATEtransaction; SQLite does not fence, so the token is always 0. - Postgres: a transaction that ensures the execution’s fence row, takes it
FOR UPDATE(waits for an in-flight acquire or answering append and blocks new ones), re-reads head and existence in a fresh statement and inserts.deleteExecutionnow takes the same row before deleting, so a delete and a conditional append are strictly ordered: no orphan is possible. When the execution is gone the token-0 row this call created is removed again; a real token (> 0) is KEPT, the tombstone that keeps a zombie writer of a re-created id fenced out (tested). - Every decorator transforms the event exactly as for
append: tenant scoping, encryption, content externalisation, the audit-chain stamp (chained onto the tail, the cached head dropped on refusal), a DST fault draw, the crash simulator’s drop; sharding routes; coalescing and profiling forward (nothing of a finished execution is pending). The conformance kit gains cases (unchanged / moved / gone-no-orphan / fence acquired after observation / token compared not presented / concurrent callers exactly one wins). ADR 0118 cross-references this method: it is the append-side twin ofacquireFenceIfand shares its row lock.
Q2 — annotate defaults to "owner"
executionAccessPolicy maps annotate to "owner"; rules: { annotate: "tenant" } widens it for a
tenant-wide reward pipeline.
Q3 — compaction never removes the terminal event
LogLifecycle.compact caps the checkpoint just below a terminal execution’s newest terminal event, so
retention always finds the end of the execution on the log and a late outcome (whose frozen handler moves
updatedAt) can never extend it. Tested: three late outcomes each inside a TTL window do not pin a compacted
log.
Q4 — compaction and the learning evidence (superseded by round 3)
Round 2 kept every OUTCOME_RECORDED and DECISION_RECORDED in place through compaction so trajectories
could still read them off the log. Round 3 reverses that for the cost it carried: see below.
Round 3 (2026-10-07; user decisions A and i, seven P3s)
A — a late outcome never blocks on a fence; a REOPEN retries over it
The “terminal event written under the current token” rule is gone (WorkflowRuntime.recordOutcome no longer
reads the log at all). A late outcome is a conditional append (existence, head, token, creation marker): a
holder that acquires before or after it simply makes it moved, and it re-observes. The risk the rule was
standing in for is the REOPEN (resume / resumeAs onto a completed or failed execution, ADR 0104): a late
outcome observed the log before the reopener acquired its token and appended just after, so it takes the slot
the reopener was about to write. Left alone that fails the reopener’s run, and the reopener is the execution’s
real writer. WorkflowRuntime now makes a reopen’s bootstrap batch (approval, discards, migration, the resume
event and its answers) durable BEFORE the run starts, as one all-or-nothing appendBatch
(landReopenBootstrap). On a slot conflict it re-reads everything above the head it folded; if every event
there is a kernel-stamped late OUTCOME_RECORDED (status-neutral by construction), it folds them, re-slots the
batch above them and writes again, at most 3 times. Anything else (a real writer, a stale fence, a store with
no appendBatch) returns the events untouched and the ordinary path fails or succeeds exactly as before.
Both orders therefore land: a late outcome never displaces a reopener, and a reopener never loses one.
onEvent for those bootstrap events now fires once they are stored (the adopt path), not when committed.
The residual the first version listed (a dead holder blocks late outcomes) no longer exists.
i — compaction keeps no learning evidence in place
Keeping every DECISION_RECORDED made a long agent run’s log unreclaimable: 2000 decisions of 2 KB took the log
from 4,356,284 bytes / 2004 events to 4,355,959 bytes / 2002 events after compaction; with the change the same compaction leaves 1
event / 193 bytes (the checkpoint, in the snapshot store, holds the folded _decisions). Everything
trajectories needs is already in the fold: _decisions has choice, alternatives, context and policy
with its propensity per decisionId; _outcomes has key, success, score, decisionId, recordedBy,
late. So @loom/learning gains evidenceEvents(state, log), which rebuilds the DECISION_RECORDED /
OUTCOME_RECORDED events of one execution from its folded state (passing the log’s terminal events through, and
synthesising one for a terminal state whose terminal event a pre-0116 compaction removed), and a learner calls
trajectories(evidenceEvents(await runtime.loadState(id), await store.read(id))). That result is identical
compacted or not (a test compares the two) EXCEPT for one input, stated precisely in round 4: the ORDER of
decisions the checkpoint absorbed. A checkpoint cannot give back WHEN a decision was made: a decision it
absorbed gets its ordinal as sequence and state.startedAt as timestamp, the ordinal being the key order of
the folded _decisions map. Only the discounted terminal credit (discount < 1) reads that order; the direct
credit, the default discount = 1, per-arm statistics and the policy evaluation are order-free. (Resolved by ADR 0128, 2026-10-11 — see the note under Round 4 item 2.) Compaction keeps nothing extra: late outcomes sit above the terminal event
(Q3) and stay on the log until the next compaction folds them. trajectories(await store.read(id)) alone is
exact only for a log that was never compacted, and the other log-reading lenses of @loom/analytics
(decisionRecords, decisionStore, the advisory correlation index) see only the events still on the log: a
consumer that must cover compacted executions reads the state. A late outcome after compaction still joins to
its decision (it validates against the folded _decisions).
P3s
- Next slot only.
appendIfUnchangedrequiresevent.sequence === expect.head + 1and answersmovedotherwise. A skip-ahead cannot be proved head-unchanged on Postgres (a non-locking append can slip in after the head read; only the next slot’s unique constraint arbitrates it).recordOutcometherefore writes athead + 1; a log whose folded sequence is ABOVE its stored head (compacted by a kernel older than this ADR, its terminal event gone) refuses withOutcomeRefusedErrorinstead of writing under the checkpoint. Compaction since Q3 never produces that shape. - Isolation is stated. The Postgres conditional write opens
BEGIN ISOLATION LEVEL READ COMMITTED, andacquireFenceIf(the same assumption: a fresh snapshot per statement after the lock) starts withSET TRANSACTION ISOLATION LEVEL READ COMMITTED. Under a server default of REPEATABLE READ the acquire used to win over an answering event that committed while it waited (tested, fails without the line). - ABA.
TipObservationand the observation result carryanchor, an opaque creation marker the store derives from the sequence-0 envelope (creationMarker: timestamp and id; ids alone are deterministic). A log deleted and re-created under the same id with the same head and token is a different execution and the write ismoved. Decorators forward it untouched. - The token-0 fence row stays. On Postgres a first conditional append leaves the inert token-0 row it
created (as a park does, ADR 0118). Deleting it while another transaction is queued on it
FOR UPDATEmakes that waiter read a vanished row, so it stays and is reclaimed with the log bydeleteExecution. - No whole-log read.
recordOutcomereads nothing but the tip, the fold and the sequence-0 event. - Decorators are tested end to end: tenant scope, encryption, content externalisation, sharding, coalescing, profiling and the hash chain (the chain stays verifiable across late outcomes).
- Content externalisation.
ContentResolvingEventStoreobserves the tip before it externalises: a late write that already lost stores no blob. A blob is content-addressed and may be shared with other events, so a refused write never deletes one; the narrow window between the observation and the write leaves an orphan thatsweepUnpinnedContentreclaims, exactly as a failed plainappenddoes.
Round 4 (2026-10-07; re-review P2 + P3s)
- The reopen retry’s three guards are pinned through
resumeAs.landReopenBootstrapfolds what sits above its head only when EVERY event is a kernel-stamped (metadata.kernel),lateOUTCOME_RECORDEDin the very next slots. Three tests take the reopener’s slot with an unstamped outcome, a stamped one withoutlate, and a store tail with a hole (a late outcome reported non-contiguously); each leaves the reopen on the ordinary path (nothing durable, fails as before), and each was checked to fail with its guard deleted. The “rogue writer” test now goes throughresumeAstoo. AnEXECUTION_DENIEDabove the tip is not a late outcome either, so a reopen over one falls to the ordinary path (rare; behaviour unchanged). - Evidence ORDER, precisely. The fold keeps
_decisionsas a plain object keyed by decisionId and records no ordinal; the fold shapes are frozen (the wire-abi fold vectors are a cross-implementation contract), so adding one is an ABI change this ADR does not make. A decision whose event is still on the log keeps its truesequence; an absorbed one takes the folded map’s key order, which is insertion order EXCEPT that array-index-like ids ("10","2") list first, ascending, and a snapshot store that normalises JSON (Postgresjsonb) has already lost the order. So a compacted execution’s evidence equals the uncompacted one’s wheneverdiscount = 1, or its absorbed ids are not array-index-like and the store keeps key order. Tests pin the exact cases and the documented limit. (Kernel-generated decision ids are never array-index-like.)Update 2026-10-11: this limit is resolved by ADR 0128 (EVENT_SCHEMA 1.24.0): the fold stamps the envelope
seqon each_decisionsentry andevidenceEventsorders absorbed decisions by it, exact whatever the id shape or the store. Only a snapshot folded before 1.24.0 still reads in map order. loom learn report|evalreads checkpoints. With a snapshot store (--snap, or--config) each execution’s evidence isevidenceEvents(folded state, log); without one, a log that looks compacted (a hole in its sequences) is counted and named in a warning, never read quietly as “no decisions”.recordOutcomechecks the anchor. A store or decorator that drops theanchoran observation must return is refused withOutcomeRefusedErroron the first observation, not retried as eightmoveds into a misleadingOutcomeContendedError.- Migration:
loom repair terminal-event/LogLifecycle.restoreTerminalEvent. A finished execution a pre-0116 kernel compacted is[0]+ a checkpoint at its terminal sequenceT; it can never take a late outcome (the one valid slot,head + 1, is below the checkpoint) and retention cannot date its end. The repair writes the terminal event atT + 1, above the checkpoint, so the fold issnapshot(T) + [T+1]: the terminal event type of the folded status, an EMPTY payload (every terminal handler leaves a terminal state unchanged on one), the foldedupdatedAtas its timestamp,metadata.kernel+restoredTerminal. It cannot useappendIfUnchanged(next slot only, P3 1), so it uses the ordinaryappendunder a freshly acquired fence on a fencing store, after re-checking the tip: run it with the executions quiesced, asloom repair blank-stream. It is dry by default, idempotent (a head at or above the folded sequence isintact), and refuses non-terminal andcontinuedexecutions. A test compacts with the old shape, repairs, and records a late outcome. @loom/analytics’s log readers (decisionRecords,outcomeRecords,decisionStore,outcomeAnalytics,advisoryPipeline,analyze,profile) read the events they are given; their JSDoc now says a compacted execution’s evidence is in the checkpoint and points atevidenceEventsto build the input.- ABA under a frozen clock is documented, not hardened.
creationMarkeristimestamp:id; an execution deleted and re-created under the same id at the same instant (a frozen or virtual clock) with the same head and token reads as the same log. A random nonce in the sequence-0 metadata would close it but would make the start envelope non-deterministic, breaking byte-identical replay (DST seeds, the golden logs and the cross-implementation vectors compare envelopes). Real clocks and retention-then-recreate are covered; a test harness that freezes time and re-creates an id under a late write should vary the id.
Source: docs/adr/0116-late-outcome-attach.md in the loom repository.