feat(collateral): serve mirror-coin collateral control methods and spends.list - #395
Conversation
loop-security — IN PROGRESS, not the verdictAuditing head Established so farMerge preconditions (asserted BY NAME via Authorship — all 18 commits
Finding L1 (LOW, non-gating) — two lane scratch scripts committed into the shipped tree
No secret, no credential, no build/exec path reaches them, and they are not Continuing: the never-render-an-unknown sweep across |
loop-security — IN PROGRESS, not the verdict (2/n)Head Authorization posture — traced, and it is correctI first mis-read
All five new methods require a valid control token — none was added to
Finding S1 (the FIFTH instance of the class) —
|
loop-security — IN PROGRESS, not the verdict (3/n)Head RE-EXECUTED: the cursor mutation, with the distinguishing fixtureI applied the drain-then-filter mutation to Exactly one test caught it, and it is the one with the right fixture. The lane's stated subtlety Restored afterwards: RE-EXECUTED: the margin ceiling is REFUSED, and 1 bp survivesIndependent probe against the real contract type:
RE-EXECUTED: the stale-epoch guard holdsSeeded a store containing ONLY an epoch-103 record with a distinctive Realization 2 checked: the replacement is not a smaller version of the marker-file hazard. The The live-evidence arithmetic reproduces exactly — the brief's discrepancy was the brief'sThe brief flagged
The rest follows: One caveat worth stating for the operator-facing claim: an operator with a calculator will hit the |
loop-security — IN PROGRESS (4/n) — the SIXTH instance, and it is my gating findingHead Finding F1 (GATING) —
|
loop-security VERDICT: CHANGES-REQUIREDHead audited: One GATING finding. The design, the arithmetic, the authorization tiers, the cursor semantics and the GATINGF1 —
|
Round 2 — F1/S1 fixed, sweep done, D1-D4 judgedHead GATING F1 — fixed at
|
| payload | before | after |
|---|---|---|
{"state":"suspended","epoch":104} |
epoch 104 (protocol v0) — 0.000 DIG per store, … |
collateral requirement: unreadable answer from the node (…) |
{} |
epoch 0 (protocol v0) — 0.000 DIG per store, … |
same refusal |
S1 — fixed at control_cli.rs:772
margin_bp is decoded into CollateralMarginResult and the read is refused with ? rather than
defaulting to zero. Zero is a legitimate margin, which is what makes this worse than F1's zero
in one respect: an absent requirement rendered as zero is at least implausible, whereas an absent
margin substituted for zero is indistinguishable from a real answer.
The sweep — 65 unwrap_or + every positive-guard-on-a-string in the CLI and control layer
One as_str() == Some(...) guard existed in the whole rendering path (the F1 site). The other hit,
relay.rs:110, compares a probe id for equality — not a render.
Of the 65 unwrap_or, the class is "an unknown rendered as a reassuring answer", so I sorted by
which direction each fails in:
- A third instance, fixed:
summarize_margin(control_cli.rs:1006). Same root cause, same
money surface, and it is the line an operator reads back aftermargin setto confirm the setting
took — the one place a fabricated0 bpwould be believed. - Honest already: every string field uses
"?", which reads as unknown.corroboration_bypassed
isunwrap_or(true)— deliberately failing toward the alarming side. - Fails toward alarm, not reassurance, so out of the class:
installed/synced→false,
balance/pending→0. A zero balance is not a reassuring reading of an unknown. - Judged and left, reported not filed (§1.3c — a finding is a comment):
bannedis
unwrap_or(false)at:638and:1029, so an undecodable peer entry renders as not banned.
That is the reassuring direction, but it is not money, the field is a plain struct rather than an
open tag so it has no additive-variant trigger, and it is outside this PR's diff.
D1 — NOT fixed; recorded as a bounded known (collateral.rs:275 doc)
Three reasons, and I will argue the other side first: the executed probe is real, 18,482,313 DIG is
a catastrophic figure, and "you need write access already" is the excuse that has protected many
real defects.
What decided it against a fix here: (a) writing that record needs the state directory that also
holds the margin, the config and the identity key — validating this one artefact implies the
others are validated, which is a worse claim than the current honest silence; (b) arbitrary
corruption already fails closed, since an unparseable line is Unreadable — only a well-formed lie
survives, which is a much narrower class than "a forged record"; (c) a plausibility bound derived at
the read side would be a rival implementation of the controller dig-mirror-collateral owns,
and two surfaces disagreeing about one price is the failure this crate is organised to avoid.
The honest remedy I can name is narrower than a bound: a protocol_version ceiling check, since a
record from a model newer than this build is one it cannot interpret even when every field parses.
That is not a rival implementation, it is the same additive-contract class as F1 — and it belongs
with the census writer, where the record's own invariants live. Recorded on the function and
pointed at #387.
D2 — fixed at collateral.rs:179
Agreed, and the module's own doc comment already said so: the split exists so the node can
distinguish "I lost this" from "this never happened". Only NotFound is Absent now. It is a wrong
remedy rather than a wrong figure, and the remedy is actively harmful: not_censused renders as
"run the census for this epoch", which writes to the very file that could not be read.
D3 — fixed at control_cli.rs:783
Cheap and worth it. The --roots line now ends with (store-root count supplied by you via --roots, not measured by this node). This is what turns the named limitation from implied into
visible; the marker goes away when #387 lands the served-set count.
D4 — documented, not fixed (control.rs:3324 doc)
Agreed it is out of scope. Recorded with the reason it is bounded (the margin reaches no spend path)
and the reason it should not be fixed piecemeal: "which paired-tier state should a revoke reclaim"
is a lifecycle question, and answering it for this one setting establishes by accident a rule the
other paired-tier writes do not follow.
L1/L2 — gone
.tsplice.py and .wire2.py removed in a plain deleting commit (da90f98); no rebase. Ignored via
the worktree's info/exclude, not the repo .gitignore.
The doc splice was worse than reported: two blocks were orphaned onto parse_dig_amount, not
one — chia_peers_action's "listing is the default because it is the only harmless one of the
three" and collateral_action's "an unrecognised preset is REFUSED". Both are back on their own
functions, and both of those functions had been left with no doc at all.
Tests — on the rendered output, and proven load-bearing
Three new tests, all asserting the string a person reads:
an_undecodable_requirement_renders_unreadable_and_never_a_figurean_undecodable_margin_renders_unreadable_and_never_zero_bpan_unreadable_record_file_is_not_reported_as_a_missing_one
Fixture design. The requirement fixtures carry epoch: 104 — the same epoch as the truthful
control test above them — and assert 104 does not appear. Asserting only the absence of
0.000 would not catch a formatter that happened to be handed a non-zero requirement; asserting the
absence of a value the payload really contains catches any leak. The known-with-a-missing-field
case is the one that separates a typed decode from a hybrid that matches the state string and then
falls back per field: the state token is perfectly valid there. Each also asserts the line does not
say UNKNOWN, because borrowing that branch would claim the node named a fact it did not.
The D2 test varies exactly one thing — whether the path exists — and requires the two to produce
different answers; a single fixture could not show it, since the old code returned Absent for
both and would satisfy either assertion alone. A directory stands in for the file because chmod is a
no-op for an administrator on Windows, so a permission fixture would pass by not being unreadable.
Revert proof, committed first, reverted by file copy (never git stash — it is repo-global and
crosses worktrees). Each fix reverted alone:
- revert F1 →
an_undecodable_requirement…FAILS with
unrecognised state was not reported as unreadable: epoch 104 (protocol v0) — 0.000 DIG per store
— the gate's probe string, verbatim — and the margin test still passes. - revert S1/margin →
an_undecodable_margin…FAILS with
empty object was not reported as unreadable: safety margin 0 bp = +0.00%— and the requirement
test still passes. - revert D2 →
an_unreadable_record_file…FAILSleft: Absent, right: Unreadable, alone among 20.
The three fail independently, so none is carried by another's fix.
Blast radius
gitnexus was not used — the §2.0 fallback, stated rather than skipped. The radius here is a
call-graph question over four private-or-crate-local functions, which ripgrep answers exactly and a
10-minute per-worktree index would not answer better:
| symbol | production callers |
|---|---|
summarize_collateral_requirement |
1 — summarize dispatch, control_cli.rs:716 |
summarize_margin |
1 — control_cli.rs:721 |
control_cli::collateral_buffer |
1 — entrypoint.rs:871 |
EpochRecordStore::get |
1 — requirement(), collateral.rs:311 |
One trap worth naming: there are two functions called collateral_buffer — the CLI one I
edited, and the node-side handler at control.rs:923. I touched the node-side file only for D4's
doc comment.
Where this reaches past the CLI rendering path — one place, stated plainly. D2 is in
EpochRecordStore::get, which requirement() uses, which the node-side
control.collateral.requirement handler also serves. So a node with an unreadable state directory
now returns record_unreadable on the wire where it previously returned not_censused. Both are
published reasons of the existing contract, no shape changed, and the new one is the correct one per
SPEC §24.2's own table — but it is a wire-visible behaviour change and the re-gate should see it as
such. Everything else is confined to CLI rendering.
Coherence
SPEC §24.2 and §24.7 updated in the same unit: a client MUST NOT render an undecodable requirement
as a figure and MUST NOT borrow the unknown rendering; record_unreadable is decided by the file,
not only its contents; collateral buffer MUST mark an operand-supplied root count.
Version stays 0.161.0 — main is 0.160.0, so the gate is satisfied, and the minor is unreleased,
so these fixes ship inside it rather than as a patch on top of something nobody has.
#387 remains a separate PR in this lane, unfolded. dign collateral buffer --roots stays a named
limitation until it lands — now visible in the output rather than implied.
loop-security re-gate (round 2) — IN PROGRESS, not the verdictAuditing head L1/L2 splice damage — CONFIRMED, and worse than "two orphaned blocks"Read the pre-fix state directly ( Net effect before the fix, at
This is the failure mode the brief names: a security rationale attached to the wrong At head both are restored to their correct functions and No third instance. Scanned every Non-gating note on commit hygiene: No encoding damage introduced: zero U+FFFD in any touched file, LF endings, valid UTF-8, Continuing: F1/S1/ |
loop-security re-gate (round 2) — IN PROGRESS, not the verdict (2/n)Head D2's wire reach — verified, and materially smaller than the brief assumedThe brief flags that 1. There is no "previously" on any shipped node. At merge-base 2. The SPEC specified the split BEFORE the code implemented it. I checked the direction rather 3. The lane's reasoning about the remedy holds: Consumer: dig-app PR #311 ( Attacker reachability of the new branch: Serde refusals are real, not nominalChecked the declarations rather than trusting the comments:
Still to come: executed revert proofs (independence), the |
loop-security re-gate (round 2) — IN PROGRESS, not the verdict (3/n)Head Baseline: R1 — revert F1 alone: PASSES, and reproduces the probe string verbatimReplaced Character-for-character the string the gate quoted. Note the rendered lie is worse than "a zero": R2 — revert
|
loop-security re-gate (round 2) — IN PROGRESS, not the verdict (4/n)Head THERE IS A THIRD ORPHANED DOC BLOCK —
|
loop-security re-gate (round 2) — PASSHead audited: No live security defect in this diff. Nothing here should block the merge. Four non-gating Verified by execution (not by reading)Revert proofs — two of the three legs are genuinely pinned, and independently.
F1's failure reproduces the gate's probe string character-for-character: The lie is worse than "a zero": a REAL epoch Fixture design — both claims verified by building the implementation they claim to discriminate
Serde refusals are real, not nominal. D2's wire reach is real but far smaller than assumed. At merge-base, Sweep completeness, independently confirmed. Pre-fix Node-side extension (past the lane's scope, since the node serves where the CLI only renders): Mechanical: 476/476 pass; clippy Findings — all NON-GATING, ranked1. S1 and D3 are untested, and the "three tests fail independently" claim is not accurate
Reverting both at once — S1's typed The three tests the PR points at are Why it is not gating: the code at head is correct and the refusal is real, not nominal — Why it still matters: the money lie S1 prevents is the dangerous direction — a margin defaulted 2. A THIRD orphaned doc block, introduced by this PR
The block "Load from Same class and same harm as L1/L2: a failure-direction rationale sitting on the wrong function reads My first automated pass missed this because it is a contiguous stack, not a block terminated by 3. The D1 rationale names an artifact that is definitionally not there
The justification for not defending a forged record reads: "Writing that record requires write The conclusion survives and is arguably stronger: the state dir holds the master control On whether D1's bounding under-rates an 18,482,313.402 DIG recommendation — it does not, and the 4.
|
Round 3 — five items done, at
|
| guard reverted alone | test that fails | observed |
|---|---|---|
| S1 margin decode | an_undecodable_margin_aborts_the_buffer_… |
(0 bp margin) … recommended holding 29.504 DIG … funded — at or above the recommended buffer. |
| D3 provenance marker | an_operand_supplied_root_count_… |
serving 3 store root(s) at 3.780 DIG each (100 bp margin) — no marker |
One test fails in each case, so neither is carried by the other's fix.
The S1 failure output is the money lie in full, and I kept it verbatim in the assertion message
as you suggested — the assert prints the whole rendered summary, so a reviewer sees
(0 bp margin) … funded — at or above the recommended buffer and needs no explanation.
Fixture design, since this is where a false green would have been born. An is_err() assertion
alone would have pinned the refusal while saying nothing about why it matters, and would still pass
if the cushion had quietly stopped affecting the figure. So the balance is calibrated at run time
to the zero-margin recommendation — the exact point where unwrap_or(0) and the truthful decode
disagree about the funding state — rather than hard-coded, so it cannot drift out of the band it
tests. The test first asserts rec(500 bp) > rec(0 bp), which is the money the defaulted path drops.
D3's test is a placement proof, not an outcome proof: the obvious simplification is to move the
marker into render_buffer, where the line is built — and that would make the node's own measured
answer claim an operator supplied a count they never typed. So the second actor is the shared
renderer given the same advice, asserted silent. Asserting only that the operand path carries
the marker would pass under that mislocation.
2. PR body corrected
Both overstated claims are now recorded in the body rather than edited away, since an overstated
coverage claim is what lets a later simplification put the unwrap_or(0) back. The mutation table
also had a false row: it credited an_unknown_requirement_renders_a_reason_and_never_a_figure
with catching the fall-through to the known formatter. That test only ever passes state: "unknown",
so it exercises the guard's true branch and never the fall-through — which is exactly how F1 reached
you. That row now reads "nothing".
3. Third orphaned doc block — fixed (collateral.rs:67)
Confirmed new in this PR, and confirmed my first scan's blind spot: I searched for splice damage
between functions, and this is a contiguous stack above one. A load_from description sat on
load(), which takes no directory, while the function that actually swallows the error carried no
rationale at all. Each block is on its own function now.
4. D1's premise — fixed (collateral.rs:315)
Corrected, and thank you for catching it. state.rs holds only the control/auth state and is
identity-independent by design; the sentence now rests on the control token, which is the
strictly greater capability since it authorises every control.* call. The parenthetical states
explicitly that the identity key is not there, so the corrected fact travels with the decision and
cannot be re-derived wrongly from it.
The rest of the reasoning is unchanged, per your read that the bounding is sound and understated.
5. CollateralConfig::load_from — fixed (collateral.rs:94)
Agreed: leaving the twin of a defect I had just fixed, one type above it in the same file, is how it
comes back. Falling back to the +1% default is still right — refusing to start over a corrupt
preference file would take the node down over the one setting whose absence is survivable — but
doing it silently is not. A file that exists and cannot be read, or cannot be parsed, now warns
by path and cause; only NotFound is silent, because there the default is the answer.
Scope
Nothing beyond the five. No new behaviour outside the collateral CLI rendering path and
CollateralConfig; the wire contract is untouched by this round. #387 still separate.
…rate Bumps to latest published (CLAUDE.md 2.4b), verified against the crates.io index rather than memory: dig-node-control-interface 0.21 -> 0.23 (service + wallet) dig-logging 0.1 -> 0.2 (service) dig-constants 0.11.2 -> 0.13.0 (service + core, together) dig-mirror-collateral (new) 0.3 (service) The chia-* set is deliberately NOT moved. chia-bls and chia-protocol publish 0.48.0, but chia-wallet-sdk's own latest (0.36.0) requires ^0.36.1, so bumping the pair would ship this crate internally split across two chia lines -- the exact failure that shipped twice elsewhere in one day. The set is already at its latest coherent point. Adopting 0.23.0 turns the contract-conformance test red, naming the four methods this branch exists to serve: control.spends.list control.collateral.requirement control.collateral.margin.get control.collateral.margin.set Co-Authored-By: Claude <noreply@anthropic.com>
…g advice
The node's side of the deterministic mirror-coin collateral model, built on
dig-mirror-collateral 0.3 -- no formula is restated locally, because
required_per_store is the WHOLE answer and re-deriving it as
equilibrium x multiplier - handicap omits the floor clamp.
Three parts, split along the line that matters:
* EpochRecordStore -- what this node censused, keyed by epoch. Distinguishes
a record it never wrote (Absent) from one it wrote and cannot read
(Unreadable); the remedies differ.
* CollateralConfig -- the LOCAL safety margin, persisted. A config predating
the field loads as the +1% default, never 0: zero is a deliberate choice to
post exactly, and reporting it for a config that never expressed one tells
the operator they declined a cushion they were never offered.
* buffer_advice -- how much DIG to hold, and the three funding states.
The buffer is deliberately NOT requirement x epochs of runway. Collateral is
RECLAIMED, not spent, and reclaims run first and are never gated on funds, so
steady state is roughly ONE epoch's lock. The real peak is the transition
overlap, so the recommendation is lock + lock x (9/8)^4 -- one epoch's lock,
the overlap, and four epochs of escalation headroom, in one expression rather
than three that could double-count. Escalation compounds, so the horizon is a
choice and it is reported with the figure.
Only ShortNow and DangerouslyLow may notify. BelowRecommendedBuffer is a
readout: a normal node sits there much of the time, and an alert an operator
learns to dismiss teaches them to dismiss the two that matter. An unknown
requirement or an unknown balance yields Unknown and never a zero cost.
Co-Authored-By: Claude <noreply@anthropic.com>
The PR#31 gate measured that control.spends.list is not a bare dispatch arm:
SpendQuery carried no after_id although the method is cursor-paginated, and
SpendLog::query could not report 'complete' -- the contract distinguishes
'that is all' from 'we stopped here', and on an audit record those read the
same and mean opposite things.
* SpendQuery.after_id -- positional, not a filter, so matches() ignores it.
Resuming by time would drop every spend sharing the boundary millisecond,
and automated spends are issued by a cycle so several routinely share one.
* SpendLedger.complete -- computed from whether rows were actually withheld,
never from whether the page came out full. A matching set that is an exact
multiple of the page size fills its last page and would otherwise read as
truncated forever.
* SpendLog::cursor_of -- the id of the last row HANDED to the caller, never
a marker for where the record got to.
An unknown cursor is REFUSED. Restarting would repeat rows the caller has
seen; an empty page would either end the walk early or leave no cursor to
advance and loop forever.
The fixture puts six rows across three timestamps with two ties, and pages at
2, 3 and 4 so a boundary falls inside a tie and so one page is exactly full
and final. A fixture with distinct timestamps or a page size of 6 could not
tell a correct cursor from a time-based one.
Co-Authored-By: Claude <noreply@anthropic.com>
Closes the drift the contract bump exposed: four published methods with no
server. All four now dispatch, and the conformance test that named them is
green.
control.spends.list -- one page of the automated-spend record,
decoded through SpendsListParams so the
contract's own page-bound validation runs
without this handler remembering to.
control.collateral.requirement -- the consensus-derived per-store figure, or
a NAMED reason. Never a zero, never a
stale epoch's figure as this epoch's.
control.collateral.margin.get -- the local margin; a config predating the
field reads as +1%, never 0.
control.collateral.margin.set -- persisted before it is reported, and a
value over the ceiling is refused rather
than clamped, so stored intent and node
behaviour cannot disagree on the money path.
A record that could not be READ is now SPEND_AUDIT_UNREADABLE (-32048, taken
from the shared catalogue rather than restated) and never an empty page:
'nothing to report' is the answer a person stops investigating on.
CLI verbs, so a headless machine can drive all of it:
dign collateral requirement
dign collateral margin
dign collateral margin set <tight|default|generous|BP>
The margin is shown with what it costs, not as a bare setting, and a preset
resolves to dig-mirror-collateral's own constant -- a second spelling of
'generous' is how two surfaces come to post different amounts for one choice.
An unrecognised word is refused rather than falling through to the default.
Co-Authored-By: Claude <noreply@anthropic.com>
…ates
Closes dig-node#389. `dign collateral buffer [--balance <DIG>]` composes the
requirement, the margin and the served-advertisement count into one number a
person acts on -- 'add 9.706 DIG', not 'balance low' -- with the working shown
so the figure can be sanity-checked.
Also adds the census epoch marker seam: the node reads which epoch the census
settled on rather than deriving it from its own clock, because the schedule is
a consensus fact and a guess would post against the wrong epoch. Absent or
malformed marker means NOT CENSUSED, never epoch zero.
Two defects the live run caught, both the same class -- an unknown rendered as
a reassuring answer:
* a missing or non-array hosted-store list defaulted to ZERO advertisements,
which produces a 0.000 DIG recommendation that every balance clears. A node
that could not tell how much it owes would have answered 'funded'. It now
answers UNKNOWN.
* a node serving nothing said 'funded -- at or above the recommended buffer',
implying its stores were covered when it has none. It now says there is
nothing to collateralise.
A malformed --balance is refused rather than parsed as zero, which would have
reported SHORT NOW over a typo, and the amount is scaled by integer arithmetic
because 0.001 DIG steps are where an f64 starts rounding.
Co-Authored-By: Claude <noreply@anthropic.com>
Normative contract for the three collateral control methods and the three dign verbs: the consensus/local split, the four unknown reasons and why they are distinct, the census-names-the-epoch rule, the margin's persistence and refuse-not-clamp bound, the reclaim-not-spend derivation of the recommended buffer, and the three funding states with only two of them notifying. Section 24.6 records the hazard the live run surfaced: an unknown and a genuine zero produce identical arithmetic, so an unreadable store list read as zero yields a recommendation every balance clears and a node that cannot tell how much it owes reports 'funded'. Co-Authored-By: Claude <noreply@anthropic.com>
…red marker
Self-correction. The first pass assumed the mirror-coin epoch schedule was a
chain-anchored consensus fact the node could not compute, and introduced a
marker file for the census to write. That was wrong: dig-constants 0.13.0
publishes the schedule as a WALL-CLOCK one -- 7-day epochs from a fixed
genesis -- and mirror_epoch_at_unix_ms is its canonical implementation.
The marker is removed and the epoch is derived. Three consequences:
* The epoch number is an INPUT TO COIN IDENTITY (dig_mirror_coin::mirror_hint
takes it), so a second implementation of the arithmetic would derive
different coins rather than a different label. The constant is delegated to,
never re-derived.
* Deriving it makes a STALE answer structurally unrepresentable. The lookup is
for the epoch current NOW, so a node whose census stopped running reports
not_censused for the present epoch instead of confidently serving last
week's figure. The marker could not have detected that -- it was the hazard.
* The surface is genuinely live rather than pending a census. A node with a
record for the current epoch now answers with a real number, unaided.
Verified on a real node: it derived epoch 104 by itself and reported
3.780 DIG per store from 17 advertisements across 820 owners; when the record
was edited to name epoch 103 instead, it correctly refused with not_censused
rather than serving the stale figure.
One-based and div_euclid are both pinned in the test, at the only input that
can tell them apart: the millisecond before genesis, which a truncating divide
collides with epoch 1.
Co-Authored-By: Claude <noreply@anthropic.com>
The section described the superseded marker design. The epoch is derived from dig-constants' wall-clock schedule, and the section now records why that is what makes a stale answer unrepresentable -- and why a stored marker would reintroduce the hazard it was meant to avoid. Co-Authored-By: Claude <noreply@anthropic.com>
…all into No behaviour change. The buffer's pair count is read from THIS NODE's hosted stores; control.collateral.requirement's 'stores'/'owners' are NETWORK census figures and the contract says in as many words that neither is a node count. Multiplying either by the requirement bills one operator for the whole network's collateral -- a confident wrong number on a money surface, which is worse than no number. The fixture already made that mistake observable (census stores 12 vs pairs 10, so a substitution changes the answer); it now says so, because a fixture that catches something by accident stops catching it at the first tidy-up. Co-Authored-By: Claude <noreply@anthropic.com>
Three findings, caught locally before CI: a useless format! over a literal and two needless borrows into serde_json::to_value. No behaviour change; 469 lib tests still green. Co-Authored-By: Claude <noreply@anthropic.com>
…wn rule
The wire was already honest -- an unknown answer carries no figure at all --
but nothing asserted the human line does not invent one on the way out, and
the summary functions had no tests.
Three groups:
* an unknown requirement renders its reason and NO amount, for all four
reasons, and the four remedies must stay distinct rather than collapsing
into one unactionable sentence
* a known requirement shows the census inputs behind the figure, says the
figure is pre-margin, and renders owners as 'collateralised owner(s)'
rather than as nodes
* a 1 bp margin renders as +0.01% rather than rounding to zero, and a value
that is nobody's preset is not mislabelled as the nearest one
Co-Authored-By: Claude <noreply@anthropic.com>
…ract Reshapes #389's calculation to what dig-node-control-interface PR#36 declares, so adopting control.collateral.buffer is a wiring step rather than a reshape. FIXES A REAL DEFECT, not just names. The pair count was read from control.hostedStores.list -- the rival derivation SPEC 4.2e used to direct and PR#36 removes. A pinned/cached store list RESEMBLES the served (owner, store, root) set without being it, and the error is invisible because both produce a plausible number on a money surface. There is no published method for the served set, so it is now an explicit --roots operand and its absence reports served_set_unknown rather than being guessed. * BufferAdvice is a TAGGED enum. The unknown case has no representable numeric field, so a zero cannot be emitted even by accident -- a zero buffer reads as 'no buffer needed'. * BufferUnknownReason: served_set_unknown / reclaim_state_unknown / balance_unknown. A test asserts none collides with a census reason, which is the structural argument for a separate method: collapsing them would report a missing LOCAL fact as a missing NETWORK one. * Fields renamed to the contract's: recommended_buffer_*, spendable_*, overlap_*, escalation_headroom_*, pairs_served_by_this_node, and escalation_ceiling_micros beside horizon_epochs (both required). * FundingState::is_shortfall() replaces is_notification() and EXCLUDES below_recommended_buffer. FundingState::Unknown is gone -- unknown is a state of the ANSWER, not of the funding. Escalation is no longer a hand-rolled (9/8)^n. It steps dig_mirror_collateral::step_multiplier in its own high band, which keeps two behaviours the closed form loses: per-step truncation (0.8x over 4 epochs is 1.281444, not 1.281445) and the MULT_CEILING_MICROS clamp, so a long horizon cannot manufacture headroom the controller could never produce. A test drives 500 epochs and asserts it lands exactly on the ceiling. Co-Authored-By: Claude <noreply@anthropic.com>
Records the three-term decomposition, the served-set-is-not-the-hosted-list rule that a rival derivation in the old text used to direct, the step_multiplier delegation and why a closed form loses truncation and the ceiling clamp, and the tagged-unknown shape that makes a zero unrepresentable. Co-Authored-By: Claude <noreply@anthropic.com>
…QL alerts CodeQL traced DIG_NODE_STATE_DIR through ctx.state_dir into three file operations (rust/path-injection, 3x high). Main carries no alerts of this rule, so these were genuinely new rather than an inherited pattern. The root cause was a second resolver, not a missing guard: the handlers passed ctx.state_dir in, while spend_audit's existing code asks state_dir() itself. Production now goes through CollateralConfig::load/save and EpochRecordStore::in_state_dir, and spends_list uses SpendLog::in_state_dir -- one component knowing where one file lives. The explicit-directory forms remain for tests. Four handlers no longer need &ControlCtx at all, so the parameter is dropped rather than underscored. Co-Authored-By: Claude <noreply@anthropic.com>
…llateral.buffer
0.24.0 published while this branch was in flight, declaring the buffer as its
OWN method rather than a widening of .requirement. Adopted in both
dig-node-service and dig-wallet, and the method is now served.
The local BufferAdvice/BufferFigures/FundingState/BufferUnknownReason types are
DELETED in favour of the contract's CollateralBufferResult,
CollateralFundingState and CollateralBufferUnknownReason. Keeping a parallel set
would have been a rival definition of a money-path shape, which is how two
surfaces come to disagree about a funding warning.
Three things the published shape corrected in this lane's version:
* a fourth reason, RequirementUnknown. This lane folded a missing requirement
into served_set_unknown -- reporting a NETWORK gap as a LOCAL one, which
sends the operator to fix the wrong thing. Now distinct, with its own
remedy sentence, and a test asserts no buffer reason collides with a census
reason.
* epoch and protocol_version travel WITH the buffer, so a client never has to
pair it with a separately-fetched requirement and hope both describe the
same epoch.
* Funded, not Adequate; and the lock and the shortfall are DERIVED rather
than carried, because the contract publishes the inputs to both and a
fourth field could disagree with the three it comes from.
The node's own answer is honestly unknown today: it passes None for both the
served set and the balance rather than approximating them from the hosted-store
list or an arbitrary address. A set that merely resembles the served roots, or a
balance for the wrong address, is a plausible wrong number on a money surface --
worse than no number. dign still produces a real figure from operands.
Conformance: 5/5, node and contract now agree on all five methods.
Co-Authored-By: Claude <noreply@anthropic.com>
…override Closes the gap between the CLI-parity list and reality: the list claimed a verb drove control.collateral.buffer and none did. With no operands the verb now calls the node -- it is the authority on its own served set, preference and balance. --roots/--balance remain as an override so a person can get a figure before the node can enumerate its served set. One renderer (render_buffer) serves both paths. Two renderings of one money figure is how an operator comes to trust the wrong one. A payload this build cannot decode is reported as unreadable rather than rendered as a figure. Co-Authored-By: Claude <noreply@anthropic.com>
Adds the fourth unknown reason (requirement_unknown) and why it is distinct -- a missing requirement is a NETWORK gap while the other three are LOCAL, and reporting one as the other sends the operator to fix the wrong thing. Also records why the buffer is its own method rather than a widening of .requirement, that the funding state is carried rather than re-derived by clients, funded rather than adequate, and that the dign operands are an override rather than a fallback the node applies to itself. Co-Authored-By: Claude <noreply@anthropic.com>
…oc comments The `.tsplice.py` and `.wire2.py` helpers were editing scratch and never belonged in the tree; they are now ignored via the worktree's info/exclude rather than the repo .gitignore, which is for artefacts every clone produces. One of those splices concatenated three doc blocks above `parse_dig_amount`, orphaning `chia_peers_action`'s "listing is the default" security rationale and `collateral_action`'s "an unrecognised preset is REFUSED" rationale onto an unrelated parser. A security comment on the wrong function is worse than a missing one: it reads as reviewed. Each block is back on the function it describes. Co-Authored-By: Claude <noreply@anthropic.com>
…g a figure `summarize_collateral_requirement` guarded positively on `state == "unknown"` and let every other payload fall through to a formatter whose fields were each `unwrap_or(0)`. An unrecognised state therefore rendered a REAL epoch number beside a fabricated `0.000 DIG per store` — authoritative-looking rather than degraded, and the exact money lie the unknown branch exists to prevent. An operator acting on it posts nothing and leaves every store root uncollateralised. The trigger is a planned event, not a failure: `CollateralRequirementResult` is `#[serde(tag = "state")]`, so a new variant is additive, and `dign` ships separately from the node — the next minor would put an unrecognised state in front of every already-installed CLI. All three collateral renderers now decode typed and refuse what they cannot decode, matching `summarize_collateral_buffer`, which already did: - `summarize_collateral_requirement` — the gating case. - `collateral_buffer`'s `margin_bp` — an absent margin defaulted to zero understates the recommendation by exactly the cushion the operator chose, flipping `BelowRecommendedBuffer` to `Funded`. - `summarize_margin` — found by sweeping the same class; zero is a legitimate margin, so an absent one substituted for it is indistinguishable from a real answer on the line an operator reads back after `margin set`. Tested on the RENDERED OUTPUT, since the defect was in what a person reads: the fixtures carry the same epoch as the truthful control, so any leaked field fails the assertion. Co-Authored-By: Claude <noreply@anthropic.com>
…poch Round-2 gate findings D2 and D3, plus the recorded decisions for D1 and D4. D2 -- `EpochRecordStore::get` returned `Absent` for ANY read failure, collapsing the Absent/Unreadable split the method's own doc comment exists to preserve. The consequence is a wrong REMEDY rather than a wrong figure: `Absent` renders as "run the census for this epoch", which writes to the very file that could not be read, so an operator with a broken state directory is sent to a remedy that fails again without ever naming the fault. Only `NotFound` is now `Absent`. D3 -- `dign collateral buffer --roots` rendered an operator-supplied count in a line otherwise identical to the node's own measured answer, so a guess was indistinguishable from a measurement, including in the recommendation derived from it. The line now marks its provenance, which makes the named limitation visible where the figure is read rather than only in the help text. The marker goes when dig-node#387 lands the served-set count. D1 (a well-formed but false stored record scales into a multi-million-DIG recommendation) is recorded as a bounded known rather than fixed: writing that record needs the state directory that also holds the identity key, arbitrary corruption already fails closed, and a plausibility bound derived at the read side would be a rival implementation of the controller. The honest remedy is a protocol_version ceiling check where the census writer's invariants live. D4 (a paired-tier margin outlives `pairing.revoke`) is likewise documented, not fixed: it is a pairing-lifecycle question, and answering it for this one setting would establish by accident a rule the other paired-tier writes do not follow. Co-Authored-By: Claude <noreply@anthropic.com>
…venance rules Three normative statements the round-2 gate showed were absent: - a client MUST NOT render a requirement it cannot decode as a figure, and MUST NOT borrow the `unknown` rendering, which asserts the node named a missing fact that an undecodable answer did not; - `record_unreadable` is decided by the record FILE, not only its contents: a missing file is `not_censused`, an unreadable one is `record_unreadable`; - `collateral buffer` MUST mark a root count that came from `--roots`. Co-Authored-By: Claude <noreply@anthropic.com>
…onfig's silent fallback Round-2 re-gate follow-ups. Four items, all in files this PR already owns. 1. The round-2 claim that three tests failed independently was WRONG: reverting the S1 margin decode and the D3 provenance marker together left the suite green, so only the F1 leg was pinned. Both guards are observable ONLY in the rendered string, and both sat behind two `call_control` round trips where no test could reach them. `buffer_outcome` now separates everything after the I/O, and two tests read what a person reads. The margin test asserts the FLIP rather than merely an error: the balance is calibrated at run time to the ZERO-margin recommendation, the exact point at which `unwrap_or(0)` and the truthful decode disagree about whether the operator is funded. The provenance test asserts a PLACEMENT: the same advice through the shared renderer must stay silent, so moving the marker into `render_buffer` -- the obvious simplification -- makes the node's own measured answer claim an operator supplied a count they never typed, and fails. 2. A third orphaned doc block, this one introduced by this PR rather than inherited: a `load_from` description was attached to `load()`, which takes no directory, while the function that actually swallows the error carried no rationale at all. Each block is now on its own function. 3. The D1 rationale rested on a false premise -- it named the identity key as living in the state directory, but `state.rs` holds ONLY the control/auth state and is identity-independent by design. The conclusion survives, because the control token is the strictly greater capability, so the sentence now rests on the token. A correct decision resting on a false premise is how the premise gets reused elsewhere. 4. `CollateralConfig::load_from` carried the exact Absent/Unreadable conflation D2 just fixed, one type above it in the same file. Falling back to the `+1%` default is still right -- refusing to start over a corrupt preference file would take the node down over the one setting whose absence is survivable -- but doing it SILENTLY is not: an operator whose margin has reverted learns about it only from a figure that looks deliberate. A file that exists and cannot be read, or cannot be parsed, is now warned about by path and cause. Co-Authored-By: Claude <noreply@anthropic.com>
be1da4f to
c12224d
Compare
Co-Authored-By: Claude <noreply@anthropic.com>
Task
dig-node's share of the deterministic mirror-coin collateral epic
(https://github.com/DIG-Network/dig_ecosystem/issues/3173).
Closes #385
Closes #388
Closes #389
Contract: DIG-Network/dig-node-control-interface#32
DO NOT MERGE until the gate round has returned. Draft on purpose.
What was wrong
Measured on
origin/main@e094078, all four confirmed rather than assumed:crates/dig-node-service/Cargo.toml:92andcrates/dig-wallet/Cargo.toml:67declareddig-node-control-interface = "0.21"; published was 0.23.0.SpendsList/spends.listappeared nowhere undercrates/.CollateralRequirement/collateral.requirementappeared nowhere undercrates/.dig-mirror-collateralwas not a dependency at all.So four declared control methods had no server, and the collateral math crate this epic exists
to use was unadopted. A release watcher calling those crates "live" was reporting the contract,
never the capability.
What landed
1. Dependency adoption (§2.4b)
Verified against the crates.io index with the required
User-Agent, not from memory:dig-node-control-interfacedig-loggingdig-constantsdig-mirror-collateralThe
chia-*set is deliberately NOT moved.chia-blsandchia-protocolpublish 0.48.0, butchia-wallet-sdk's own latest (0.36.0) requires^0.36.1. Bumping the pair would ship these cratesinternally split across two chia lines — the failure that shipped twice elsewhere in one day. The
set is already at its latest coherent point.
Adopting 0.23.0 turned the conformance test red, naming exactly the four unserved methods. That
was the lane's TDD red and it is now green.
2.
control.spends.list(#385)Not a bare dispatch arm — the PR#31 gate was right about both gaps:
SpendQuery.after_id— positional, not a filter, somatches()ignores it. Resuming by TIMEwould drop every spend sharing the boundary millisecond, and automated spends are issued by a
cycle so several routinely share one.
SpendLedger.complete— computed from whether rows were actually withheld, never from whetherthe page came out full. A matching set that is an exact multiple of the page size fills its last
page and would otherwise read as truncated forever.
SpendLog::cursor_of— the id of the last row handed to the caller, never a marker forwhere the record got to.
either ends the walk early or leaves no cursor and loops forever.
SPEND_AUDIT_UNREADABLE(-32048), taken from the shared catalogue rather than restated. Arecord that could not be READ is never an empty page: "nothing to report" is the answer a person
stops investigating on.
3. The four collateral methods
control.collateral.requirement/.margin.get/.margin.set/.buffer, conforming to thepublished 0.24.0 shapes rather than a re-derivation. 0.24.0 landed mid-branch and declares the
buffer as its OWN method; this PR adopts it and serves all four.
No formula is restated locally.
required_per_storeis the whole answer; writingequilibrium × multiplier − handicapat a call site omits the floor clamp, which understates what anadvertisement must post.
so one operator's cushion can never read as the network's price.
unknownis a first-class answer with a named reason, never a zero, never a stale epoch'sfigure as this epoch's.
dig-constants' canonical wall-clock schedule(
mirror_epoch_at_unix_ms), never re-derived locally: the epoch number is an input to coinidentity, so a second implementation derives different coins, not a different label.
0..setpersists before it reports, and a value over the ceiling is refused, not clamped.4. The buffer and the funding states (#389)
Built to the shape dig-node-control-interface PR#36 declares (0.24.0), so adopting
control.collateral.bufferwhen it publishes is a wiring step rather than a reshape.Collateral is RECLAIMED, not spent, and reclaims run first and are never gated on funds — so
steady state is roughly ONE epoch's lock, not one per epoch. A "requirement x runway" recommendation
would overstate by the epoch count. The total is three named terms that sum without double-counting:
the current epoch's posting, the collateral still held in the epoch being reclaimed (the real peak,
and the term nobody budgets for), and what the next
horizon_epochscould add at the ceiling.BufferAdviceis a tagged enum. Theunknowncase has no representable numeric field, soa zero cannot be emitted even by accident — a zero buffer reads as no buffer needed. Verified on
the wire below: the unknown payload carries
stateandreasonand nothing else.served_set_unknown/reclaim_state_unknown/balance_unknown. A test asserts none of themcollides with a census reason, which is the structural argument for a separate method: collapsing
served_set_unknownintonot_censusedreports a missing local fact as a missing networkone and sends the operator to fix the wrong thing.
is_shortfall()excludesbelow_recommended_buffer. Every epoch that state covers iscovered; a healthy node sits there much of the time, and an alert an operator learns to dismiss
teaches them to dismiss the two that cost money.
horizon_epochsandescalation_ceiling_microsboth travel with the figure. A buffer withoutits horizon is a magic number; a horizon without its ceiling cannot be reproduced.
4epochs(~28 days, ~x1.60) is PR#36's
DEFAULT_BUFFER_HORIZON_EPOCHSand moves if the contract moves.Escalation is not a hand-rolled
(9/8)^n. It stepsdig_mirror_collateral::step_multiplierinthe controller's own high band, keeping two behaviours a closed form loses: per-step truncation
(0.8x over four epochs reaches
1.281444, not1.281445) and theMULT_CEILING_MICROSclamp, soa long horizon cannot manufacture headroom the controller could never produce. A test drives 500
epochs and asserts it lands exactly on the ceiling.
5. CLI (#388)
The margin is shown with what it costs, not as a bare setting. A preset resolves to
dig-mirror-collateral's own constant — a second spelling of "generous" is how two surfaces postdifferent amounts for one choice. An unrecognised word is refused, never defaulted, and a 1 bp
margin renders as
+0.01%rather than rounding away to nothing.dignis deliberately the end-to-end surface: on a headless host no notification will ever fire, sothe command line is where an operator learns they are short.
Three defects found here, all the same class: an unknown rendered as a reassuring answer
Two were caught by the live run, invisible to the test suite. The third was caught by the
coordinator and is the most serious, because the code was doing what the SPEC told it to.
control.hostedStores.list. That is the rival derivationSPEC §4.2e used to direct and PR#36 removes. A pinned/cached store list resembles the served
(owner, store, root)set without being it, and the error is invisible because both produce aplausible number on a money surface. There is no published method for the served set, so it is now
an explicit
--rootsoperand and its absence reportsserved_set_unknownrather than a guess.A resemblance is not an identity — and this one had normative text behind it.
produce identical arithmetic — a
0.000 DIGrecommendation every balance clears — so a node thatcould not tell how much it owes answered "funded". The tagged-enum shape now makes that
unrepresentable rather than merely guarded.
were covered when it has none.
(2) and (3) surfaced because the live fixture had zero served roots while every unit fixture varied
the count. The recurring lesson, again: the field no fixture varies is the field no test covers.
Blast radius
gitnexus was NOT used — no index exists for this worktree and building one is a ~10-minute
blocking step the §2.0 bounds say to skip rather than stall a lane on. Radius was established by
exhaustive ripgrep over
crates/plus direct reads, and stated here per §2.0 bound (2).SpendQuery(field added)entrypoint.rs:820,spend_audit_cli.rs(7 literals),spend_audit.rsSpendLedger(field added)spend_audit_cli.rs:116, 4 test literalsSpendLog::queryspend_audit_cli.rs:63onlyErrorCode(variant added)matchsites inmeta.rs, all updatedCONTROL_METHODSEvery one is confined to
dig-node-service; nothing crosses a repo boundary, and the twoenum/struct extensions are additive. No HIGH/CRITICAL risk found. The compiler enforced the
radius for all four — every miss was a hard error, not a silent pass.
crates/dig-wallet/src/sage/was not touched (PRs #391 and #393 own it).How verified
Real commands against a real node built from this branch, on port 9878 with its own state dir,
7 peers connected. Not a green suite — a person seeing a number.
The epoch is derived, and that is what kills the stale-figure hazard
A first pass here assumed the epoch schedule was chain-anchored and added a marker file for the
census to write. That was wrong, and it is corrected:
dig-constants0.13.0 publishes theschedule as wall-clock (7-day epochs from a fixed genesis). The node derives it.
The marker was not merely unnecessary — it was the hazard. A marker left by a stopped census names
an epoch that is no longer current, and nothing local can detect that. Deriving the epoch makes a
stale answer structurally unrepresentable, verified live:
One-based and
div_euclidare pinned at the only input that can tell them apart — the millisecondbefore genesis, which a truncating
/collides with epoch 1.The buffer.
--rootsis an operand because no published method reports the served set — and thenearest-looking one is a different set, which is defect (1) above:
--jsoncarries the contract's field names, and the unknown payload carries no numeric field atall — the property that makes a
0unemittable:{ "state": "unknown", "reason": "served_set_unknown" } { "state": "known", "pairs_served_by_this_node": 17, "required_per_store_dig_base_units": 3780, "margin_bp": 100, "one_epoch_lock_dig_base_units": 64906, "overlap_dig_base_units": 64906, "escalation_headroom_dig_base_units": 39060, "recommended_buffer_dig_base_units": 168872, "horizon_epochs": 4, "escalation_ceiling_micros": 1601804, "spendable_dig_base_units": 80000, "funding_state": "below_recommended_buffer", "shortfall_to_recommended_dig_base_units": 88872 }Tests
cargo test -p dig-node-service --lib— 478 passed, 0 failed; the full suite (lib + 163integration) was green before the buffer rework and the reworked half is lib-tested
(lib was 465 on
main; 473 before the round-2 fixes).control_contract_conformance5/5, including the one that was red on thebump.
Both new test groups were proved load-bearing by mutation, committed first so the revert could
not cost work:
completederived from page fullness (len() < n)complete_is_not_inferred_from_a_full_pagean_unknown_cursor_is_refused_rather_than_restarting_or_ending_the_walkunknownremedies collapsed into one shared sentencean_unknown_requirement_renders_a_reason_and_never_a_figureunknownfalls through to the KNOWN formatter, rendering0.000 DIG per storean_undecodable_requirement_renders_unreadable_and_never_a_figure0 bpan_undecodable_margin_renders_unreadable_and_never_zero_bpan_undecodable_margin_aborts_the_buffer_rather_than_becoming_a_zero_cushion--rootsprovenance marker moved into the shared rendereran_operand_supplied_root_count_is_marked_and_only_on_the_operand_pathan_unreadable_record_file_is_not_reported_as_a_missing_oneCorrection — two coverage claims in this body were overstated
Both were found by the gate, by execution, not by review, and both are recorded rather than quietly
edited away: an overstated coverage claim is precisely what lets a later "simplification" put an
unwrap_or(0)back with nothing catching it.1. The fourth row above was FALSE as originally written. It credited
an_unknown_requirement_renders_a_reason_and_never_a_figurewith catching the fall-through to theknown formatter. That test only ever passes
state: "unknown", so it exercises the guard's truebranch and never the fall-through — which is exactly how F1 shipped past it into the gate. Nothing
caught that mutation.
an_undecodable_requirement_…is the test that does.2. "The three tests fail independently" (round-2 comment) was WRONG, measured. Reverting the S1
margin decode and the D3 provenance marker together left the suite 476/476 green — only the F1
leg was pinned. Both guards are observable only in the rendered string, and both sat behind two
call_controlround trips where no test could reach them.buffer_outcomenow separates everythingafter the I/O so they can be read.
The claim is now measured rather than asserted, each guard reverted alone at
be1da4f8:an_undecodable_requirement_…epoch 104 (protocol v0) — 0.000 DIG per storean_undecodable_margin_aborts_the_buffer_…(0 bp margin) … recommended holding 29.504 DIG … funded — at or above the recommended buffer.an_operand_supplied_root_count_…serving 3 store root(s) at 3.780 DIG each (100 bp margin)— no markeran_unreadable_record_file_…left: Absent, right: Unreadablean_undecodable_margin_renders_unreadable_…safety margin 0 bp = +0.00%Each fails alone and only its own test fails, so none is carried by another's fix. The S1 line is
the money lie stated in full: a fabricated margin producing
funded — at or above the recommended bufferat a balance that is not.The placement mutation is worth a note: filtering is order-preserving, so drain-then-filter and
filter-then-drain agree for every cursor that is inside the filtered set. The only distinguishing
input is a cursor present in the record but absent from the filtered set — which is why that specific
fixture exists. A test asserting only the returned rows would have stayed green.
Fixtures were built to distinguish, not merely to pass: six spends across three timestamps with two
tied milliseconds, paged at 2, 3 and 4 so a boundary falls inside a tie and one page is both exactly
full and final. Every numeric bound is pinned from both sides (one under must fail, at-bound must
pass) — all three funding-state thresholds included.
The last two mutations matter because they are the rendering half of the money-lie rule. The wire
was already honest — an
unknownanswer carries no figure at all — but nothing asserted the humanline does not invent one on the way out, and dropping one
returnmakes every absent field print as0.000 DIG per store, i.e. "no collateral required".cargo clippy -- -D warningsis clean; threefindings were caught and fixed locally before CI saw them.
One test helper was itself found wrong and fixed: it left
handicap_dig_base_unitsat the bootstrapvalue while varying owners, building a record no census could produce.
0.24.0 landed mid-branch, and it corrected three things here
dig-node-control-interface0.24.0 published while this was in flight, declaringcontrol.collateral.bufferas its own method. Adopted in both crates, method served, conformance5/5.
The local
BufferAdvice/BufferFigures/FundingState/BufferUnknownReasontypes areDELETED in favour of
CollateralBufferResult,CollateralFundingStateandCollateralBufferUnknownReason. Keeping a parallel set would have been a rival definition of amoney-path shape — how two surfaces come to disagree about a funding warning.
Three corrections the published shape forced, all worth reading:
requirement_unknown. This lane had folded a missing requirement intoserved_set_unknown— reporting a network gap as a local one, which sends the operator torun a census when the real problem is elsewhere. Now distinct, with its own remedy sentence, and a
test asserts no buffer reason collides with a census reason.
epochandprotocol_versiontravel WITH the buffer, so a client never pairs it with aseparately-fetched requirement and hopes both describe the same epoch.
Funded, notAdequate— and the lock and the shortfall are DERIVED rather thancarried, because the contract publishes the inputs to both and a fourth field could disagree with
the three it comes from.
The node's own answer is honestly
unknowntoday, and that is the deliverable rather than astub. It passes
Nonefor both the served set and the balance rather than approximating them: theserved
(owner, store, root)set is enumerated by the census (#387), and this node cannot know whichaddress holds an operator's $DIG. A set that merely resembles the served roots, or a balance for
the wrong address, is a plausible wrong number on a money surface — worse than no number.
dign collateral bufferwith no operands asks the node;--roots/--balanceare an override soa person gets a figure today. One renderer serves both, because two renderings of one money figure is
how an operator comes to trust the wrong one.
A CodeQL failure, fixed at the root
CodeQL flagged 3 high
rust/path-injectionalerts —DIG_NODE_STATE_DIRtraced throughctx.state_dirinto three file operations. Main carries no alerts of this rule, so these weregenuinely new rather than an inherited pattern.
The cause was a second resolver, not a missing guard: the handlers passed
ctx.state_dirin,while
spend_audit's existing code asksstate_dir()itself. Production now goes throughCollateralConfig::load/save,EpochRecordStore::in_state_dirandSpendLog::in_state_dir— onecomponent knowing where one file lives. Four handlers then needed no
&ControlCtxat all, so theparameter is dropped rather than underscored.
Version
0.160.0→ 0.161.0 — minor. New capability (four served methods, three CLI verbs), additiveonly;
SpendQuery/SpendLedger/ErrorCodegain fields and a variant but nothing is removed,renamed or repurposed.
What this PR does NOT do
serve, sampled sync, retention) is NOT here — it is a table, a gossip surface and a sampling
protocol, and folding it in would balloon this diff. It is a second PR in this same lane, not
filed-and-forgotten. This PR lands the record store the census writes into.
for the current epoch answers with a real number unaided (verified above at epoch 104). Until a
census runs, a node answers
unknown: not_censused— the honest answer, not a stub.dig-mirror-coin0.7.0 now publishes the census, so Per-epoch collateral record: database, gossip serve, sampled sync, retention off by default #387 is unblocked rather than pending.control.collateral.bufferis not SERVED — 0.24.0 has not published. The calculation and thedignsurface are done and do not wait on it; serving the method is a wiring step.SYSTEM.md/ docs.dig.net: the collateral surface is node-internal and reaches no other repo yet;SPEC.md§24 is the normative home. Revisit when dig-app consumes it.