Decision records
ADR 0128 — Carry decision order through checkpoints: a recorded sequence on each folded decision
- Status: Accepted (2026-10-11) — option A, by the user’s decision; implemented (see “Implementation note”).
- Date: 2026-10-11
- Closes (if accepted): the logged follow-up “carry decision order through checkpoints (needs a fold ordinal — ABI)” (ADR 0116, round 4 item 2).
- Builds on: ADR 0106 (what compaction keeps), ADR 0116 (evidence from the fold,
evidenceEvents), ADR 0079 / ADR 0124 (the learning consumers), ADR 0026 (the fold vectors are a cross-implementation contract). - Placement: kernel for the one field (the information is lost INSIDE the kernel’s fold; no consumer can reconstruct it afterwards); 1P (
@loom/learning) for reading it. If the user prefers no kernel change, the 1P-only fallback (option E) is the answer and this ADR is Rejected. - Version impact if accepted:
EVENT_SCHEMA1.23.0 -> 1.24.0 (additive).KERNEL_ABI, every SPI: unchanged.
Context
A checkpoint (compaction, ADR 0106) folds DECISION_RECORDED events into state.variables._decisions and frees them. _decisions is a plain object keyed by decisionId (packages/replay-vm/src/eventHandlers.ts, DECISION_RECORDED), so the ONLY record of the order decisions were made in, once the events are gone, is the object’s key order. That is not a durable fact:
- JavaScript lists array-index-like keys (
"10","2") first, ascending, regardless of insertion order. - A snapshot store that normalises JSON loses it entirely. The Postgres snapshot store writes
jsonb(plugins/postgres/src/PostgresSnapshotStore.ts,$5::jsonb), andjsonborders keys by length, then bytes. This one is not an edge case: it affects EVERY compacted execution on Postgres with more than one decision. - SQLite stores text JSON and keeps order, so the same execution behaves differently per backend.
The fold is “complete” for what each decision WAS (ADR 0116) but not for WHEN. The log never had this problem because the event sequence is the order.
Who needs it
@loom/learningtrajectories(evidenceEvents(state, log)): the discounted terminal credit (discount < 1) weights each decision by its distance from the end of the run. Direct-outcome credit,discount = 1(the default), per-arm statistics and policy evaluation are order-free, which is why this stayed a logged follow-up.evidenceEventsrebuilds absorbed decisions with their key-order ordinal assequence; that number is currently wrong on Postgres.loom learn report|eval(reads checkpoints since ADR 0116 round 4) inherits the same error.- Second consumer to confirm before acceptance (ADR 0083):
@loom/analyticsreplay.tsreads_decisionsfor the decision timeline; any per-execution “what was decided, in order” view over a compacted run has the same hole. If the user finds only the learning lens real, option E is the honest alternative.
Concrete failing scenario
Policy-driven agent, trajectories(..., { discount: 0.5 }), terminal success. It records decisions route-b then route-a (same length, so jsonb sorts by bytes). Uncompacted, route-a is last and takes full terminal credit, route-b half. Compact, load the snapshot from Postgres: _decisions now reads route-a, route-b; evidenceEvents assigns ordinals 1, 2 in that order, so route-b takes full credit and route-a half. The learner trains on swapped credit, silently, and only for compacted executions. The in-repo tripwire is it.fails("IDEAL (known limit): absorbed array-index-like ids keep their true order") in packages/learning/src/loop.test.ts (it covers the JS half; the Postgres half has no test because the in-memory snapshot store keeps order).
Options
A. Stamp the event sequence on each folded decision (recommended). DECISION_RECORDED’s handler writes seq: event.sequence into the entry, taken from the ENVELOPE (not the payload) and kept from the FIRST record of an id (a repeated id keeps its slot today; it keeps its seq too). Descriptive, never a replay input: getDecision still returns only choice.
- The fold stays pure:
event.sequenceis part of the log, so replaying events is byte-identical and the event bytes never change. Precedent: the same handler already stampsrecordedAtSeq: event.sequenceon inner-step overflow, andReplayVMalready reads it. - It is a value, so it survives
jsonb, key reordering and array-index ids. It is also EXACT, not a re-derived ordinal:evidenceEventscan use it as the rebuilt event’ssequence. - Cost: one integer per decision (~12 bytes folded); no new container.
- Impact:
EVENT_SCHEMA_VERSION1.24.0 plus a history comment (“additive field on the folded_decisionsentry”); fold vectors inpackages/wire-abi/src/foldVectors.tsgain/alter vectors (every existing_decisionsexpectation growsseq);eventSchema.test.tspin;docs/spec/compatibility-matrix.mdrow (regenerate withloom kernel-manifest --emit-docs), the version strings indocs/spec/loom-kernel-specification.md(section on the event schema),docs/cheatsheet-features.md,docs/usage-cli.md. WhetherWIRE_ABI_VERSION(1.0.0) bumps for added vectors is for ADR 0026’s rule to settle during implementation (the vectors are the contract, so a foreign fold that does not stampseqfails them either way, which is the intent).KERNEL_ABI(syscalls) and all SPIs: no change. A third-party snapshot store needs nothing: it stores state opaquely. - Existing snapshots. A snapshot written by a 1.23 fold has no
seqon its absorbed entries. The spec invariantfold(snapshot_k, tail) == fold(events)(spec sections on snapshots, 1.21.0 precedent) then holds EXCEPT for the missingseqof absorbed decisions, exactly as for the 1.21.0 shapes; the fix is the same pattern, an entry withoutseqis “legacy” and is read in folded-map order (today’s behaviour). No rewrite pass: nothing is destroyed, and the lost information cannot be recovered for already-compacted runs anyway. State this explicitly in the spec text; do NOT makenormalizeSnapshotStateinvent aseq. - Replay byte-identity: event log bytes, hash chains (computed over envelopes) and
getDecisionare unchanged. Folded STATE bytes change (a new key), so any golden test that snapshots a folded_decisionsupdates; implementer must grep for state hashing (none known).
B. A parallel _decisionOrder: string[]. Order survives jsonb (arrays are ordered) but duplicates every id (O(D) strings versus O(D) integers), needs an in-place owned-container rule like _decisions (R1a), and two structures can disagree. Same version impact as A. Rejected as strictly larger.
C. Order in the snapshot record, not the state. Snapshot gains decisionOrder; SNAPSHOT_STORE_SPI_VERSION 1.1.0 -> 1.2.0, every store (memory, SQLite, Postgres, encrypting decorator), conformance kit, loadState consumers would not see it, and fold(snapshot, tail) would need a side channel. Rejected: SPI churn across three stores to avoid one integer in the fold.
D. Derive from the log. Keep decision events in place at compaction. Already rejected in ADR 0116 round 3 (2000 decisions x 2 KB stayed at 4 MB; compaction becomes unable to reclaim a long agent run).
E. No kernel change (1P/doc only). Keep today’s behaviour: discount = 1 is exact, document that discount < 1 over compacted Postgres executions is approximate, and let apps that need exact order use sortable ids (zero-padded counter) AND a snapshot store that keeps key order. Zero risk, but the error is silent and backend-dependent, and a learner cannot detect it. Acceptable only if the user judges discount < 1 on compacted runs not worth a schema minor.
Triage (ADR 0083 / P10)
Kernel only for the one field, because the loss happens inside the fold and the checkpoint is the kernel’s own artifact: a plugin cannot add the information back after the events are freed. Everything that USES the order is 1P: evidenceEvents sorts absorbed entries by seq when present and uses it as the rebuilt sequence, falls back to map order for legacy entries, and the it.fails test becomes a plain passing test (the file’s comment says to delete .fails the day order is carried). No new package, no new syscall, no new SPI method. Smallest safe change = option A, ~15 lines in the fold plus vectors and docs.
Decision needed from the user
- Accept a kernel schema minor (EVENT_SCHEMA 1.24.0) for option A, or choose option E (document, no kernel change)? Recommendation: A.
- If A: does a repeated
decisionIdkeep its FIRSTseq(matches the slot rule; recommended) or take the last? - If A: field name (
seq, proposed;recordedAtSeqis already taken by the overflow semantic, so it is avoided) and confirmation that legacy snapshots are read in map order with no migration. - Is
WIRE_ABI_VERSIONbumped for new fold vectors? (Settle against ADR 0026 at implementation; flagged here so it is not discovered late.)
Test plan (when accepted)
Fold vectors for first-record seq, repeated id, and a vector WITHOUT seq (legacy) folding unchanged; evidenceEvents equality compacted vs uncompacted for discount < 1 including array-index ids and a jsonb round trip against a throwaway Postgres 17 (the Postgres half has no test today); snapshot-plus-tail equals full fold for a post-1.24 snapshot; a legacy snapshot reads in map order.
Implementation note (2026-10-11)
User decisions: option A; a repeated decisionId KEEPS its first seq; the field is seq; legacy snapshots are read in map order with no migration; WIRE_ABI_VERSION is not bumped.
- Fold (
packages/replay-vm/src/eventHandlers.ts,DECISION_RECORDED): stampsseq: event.sequencefrom the envelope. A repeat keeps the firstseq; a repeat of a legacy entry (noseq) stays without one, because its first sequence is unknowable and stamping a later one would misplace it.EVENT_SCHEMA_VERSION1.23.0 -> 1.24.0. - WIRE_ABI_VERSION stays 1.0.0. ADR 0026 versions the wire protocol (a projection of the syscall ABI that a wire client negotiates on); it sets no rule that the fold vectors are versioned with it, and the 1.23.0 vectors were added with no bump. The vectors are the conformance contract, and a foreign fold that does not stamp
seqfails them, as intended. Existing_decisionsexpectations inFOLD_VECTORSgainedseq; one new vector pins first-recordseq, a repeated id, an array-index-like id and a payload-suppliedseqbeing ignored. - State hashing: none. The folded
_decisionsis hashed nowhere (hash chains are over envelopes;approvalBinding, content and policy hashes cover other inputs), so no persisted or compared hash changes for an existing execution. Folded STATE bytes change; the goldens that pinned an entry (decisionD1,foldScaleoracle, wire-abi vectors) were updated. - Consumer:
@loom/learningevidenceEventsrebuilds absorbed decisions sorted byseq, withseqas the eventsequence(and drops it from the rebuilt payload). Legacy entries come first, in map order, with negative ordinals so the two kinds never interleave. Theit.failstripwire inloop.test.tsis now a plain passing test, with a legacy-snapshot test beside it and the Postgres half: a run decidingroute-bthenroute-ais compacted on the real Postgres stores (jsonblistsroute-afirst) and itsdiscount: 0.5credit equals the uncompacted one; it fails without the stamp.@loom/analyticsreplay.tsonly countsDECISION_RECORDEDevents, orders nothing from_decisions, so it is not a second consumer and is unchanged. - Docs: specification section 3 (compatibility),
10-event-schema-reference.md, compatibility matrix, cheatsheet,usage-cli.md, CHANGELOG.
Source: docs/adr/0128-decision-order-through-checkpoints.md in the loom repository.