Order Lifecycle Registry
How the engine answers one question — does this order exist on chain, and how sure are we? — from one record instead of seven.
Status: shipped in v1.7.1 (
84b935d9). The registry is the single read surface for chain existence; the five legacy structures (_placedAt,_orderIdAssignedAt,_committedOrderIds,_pendingBroadcasts,_chainSizeAppliedFills) are facade views. The persistedcreateUncertainflag is still read as evidence;_lastUnmatchedChainOrdersremains a chain-diff cache for orphan diagnostics, not an existence answer.See also: ORDER_ENGINE_POST_1.0_RETROSPECTIVE.md (§9.11.3) · FILL_PIPELINE.md (the fill contract that consumes this record) · COW_INVARIANTS.md · TIMING_INVENTORY.md.
The problem this replaces
An order's chain existence used to be tracked by seven independent structures, each with its own clock:
| Structure | Kind | Clock |
|---|---|---|
_pendingBroadcasts |
fingerprint → entry | PENDING_BROADCAST_TTL_MS (30 min) |
createUncertain |
slot flag | none (persisted across restarts) |
_committedOrderIds + _committedOrderIdsBuiltAt |
Set + stamp | SYNC_LOCK_TIMEOUT_MS (20 s) |
_orderIdAssignedAt |
orderId → timestamp | none |
_placedAt |
orderId → timestamp | SURPLUS_CANCEL_GRACE_MS (15 min) |
_chainSizeAppliedFills |
orderId → marker | CHAIN_SIZE_APPLIED_MARKER_TTL_MS (5 min) |
_lastUnmatchedChainOrders + …At |
array | none |
Four read sites consulted four different subsets:
| Site | Consulted |
|---|---|
modules/order/accounting.ts (fund recalibration) |
committed and assigned |
modules/order/grid_reconcile.ts (startup phantom cleanup) |
assigned only |
modules/order/sync_engine.ts pass 1 |
committed only |
modules/order/grid_reconcile_internal.ts |
createUncertain flag only |
Each site was locally correct. Together they could give four different answers for the same order — which is why the resulting bugs ("phantom cleanup virtualized a live order", "lost receipt became a clean hole the next cycle re-CREATED") kept re-appearing in a new path after each fix.
The model
modules/order/lifecycle.ts owns one record per slot (a slot is the stable identity across its order incarnations: intent → chain id → partial → gone).
phase: intent | broadcasting | landed | filling | gone | unknown
evidence: none | local | broadcast-receipt | chain-snapshot | fill-history | adoption
Clocks live on the record, once:
recordedAt— when the current phase was recorded (our clock)observedAt— when the chain last told us anything (0= never)assignedAt/placedAt/committedAt— the legacy per-structure stampsappliedFillInt/appliedFillAt— the pass-1 resize dedupe marker
unknown is a real phase, not an absence. A lost receipt, an ambiguous read and an unparseable chain entry all land there, and mayExistOnChain is true for it. Reading unknown as "gone" is the bug class this whole module exists to make unrepresentable.
Evidence is monotonic
evidence records how we know the current phase, ranked weakest → strongest: none < local < broadcast-receipt < adoption < chain-snapshot < fill-history.
Within one phase the rank can only go up. The write funnel only knows the LOCAL slot state ("this slot is ACTIVE"), so it reports local; that must not overwrite what a sync pass just observed (chain-snapshot), or the registry would claim a purely local basis for the strongest thing we know about the order. A PHASE CHANGE is new information, so it adopts its own evidence.
Clocks cannot be refreshed for free
observedAtadvances only for chain-backed evidence (chain-snapshot,fill-history,adoption). Local bookkeeping cannot move "when did the chain last tell us anything".recordedAt— the anchor of the unresolved bound — advances only when the phase changes or chain-backed evidence arrives. Re-asserting the same unresolved phase is not new information: refreshing the clock for it would keepisInFlight()true forever, which is the opposite of the documented escape hatch. A bot that mutates a slot every cycle (a re-plan, a size tweak) re-assertsunknownon every write, so this rule is what makes the bound real.
Legacy structures are views, not copies
Six of the seven are now live Map/Set views onto the registry (LifecycleMapView / LifecycleSetView):
| Structure | View |
|---|---|
_placedAt |
registry.placedAt |
_orderIdAssignedAt |
registry.assignedAt |
_committedOrderIds |
registry.committedIds |
_committedOrderIdsBuiltAt |
registry.committedBuiltAt |
_pendingBroadcasts |
registry.pendingBroadcasts |
_chainSizeAppliedFills |
registry.appliedFills |
Writes through them land on the record; reads see the record. Old call sites keep working; there is one copy of the data.
createUncertain is folded in at read time (getSlotLifecycle reports unknown), so the persisted flag and the registry cannot disagree.
_lastUnmatchedChainOrders is deliberately not a view: it is a per-read output of the sync engine ("chain orders this snapshot could not place"), not lifecycle state. It is regenerated from each snapshot and must not be mistaken for evidence about a specific slot.
_committedOrderIdsBuiltAt is the registry's single committedBuiltAt stamp: the COW engine publishes a whole Set at once, and the protection window is measured from that publish, not from each id.
The read surface
const view = manager.getLifecycle(orderIdOrSlotId);
view.phase; // 'landed' | 'unknown' | ...
view.isConfirmedGone; // only when the chain confirmed absence
view.mayExistOnChain; // broadcasting | landed | filling | unknown
view.isInFlight(); // absence is NOT yet trustworthy
view.isFreshlyPlaced(); // inside the surplus-cancel grace
view.isRecentlyCommitted(); // inside the COW commit window
view.appliedFillMatches(n); // pass-1 resize dedupeisInFlight() is the single definition of the "absence is not trustworthy" guard. It is time-bounded (2× PENDING_BROADCAST_TTL_MS) so a lagging node cannot park the grid, matching the escape hatch each old per-site guard carried. All three absence-decision sites ask it, through readSlotLifecycle: the sync pass-1 phantom cleanup, the startup reconcile, and the fund recalibration sweep. Asking a subset of it is what re-opened the bug class: the sync engine used to consult the committed window and the fresh-assignment window separately and never the unresolved phases, so it could virtualize an order whose CREATE receipt was merely lost.
readLifecycle(manager, id) reads through getLifecycle and falls back to synthesizing a record from the raw maps, so partial test doubles keep working while the vocabulary stays unified. readSlotLifecycle(manager, slot) is the preferred form wherever the slot object is at hand: a slot can carry evidence the registry does not (the persisted createUncertain marker), and an orderId-keyed read would silently ignore it.
Two read rules:
- Reads do not write.
getSlotLifecyclereportsunknownfor a slot that carriescreateUncertainviawithPhase, which never touches the record. Recording it on read would refreshrecordedAtevery call and keep the unresolved bound alive forever. AwithPhaseview over a slot that was never recorded has no clock at all (recordedAt: 0), so an aged marker cannot look fresh on every read. - Unresolved evidence is sticky — but not eternal. A slot mutation that clears the chain id uses
markIntentIfResolved: it will not downgrade anunknown/broadcastingrecord tointentwhile the uncertainty is inside the bound. Past the bound the release has already happened for every decision (isInFlight()is false), so the same call resolves the phase tointentinstead of leaving a suppression nothing honours andmayExistOnChaintrue forever. Only an explicit decision (markIntent) or the chain can resolve it sooner.
Writes are funnelled
Every order mutation goes through _applyOrderUpdate, which calls _syncLifecycleFromMutation(previous, next, context). That funnel is the ONLY writer of phase, assignedAt and placedAt — the stamps used to be written a second time at the call site, which is how two copies of every clock appear. A createUncertain write (including the persisted marker restored at startup and the load-time sanitizer) is folded into phase: 'unknown' automatically.
One mutation does not go through _applyOrderUpdate: the COW commit replaces the master map wholesale (_commitWorkingGrid). It projects every slot of the committed map through the same funnel before swapping, for the same reason — the primary placement path must stamp assignedAt/placedAt, or the fresh-placement grace is inert for every COW-created order and a competing sweep may cancel a seconds-old order. The committed-set publish then stamps committedAt for the whole map in one call (markCommittedSnapshot); a second per-id stamp would only re-date the same ids.
Two distinct pending-CREATE outcomes are kept distinct:
- resolved (
deletePending) → the slot is a cleanintentagain - abandoned (
_expirePendingBroadcast, TTL sweep) →phase: 'unknown'
Collapsing those two is how a lost receipt used to turn into a clean hole.
Identity and cleanup
A record is keyed by slot, so a rotation updates one record instead of forking a new one per chain id. When a chain id is retired, its index entry is dropped; a lookup for it returns an empty view (phase intent, nothing on chain), not a stale record. If a stamp was written before the slot was known, linkOrderId folds the order-only record into the slot record (max of each clock) and deletes it, so placedAt can never be stranded on an unreachable record.
Invariants
INV-LIFE-001Unknown is never gone.phase === 'unknown'impliesmayExistOnChain === trueandisConfirmedGone === false.INV-LIFE-002One record per slot. A chain-id change (rotation, re-place) updates the existing record; it never starts a parallel one.INV-LIFE-003No site invents its own evidence set. Absence decisions useisInFlight()(read through the slot:readSlotLifecycle); the committed guard usesisRecentlyCommitted(); the fresh-id guard usesisRecentlyAssigned().INV-LIFE-004A LIVE unresolved record is never evicted. Registry pruning skipsunknownandbroadcastingrecords while their uncertainty is inside the bound; past it they are no less settled than anything else (no decision consults them any more), so they become evictable and the registry keeps a ceiling even on a bot that keeps losing CREATE receipts.INV-LIFE-005A chain entry that cannot be parsed makes its slot's absence UNKNOWN, never absent (sync_engineguard 0, mirroring the accounting recovery sweep'smalformedOrderIds).INV-LIFE-006Evidence never downgrades within a phase, andobservedAtadvances only for chain-backed evidence.INV-LIFE-007The unresolved bound is measured from when the uncertainty was RECORDED. Re-asserting the same phase without new evidence does not move it.
Tests
tests/test_order_lifecycle.ts— the record model,unknown ≠ gone, resolved vs abandoned pending, the committed snapshot stamp, the COW commit projection, evidence monotonicity, the unresolved bound against re-assertions, expired-unknown resolution, eviction policy, the unified absence guard at the sync site, and thereadLifecyclefallback.tests/recon_fuzz.ts— the interleaving fuzzer; assertsINV-LIFE-004coverage (I3/I4) after every event.