Skip to content

feat(collateral): serve mirror-coin collateral control methods and spends.list - #395

Merged
MichaelTaylor3d merged 23 commits into
mainfrom
loop/385-collateral-control
Aug 28, 2026
Merged

feat(collateral): serve mirror-coin collateral control methods and spends.list#395
MichaelTaylor3d merged 23 commits into
mainfrom
loop/385-collateral-control

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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:92 and crates/dig-wallet/Cargo.toml:67 declared
    dig-node-control-interface = "0.21"; published was 0.23.0.
  • SpendsList / spends.list appeared nowhere under crates/.
  • CollateralRequirement / collateral.requirement appeared nowhere under crates/.
  • dig-mirror-collateral was 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:

crate from to
dig-node-control-interface 0.21 0.24 (service + wallet)
dig-logging 0.1 0.2
dig-constants 0.11.2 0.13.0 (core + service, moved together)
dig-mirror-collateral 0.3 (new)

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. Bumping the pair would ship these crates
internally 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, 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 repeats rows the caller has seen; an empty page
    either ends the walk early or leaves no cursor and loops forever.
  • SPEND_AUDIT_UNREADABLE (-32048), taken from the shared catalogue rather than restated. A
    record 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 the
published 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_store is the whole answer; writing
equilibrium × multiplier − handicap at a call site omits the floor clamp, which understates what an
advertisement must post.

  • The requirement is pre-margin and consensus-derived; the margin is local and served separately,
    so one operator's cushion can never read as the network's price.
  • unknown is a first-class answer with a named reason, never a zero, never a stale epoch's
    figure as this epoch's.
  • The epoch is derived from dig-constants' canonical wall-clock schedule
    (mirror_epoch_at_unix_ms), never re-derived locally: the epoch number is an input to coin
    identity, so a second implementation derives different coins, not a different label.
  • A config predating the margin field loads as +1%, never 0.
  • .set persists 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.buffer when 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:

recommended = lock + overlap + escalation_headroom

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_epochs could add at the ceiling.

  • 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. Verified on
    the wire below: the unknown payload carries state and reason and nothing else.
  • served_set_unknown / reclaim_state_unknown / balance_unknown. A test asserts none of them
    collides with a census reason, which is the structural argument for a separate method: collapsing
    served_set_unknown into not_censused reports a missing local fact as a missing network
    one and sends the operator to fix the wrong thing.
  • is_shortfall() excludes below_recommended_buffer. Every epoch that state covers is
    covered; 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_epochs and escalation_ceiling_micros both travel with the figure. A buffer without
    its horizon is a magic number; a horizon without its ceiling cannot be reproduced. 4 epochs
    (~28 days, ~x1.60) is PR#36's DEFAULT_BUFFER_HORIZON_EPOCHS and moves if the contract moves.

Escalation is not a hand-rolled (9/8)^n. It steps dig_mirror_collateral::step_multiplier in
the controller's own high band, keeping two behaviours a closed form loses: per-step truncation
(0.8x over four epochs reaches 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.

5. CLI (#388)

dign collateral requirement
dign collateral margin [set <tight|default|generous|BP>]
dign collateral buffer [--roots <N>] [--balance <DIG>]

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 post
different 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.

dign is deliberately the end-to-end surface: on a headless host no notification will ever fire, so
the 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.

  1. The served-pair count was read from control.hostedStores.list. That is 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 a guess.
    A resemblance is not an identity — and this one had normative text behind it.
  2. An unreadable hosted-store list defaulted to ZERO. Zero obligations and an unreadable list
    produce identical arithmetic — a 0.000 DIG recommendation every balance clears — so a node that
    could not tell how much it owes answered "funded". The tagged-enum shape now makes that
    unrepresentable rather than merely guarded.
  3. A node serving nothing said "funded — at or above the recommended buffer", implying its roots
    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).

symbol edited callers found scope
SpendQuery (field added) entrypoint.rs:820, spend_audit_cli.rs (7 literals), spend_audit.rs one crate
SpendLedger (field added) spend_audit_cli.rs:116, 4 test literals one crate
SpendLog::query spend_audit_cli.rs:63 only one crate
ErrorCode (variant added) 6 exhaustive match sites in meta.rs, all updated one crate
CONTROL_METHODS conformance test + CLI-parity test, both green one crate

Every one is confined to dig-node-service; nothing crosses a repo boundary, and the two
enum/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.

$ dign collateral requirement
epoch 12 (protocol v1) — 3.000 DIG per store, before any safety margin
  from 31 advertisement(s) across 750 collateralised owner(s) · multiplier 0.800000x · handicap 1.000 DIG

$ dign collateral margin
safety margin 100 bp (default) = +1.00% over the per-store requirement

$ dign collateral margin set 1
safety margin 1 bp (tight) = +0.01% over the per-store requirement      # 0.01%, not rounded to 0

$ dign collateral margin set 10001
error: dig-node: margin_bp must be at most 10000 basis points (+100%); got 10001

$ dign collateral margin                                                # refusal changed nothing
safety margin 1 bp (tight) = +0.01% over the per-store requirement

$ dign collateral margin set genrous
error: "genrous" is not a preset (tight, default, generous) nor a basis-point 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-constants 0.13.0 publishes the
schedule 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:

# node derives the epoch itself; record seeded for the real current epoch
$ dign collateral requirement
epoch 104 (protocol v1) — 3.780 DIG per store, before any safety margin
  from 17 advertisement(s) across 820 collateralised owner(s) · multiplier 0.900000x · handicap 0.720 DIG

# same record edited to name epoch 103 (last week) instead
$ dign collateral requirement
collateral requirement UNKNOWN — this node has not censused the epoch · run the census for this epoch

$ dign collateral buffer --balance 100
collateral buffer UNKNOWN — this node cannot state its per-store requirement yet, so it cannot say what you should hold.

One-based and div_euclid are pinned at the only input that can tell them apart — the millisecond
before genesis, which a truncating / collides with epoch 1.

The buffer. --roots is an operand because no published method reports the served set — and the
nearest-looking one is a different set, which is defect (1) above:

$ dign collateral buffer --balance 100          # no --roots
collateral buffer UNKNOWN — this node cannot list the store roots it serves.

$ dign collateral buffer --roots 17             # no --balance
collateral buffer UNKNOWN — this node does not know your spendable $DIG.

$ dign collateral buffer --roots 17 --balance 80
serving 17 store root(s) at 3.780 DIG each (100 bp margin)
  this epoch locks 64.906 DIG · reclaim overlap 64.906 DIG · escalation headroom 39.060 DIG over 4 epochs (x1.601804 ceiling — a worst case, not a forecast)
  recommended holding 168.872 DIG
  below the recommended buffer — every epoch is covered, but there is no cushion. Add 88.872 DIG to reach it.

--balance 60      -> SHORT NOW — ... Add at least 4.906 DIG now, 108.872 DIG to reach the recommendation.
--balance 64.906  -> DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be.
--balance 168.872 -> funded — at or above the recommended buffer.

--json carries the contract's field names, and the unknown payload carries no numeric field at
all
— the property that makes a 0 unemittable:

{ "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 --lib478 passed, 0 failed; the full suite (lib + 163
integration) 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_conformance 5/5, including the one that was red on the
bump.

Both new test groups were proved load-bearing by mutation, committed first so the revert could
not cost work:

mutation caught by
complete derived from page fullness (len() < n) complete_is_not_inferred_from_a_full_page
cursor resolved BEFORE filtering (a placement change) an_unknown_cursor_is_refused_rather_than_restarting_or_ending_the_walk
two unknown remedies collapsed into one shared sentence an_unknown_requirement_renders_a_reason_and_never_a_figure
unknown falls through to the KNOWN formatter, rendering 0.000 DIG per store nothing — see the correction below
an undecodable requirement rendered as a figure an_undecodable_requirement_renders_unreadable_and_never_a_figure
an undecodable margin rendered as 0 bp an_undecodable_margin_renders_unreadable_and_never_zero_bp
an undecodable margin becoming a zero cushion in the buffer an_undecodable_margin_aborts_the_buffer_rather_than_becoming_a_zero_cushion
the --roots provenance marker moved into the shared renderer an_operand_supplied_root_count_is_marked_and_only_on_the_operand_path
an unreadable record FILE reported as a missing one an_unreadable_record_file_is_not_reported_as_a_missing_one

Correction — 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_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 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_control round trips where no test could reach them. buffer_outcome now separates everything
after the I/O so they can be read.

The claim is now measured rather than asserted, each guard reverted alone at be1da4f8:

guard reverted alone test that fails observed output
F1 typed decode an_undecodable_requirement_… epoch 104 (protocol v0) — 0.000 DIG per store
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
D2 file/contents split an_unreadable_record_file_… left: Absent, right: Unreadable
margin renderer decode an_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 buffer at 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 unknown answer carries no figure at all — but nothing asserted the human
line does not invent one on the way out, and dropping one return makes every absent field print as
0.000 DIG per store, i.e. "no collateral required". cargo clippy -- -D warnings is clean; three
findings were caught and fixed locally before CI saw them.

One test helper was itself found wrong and fixed: it left handicap_dig_base_units at the bootstrap
value 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-interface 0.24.0 published while this was in flight, declaring
control.collateral.buffer as its own method. Adopted in both crates, method served, conformance
5/5.

The local BufferAdvice / BufferFigures / FundingState / BufferUnknownReason types are
DELETED
in favour of CollateralBufferResult, CollateralFundingState and
CollateralBufferUnknownReason. Keeping a parallel set would have been a rival definition of a
money-path shape — how two surfaces come to disagree about a funding warning.

Three corrections the published shape forced, all worth reading:

  1. A fourth reason, requirement_unknown. This lane had folded a missing requirement into
    served_set_unknown — reporting a network gap as a local one, which sends the operator to
    run 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.
  2. epoch and protocol_version travel WITH the buffer, so a client never pairs it with a
    separately-fetched requirement and hopes both describe the same epoch.
  3. 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, and that is the deliverable rather than a
stub. It passes None for both the served set and the balance rather than approximating them: the
served (owner, store, root) set is enumerated by the census (#387), and this node cannot know which
address 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 buffer with no operands asks the node; --roots/--balance are an override so
a 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.

$ dign collateral buffer                        # the node's own answer
collateral buffer UNKNOWN — this node cannot list the store roots it serves.

$ dign collateral buffer --roots 17             # each missing fact, its own reason
collateral buffer UNKNOWN — this node does not know your spendable $DIG.

$ dign collateral buffer --roots 17 --balance 80
serving 17 store root(s) at 3.780 DIG each (100 bp margin)
  this epoch locks 64.906 DIG · reclaim overlap 64.906 DIG · escalation headroom 39.060 DIG over 4 epochs (x1.601804 ceiling — a worst case, not a forecast)
  recommended holding 168.872 DIG
  below the recommended buffer — every epoch is covered, but there is no cushion. Add 88.872 DIG to reach it.

--balance 60      -> SHORT NOW — ... Add at least 4.906 DIG now, 108.872 DIG to reach the recommendation.
--balance 64.906  -> DANGEROUSLY LOW — this epoch is covered, but a rise at the ceiling would not be.
--balance 168.9   -> funded — at or above the recommended buffer.

A CodeQL failure, fixed at the root

CodeQL flagged 3 high rust/path-injection alerts — DIG_NODE_STATE_DIR traced through
ctx.state_dir into three file operations. Main carries no alerts of this rule, so these were
genuinely new rather than an inherited pattern.

The 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, EpochRecordStore::in_state_dir and SpendLog::in_state_dir — one
component knowing where one file lives. Four handlers then needed no &ControlCtx at all, so the
parameter is dropped rather than underscored.

Version

0.160.00.161.0 — minor. New capability (four served methods, three CLI verbs), additive
only; SpendQuery/SpendLedger/ErrorCode gain fields and a variant but nothing is removed,
renamed or repurposed.

What this PR does NOT do

Comment thread crates/dig-node-service/src/collateral.rs Fixed
Comment thread crates/dig-node-service/src/collateral.rs Fixed
Comment thread crates/dig-node-service/src/collateral.rs Fixed
Comment thread crates/dig-node-service/src/collateral.rs Fixed
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing head 3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1 (resolved from remote, matches dispatch).
Own worktree C:\tmp\worktrees\sec395, detached; no shared checkout touched.

Established so far

Merge preconditions (asserted BY NAME via check-merge-preconditions.sh) — all five required
contexts present and SUCCESS: Lint commit messages, Check version increment, Rustfmt, Clippy,
Test + coverage. unresolvedReviewThreads=0. mergeStateStatus=CLEAN. BLOCKED on draft=true
alone
, which is the correct state for a PR whose gate round is still running.

Authorship — all 18 commits Michael Taylor <michael@michaeltaylor.dev>. No fabricated identity.

crates/dig-wallet/src/sage/ is untouchedgit diff --stat origin/main...3b3e22d8 -- crates/dig-wallet/src/sage/ is empty. No collision with #391/#393.

Finding L1 (LOW, non-gating) — two lane scratch scripts committed into the shipped tree

.tsplice.py (+28) and .wire2.py (+44) are in the diff. They are the lane's own source-mutating
splice scripts: they open crates/dig-node-service/src/collateral.rs and control.rs, string-replace
regions, and write them back. Both read helper fragments (.t.rs, .bufh.rs) that do not exist in
the tree
— so they are dead on arrival and would corrupt the source if anyone ran them.

No secret, no credential, no build/exec path reaches them, and they are not include!d — so this is
not a vulnerability and does not gate. It is committed scratch in a repo that squash-merges to a
released binary. Recommend deleting both in this PR (cheaper to fix than to file, CLAUDE.md 1.3c
rule 3) rather than a follow-up ticket.

Continuing: the never-render-an-unknown sweep across collateral.rs, the spends.list cursor
completeness attack, live re-execution of the arithmetic and the stale-epoch guard.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/n)

Head 3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1.

Authorization posture — traced, and it is correct

I first mis-read OWNED_CONTROL_METHODS as a privilege tier. It is not — it is a routing
partition (shell-owned vs engine-delegated), asserted by
control_methods_partition_into_owned_and_delegated. The real tiers are three:

tier predicate new methods
open, no token is_open_control_read (control.rs:144) none of the five
ordinary (paired) token default all five
master token only ControlMethod::requires_master_token none of the five

All five new methods require a valid control token — none was added to is_open_control_read,
so control.spends.list cannot be read by an unauthenticated local process. That is the right call
for the audit record: it carries amounts, funding coin ids, store ids and authority principals.

requires_master_token correctly delegates to the contract (control.rs:351-367) rather than
restating a string list, so the drift-fails-open class that once let a paired token install a trusted
Chia peer cannot recur here.

Finding S1 (the FIFTH instance of the class) — control_cli.rs:768

let margin_bp = margin_json["margin_bp"].as_u64().unwrap_or(0);

An absent margin becomes a definite 0, and 0 is a legal, meaningful margin ("post the
requirement exactly, no cushion"). The operator cannot distinguish it from "I could not read your
setting". This is the lane's own defect #2an unreadable list defaulted to zero — reproduced one
layer up, on the client side.

It is not merely displayed. It is passed straight into buffer_advice(pairs, &requirement, margin_bp, spendable, horizon) at :770-776, so a fallback 0 produces three wrong outputs at
once:

  1. lock = one_epoch_lock(pairs, req, 0) drops the margin entirely -> the recommendation is
    understated
    by the margin (5% at generous).
  2. funding_state is compared against those understated thresholds, so a balance in the band
    between the true and understated recommendation reports Funded when the operator is really
    BelowRecommendedBuffer. The error is in the reassuring direction.
  3. The rendered line says (0 bp margin) -- a false claim about what the operator configured.

The correct pattern is two lines above it. :761 decodes the requirement as
serde_json::from_value::<CollateralRequirementResult>(...).map_err(std::io::Error::other)? -- a
malformed payload is an ERROR. The margin, decoded by hand, fails open instead. CollateralMarginResult
is a published contract type (results.rs:3118) with exactly one field; decoding through it would
make a missing margin_bp an error for free.

Related: control.collateral.margin.get (control.rs:3321) hand-builds json!({"margin_bp": ...})
rather than serializing CollateralMarginResult. The shapes agree today -- I diffed them -- but that
is a restatement of a wire shape in a module whose own header forbids restating the crate's arithmetic
for the same reason.

Severity: NON-GATING (defense-in-depth), and I want to be precise about why. I traced
reachability rather than asserting it. call_control turns a JSON-RPC error into Err
(control_client.rs:81-87), so version skew is caught: a 0.160.0 node that does not serve
control.collateral.margin.get returns METHOD_NOT_FOUND and the CLI fails loudly. The fallback is
reached only via control_client.rs:88 -- Ok(v.get("result").cloned().unwrap_or_else(|| json!({})))
-- i.e. an ok response whose result is absent or null. This build's handler always emits the
field, so there is no exploit against matched versions today. It is a latent fail-open on a money
path, armed for whichever future build changes that shape.

Recommend fixing in THIS PR (a two-line change, and the file is already open) rather than a follow-up
ticket. I am not gating on it.

Finding L2 (LOW, non-gating) -- a doc comment was spliced onto the wrong function

entrypoint.rs:1044-1064. The .wire2.py / .tsplice.py string-splices (finding L1) inserted text
between an existing doc comment and its function. The result:

  • parse_dig_amount (:1064) now carries three concatenated doc comments -- chia_peers_action's,
    collateral_action's, and its own.
  • collateral_action (:1089) and chia_peers_action (:1119) are now undocumented.

The reason this is more than a style nit: the orphaned text is a security rationale --
"defaulting to add would make a bare dign chia-peers grant trust, and a default must never be the
act that costs something."
That warning now sits above a DIG amount parser. A future editor of
chia_peers_action no longer sees the reason its default must stay list, and chiaPeers.add is one
of only two non-pairing methods on the master tier precisely because it grants unrevocable authority.

Losing the rationale does not change behaviour today, so it does not gate. It should be fixed here.

Next: spends.list cursor completeness, the in_state_dir path-resolver check, and the executable
probes (margin 10001 / 1, the stale-epoch guard, the cursor mutation).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (3/n)

Head 3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1. Executed in my own worktree
C:\tmp\worktrees\sec395 (detached at that SHA). No shared checkout touched.

RE-EXECUTED: the cursor mutation, with the distinguishing fixture

I applied the drain-then-filter mutation to SpendLog::query (spend_audit.rs:548-563) — moved
records.retain(|r| q.matches(r)) from BEFORE the after_id block to AFTER it — and ran the suite.

an_unknown_cursor_is_refused_rather_than_restarting_or_ending_the_walk ... FAILED
a_cursor_narrowed_by_a_filter_still_names_a_position_in_that_filtered_order ... ok
the_documented_order_is_newest_first_then_id_ascending ... ok
a_walk_visits_every_row_exactly_once_across_a_tied_millisecond ... ok
complete_is_not_inferred_from_a_full_page ... ok

Exactly one test caught it, and it is the one with the right fixture. The lane's stated subtlety
is confirmed by execution: the filter-narrowed test passes under BOTH orderings (cursor "b" is
inside the store-1 set, so drain-then-filter and filter-then-drain agree), and only
after_id: "d" — an id present in the record but absent from the filtered set — separates them.
A weaker fixture would have made this guard vacuous. It is not.

Restored afterwards: git status --porcelain empty, HEAD still 3b3e22d8.

RE-EXECUTED: the margin ceiling is REFUSED, and 1 bp survives

Independent probe against the real contract type:

PROBE 10001 -> code=-32602 msg=margin_bp must be at most 10000 basis points (+100%); got 10001
PROBE 1 bp survives validation as 1

10000 is accepted (the boundary is inclusive), u64::MAX is refused. The node calls
.validated() before persisting (control.rs:3336-3339) and persists BEFORE reporting
(:3345-3352), so a write failure cannot be answered with a success. Refused, never clamped
the applied value can never silently differ from the requested one. Confirmed.

RE-EXECUTED: the stale-epoch guard holds

PROBE current epoch by the canonical clock = 104
PROBE answer for the CURRENT epoch = Unknown { reason: NotCensused }
PROBE the stale epoch is still readable when named explicitly

Seeded a store containing ONLY an epoch-103 record with a distinctive
required_per_store_dig_base_units, then asked for the current epoch. The node answers
not_censused rather than serving last week's figure. The clock-derived epoch is 104, matching
the live evidence in the brief.

Realization 2 checked: the replacement is not a smaller version of the marker-file hazard. The
epoch is derived from dig_constants::mirror_epoch_at_unix_ms at read time
(collateral.rs:254-268), so there is no stored "current epoch" to go stale. Staleness is
structurally unrepresentable in that direction.

The live-evidence arithmetic reproduces exactly — the brief's discrepancy was the brief's

The brief flagged 17 x 3.780 = 64.26, not 64.906, and that 64.26 x 1.01 = 64.9026 does not
match either. Resolved: the margin is applied PER STORE and rounded UP per store, then
multiplied
— not applied to the aggregate.

apply_safety_margin(3780, 100) = ceil(3780 * 10100 / 10000) = ceil(3817.8) = 3818
3818 * 17                                                                 = 64906  -> 64.906 DIG

apply_safety_margin is (x * (10000 + bp) + 9999) / 10000 (dig-mirror-collateral-0.3.0 margin.rs:28-34) — a genuine ceiling, verified from crate source rather than inferred from the
match. Per-store is the CORRECT unit: each advertisement posts individually and must independently
clear the margined requirement, so rounding up per store is also the safe direction.

The rest follows: overlap = lock = 64.906; relative_ceiling(4) = 1_601_805 micros gives
floor(64906 * 1601805 / 1e6) - 64906 = 39_060 -> 39.060; total 168.872. Every term
reproduces. required_per_store is not restated anywhererequirement() reads
rec.required_per_store_dig_base_units off the stored record, which the census computed via the
crate's own required_per_store() (which carries the MIN_REQUIRED_PER_STORE floor clamp at
requirement.rs:54). No call site writes equilibrium x multiplier - handicap.

One caveat worth stating for the operator-facing claim: an operator with a calculator will hit the
same confusion the brief did, because the rendered line shows 3.780 DIG each (100 bp margin) and
then locks 64.906, and 17 x 3.780 is not that. The working is shown but the ROUNDING STEP is
not. Not a defect — a legibility note on a figure whose whole purpose is to be checkable.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (4/n) — the SIXTH instance, and it is my gating finding

Head 3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1. Falsified by execution, not by reading.

Finding F1 (GATING) — control_cli.rs:906 renders 0.000 DIG per store from an unknown

summarize_collateral_requirement guards positively on "unknown":

if result["state"].as_str() == Some("unknown") { ... return "collateral requirement UNKNOWN ..." }
// everything else falls through to:
let dig = |key: &str| crate::collateral::format_dig(result[key].as_u64().unwrap_or(0));

Anything that is not literally state == "unknown" reaches the KNOWN formatter, where every field
is unwrap_or(0). I inserted two probes into the real module and ran them:

PROBE empty result renders: epoch 0 (protocol v0) — 0.000 DIG per store, before any safety margin
  from 0 advertisement(s) across 0 collateralised owner(s) · multiplier 0.000000x · handicap 0.000 DIG

PROBE unrecognised state renders: epoch 104 (protocol v0) — 0.000 DIG per store, before any safety margin
  from 0 advertisement(s) across 0 collateralised owner(s) · multiplier 0.000000x · handicap 0.000 DIG

The second is the dangerous one. It carries a REAL epoch number beside a fabricated
0.000 DIG per store — it does not read as a degraded answer, it reads as an authoritative current
one. An operator acting on it posts nothing and leaves every store root uncollateralised.

This is the money lie the function's own comment says it exists to prevent, word for word:
"Emphatically NOT 0 DIG. An absent requirement rendered as a zero cost is the money lie this
surface exists to prevent."
The guard is there; it is just pointed the wrong way.

Two triggers, and the second is a PLANNED event rather than a failure.

  1. An ok response whose result is absent -> call_control returns json!({})
    (control_client.rs:88). Not producible by this build's handler.
  2. An unrecognised state. CollateralRequirementResult is
    #[serde(tag = "state", rename_all = "snake_case")]. Adding a variant is an ADDITIVE,
    backwards-compatible contract change — exactly what the ecosystem's own store-format rule (§5.1)
    and every minor bump of this crate are designed to permit. So the next contract minor that adds a
    third state makes every already-installed dign print 0.000 DIG per store against a live epoch.
    dign and the node are separately installed, separately updated binaries; control_client exists
    precisely because they are different processes at different versions.

The correct pattern is in the same file, two functions away. render_buffer (:786) takes a
typed &CollateralBufferResult and matches its variants exhaustively, so an unknown cannot reach the
figure path and a new variant is a compile error. collateral_buffer (:761) likewise decodes the
requirement through serde_json::from_value::<CollateralRequirementResult> and errors on anything
malformed. Only this renderer parses the wire shape by hand.

Suggested fix, either of:

  • match positively on state == "known" and treat everything else as UNKNOWN (fail closed), or
  • decode into CollateralRequirementResult and match, as the two neighbours already do — which makes
    the next added variant a build failure instead of a zero.

Why I am gating on a defect that is not exploitable today

I want to be precise, because this is not a live exploit against matched versions and I checked that
rather than assuming it. A 0.160.0 node returns METHOD_NOT_FOUND and call_control turns that into
an Err (control_client.rs:81-87), so ordinary backward skew fails LOUDLY. Nothing an attacker
sends today reaches this.

I am gating anyway, for three reasons:

  1. The brief's acceptance property is falsified. "No path may render a figure from an unknown.
    Not zero, not a default... Check the dign formatter too."
    A path does. I did not have to
    construct an exotic input to find it — an empty object and a plausible future tag both suffice.
  2. This PR's whole subject is this class. The lane found four instances and fixed them. F1 and S1
    are the fifth and sixth, in the same commit range, on the same surface. The class is not yet
    under control here, and the cheapest moment to close it is before the release that declares it
    closed.
  3. The fix is roughly five lines and does not touch the design, the contract, or the arithmetic
    — all of which I verified and found sound.

Restored after probing: git status --porcelain empty, git clean -nd empty, HEAD still 3b3e22d8.
All probing was in my own worktree C:\tmp\worktrees\sec395; no shared checkout was touched.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED

Head audited: 3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1 (resolved from remote via
gh pr view 395 --json headRefOid; matches the dispatch, and did not move during the audit).

One GATING finding. The design, the arithmetic, the authorization tiers, the cursor semantics and the
CodeQL fix are all sound and I verified each by execution rather than by reading. The gate is a single
renderer that still turns an unknown into 0.000 DIG per store.


GATING

F1 — crates/dig-node-service/src/control_cli.rs:906 renders a figure from an unknown

summarize_collateral_requirement guards positively on "unknown" and lets everything else fall
through to a formatter where every field is unwrap_or(0). Probes inserted into the real module and
executed:

PROBE empty result renders: epoch 0 (protocol v0) — 0.000 DIG per store, before any safety margin
PROBE unrecognised state renders: epoch 104 (protocol v0) — 0.000 DIG per store, before any safety margin

Scenario -> impact. The contract publishes CollateralRequirementResult as
#[serde(tag = "state")]. Adding a variant is an ADDITIVE, backwards-compatible change — the kind
every minor of that crate is designed to make. The next contract minor that adds a third state makes
every already-installed dign print 0.000 DIG per store beside a live epoch number. That does
not read as degraded; it reads as authoritative. An operator acting on it posts nothing and leaves
every store root uncollateralised for the epoch. dign and the node are separately installed,
separately updated binaries — control_client exists because they are different processes.

This is the lie the function's own comment says it prevents: "Emphatically NOT 0 DIG. An absent
requirement rendered as a zero cost is the money lie this surface exists to prevent."
The guard is
present and pointed the wrong way.

The correct pattern is 20 lines away. summarize_collateral_buffer (:885) decodes into the
typed contract enum and answers "unreadable answer from the node" on failure; render_buffer
(:786) matches typed variants exhaustively. Only the requirement renderer parses the wire by hand.

Fix (~5 lines): match positively on state == "known" and treat everything else as UNKNOWN, or
decode into CollateralRequirementResult as both neighbours already do — which makes the next added
variant a compile error rather than a zero.

Why I gate on something not exploitable against matched versions. I checked, rather than assumed:
a 0.160.0 node returns METHOD_NOT_FOUND, call_control turns that into Err
(control_client.rs:81-87), so ordinary backward skew fails loudly, and nothing an attacker sends
today reaches this. I gate because (1) the brief's stated acceptance property — no path may render a
figure from an unknown, check the dign formatter too
— is falsified, with no exotic input needed;
(2) this PR's entire subject is this class, the lane found four instances, and F1 plus S1 are the
fifth and sixth in the same commit range; (3) the fix is trivial and touches neither the contract nor
the arithmetic.


NON-GATING — ranked. Fix S1 with F1 (same class, same file); the rest are follow-ups.

S1 — control_cli.rs:768, margin_json["margin_bp"].as_u64().unwrap_or(0). An unreadable margin
becomes a definite 0, and 0 is a legal margin ("post exactly, no cushion"), so the two are
indistinguishable. It is not merely displayed — it feeds buffer_advice at :770, so it
(a) understates the recommendation by the whole margin, (b) compares the balance against understated
thresholds, flipping BelowRecommendedBuffer -> Funded for a balance in that band, and
(c) prints (0 bp margin), a false claim about what the operator configured. The correct pattern is
two lines above, at :761, where the requirement is decoded typed and errors on malformed input.
Not reachable against matched versions (this build always emits the field).

D1 — collateral.rs:275-301: a stored epoch record is served verbatim, with no coherence check.
Executed: a record with multiplier_micros: 0 and required_per_store: 37_800 written straight to
collateral-epochs.jsonl yields escalation_ceiling_micros = 28_476_000_000 (x28,476) and
recommended = 18_482_313_402 base units = 18,482,313.402 DIG, funding_state: ShortNow. Per
CLAUDE.md's persisted state is untrusted at rest, this file is attacker-influenced by definition.
Contained today: it needs write access to the hardened machine state dir (the #501 work), and
corruption fails closedEpochRecord has no #[serde(default)], so a truncated line fails to
deserialise and becomes RecordUnreadable. So this needs a deliberate write, not an accident.
Recommend cross-checking required_per_store(rec.multiplier_micros, rec.census.owners) against
rec.required_per_store_dig_base_units on READ and answering RecordUnreadable on mismatch — the
module's own test helper (:490-506) already performs exactly that recomputation to build a coherent
fixture, so the coherence property is understood and pinned in the fixture but not in the read. Worth
closing before #387 lands a real writer.

D2 — collateral.rs:180: EpochRecordStore::get returns Absent on any non-parse read failure.
let Ok(text) = std::fs::read_to_string(&self.path) else { return StoredEpoch::Absent } — a
permission error or invalid UTF-8 collapses into "I never censused this", the exact distinction the
Absent/Unreadable split was created to preserve (:127-132). No figure is rendered either way, so
this is not a money lie; it is the wrong remedy — the operator is told to run the census when the
real problem is an unreadable file. Same shape as the lane's own defect 4, one layer down.

D3 — the --roots operand is returned as a node measurement. render_buffer is shared between
the node-computed and operator-supplied forms with no provenance marker, and the operand is placed
in pairs_served_by_this_node, whose contract documentation reads "Qualifying (owner, store, root)
pairs THIS NODE serves ... This node's own set"
. The human line says serving 17 store root(s) — an
assertion the node cannot make. Answering the brief's question directly: yes, a wrong --roots
produces a confident wrong number, and no, it is not surfaced
— but the number the operator typed is
echoed in the same output, so it is checkable at the moment of use. The durable defect is that once
#387 lands, identical output and identical JSON will mean two different things, including in logs and
--json consumed by scripts. Recommend labelling the operand form.

D4 — control.collateral.margin.set sits on the ordinary (paired) token tier. The contract's own
master-tier rule is "every method whose effect OUTLIVES the token that invoked it"
(method.rs:407-439), and this method persists collateral.json, which survives pairing.revoke.
Bounded today because margin_bp reaches no spend path — I enumerated every consumer, and they
are buffer_advice and the display only. The remedy lives in the contract repo, and this PR faithfully
adopts what the contract declares (correctly delegating rather than restating, which is the fix that
closed an earlier fail-open). Settle it before #387 wires posting.

L1 — .tsplice.py (+28) and .wire2.py (+44) are committed lane scratch. Source-mutating splice
scripts that read helper fragments (.t.rs, .bufh.rs) absent from the tree. No secret, no exec path,
not include!d. Delete them here rather than filing.

L2 — a doc comment was spliced onto the wrong function (entrypoint.rs:1044-1064). parse_dig_amount
now carries three concatenated doc comments; collateral_action and chia_peers_action are
undocumented. The orphaned text is a security rationale"defaulting to add would make a bare
dign chia-peers grant trust"
— now sitting above a DIG amount parser, where the next editor of
chia_peers_action will not see it. chiaPeers.add is one of only two non-pairing master-tier methods
precisely because it grants unrevocable authority.


VERIFIED CLEAR — by execution, not by reading

Cursor completeness. Applied the drain-then-filter mutation to SpendLog::query. Exactly one
test failed: an_unknown_cursor_is_refused..., whose fixture uses after_id: "d" — present in the
record, absent from the store-1 filtered set. a_cursor_narrowed_by_a_filter... PASSED under both
orderings, confirming the lane's stated subtlety. The guard is real, not vacuous.

spends.list paging. A cursor is the last row HANDED over; complete is computed from rows
withheld, never from a full page; an unknown cursor is InvalidInput, never a silent restart or an
early end; a walk visits every row once across a tied millisecond at a page boundary inside the tie.

Unreadable vs empty. A missing file is complete: true + empty (honest). A file that cannot be
READ returns Err then SPEND_AUDIT_UNREADABLE (-32048), never an empty page. Corrupt LINES are
counted in unreadable_lines, carried on every page, and printed by the CLI
(spend_audit_cli.rs:124) — the doc claim is true.

Margin refuse-not-clamp. Executed: 10001 gives code=-32602, message
margin_bp must be at most 10000 basis points (+100%); got 10001. 10000 accepted, u64::MAX
refused. .validated() runs before persisting (control.rs:3336) and the value is persisted before
being reported (:3345), so a write failure cannot be answered with a success.

1 bp survives. Executed: PROBE 1 bp survives validation as 1. summarize_margin renders
+0.01% via bp/100 and bp%100, pinned by
the_margin_line_names_its_preset_and_keeps_sub_percent_values.

Stale-epoch guard. Executed: seeded ONLY an epoch-103 record, asked for the current epoch, got
Unknown { reason: NotCensused }. Clock-derived epoch = 104, matching the live evidence.
Realization 2 checked: the epoch is derived at read time from
dig_constants::mirror_epoch_at_unix_ms, so there is no stored current-epoch marker to go stale. The
hazard is structurally unrepresentable, not merely guarded — the replacement is not a smaller version
of the marker-file design.

The arithmetic reproduces exactly, and the brief's discrepancy was the brief's. The margin is
applied per store with a ceiling, then multiplied — not to the aggregate.
ceil(3780 * 10100 / 10000) = 3818, and 3818 * 17 = 64906, i.e. 64.906 DIG. Then
relative_ceiling(4) = 1601805 micros gives headroom 39060, and the total is 168.872. Per-store
is the correct unit: each advertisement posts individually and must independently clear the margined
requirement, and rounding up per store is the safe direction.

No restated formula. apply_safety_margin verified as a true ceiling from crate source
(margin.rs:28-34); required_per_store carries the floor clamp (requirement.rs:54). No call site
writes the equilibrium-times-multiplier-minus-handicap form. requirement() reads the record's
stored figure, which the census computed through the crate.

Units. Every amount is DIG base units, formatted from the integer by format_dig, never through
an f64. margin_bp stays basis points end to end and is never converted. No mojo identifier appears
anywhere in the collateral module. spend_row emits amount_mojos and fee_mojos as decimal
STRINGS, preserving the full u64 range through JSON parsers.

Four funding states. is_shortfall() excludes BelowRecommendedBuffer and Funded, tested — so
below_recommended_buffer can never become a notification. Funded with zero served roots renders
"no store roots to collateralise — nothing to fund", not "funded" (the lane's defect 3, correctly
fixed at control_cli.rs:869).

Authorization. None of the five methods is in is_open_control_read (control.rs:144), so all
require a control token and the audit record is not anonymously readable by a local process.
requires_master_token delegates to the contract rather than restating a string list, closing the
drift-fails-open class that once let a paired token install a trusted Chia peer.
control_contract_conformance is green, including
the_node_and_the_contract_agree_on_the_token_less_wallet_surface — so the open-read set is
contract-agreed, not a local divergence.

CodeQL and the path resolver. ZERO open alerts in any file this PR touches, and ZERO
rust/path-injection alerts in any state. Exactly ONE production resolver: every load_from,
save_to and EpochRecordStore::at call site outside the definitions is at line 509 or later, inside
mod tests (which starts at 478); spend_audit_cli.rs:306 is likewise inside mod tests (starts
287). Production reaches the state dir only through load(), save() and in_state_dir(). The
centralisation is genuine, not a CodeQL silencing. One residual note: load_from, save_to and at
remain pub with no #[cfg(test)] gate, so a future in-crate caller could reintroduce the second
resolver — the same reasoning the lane applied when it made SpendLog::append private was not applied
here.

Adoption. dig-node-control-interface moves "0.21" to "0.24" in both dig-node-service
and dig-wallet; dig-mirror-collateral = "0.3" is a real dependency; dig-constants moves
0.11.2 to 0.13.0. The Cargo.lock diff is exactly five entries and touches no chia line at
all
. dig-mirror-collateral 0.3.0 pulls only serde and thiserror; dig-node-control-interface 0.24.0 pulls no chia; dig-constants 0.13.0 sits on chia 0.36.1, the current line. No chia split
introduced.
The repo's pre-existing multi-line chia state is unchanged and out of scope — 2.4b binds
when a PR MOVES a chia crate, and this one moves none.

Secrets. No key, token, credential, projectId or PAT added, logged or printed. The diff was
scanned for secret-shaped additions: none.

Scope. crates/dig-wallet/src/sage/ is untouched — the diff restricted to that path is empty. No
collision with #391 or #393.

Merge preconditions. All five required contexts present and SUCCESS by name: Lint commit
messages, Check version increment, Rustfmt, Clippy, Test + coverage. unresolvedReviewThreads=0,
mergeStateStatus=CLEAN, BLOCKED on draft=true alone. All 18 commits authored
Michael Taylor <michael@michaeltaylor.dev>. Full lib suite: 473 passed, 0 failed.


The two judgments you asked for

DEFAULT_BUFFER_HORIZON_EPOCHS = 4 is defensible. Four epochs is 28 days at x1.60 worst-case
headroom, against x1.12 at one epoch and x4.62 at thirteen. One epoch leaves essentially no escalation
cushion, which defeats the buffer's purpose; thirteen is absurd. The error direction is SAFE
(over-recommend, never under); the horizon is carried on the wire and rendered with its ceiling; and
the output labels it "a worst case, not a forecast", so the operator is told what the number assumes.
A client cannot choose it, deliberately, so nobody can quietly shrink a money figure by asking for a
shorter horizon. The one honest criticism: inside the dead band the multiplier does not move at all,
so the recommendation systematically overstates for a typical operator. A band-aware horizon would
tighten it. That is a refinement, not a defect — and over-recommending capital is the right way to be
wrong on this surface.

dign collateral buffer --roots is a genuine named limitation, not a gate on the user — but it CAN
produce a wrong answer.
See D3: the operand is not surfaced as operator-supplied in either the human
line or the JSON, and it lands in a field the contract documents as the node's own served set. It is
checkable today only because the typed number is echoed back in the same output. It stops being
checkable the moment #387 makes identical output mean something different.


Coverage I did NOT achieve — stated plainly

  • No live end-to-end RPC test over a real socket. Every probe ran in-process against library
    functions. The reachability analysis for F1 and S1 rests on reading call_control, not on driving a
    real node process at a skewed contract version.
  • I did not build the full workspace or run clippy. I ran the dig-node-service lib suite (473
    pass), the conformance target, and my own probe target. CI's Rustfmt, Clippy and Test+coverage are
    green by name, which is the assurance I am relying on for the rest.
  • I treated dig-mirror-collateral 0.3.0 as the authority, verifying only apply_safety_margin,
    required_per_store and the constants against its source. Its controller — step_multiplier, the
    band edges, EpochRecord::bootstrap — was not independently audited. That is this PR's own stated
    design, but it means the arithmetic is proven consistent with the crate, not correct in absolute
    terms.
  • The census writer (Per-epoch collateral record: database, gossip serve, sampled sync, retention off by default #387) does not exist in this diff, so I could not audit how records will
    actually be produced. D1 matters most at that moment, not this one.
  • No dig-app-side verification of the 0.24.0 contract adoption.
  • No concurrency or TOCTOU testing of the append-only files under simultaneous put and get.
  • DIG_NODE_ALLOW_REMOTE makes token-gated methods network-reachable. I confirmed none of the five
    is an open read, but did not exercise that configuration.

Housekeeping

All work was done in my own worktree C:\tmp\worktrees\sec395, cut detached from 3b3e22d8. No
shared checkout was touched
, and none of the lanes' worktrees was used or removed. After probing:
git status --porcelain empty, git clean -nd empty, no stash, no local commits, HEAD still
3b3e22d8ce14abd8049613d9f7ff3795ca33d6f1. Probe files removed.

Verdict: CHANGES-REQUIRED on F1. Fix S1 alongside it — same class, same file, and both are a few
lines. D1 through D4 and L1/L2 are follow-ups; I am not gating on them.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Round 2 — F1/S1 fixed, sweep done, D1-D4 judged

Head 53463e0. Still DRAFT. 476 tests pass (473 + 3 new), clippy -D warnings clean, fmt clean.

GATING F1 — fixed at control_cli.rs:925

summarize_collateral_requirement now decodes CollateralRequirementResult typed and refuses what
it cannot decode, matching summarize_collateral_buffer 20 lines away. The unknown branch also
matches on CollateralUnknownReason rather than on a string, so an unrecognised reason is refused
too — the reason taxonomy is as additive as the state tag.

The gate's probe reproduces exactly, and the fix removes it:

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 after margin set to confirm the setting
    took — the one place a fabricated 0 bp would be believed.
  • Honest already: every string field uses "?", which reads as unknown. corroboration_bypassed
    is unwrap_or(true) — deliberately failing toward the alarming side.
  • Fails toward alarm, not reassurance, so out of the class: installed/syncedfalse,
    balance/pending0. A zero balance is not a reassuring reading of an unknown.
  • Judged and left, reported not filed (§1.3c — a finding is a comment): banned is
    unwrap_or(false) at :638 and :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_figure
  • an_undecodable_margin_renders_unreadable_and_never_zero_bp
  • an_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… FAILS left: 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.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (round 2) — IN PROGRESS, not the verdict

Auditing head 53463e0cdadf51a22553269fefe7451aaf6c7d81, merge-base e0940780.
Own detached worktree C:\tmp\worktrees\dn395-sec. No shared checkout touched.

L1/L2 splice damage — CONFIRMED, and worse than "two orphaned blocks"

Read the pre-fix state directly (git show 3916d55 -- crates/dig-node-service/src/entrypoint.rs).
The two doc blocks were not merely orphaned: they were stacked on top of
parse_dig_amount's own doc
, forming one contiguous block attached to a
money-parsing function (--balance operand -> DIG base units).

Net effect before the fix, at entrypoint.rs:1041:

  • chia_peers_action — no doc at all (lost "Listing is the default ... a default
    must never be the act that costs something").
  • collateral_action — no doc at all (lost "An unrecognised word is REFUSED").
  • parse_dig_amount — three doc blocks, two describing other functions.

This is the failure mode the brief names: a security rationale attached to the wrong
function still reads as reviewed, and here it landed on the one function in that file
that converts an operator's typed amount into base units.

At head both are restored to their correct functions and parse_dig_amount carries
only its own doc — verified at entrypoint.rs:1076-1083 (collateral_action) and
entrypoint.rs:1114-1118 (chia_peers_action).

No third instance. Scanned every /// block in all six touched .rs files for the
orphan signature (doc block terminated by a blank line rather than an item): zero hits.
Diffed per-function doc presence base-vs-head across all six files: the only function at
head with no doc that is new in this PR is collateral.rs:55 default_margin_bp
(readable-code nit, not security). No function present at base lost its doc.

Non-gating note on commit hygiene: da90f98's message says "restore spliced doc
comments" but its diff is the two .py deletions only (72 deletions, 2 files). The
restoration is actually in 3916d55. Message/content mismatch, cosmetic on a squash-merge.

No encoding damage introduced: zero U+FFFD in any touched file, LF endings, valid UTF-8,
240 em-dashes intact in control.rs.

Continuing: F1/S1/summarize_margin revert proofs, the D2 wire change, the unwrap_or sweep.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (round 2) — IN PROGRESS, not the verdict (2/n)

Head 53463e0cdadf51a22553269fefe7451aaf6c7d81.

D2's wire reach — verified, and materially smaller than the brief assumed

The brief flags that EpochRecordStore::get (collateral.rs:179) feeds requirement(), which the
node-side handler also serves, so control.collateral.requirement now returns record_unreadable
where it previously returned not_censused. Three checks:

1. There is no "previously" on any shipped node. At merge-base e0940780,
crates/dig-node-service/src/collateral.rs does not exist, and control.rs contains zero
occurrences of collateral. The entire method family (control.collateral.requirement,
.margin.get, .margin.set, .buffer) is introduced by this PR. The not_censused -> record_unreadable
change is therefore a change within an unreleased branch, invisible to every deployed client.
This is not a wire regression; there is no prior wire.

2. The SPEC specified the split BEFORE the code implemented it. I checked the direction rather
than accepting it. The sentence "A record the node never wrote and one it wrote and cannot read are
different answers (not_censused vs record_unreadable)"
appears in 93a993b — commit 15 of 22,
five commits before the D2 fix in 74c6c67. So the code was the deviation and D2 brought it to
spec, not the reverse. 53463e0 only sharpens how the split is decided (by file existence).

3. record_unreadable is a pre-existing published value, not a new one:
CollateralUnknownReason::RecordUnreadable is declared in dig-node-control-interface 0.24.0
src/results.rs:2826, the exact version this PR adopts — so no client can meet a token its pinned
interface lacks. SPEC §24.2's table agrees: record_unreadable = "a record exists and could not be
read", remedy "re-run the census for the epoch".

The lane's reasoning about the remedy holds: not_censused renders "run the census for this epoch",
which writes to the file that could not be read. Confirmed at control_cli.rs:954-965.

Consumer: dig-app PR #311 (94d01de) is OPEN and DRAFT — unmerged, so nothing in production
distinguishes the two reasons. Coordination item, not a break.

Attacker reachability of the new branch: Unreadable requires a read_to_string error that is
not NotFound on the node's own state directory. Reaching it requires local filesystem control over
that directory, which also holds the margin, the config and the identity key. An actor with that
access does not need this path. Not a remote-triggerable state transition.

Serde refusals are real, not nominal

Checked the declarations rather than trusting the comments:

  • CollateralRequirementResult#[serde(tag = "state", rename_all = "snake_case")], no
    #[serde(other)] -> an unrecognised state fails to decode.
  • CollateralUnknownReason#[serde(rename_all = "snake_case")], no #[serde(other)] ->
    an unrecognised reason also fails to decode. The F1 claim that the reason taxonomy is as
    additive as the state tag is correct.
  • CollateralMarginResult — plain struct, margin_bp: u64 with no #[serde(default)] ->
    absent or wrongly-typed field fails to decode.
  • No deny_unknown_fields anywhere, so extra fields stay tolerated. Correct for forward compat,
    and not a money-lie vector.
  • No #[serde(default)] on any Known field, which is what makes the known-missing-a-field
    fixture discriminate a typed decode from a per-field fallback.

Still to come: executed revert proofs (independence), the unwrap_or sweep spot-check, clippy,
merge preconditions.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (round 2) — IN PROGRESS, not the verdict (3/n)

Head 53463e0cdadf51a22553269fefe7451aaf6c7d81. Revert proofs EXECUTED in my own detached
worktree (C:\tmp\worktrees\dn395-sec), one leg at a time, tree restored clean between each.

Baseline: cargo test -p dig-node-service --lib --locked -> 476 passed, 0 failed. The claimed
count is exact.

R1 — revert F1 alone: PASSES, and reproduces the probe string verbatim

Replaced summarize_collateral_requirement with its ba8e00b (pre-fix) body, leaving the other two
legs intact. Result:

test result: FAILED. 475 passed; 1 failed
panicked at crates\dig-node-service\src\control_cli.rs:1306:13:
unrecognised state was not reported as unreadable: epoch 104 (protocol v0) — 0.000 DIG per store, before any safety margin

Character-for-character the string the gate quoted. Note the rendered lie is worse than "a zero":
a REAL epoch 104 beside a fabricated 0.000 DIG per store and a fabricated protocol v0.
Exactly one test failed -> F1's test is independent.

R2 — revert summarize_margin alone: PASSES, independent

test result: FAILED. 475 passed; 1 failed
panicked at crates\dig-node-service\src\control_cli.rs:1376:13:
empty object was not reported as unreadable: safety margin 0 bp = +0.00% over the per-store requirement

A different single test. So R1 and R2 are genuinely two proofs, not one test failing on everything.

R3 — revert collateral_buffer (S1 and D3 together): SUITE STAYS GREEN

test result: ok. 476 passed; 0 failed

The "three tests fail independently" claim is not accurate. Two do. The third leg has no test.
Reverting control_cli.rs:772-774 (S1's typed CollateralMarginResult decode) back to
margin_json["margin_bp"].as_u64().unwrap_or(0) and deleting D3's provenance marker at
control_cli.rs:791-796 — both at once — leaves the entire suite green.

Corroborated structurally, not just by the run:

  • control_cli::collateral_buffer has exactly ONE caller, entrypoint.rs:871, and no test
    anywhere in crates/ or tests/.
  • grep -rln collateral crates/dig-node-service/tests/ returns nothing — no integration test
    touches this family, so the gap is not hidden in a suite I did not run.
  • The marker string "supplied by you via" occurs exactly once in the whole repo: its own
    definition at control_cli.rs:794. No assertion references it.

The three tests the PR points at are an_undecodable_requirement_..., an_undecodable_margin_...
and the_margin_line_names_its_preset_and_keeps_sub_percent_values. The third is a truthful-control
test for summarize_margin, not a proof of S1 — it did not fail under any of the three reverts.

Severity: NOT GATING, but the record must be corrected

The code at head is correct. I read S1 and confirmed the refusal is real rather than nominal:
CollateralMarginResult.margin_bp is a required u64 with no #[serde(default)], so
from_value errors and map_err(std::io::Error::other)? propagates — dign collateral buffer
fails loudly instead of printing a fabricated recommendation. There is no exploit at this head.

What is defective is the durability of the fix, and the claim about it. The money lie S1 prevents
is real and the lane described it correctly: a margin defaulted to zero understates the
recommendation by exactly the operator's chosen cushion, which flips BelowRecommendedBuffer
("no cushion, add N DIG") to Funded ("at or above the recommended buffer"). That is a surface
lying about money. It is fixed; nothing stops the next refactor from un-fixing it silently, and the
next reader will believe otherwise because the PR says three tests pin it.

Recommend a follow-up ticket to cover collateral_buffer (S1 + D3) rather than blocking this merge
— consistent with end-to-end-first and gate-to-risk. Reporting it prominently because an
overstated coverage claim on a money path is the thing that makes a future regression invisible.

Still to come: unwrap_or sweep spot-check, clippy, merge preconditions, secrets/scratch scan.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (round 2) — IN PROGRESS, not the verdict (4/n)

Head 53463e0cdadf51a22553269fefe7451aaf6c7d81.

THERE IS A THIRD ORPHANED DOC BLOCK — collateral.rs:68-73 (NEW in this PR)

The brief asked me to check for a third. There is one, and my first automated pass missed it: it is a
contiguous stack, not a block terminated by a blank line, so an orphan-signature scan cannot see it.
I found it by reading, then built a detector for the exact shape.

collateral.rs:67   impl CollateralConfig {
collateral.rs:68       /// Load from `dir`, falling back to the default for a missing OR unreadable file.
collateral.rs:69       ///
collateral.rs:70-73    /// An unreadable file yields the default rather than an error because the margin
                       /// is a cushion: ... The fallback is the `+1%` default, never `0`, so the
                       /// degraded path still errs toward over-posting.
collateral.rs:74       /// Load from the node's own machine-wide state directory.      <-- load()'s real doc
collateral.rs:75-80    /// The production entry point. It resolves the directory ITSELF ...
collateral.rs:81       pub fn load() -> Self {

load() takes no dir. The block at :68-73 describes load_from, and it carries the
failure-direction rationale — why an unreadable config silently yields a default instead of an
error. Meanwhile the function that actually performs that swallow:

collateral.rs:90       /// Load from an explicit directory.
collateral.rs:92       /// For tests and for callers that already own a directory. Production uses [`Self::load`].
collateral.rs:93       pub fn load_from(dir: &Path) -> Self {
collateral.rs:94-97        std::fs::read_to_string(dir.join(COLLATERAL_CONFIG_FILE))
                               .ok()                       <-- error kind discarded
                               .and_then(|t| serde_json::from_str(&t).ok())
                               .unwrap_or_default()

...carries no explanation of it at all. This is the same class as L1/L2 and the same harm the brief
names: the rationale reads as reviewed while sitting on the wrong function, and rustdoc will render
CollateralConfig::load's summary as "Load from dir" for a function with no dir parameter.

Scope confirmed: collateral.rs does not exist at merge-base, so this orphan is introduced by
this PR. I scanned all six touched files for the contiguous-stack signature; three other hits exist
in control.rs (:2109, :2193, :4805) and all three are present at base (at_base=1) and
read as legitimate continuation paragraphs. So: exactly one new orphan, and this is it.

Mechanism, for the record: the deleted splice scripts anchor on doc-comment lines —
.wire2.py does s.replace(anchor_doc_line, frag + anchor) and .tsplice.py replaces
lines[start:end] where start is a /// line. Splicing on doc anchors is what produces this
signature, so it should be assumed to recur wherever those scripts ran.

Fixture design — BOTH claims verified by execution, and the design is sound

I did not judge the fixtures by reading. I implemented the hybrid they claim to discriminate
against — guard on the state string, then fall back per field — and ran the suite against it.

known missing owners was not reported as unreadable:
epoch 104 (protocol v1) — 3.780 DIG per store, before any safety margin
  • 3.780 is NON-ZERO. So !line.contains("0.000") passed against the hybrid. The claim that
    asserting only the absence of 0.000 would miss a formatter handed a non-zero requirement is
    literally correct — demonstrated, not argued. The assertions that caught it are !contains("104")
    and !contains("per store"), which makes the shared-epoch:104-with-the-truthful-control decision
    load-bearing rather than decorative.
  • The known-missing-a-field fixture is the uniquely discriminating one. Of the four fixtures,
    the hybrid failed only that one; unrecognised state, empty object and unrecognised reason all
    passed under it. So it is precisely what separates a typed decode from a per-field fallback, exactly
    as claimed.

This is a genuinely well-built fixture set, not one that merely looks careful.

Node-side sweep — extended past the lane's scope, one bounded finding

The lane swept the CLI rendering path. I extended to the node side, where a value is served to
every client rather than rendered for one operator.

  • collateral.rs:94-97 CollateralConfig::load_from.ok() discards the error kind, so an
    unreadable config is indistinguishable from a missing one. This is the same conflation
    D2 just fixed one type above in the same file (EpochRecordStore::get), left unfixed for the
    config. Consequence: an operator who set generous (500 bp) whose config becomes unreadable gets
    100 bp served by collateral_margin_get as fact, and summarize_margin renders
    "safety margin 100 bp (default)" — indistinguishable from a real setting. Bounded: it fails
    toward the documented +1% default rather than toward 0, it is visible on the margin readback
    line, and per the D4 note the margin reaches no spend path. Not gating; same bounded class as D1/D4.
  • collateral.rs:268-273 current_epoch_now.unwrap_or(0) on duration_since(UNIX_EPOCH).
    Traced it: 0 -> current_epoch_at(0) -> epoch < 1 -> CurrentEpoch::NotCensused. Fails
    closed to the honest "unknown"
    , not to a figure. Correct.
  • collateral.rs:383/396/458unwrap_or(u64::MAX) on overflow. Saturates HIGH, so it over-states
    the requirement. Alarming direction; an operator over-funds rather than under-posts. Safe.
  • control.rs:3295 node-side collateral_buffer passes None for both the served set and the
    balance rather than approximating them, each reported through its own reason. Correct, and it is
    the same never-guess-a-money-figure discipline the CLI legs enforce.

Sweep completeness — independently confirmed

  • Pre-fix control_cli.rs contained exactly one positive string guard on a payload:
    result["state"].as_str() == Some("unknown") at ba8e00b:906, the F1 site. Zero remain at head.
    The lane's "only one such guard existed" judgement is correct.
  • This PR adds no new unwrap_or to the rendering path outside doc comments and one test helper
    (control_cli.rs:1246, inside mod tests which starts at :1209).
  • banned at :638/:1085/:1104 is unwrap_or(false) and is pre-existing (present at base),
    so correctly reported-not-filed. Direction is reassuring-on-absence, which is the weaker direction,
    but it is outside this diff.

Mechanical

  • 476 passed, 0 failed (cargo test -p dig-node-service --lib --locked). Exact.
  • clippy --workspace --all-targets --locked -- -D warnings: exit 0, clean.

Remaining: D1 judgement, merge preconditions by name, secrets/scratch scan, dig-wallet/src/sage/ diff.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (round 2) — PASS

Head audited: 53463e0cdadf51a22553269fefe7451aaf6c7d81 (resolved from
gh pr view 395 --json headRefOid; merge-base e0940780). Own detached worktree
C:\tmp\worktrees\dn395-sec, removed after. No shared checkout mutated, no git stash/reset/
checkout of a branch anywhere. Worktree verified clean after every probe: git status --porcelain
empty, git clean -nd empty, HEAD unmoved.

No live security defect in this diff. Nothing here should block the merge. Four non-gating
findings follow, ranked; one of them corrects a claim in the PR record and is worth a follow-up
ticket before anyone relies on it.


Verified by execution (not by reading)

Revert proofs — two of the three legs are genuinely pinned, and independently.

probe result
baseline 476 passed, 0 failed — the claimed count, exact
revert F1 (summarize_collateral_requirement -> ba8e00b) 475 passed, 1 failed
revert summarize_margin -> ba8e00b 475 passed, 1 failed (a different test)
revert collateral_buffer (S1 and D3) -> ba8e00b 476 passed, 0 failed

F1's failure reproduces the gate's probe string character-for-character:

unrecognised state was not reported as unreadable: epoch 104 (protocol v0) — 0.000 DIG per store, before any safety margin

The lie is worse than "a zero": a REAL epoch 104 beside a fabricated 0.000 DIG per store and
a fabricated protocol v0. R1 and R2 each fail exactly one, different, test — so they are two
proofs, not one test failing on everything. R3 is finding 1 below.

Fixture design — both claims verified by building the implementation they claim to discriminate
against.
I did not judge these by reading. I spliced in a hybrid (match on the state string,
then per-field unwrap_or) and ran the suite:

known missing owners was not reported as unreadable:
epoch 104 (protocol v1) — 3.780 DIG per store, before any safety margin
  • 3.780 is NON-ZERO, so !line.contains("0.000") passed against the hybrid. The claim that
    asserting only the absence of 0.000 would miss a formatter handed a non-zero requirement is
    literally correct — demonstrated. What caught it was !contains("104") / !contains("per store"),
    which makes sharing epoch: 104 with the truthful control load-bearing, not decorative.
  • The known-missing-a-field fixture is uniquely discriminating: of the four fixtures, the hybrid
    failed only that one. The other three passed under it. It is exactly what separates a typed decode
    from a per-field fallback, as claimed.

Serde refusals are real, not nominal. CollateralRequirementResult is
#[serde(tag = "state")] with no #[serde(other)]; CollateralUnknownReason likewise (so an
unrecognised reason also refuses — the F1 claim holds); CollateralMarginResult.margin_bp is a
required u64 with no #[serde(default)]. No deny_unknown_fields, so extra fields stay
tolerated — correct for forward compat and not a money-lie vector.

D2's wire reach is real but far smaller than assumed. At merge-base, collateral.rs does not
exist and control.rs contains zero occurrences of collateral — the entire method family is
introduced by this PR, so not_censused -> record_unreadable changes nothing any deployed client
ever saw. The SPEC rule predates the fix (93a993b, commit 15/22, vs the fix at 74c6c67, 20/22),
so code was brought to spec rather than spec written to fit code. RecordUnreadable is published in
the exact interface version adopted (0.24.0 results.rs:2826) and matches SPEC §24.2's table. The
Unreadable branch needs a non-NotFound read error on the node's own hardened state directory —
not remotely reachable. Consumer dig-app#311 is open and draft, so nothing in production
distinguishes the two reasons.

Sweep completeness, independently confirmed. Pre-fix control_cli.rs contained exactly one
positive string guard on a payload (ba8e00b:906, the F1 site); zero remain. This PR adds no new
unwrap_or to the rendering path outside doc comments and one test helper (:1246, inside
mod tests at :1209). banned unwrap_or(false) at :638/:1085/:1104 is present at base —
correctly reported-not-filed.

Node-side extension (past the lane's scope, since the node serves where the CLI only renders):
current_epoch_now unwrap_or(0) traces to CurrentEpoch::NotCensused — fails closed to the honest
unknown, not to a figure. scale_micros/one_epoch_lock unwrap_or(u64::MAX) saturate HIGH, the
alarming direction. Node-side collateral_buffer (control.rs:3295) passes None for both served
set and balance rather than approximating — same never-guess-a-money-figure discipline.

Mechanical: 476/476 pass; clippy --workspace --all-targets --locked -- -D warnings exit 0;
merge preconditions asserted by nameLint commit messages, Check version increment,
Rustfmt, Clippy, Test + coverage all present and SUCCESS, unresolvedReviewThreads=0,
mergeStateStatus=CLEAN, BLOCKED on draft=true alone; zero secret-shaped strings in added
lines (the only long literals are Cargo.lock checksums); no scratch files at HEAD
(git ls-tree ... | grep -E '\.py$|lanework' -> none); every commit authored and committed as
Michael Taylor <michael@michaeltaylor.dev>, no fabricated identity; crates/dig-wallet/src/sage/
diff is empty — PR #393's territory untouched; dig-mirror-collateral 0.3.0 is genuinely
published, registry-sourced, and its lock checksum matches the crates.io index.


Findings — all NON-GATING, ranked

1. S1 and D3 are untested, and the "three tests fail independently" claim is not accurate

crates/dig-node-service/src/control_cli.rs:772-774 (S1) and :791-796 (D3)

Reverting both at once — S1's typed CollateralMarginResult decode back to
margin_json["margin_bp"].as_u64().unwrap_or(0), and deleting D3's provenance marker — leaves the
suite fully green, 476/476. Corroborated structurally: control_cli::collateral_buffer has one
caller (entrypoint.rs:871) and no test anywhere; grepping the integration tests for collateral
returns nothing, so the gap is not hidden in a suite I could not run; the marker string
"supplied by you via" occurs exactly once in the repo — its own definition at :794.

The three tests the PR points at are an_undecodable_requirement_..., an_undecodable_margin_...
and the_margin_line_names_its_preset_and_keeps_sub_percent_values. The third is a truthful-control
test for summarize_margin; it failed under none of my three reverts. Two legs are pinned, not three.

Why it is not gating: the code at head is correct and the refusal is real, not nominal —
margin_bp is required with no serde default, so from_value errors and the ? propagates;
dign collateral buffer fails loudly instead of printing a fabricated recommendation. No exploit
at this head.

Why it still matters: the money lie S1 prevents is the dangerous direction — a margin defaulted
to zero understates the recommendation by the operator's chosen cushion, flipping
BelowRecommendedBuffer ("no cushion, add N DIG") to Funded ("at or above the recommended
buffer"). Nothing stops a refactor from silently un-fixing it, and the next reader will believe
otherwise because the PR says three tests pin it. Recommend a follow-up ticket covering
collateral_buffer.

2. A THIRD orphaned doc block, introduced by this PR

crates/dig-node-service/src/collateral.rs:68-73

The block "Load from dir, falling back to the default for a missing OR unreadable file … the
fallback is the +1% default, never 0, so the degraded path still errs toward over-posting"
is
stacked directly on top of load()'s own doc and attaches to pub fn load() at :81, which
takes no dir. It describes load_from. Meanwhile load_from at :93 — the function that
actually performs the swallow at :94-97 — carries no explanation of that behaviour at all.

Same class and same harm as L1/L2: a failure-direction rationale sitting on the wrong function reads
as reviewed, and rustdoc renders CollateralConfig::load's summary as "Load from dir".

My first automated pass missed this because it is a contiguous stack, not a block terminated by
a blank line. I then built a detector for that signature: three other hits exist in control.rs
(:2109, :2193, :4805) and all three are present at merge-base and read as legitimate
continuation paragraphs. So exactly one new orphan, and this is it. Mechanism worth recording: the
deleted splice scripts anchor on doc-comment lines, so this signature should be assumed to recur
wherever they ran.

3. The D1 rationale names an artifact that is definitionally not there

crates/dig-node-service/src/collateral.rs:285-286

The justification for not defending a forged record reads: "Writing that record requires write
access to the node's state directory, which is also where the margin, the config and the identity
key live
"
. But state.rs:22-24 states the machine-wide state_dir holds "ONLY the control/auth
state (control token + paired-tokens.json)"
and is identity-INDEPENDENT by design — that
independence is the entire point of the module. The identity seed defaults under the user's home.

The conclusion survives and is arguably stronger: the state dir holds the master control
token
, so an attacker who can write there owns the whole control plane including the wallet
methods — strictly greater capability than inflating a displayed figure. Only the cited fact is
wrong. Worth correcting precisely because it is the doc justifying not fixing a money-path issue.

On whether D1's bounding under-rates an 18,482,313.402 DIG recommendation — it does not, and the
lane under-stated its own case.
EpochRecordStore::put is called only from tests
(collateral.rs:568, 569, 600, 671, 739); there is no production writer at all, and the only
production uses are the read paths at control.rs:3272/:3296. So no census-ingestion path exists
that could launder remote or chain-derived data into a record — the threat strictly requires direct
filesystem write to a hardened, fail-closed directory. Add that the figure reaches no spend path,
and that the residual error is an overstatement: implausible and alarming, causing disbelief
rather than silent loss. The dangerous direction here is the understatement — precisely what
F1/S1/summarize_margin fix. Refusing a read-side plausibility bound as a rival implementation of
dig-mirror-collateral is principled, not an excuse. Bounding sound.

4. CollateralConfig::load_from keeps the exact conflation D2 just fixed

crates/dig-node-service/src/collateral.rs:94-97

.ok() discards the error kind, so an unreadable config is indistinguishable from a missing
one — the same Absent/Unreadable collapse D2 fixed one type above in the same file. An operator who
set generous (500 bp) whose config becomes unreadable gets 100 bp served by
collateral_margin_get as fact, and summarize_margin renders "safety margin 100 bp (default)" —
indistinguishable from a real setting. Bounded: fails toward the documented +1% default rather
than 0, is visible on the readback line, and the margin reaches no spend path. Same bounded class
as D1/D4.


Coverage I did NOT achieve — stated plainly

  • I did not execute the integration-test binaries. dign,
    control_contract_conformance, spend_audit_e2e, never_log, openrpc_drift_guard,
    https_serve, beacon_cli_process, content_serve and server do not link on this Windows host
    (cranelift_codegen ... required to be available in rlib format; chialisp staticlib). I
    confirmed those failures are environmental and not PR-introduced, but I did not run them. CI
    (ubuntu) reports Test + coverage SUCCESS — for those binaries I rely on CI, not my own
    execution. My 476 figure is -p dig-node-service --lib only.
  • No live-node / on-the-wire observation. I never started a node, so I did not watch
    control.collateral.requirement actually emit record_unreadable. The D2 analysis is static plus
    the SPEC 24.2 table.
  • dig-app feat(routing): rank the forwarded ask by locally-observed answer quality #311 not audited, beyond confirming it is OPEN and DRAFT and therefore not in
    production.
  • Round-1 items deliberately not re-opened per the brief: cursor mutation, margin set 10001
    refusal, stale-epoch guard, the live arithmetic, CodeQL, single production path resolver,
    adoption.
  • Coverage percentage not measured locally — no cargo llvm-cov run; the 80% floor is CI's
    Test + coverage check, which is green.

Verdict: PASS. Custody/privilege, input/boundary, crypto/protocol, authZ/exposure,
amplification, persisted-state-at-rest, secrets and dependencies all check out for this diff. The
one genuinely peer-or-remote-reachable question — whether the new wire reason or a forged record can
be induced by anyone but a local filesystem-privileged actor — is answered no, twice over: the
Unreadable branch needs a non-NotFound error on a hardened fail-closed directory, and the record
store has no production writer at all. Findings 1-4 are follow-ups, not merge blockers.

Still DRAFT and correctly BLOCKED on draft=true; I did not undraft, did not merge, and edited no
code.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Round 3 — five items done, at be1da4f8

Still DRAFT. 478 tests pass (473 + 5), clippy -D warnings clean, fmt clean.

1. The coverage claim was wrong — now measured, not asserted

You were right and my round-2 report was overstated. Reverting S1 and D3 together left the suite
green; only F1 was pinned. The cause is structural: both guards are observable only in the
rendered string, and both sat behind two call_control round trips, so no test could reach them
without a listening node.

buffer_outcome (control_cli.rs:779) now separates everything after the I/O. Two new tests read
what a person reads, and each guard was reverted alone:

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.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 28, 2026 18:39
MichaelTaylor3d and others added 10 commits August 28, 2026 11:40
…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>
MichaelTaylor3d and others added 12 commits August 28, 2026 11:40
…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>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/385-collateral-control branch from be1da4f to c12224d Compare August 28, 2026 18:40
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants