Skip to content

feat(economic): admission lifecycle, pending fence, validated successor - #728

Merged
cryptskii merged 1 commit into
mainfrom
feat/economic-admission-lifecycle
Aug 25, 2026
Merged

feat(economic): admission lifecycle, pending fence, validated successor#728
cryptskii merged 1 commit into
mainfrom
feat/economic-admission-lifecycle

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

Step 3, sub-PR 3.4 of 6. The window between local acceptance and registration, and what makes it safe.

Why there is a window at all

Both naive orderings are wrong. Register first burns a write-once position on something that may never validate — and the cell can never be reused. Accept locally first advances value that nothing may yet treat as economic ancestry. So the lifecycle has a window, and the fence makes it safe rather than merely brief.

ECON_PREPARED -> LOCAL_ACCEPTED_PENDING_ECON -> ECON_EVIDENCE_PUBLISHED
              -> ECON_REGISTERED -> ECON_ADMITTED

No timeout ever aborts an admission. Abandoning one would leave locally-accepted value with no economic ancestry and a permanently burned position behind it.

The fence rides on the head, not on an argument

Per your correction: no caller-chosen pending: bool — that just moves the bypass one argument inward, since anyone wanting to spend fenced value would pass false. advance() already takes &self, so the pending admission is a DeviceState field the gate reads directly. Every route and every direct internal caller crosses the same gate — the invariant that matters, given the builtin-mint incident found three suites calling advance() directly and bypassing the route.

It is deliberately not in encode_device_state: that needs a DEVICE_STATE_VERSION bump, which under beta's no-legacy rule wipes every head. It lives in its own table, and DeviceState::restore takes it as a required argument, so every rebuild path must supply it or fail to compile.

That requirement paid off twice:

  • The compiler enumerated the rebuild paths, and an audit confirmed exactly one production restore call site — inside decode_device_state, whose loader now reads the pending row under the same lock as the head. The other nine are test-gated. Two separate reads could straddle a writer and pair a post-admission head with a pre-admission fence.
  • It surfaced that five DeviceState sites derive a new head from self, and every one must carry the fence forward. A single None there is a one-transition escape hatch — advance once, fence gone — that no test of the fence predicate alone would catch.

The predicate is the economic classifier

Not Operation::is_value_bearing, which exists for recovery gating and is too coarse: DlvUnlock is value-egress by its measure while producing no R_econ mutation at all.

None                       => ALLOW
ClosedWriteSet             => BLOCK
UnsupportedValueTransition => BLOCK
OfflineAccountOnly         => depends on the pending SUBSTRATE

OfflineAccountOnly is not blanket-allowed, per your refinement. A DSM-backed admission doesn't touch the offline regime, so unrelated bearer activity continues. A pending load or unload boundary blocks bearer use of that asset and allows others. An operation classified OfflineAccountOnly that names no identifiable asset fails closed — "cannot be shown unrelated" is not "is unrelated".

Ordinary non-value relationship activity continues throughout. The fence is not a freeze; blocking it would make a publication delay look like a device fault.

The successor constructor

Writable now, because the substrate acceptance and the shared operation_digest exist. The digest clause is the one easiest to omit and most costly to omit: without it a trader presents a perfectly valid successor and a perfectly valid economic transition describing different operations — each verifies alone, and the pair means nothing.

The validated root is recomputed from the mutations, never copied from the registration.

Atomicity

commit_advance_with_pending_admission rides the existing in_tx_extra seam (the §16.6 precedent — "all exist, or none do"), so a producer cannot write the pending row in a second transaction. Its guard catches an asymmetric failure:

Crash between Result
head committed, row missing reloads unfenced while nothing records why it should be fenced — a silent unfencing
row committed, head missing a fence with no accepted value — a stall

Nothing at read time can detect the first, because both rows are individually well-formed. Hence the guard checks the head carries the admission, rather than just that both exist.

What is NOT in this PR

Active recovery — republishing the exact frozen evidence and completing registration after a restart — needs the quorum publication machinery that doesn't exist until 3.6. Saying "recovery" without that split would overclaim.

Passive recovery is done and tested: a restarted device comes back fenced, because the loader re-attaches the pending row.

Verification

12 new tests. Six gates mutation-proven, each with a positive control asserting the edit landed:

Gate removed Test that goes red
fence gate in advance() advance_refuses_an_economic_write_while_an_admission_is_pending
carry-forward on derivation the_fence_is_carried_forward_by_advance
loader re-attachment the_fence_survives_a_reload_through_the_production_loader
substrate-sensitive offline offline_bearer_activity_is_judged_against_the_pending_substrate
shared operation_digest a_successor_paired_with_a_different_operation_is_refused
atomicity guard a_head_that_does_not_carry_the_admission_is_refused

The restart test is the one the design most needs: the fence is deliberately not in head_bytes, so the loader re-attaching it is the only thing making it survive a restart.

Two positive controls fired and prevented false results — one mutation didn't apply because field ordering differed at the target site, another because cargo fmt had reflowed the statement. Both would otherwise have read as passing gates.

make lint also caught a defect the 3799-test board structurally could not: an inserted accessor had split #[allow(clippy::too_many_arguments)] from restore, silently re-attaching it to an unrelated method. A misattached attribute changes no behaviour, so no test can see it.

Board — both halves, on this exact tree

cargo test --locked --workspace --exclude dsm_storage_node -- --nocapture --test-threads=1
  67 suites, 3801 passed, 0 failed, 19 ignored, WORKSPACE_EXIT=0

make lint   (repo root)
  LINT_EXIT=0, 0 errors, "Lint passed."

Blast radius, stated plainly: the required argument on DeviceState::restore touched 10 call sites across two crates, and decode_device_state's arity touched 8 more. All mechanical and compiler-found, but this is the widest of the economic PRs so far.

Step 3 sub-PR 3.4 of 6. The window between local acceptance and registration,
and what makes it safe.

Why a fence exists
------------------
Both naive orderings are wrong. Register first burns a write-once position on
something that may never validate, and the cell can never be reused. Accept
locally first advances value that nothing may yet treat as economic ancestry.
The lifecycle therefore HAS a window, and the fence makes it safe rather than
merely brief.

  ECON_PREPARED -> LOCAL_ACCEPTED_PENDING_ECON -> ECON_EVIDENCE_PUBLISHED
                -> ECON_REGISTERED -> ECON_ADMITTED

No timeout ever aborts an admission. Abandoning one would leave locally
accepted value with no economic ancestry and a permanently burned position.

The fence rides on the head, not on an argument
-----------------------------------------------
`advance()` already takes &self, so the pending admission is a DeviceState
field the gate reads directly. A caller-supplied `pending: bool` would move
the bypass one argument inward — anyone wanting to spend fenced value passes
false. This way every route AND every direct internal caller crosses the same
gate, which is the invariant that matters: the builtin-mint incident found
three suites calling advance() directly, bypassing the route entirely.

It is deliberately NOT in encode_device_state. Serializing it needs a
DEVICE_STATE_VERSION bump, which under the beta no-legacy rule wipes every
head. It lives in its own table, and DeviceState::restore takes it as a
REQUIRED argument — so every rebuild path must supply it or fail to compile.

That requirement paid off twice. The compiler enumerated the rebuild paths,
and an audit confirmed EXACTLY ONE production restore call site (inside
decode_device_state, whose loader now reads the pending row under the same
lock as the head); the other nine are test-gated. It also surfaced that FIVE
DeviceState sites derive a new head from self and every one must carry the
fence forward — a single None there is a one-transition escape hatch.

The predicate is the economic classifier
----------------------------------------
Not Operation::is_value_bearing, which exists for recovery gating and is too
coarse: DlvUnlock is value-egress by its measure while producing no R_econ
mutation at all.

  None                       => ALLOW
  ClosedWriteSet             => BLOCK
  UnsupportedValueTransition => BLOCK
  OfflineAccountOnly         => depends on the pending SUBSTRATE

OfflineAccountOnly is not blanket-allowed. A DSM-backed admission does not
touch the offline regime, so unrelated bearer activity continues. A pending
load or unload boundary blocks bearer use of THAT asset and allows others. An
operation classified OfflineAccountOnly that names no identifiable asset FAILS
CLOSED — "cannot be shown unrelated" is not "is unrelated".

Ordinary non-value relationship activity continues throughout. The fence is
not a freeze; blocking it would make a publication delay look like a fault.

The successor constructor
-------------------------
Now writable, because the substrate acceptance and the shared operation_digest
exist. Every clause is checked, and the digest clause is the one that is
easiest to omit and most costly to omit: without it a trader presents a
perfectly valid successor and a perfectly valid economic transition DESCRIBING
DIFFERENT OPERATIONS. Each verifies alone; the pair means nothing.

The validated root is RECOMPUTED from the mutations, never copied from the
registration.

Atomicity
---------
commit_advance_with_pending_admission rides the existing in_tx_extra seam
(§16.6 precedent: "all exist, or none do"), so a producer cannot write the
pending row in a second transaction. Its guard catches an ASYMMETRIC failure:
a head written WITHOUT its admission reloads unfenced while the row claims one
is in flight, and nothing at read time can detect that — both rows are
individually well-formed. The reverse is only a stall.

What is NOT here
----------------
ACTIVE recovery — republishing the exact frozen evidence and completing
registration after a restart — needs the quorum publication machinery that
does not exist until 3.6. PASSIVE recovery is done and tested: a restarted
device comes back fenced, because the loader re-attaches the pending row.

Verification
------------
12 new tests. Six gates mutation-proven, each with a positive control on the
edit:

  fence gate in advance()      -> advance_refuses_an_economic_write_while_an_
                                  admission_is_pending
  carry-forward on derivation  -> the_fence_is_carried_forward_by_advance
  loader re-attachment         -> the_fence_survives_a_reload_through_the_
                                  production_loader
  substrate-sensitive offline  -> offline_bearer_activity_is_judged_against_
                                  the_pending_substrate
  shared operation_digest      -> a_successor_paired_with_a_different_
                                  operation_is_refused
  atomicity guard              -> a_head_that_does_not_carry_the_admission_
                                  is_refused

Two positive controls FIRED and prevented false results: one mutation did not
apply because field ordering differed at the target site, another because
cargo fmt had reflowed the statement.

Board: 67 suites, 3801 passed, 0 failed, 19 ignored, exit 0.
make lint: exit 0, 0 errors.
@cryptskii
cryptskii merged commit ed488a8 into main Aug 25, 2026
18 checks passed
@cryptskii
cryptskii deleted the feat/economic-admission-lifecycle branch August 25, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant