feat(economic): admission lifecycle, pending fence, validated successor - #728
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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 passfalse.advance()already takes&self, so the pending admission is aDeviceStatefield 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 callingadvance()directly and bypassing the route.It is deliberately not in
encode_device_state: that needs aDEVICE_STATE_VERSIONbump, which under beta's no-legacy rule wipes every head. It lives in its own table, andDeviceState::restoretakes it as a required argument, so every rebuild path must supply it or fail to compile.That requirement paid off twice:
restorecall site — insidedecode_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.DeviceStatesites derive a new head fromself, and every one must carry the fence forward. A singleNonethere 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:DlvUnlockis value-egress by its measure while producing noR_econmutation at all.OfflineAccountOnlyis 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 classifiedOfflineAccountOnlythat 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_digestexist. 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_admissionrides the existingin_tx_extraseam (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: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:
advance()advance_refuses_an_economic_write_while_an_admission_is_pendingthe_fence_is_carried_forward_by_advancethe_fence_survives_a_reload_through_the_production_loaderoffline_bearer_activity_is_judged_against_the_pending_substrateoperation_digesta_successor_paired_with_a_different_operation_is_refuseda_head_that_does_not_carry_the_admission_is_refusedThe 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 fmthad reflowed the statement. Both would otherwise have read as passing gates.make lintalso caught a defect the 3799-test board structurally could not: an inserted accessor had split#[allow(clippy::too_many_arguments)]fromrestore, 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
Blast radius, stated plainly: the required argument on
DeviceState::restoretouched 10 call sites across two crates, anddecode_device_state's arity touched 8 more. All mechanical and compiler-found, but this is the widest of the economic PRs so far.