Uh oh!
There was an error while loading. Please reload this page.
feat(collateral): declare control.collateral.buffer, the recommended $DIG buffer + funding state - #36
Conversation
…$DIG buffer + funding state Declares a SEPARATE collateral method rather than extending `control.collateral.requirement`. The requirement is consensus-derived and identical on every node; the buffer is local — it rests on the pairs THIS node serves, on an operator preference (the safety margin), on unreclaimed collateral, and on a horizon this node chose. 0.23.0 kept the margin out of `.requirement` on exactly that grounds, and folding a local position into a consensus result would make one node's preferences look like the network's price. The payload carries the recommended buffer, the funding state, and the working behind both: pairs served by this node, the pre-margin per-store requirement, the margin in basis points, the unreclaimed transition overlap, the escalation headroom, the spendable balance the verdict was reached against, and the HORIZON plus the compounded worst-case ceiling that headroom assumed. Amounts are DIG base units (3 decimals); a mojo is XCH's unit and is nine orders of magnitude away. The horizon is required on the wire: escalation compounds at up to +12.5% per epoch, so the same buffer over a different horizon is a different claim. `unknown` is a tagged variant with a named reason and no representable number, because a zero here reads as "no buffer needed" — an operator acting on it posts nothing and loses the epoch. The funding state is the node's verdict rather than a threshold each client re-derives; `below_recommended_buffer` is a readout and never a recurring notification. Also excludes AGENTS.md and CLAUDE.md from the published tarball (27 -> 25 entries): a crates.io version is immutable, so 0.23.0 carries them permanently. Closes#35
64ffa45 to
aea2298CompareMichaelTaylor3d
commented
Aug 28, 2026
IN PROGRESS — not the verdict. Gate on Confirmed so far, by direct read of the diff at this head:
Still running: test suite, independent re-execution of two mutations, the empty-run guard, |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS
Head audited: aea2298ff834298b599c2a7fe0db94bfd17b5209 (resolved from the remote; unchanged throughout).
One correctness gate. No gating findings. Draft, unmerged, no code edited.
Shape decision — judged, not rubber-stamped
Argument 3 holds, and it is the strongest of the three. I read the census taxonomy at src/results.rs:2820-2828. All four reasons — NotCensused, BehindFinalityDepth, RecordUnreadable, NoChainSource — are facts about the census record and the chain view. RecordUnreadable is scoped to the census record specifically, not to arbitrary local state. None can express "I cannot enumerate my own served set", "I cannot read my reclaim state", or "I cannot read my balance": those are facts about node-local bookkeeping that the census taxonomy has no vocabulary for. Collapsing them into not_censused would be false, and also unactionable — the three have different remedies (hosted-store view, transition bookkeeping, chain source). The three reasons are genuinely unrepresentable. Separate taxonomy is correct.
The destructuring claim is verified.src/kats.rs:4636 destructures CollateralRequirementResult::Knownexhaustively, with no .. — all seven fields bound. Adding eight fields there is a compile break for every reader written that way, while a new method is invisible. "Additive in SemVer is not additive in blast radius" is accurate as stated.
The duplicated triple earns its keep.epoch / protocol_version / required_per_store_dig_base_units are what let a client detect that a buffer is quoted against a different epoch, or computed under a different protocol version, than the requirement already on screen. Without them the two results are mutually uncheckable and a stale buffer is indistinguishable from a current one — on a money surface. The two-round-trip cost is real, correctly disclosed, and buys a detectable disagreement.
Honesty properties — all four verified
unknowncannot hold a number, structurally.CollateralBufferResult::Unknown { reason: CollateralBufferUnknownReason }(src/results.rs:3089) has exactly one field, of enum type. There is noOption<u64>and no representable numeric state — a type property, not a convention.kats.rsadditionally assertsobj.len() == 2and that no value is a number or null, across every reason.horizon_epochsandescalation_ceiling_microsare required on the wire. Proven by execution, not by reading: I applied#[serde(default)]tohorizon_epochsin my own worktree and the test went red atkats.rs:4820— "horizon_epochs must be REQUIRED". The test itself decodes payloads with each field stripped, with the intact payload as a control.- The third funding state cannot be swept into an alert via
is_shortfall().src/results.rs:2977matches onlyShortNow | DangerouslyLow. I foldedBelowRecommendedBufferin; the test went red atkats.rs:4899. The KAT writes the shortfall set out literally rather than deriving it fromis_shortfall, so it pins the SET and not the implementation's opinion of itself. - Units: no conflation found. Every amount field carries
_dig_base_units; the two non-amount numerics arepairs_served_by_this_nodeandhorizon_epochs, both named as counts;margin_bpis basis points andescalation_ceiling_microsmillionths, both stated at every mention. "mojo" appears in this diff only in the sentence distinguishing it (results.rs:3019,method.rs:588).pairs_served_by_this_nodeis documented and tested as this node's own set, with the mock answering12against a censusstoresof4_200so a substitution is visible.
Instrument verified, not trusted
- The disclosed harness failure reproduces exactly.
cargo test --lib a_buffer_without_its_horizon_does_not_decode -- --exactreturns0 passed; 173 filtered outand exit 0. Withkats::prefixed:1 passed. The lane's self-report is accurate. - I re-executed three of the five mutations independently (not the two required):
#[serde(default)]onhorizon_epochs→ killed;BelowRecommendedBufferfolded intois_shortfall()→ killed;CollateralBufferremoved fromControlMethod::ALL→ killed atkats.rs:4781, confirming thefrom_nameroute catches the omission anALL-iterating test is structurally blind to. Verdicts were not uniform across the run — each named its own distinct assertion, which is what a code claim looks like rather than an instrument claim. - Tree clean.
git status --porcelainempty after every restore and at completion;HEADstillaea2298. Suite: 173 lib + 9 doc pass.
Realization 1 — the fix is complete
SPEC §4.2e's superseded paragraph is gone; the replacement (SPEC.md:968-971) points at control.collateral.buffer and pairs_served_by_this_node. I swept the whole SPEC and README rather than spot-checking: hostedStores now appears only in the four catalog table rows (SPEC.md:90-93), never as a buffer input, and no surviving sentence points a client at a locally-assembled buffer or store count. §4.2f restates the prohibition as a MUST.
Also confirmed
- Every new doc sentence checks out against code in this same diff.
DEFAULT_SAFETY_MARGIN_BPexists (params.rs:1779);control.wallet.watchedis gated for the cited reason (method.rs:584);(9/8)^4 x 1e6 = 1_601_806.64, so the quoted1_601_806is that value truncated, and x1.125 / x4.6236 match "x1.12" and "x4.62". The ceiling is stated as a worst case with the dead-band caveat at every mention —results.rs,method.rs:588,traits.rs,SPEC.md:1011,README.md. No sentence implies a forecast. - SemVer minor is right. The new required
ControlHandler::collateral_buffermethod is a break for any external implementor, and on0.xa minor is the semver-incompatible bump (^0.23does not accept0.24). Patch would have been wrong. Matches the 0.21-0.23 precedent. excludedrops exactly two files and no source. I re-rancargo package --listwith and without the key and diffed: removed =AGENTS.md,CLAUDE.md; added = none; all eightsrc/*.rspresent. 25 entries.DEFAULT_BUFFER_HORIZON_EPOCHS = 4is defensible. 28 days at ~x1.60 headroom sits between one epoch (x1.12 — one bad epoch fromdangerously_low) and thirteen (x4.62 — locking capital against a ceiling the controller reaches only by escalating every epoch, which the dead band makes unlikely). More importantly the constant is not load-bearing: the horizon the node used always travels in the payload, both fields are required on the wire, and the doc marks it recognisable-never-substitutable. A wrong default here cannot produce a wrong claim.- Merge preconditions by name: all four required contexts present and
SUCCESS—Format / Clippy / Build / Docs,Coverage (>=80% lines),Lint commit messages,Check version increment.unresolvedReviewThreads=0.RESULT: BLOCKEDondraft=truealone, correct for a gate round. Single commit, authormichael@michaeltaylor.dev— the configured identity, not a fabricated one.
Coverage I did NOT achieve
- No producer or consumer was exercised. dig-node#389 and dig-app#306 do not exist yet, so the contract's fit with both is argued, not measured. Inherent to release-first; stated, not a defect.
- I re-executed three of five mutations, not five. The two I did not re-run are the
Unknown-variant defaulted-field mutation and the census-substitution handler mutation. Both have assertions I inspected and judged load-bearing by reading (obj.len() == 2over every reason;assert_ne!(pairs, census_stores)with differing fixture values), but I did not watch them go red myself. - No gitnexus index. Blast radius was measured by grep plus direct read of every catalog site, matching the lane's own method. A call-graph tool could surface an external destructuring site this crate cannot see.
- I did not verify the crates.io publish workflow itself, only the tarball contents it would ship.
escalation_ceiling_microstruncation (...806vs...806.64) is noted, not raised: it appears only in the mock and prose, this crate computes no escalation by design, and nothing here checks a producer's ceiling. Pre-accepted in the brief.
No inline threads opened — there is nothing gating to anchor one to.
Uh oh!
There was an error while loading. Please reload this page.
DO NOT MERGE — gate round pending.Closes#35.
Producer DIG-Network/dig-node#389 · consumer DIG-Network/dig-app#306 (PR #311). Release-first: both wait on this publishing.
The shape decision: a SEPARATE method,
control.collateral.bufferExtending
control.collateral.requirement's result was the cheaper option and I rejected it.(owner, store, root)pairs this node serves, an operator preference (the safety margin), this node's unreclaimed collateral, and a horizon this node chose. One result object carrying both would put a node's private position inside the value clients read as the network's price.CollateralRequirementResult's own doc says "The margin is deliberately absent here… the requirement is a consensus-derived value every node derives identically; the margin is a local operator preference that MUST NOT be a consensus input." The buffer is that same argument with more force: it is built from the margin, so anything the margin was excluded for applies to it a fortiori.not_censused(false and unactionable) or make an already-tagged union carry two independent unknown axes.CollateralRequirementResult::Knownis exhaustively destructured by consumers; adding eight fields to it is a change every existing reader sees, whereas a new method is invisible to them. Additive-in-SemVer is not the same as additive-in-blast-radius.Argued against, honestly: two round trips instead of one, and
epoch/protocol_version/required_per_store_dig_base_unitsare duplicated across the two results. I accepted the duplication because those three fields are what make the buffer checkable against the requirement a client may already hold — a buffer quoted against a different epoch than the one on screen is exactly the disagreement worth being able to see.The declared payload — every unit named
CollateralBufferResult::Known({"state":"known", …}):epochprotocol_versionfunding_stateshort_now|dangerously_low|below_recommended_buffer|fundedrecommended_buffer_dig_base_unitsspendable_dig_base_unitspairs_served_by_this_node(owner, store, root)pairs this node servesrequired_per_store_dig_base_unitsmargin_bp100= +1%), never convertedoverlap_dig_base_unitsescalation_headroom_dig_base_unitshorizon_epochsescalation_ceiling_micros1_000_000= x1.0), a worst case$DIG has 3 decimals, so one base unit is
0.001 DIG. Not a mojo — that is XCH's base unit at1e-12XCH, nine orders of magnitude away. The margin stays in basis points because that is the unitdig_mirror_collateral::apply_safety_margintakes and the one dig-appSPEC.md§3.7b fixes; it is never converted here.The total is authoritative, the terms are the working. Documented, and specced as a MUST, that a client renders the node's total rather than re-adding the terms — the rounding lives in the node's arithmetic.
The horizon is a required wire field, not an implied default. Escalation is bounded at
+12.5%/epoch (UP_STEP_DENOM = 8) and compounds — x1.12 at one epoch, x1.60 at four, x4.62 at thirteen — sohorizon_epochsandescalation_ceiling_microsboth fail to decode when absent (test below).escalation_ceiling_microsis documented as a worst case, not a forecast: in the controller's dead band the multiplier does not move at all.The funding state is carried, not re-derived. Every input is in the payload, so a client could threshold it — and two that did would pick different thresholds and disagree.
is_shortfall()names the two states in which an epoch is actually uncovered;below_recommended_bufferis deliberately excluded and specced as a readout that MUST NOT be a recurring notification, because a healthy node sits there much of the time. Whether a state interrupts somebody stays the client's call.Two constants published alongside:
DEFAULT_BUFFER_HORIZON_EPOCHS = 4andESCALATION_UP_STEP_DENOM = 8, restated rather than imported for the same reasonDEFAULT_SAFETY_MARGIN_BPis (dig-mirror-collateralsits at the same crate level; same-level deps are forbidden).DEFAULT_BUFFER_HORIZON_EPOCHSis documented as recognisable, never substitutable — a reader that used it in place of a payload it failed to read would state a claim the node never made.How
unknownis expressedA tagged variant,
{"state":"unknown","reason":…}, with no representable numeric field — not anOption<u64>beside a reason. On.requirementa fabricated zero reads as a free requirement; here it reads as no buffer needed, and an operator acting on it posts nothing and loses the epoch. Four reasons, each naming a different missing fact with a different remedy:requirement_unknown,served_set_unknown,reclaim_state_unknown,balance_unknown.requirement_unknowndeliberately does not restate §4.2e's four census reasons — a copy would drift — and points atcontrol.collateral.requirementinstead.Blast radius checked
gitnexus was not used: the global index is the superproject's and a per-worktree
analyzeon a 12k-line crate is not worth its cost here; blast radius was measured by grep + direct read of every site the catalog touches, and stated rather than assumed.ControlMethodis#[non_exhaustive]and every consumer match must already carry a_arm, so a new variant is additive by construction. Nothing existing was edited:name(),category()andALLgained one arm/entry each;routing()falls through its_ => Routing::Ownedandrequires_auth()through!is_open_read(), both correct without change.ControlHandlergained one required method, which is the only breaking-shaped part of the diff. It is the established pattern for every method this crate has added (each of 0.21.0–0.23.0 did the same) and is why this is a minor on0.x; the sole implementor in-repo is the KAT mock, and dig-node adopts it as the producer half of the epic.git difftouches only additions plus one superseded SPEC paragraph (below) and the version.control.hostedStores.list" — the exact client-side derivation Carry the recommended $DIG buffer and the node's funding state — the app cannot derive either #35 exists to forbid. Rewritten to point atcontrol.collateral.buffer.Tests — and the proof each one is load-bearing
Five new KATs. Each was proved by mutating only the thing it claims to pin and confirming the test goes red (source committed first; restore verified against
git status):#[serde(default)]onhorizon_epochs(the optional-with-fallback reading)a_buffer_without_its_horizon_does_not_decodeCollateralBufferdropped fromControlMethod::ALLthe_buffer_method_is_in_the_catalog_categorised_and_gatedbelow_recommended_bufferfolded intois_shortfall()exactly_two_funding_states_mean_an_epoch_is_uncoveredrecommended_buffer_dig_base_unitsadded to theUnknownvariantan_unknown_buffer_carries_a_reason_and_never_a_numberstorescount as its served setthe_buffer_read_returns_this_nodes_served_set_not_the_census_countTwo fixture notes, because both are places a green would otherwise have been false:
12pairs while its requirement handler answers a censusstoresof4_200. Both areu64counts in the same domain, so a handler wired to the census figure compiles and looks plausible; only a fixture where the two differ can see the substitution — which is the "confident, badly wrong number" the ticket names.ControlMethod::from_name, not by naming the variant.from_namesearchesALL, so a variant added to the enum and forgotten inALL— invisible to discovery and to every sweep, while compiling everywhere — fails here. A directCollateralBuffer.name()assertion would have passed.Instrument honesty: the first mutation run reported all five as SURVIVED. The cause was the harness, not the tests —
cargo test --lib <name> -- --exactneeds the fullkats::path, so zero tests ran and the run exited 0. The harness now asserts a non-zero test count and reportsINSTRUMENT BROKENrather than SURVIVED for an empty run. A uniform verdict across five unrelated mutations is an instrument claim, not a code claim.cargo test: 173 lib + 9 doc tests pass.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.Version + deps
0.23.0 → 0.24.0 (minor). Additive new capability: a new
ControlMethodvariant on a#[non_exhaustive]enum, new result/params types, and one newControlHandlermethod. No existing wire name, field, type or predicate changed, so nothing published breaks for a reader; the added trait method is why this is not a patch. Tag is cut by the merge — no hand-pushed tag.§2.4b: this crate declares no
dig-*and nochia-*dependencies at all —serde,serde_json,async-trait,semver,futuresonly, all already at their latest majors. Nothing to bring forward, and nogit = …dep to check against a branch. Verified by readingCargo.toml, not from memory.Crate tarball hygiene
exclude = [".claude", "AGENTS.md", "CLAUDE.md"]added.cargo package --list: 27 entries → 25, withAGENTS.mdandCLAUDE.mdgone (no.claudedir exists here; the entry is prophylactic)..github/,cliff.tomlandcommitlint.config.mjsdeliberately left in. 0.23.0 ships those two files permanently — a crates.io version is immutable — and this stops 0.24.0 becoming another instance. The ecosystem-wide gate is https://github.com/DIG-Network/dig_ecosystem/issues/3182 and is not taken here.Stated plainly, not verified
escalation_ceiling_micros = 1_601_806for a four-epoch horizon appears only in the KAT mock and in prose. It is consistent with(9/8)^4, but this crate declares the contract and does not compute escalation — deliberately, to avoid a rival implementation ofdig-mirror-collateral's arithmetic. Nothing here checks the producer's ceiling against the crate's.DEFAULT_BUFFER_HORIZON_EPOCHS = 4is my judgement, not a value read fromdig-mirror-collateral(no such constant exists there). The rationale is in its doc-comment; the node reports what it actually used regardless.