fix(mirror): skip unauthenticatable funding candidates and alert on a funding shortfall - #469
Conversation
…efusing the create One coin nobody can authenticate, at the publicly derivable operator $DIG address, carrying a larger declared amount than any honest coin, was walked first by largest-first selection and aborted the whole selection. Cost to the attacker: dust. Cost to the operator: no mirror coin could ever be created. A skipped candidate is counted, reported and logged at warn, and leaves the candidate POOL rather than occupying a selection input slot -- so dust cannot reinstate the denial by volume either. Refs #461 Co-Authored-By: Claude <noreply@anthropic.com>
…eate The mirror pass runs unattended every ten minutes and refused a create for want of funds silently, forever -- the operator's content stops being bonded while every surface still looks healthy. Policy, stated in the code because how often it fires is the design: alert on the transition into the short state; while short, again only if the remedy changes or the deficit grows 50%; once on recovery; never on an unreadable balance, which also does not count as recovery. Reclaims are NOT covered: lifecycle.rs builds every reclaim at fee = 0 with no fee coins, unconditionally, so a reclaim cannot fail for want of funds. Refs #463 Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
|
Both fixes are written, committed and pushed. Verification is BLOCKED on host disk ( Branch What is proven and what is not
Nothing here should be read as a green. The tests below are written and unrun. #461 — the skip
A skip therefore cannot consume a selection input slot: the returned set contains authenticated A chain that cannot ANSWER stays fatal. An unreadable source is not a verdict about a coin.
#463 — the alert gate
Blast radius
Wiring still owed, and it is NOT mine to makeThe gate decides; nothing delivers yet. There is no OS-notification mechanism in dig-node today. The updater surfaces its messages over |
Reconciles dig-node#427's input bound (MAX_SELECTED_FUNDING_COINS, from #457) with dig-node#461's skip-instead-of-abort selection. - The bound is applied to the CURRENT selection inside the pool loop, which by construction holds no candidate already proven unauthenticatable. A skipped coin therefore costs no input slot, so an attacker cannot reinstate #461 in a slower form by dusting the address until the bound alone refuses every create. - `FundingError::TooManyInputs` gains the amounts it already had in scope, so `FundingObservation::from_error` can classify it as a `Consolidate` shortfall. Before this it was the exhaustive match's uncovered variant, and `Consolidate` had no producer outside tests. - Main's bound test asserted the pre-#461 abort semantics at the bound. Its at-bound half now asserts that the bound did NOT speak and that authentication WAS reached, which still goes red on the `>=` off-by-one. Version 0.201.0, free above main's 0.198.0 and unused by the sibling lanes. Co-Authored-By: Claude <noreply@anthropic.com>
…ate across runners dig-node#463's gate was a library nobody called. Its nine unit tests each drive ONE gate over many observations, so all nine stay green against a node that builds a fresh gate every pass and notifies 144 times a day. - `PassError::Funding(FundingError)` carries the refusal structurally. It was flattened to a string in `funding_refusal`, so the only surface that can tell an operator what to DO would have had to recover the classification from prose. Display delegates, so every consumer that only renders a PassError is unchanged. - `PassRunner` holds the gate beside the presence tracker, and the scheduler carries both across the runners it rebuilds each round. - `PassReport::funding_alert` returns the message; the runner also logs it at warn, so it reaches stderr on a node that cannot write its state dir (#440). - A create that stopped for a NON-funding reason maps to `Unknown`, so an unrelated failure cannot be announced to an operator as a recovery. Two tests cover the wiring itself: the alert fires once across rebuilt runners and never from a stringly refusal, and an unrelated failure neither alerts nor clears a live shortfall. Co-Authored-By: Claude <noreply@anthropic.com>
Verification record — the lane that wrote this never ran a test; it has now been runHead Merged main (
|
| run | result |
|---|---|
--lib mirror::funding |
15 passed, 0 failed (699 filtered) |
--test mirror_operator_funding |
11 passed, 0 failed, 0 filtered |
--lib mirror:: |
133 passed, 0 failed |
cargo test -p dig-node-service --locked (whole crate) |
716 lib + all integration, 0 failed, RC=0 |
cargo clippy --all-targets -- -D warnings |
RC=0 |
cargo fmt --all -- --check |
RC=0 |
Revert-proofs — on a committed tree, restored after each
| fix reverted | tests that went red | reason |
|---|---|---|
| #461 skip arm disabled | an_unauthenticatable_candidate_is_skipped_…, many_unauthenticatable_candidates_… (9 passed / 2 failed) |
both Err(Unauthenticated { reason: "its creating spend is not on chain" }) — the whole selection refused, i.e. the DoS |
#463 dedup removed + Unknown clears |
consecutive_short_passes_…, a_recovery_then_a_second_shortfall_…, an_unknown_funding_state_…, the_deficit_must_grow_materially_… (11 passed / 4 failed) |
10 alerts where 1 was required; shortfall re-announced after an unreadable pass |
gate carry dropped from with_funding_gate |
a_funding_shortfall_alerts_once_across_rebuilt_runners_…, a_non_funding_create_failure_neither_alerts_… (17 passed / 2 failed) |
second consecutive short pass alerted again |
Notes
- Version 0.201.0 — free above main's 0.198.0 and unused by the five sibling lanes (0.200/0.202–0.205).
Cargo.lockcarries it;--lockedpasses. - The first two full-suite runs failed in
target/withinvalid metadata/required to be available in rlib format/cannot find type Option. Not code — leftover artifacts from the lane's ENOSPC death plus a parallel-build race.rm -rf targetand-j 4cleared it; the toolchain probe and registry sources were verified intact. target/is left in place for the gate round rather than deleted per §1.6, since a cold rebuild here is ~15 minutes. 123 GB free.
loop-security — IN PROGRESS, not the verdictAuditing at head Two findings established from the code so far. Posting now rather than at the end. Probe build running. A. The #461 fix trades a fixed-cost DoS for an UNBOUNDED-work one — no limiter runs before the expensive step
The new loop at
So for
The bound's own claim at Severity depends on the cost of planting N coins, which I am still pricing. Ranking in the verdict. B.
|
loop-security — IN PROGRESS, not the verdict (2/2)Head C. The 32-input bound returns BEFORE authentication — so an attacker chooses which remedy the operator is told to perform (HIGH)The brief asked me to confirm
Authentication does not begin until The comment at
The first clause is true and I verified it. The conclusion does not follow, because the class that matters is candidates not yet proven — and those are exactly what an attacker supplies. A coin costs an input slot for as long as it has not been disproven, and on this path it is never disproven. The exploit. State: an operator whose honest holdings are below the epoch requirement — a node that is short, or a fresh node that has not funded yet. This is precisely the population #463 exists to serve.
The operator may hold zero $DIG and be told they hold enough, and be told the one action that would fix it will not help. That is the money-lie class the contract says still stops a merge. Two things make it worse rather than transient:
What I checked that came out CLEAN, so the fix is not "revert the bound": a funded operator cannot be pushed over the bound. Root cause shared with finding B: (Interim. Probes building; verdict to follow.) |
loop-security: CHANGES-REQUIREDHead audited: Four LIVE findings. Three are proven by executable probes ( The #461 fix is structurally right, and the specific composition I was asked to break holds (see CLEAN, below). What is wrong is that the two figures the fix hands to #463 — 1. HIGH — an attacker chooses the remedy; an operator holding ZERO $DIG is told they hold enough and that adding more will not help
The defence at Exploit. An operator whose honest holdings are under the epoch requirement (short, or a fresh node) — the population #463 exists for. The attacker derives Probe C, executed, verbatim output — the operator holds nothing: with It is also announced once and then silenced: for The Answering the brief directly: 2. HIGH — the ordinary shortfall is classified
|
…se the operator's remedy Both figures the funding selection reports to an operator -- the total they can spend, and whether their money is merely in too many pieces -- were computed over candidates nobody had authenticated, at a puzzle hash anyone can derive. So a stranger who paid 33 small coins into it made an operator holding zero $DIG read 'the operator wallet holds enough $DIG ... adding more will not help', on every pass, forever: no planted coin was ever authenticated, so none was ever removed. Authentication now runs FIRST and the input bound is applied to what survives it. That moves the cost, so the cost is bounded in its own right by a constant, MAX_AUTHENTICATION_ATTEMPTS -- previously one chain round trip per planted coin, per pass, measured linear at 11/51/201 reads for 10/50/200 coins. A truncated walk refuses as CandidatesUnverifiable and states no total, because it does not have one; an understated total sends an operator to buy $DIG they already hold and grew_materially then suppresses the correction. Refs dig-node#469
…ealthy node A pass reaches PassError::Funding only when a create was ATTEMPTED and refused. A wallet holding less than one create's collateral never attempts one: decide hands execute the affordable prefix, and an empty prefix does not iterate. So nothing stopped, and the pass classified itself Healthy -- leaving dig-node#463's Short alert with no producer for the commonest real shortfall there is, and CLEARING a live one with a false 'collateral resumed / your content is being bonded'. Neither clause is true, and it needs no attacker: coins committed to an in-flight bundle are withheld from selection but counted by the balance oracle. The funds split already knew. decide now carries the shortfall it computed -- the leftover $DIG against the cost of the creates it could not make, so the deficit is the money that must actually be ADDED -- and the classification reads it instead of inferring health from an empty create loop. Refs dig-node#469
CHANGES-REQUIRED addressed — all four findings, head
|
| # | revert applied | test | result with the revert |
|---|---|---|---|
| 1 | input bound re-applied to the raw pool | coins_a_stranger_planted_never_become_a_statement_about_the_operators_money |
0 passed; 1 failed |
| 2 | Healthy arm restored |
a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery |
0 passed; 1 failed |
| 3 | authentication budget removed | authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends |
0 passed; 1 failed |
| 4 | address total reported again | the_reported_total_is_what_the_chain_proved_not_what_the_address_holds |
0 passed; 1 failed |
The two integration probes reproduce the audit's own output verbatim under revert:
- probe B —
left: Insufficient { have_dig_base_units: 30000, need: 40000 }against the requiredhave: 20000; - probe C —
left: TooManyInputs { needed: 36, limit: 32, have_dig_base_units: 45780, need: 40000 }against the requiredInsufficient { have: 1000 }.
On the fixtures, since this is where the false greens come from
The unit fixtures can only build candidates that fail authentication — a Cat exists only once a real creating spend has been executed — so on their own they are the all-hostile fixture that cannot see a missed honest coin. Three things address that:
select_within_input_boundwas split out so the bound and the totals are provable over authenticated coins directly, from both sides (exactlyMAX_SELECTED_FUNDING_COINSmust pass; one over must be refused with the count, the limit and the real total). Driving the bound through candidates that fail authentication could only ever prove the defect.- Two integration tests put genuine CAT lineage beside the planted coins and vary only the stranger. The operator holds a real 1.000 DIG in the probe-C fixture, so the assertion distinguishes authenticated coins are counted from everything is refused — the zero-total implementation is red there.
coins_a_stranger_planted…requires the planted and empty addresses to produce the SAME refusal. "Is notTooManyInputs" alone is satisfied by an implementation that refuses everything.- The bound test is asserted at two sizes (
2×then4×the budget) with the counts required to be equal. One large fixture is green against any limit at or above it. - The three-pass runner test keeps a truthful control: the third pass genuinely funds the create and must announce a recovery. Without it the test is equally green against an implementation that never recovers.
The old a_create_needing_more_inputs_than_the_bound_is_refused_before_any_lineage_read was deleted: it asserted the defect — that the bound fires before authentication over a wallet of unauthenticatable dust — and would have kept this PR's behaviour pinned to the finding.
Blast radius checked
.gitnexus was not used: the registered index for this repo is ~301 commits behind and returns the documented false-safe impactedCount: 0, which is worse than no answer on a money seam. Done by grep with match counts, plus direct read.
select_operator_dig_cats_detailed— one production caller (select_operator_dig_cats), which has one production caller (lifecycle.rs:454); rest are tests. Signature unchanged.FundingError— new variant;from_error's match is exhaustive, so every consumer was forced to decide.Displayarm added; two existing arms corrected, both of which had gone stale (Unauthenticatedstill said "the whole selection is refused" after One dust coin at the public CAT address permanently blocks every mirror create #461 made it a skip;TooManyInputsstill said "no coin was authenticated").PassDecision— one production consumer (runner.rs:380). Field added, destructured there.MAX_SELECTED_FUNDING_COINS— no consumer outside this module.- Verified with
--all-targets, not--lib: the signature changes reach the integration tests.
Evidence
cargo test -p dig-node-service --all-targets— 726 passed; 0 failed (lib) plus every integration suite green.cargo test -p dig-node-service --test mirror_operator_funding— 13 passed; 0 failed.cargo clippy --workspace --all-targets -- -D warnings— 0 errors.cargo fmt --allclean; no CRLF; no backslash-continued string literals (concat!throughout).
SPEC.md gains §25.11 (authentication precedes every figure the operator is told, and the read bound) and §25.12 (the shortfall-reporting contract, including that a pass which could afford nothing is short rather than healthy). Both were previously unwritten, so the ordering had no normative home.
Still DRAFT — the re-gate has not returned.
…l contract (§25.11, §25.12) SPEC was silent on both, so neither the authenticate-before-you-report ordering nor the empty-wallet-is-short classification had a normative home. Refs dig-node#469
…or's real $DIG The unit fixtures can only build candidates that FAIL authentication -- a Cat exists only once a real creating spend has been executed -- so they prove what a stranger's coins are worth and cannot prove that the operator's own are still counted. These two put genuine CAT lineage beside the planted coins and vary only the stranger, reproducing the audit's probe B and probe C. Refs dig-node#469
8ead74e to
c33826b
Compare
loop-security re-gate — IN PROGRESS, not the verdictHead being audited: Scope: the delta Structural reads done so far — probes still to run
One judgement call settled, so it is not re-litigated laterThe bounded walk means a stranger who plants 128 coins that out-value the honest ones exhausts the budget, and It is not a regression and I will not gate on it. On Probes from the original audit are being re-run against this head. Verdict to follow. |
loop-security re-gate — IN PROGRESS (2/3), not the verdictHead Forward run — the three original probes now produce the opposite of what they produced at
|
| probe | at fde43d3d (the finding) |
at c33826bb |
|---|---|---|
| A — reads vs pool size | planted=200 chain_reads=201, no bound |
planted=200 reads=128 and planted=400 reads=128 |
| B — reported total | Insufficient { have: 30000 } with reads == 0 |
Insufficient { have_dig_base_units: 20000 } with reads=3 |
| C — the remedy | Consolidate — "Consolidate the wallet DIG into fewer coins - adding more will not help", reads == 0 |
Insufficient { have_dig_base_units: 1000 } → TopUp, reads=41 |
Verbatim from the run:
PROBE A: planted=200 reads=128 outcome=Err(CandidatesUnverifiable { attempted: 128, skipped: 128, need_dig_base_units: 40000 })
PROBE A: planted=400 reads=128 outcome=Err(CandidatesUnverifiable { attempted: 128, skipped: 128, need_dig_base_units: 40000 })
PROBE B: reads=3 outcome=Err(Insufficient { have_dig_base_units: 20000, need_dig_base_units: 40000 })
PROBE C: reads=41 refusal=Insufficient { have_dig_base_units: 1000, need_dig_base_units: 40000 }
PROBE C ALERT BODY: Your node cannot bond content: it needs 40.000 DIG of collateral for this epoch and
the operator wallet holds 1.000 DIG that it can spend, so it is 39.000 DIG short. Add $DIG to the
operator wallet. Until then no new content is collateralised and it earns nothing.
PROBE D: reads=1 cats=1
Three things I checked that the fix lane's own tests do not, because a bound can be satisfied by refusing everything:
- The read bound is asserted at TWO pool sizes with the counts required to be EQUAL (200 and 400 both give 128). One size is green against any implementation whose limit happens to sit at or above it.
- Probe C keeps a truthful control. The operator holds a real, authenticatable 1.000 DIG. The assertion is not merely "not
TooManyInputs" — it requires the alert body to quote that figure, so an implementation that refused everything or quoted zero is red. - Probe D is the no-regression half (mine, not in the diff): a funded operator with 60 planted coins beside their honest one still funds the create, at
reads=1. So the ordering change did not buy honesty by making a healthy wallet pay the budget, and it did not break funding through noise.
CandidatesUnverifiable was also asserted to map to FundingObservation::Unknown in the same probe, so the truncated walk states no total and clears no live shortfall.
Revert-proof is running now: the input bound re-applied to the raw pool, the attempt budget removed, and the Healthy arm restored — each expected to red a different probe. Verdict after that.
Two of the lane's realizations checked — one refuted, one confirmed and worth acting onRefuted: the CI gap does not exist. The lane flagged that That is true of Worth recording why the concern was reasonable: Confirmed and it matters: That is the more useful finding of the two. A guard with no normative statement behind it cannot be checked against anything — a reviewer sees code that looks defensive and has no contract to test it against. It is the same shape as the old bound test encoding the defect and passing the triple gate that shipped #427: with nothing written down, "what this must do" and "what this happens to do" are indistinguishable. Residual, named rather than hiddenA stranger who buries the honest coins under 128 larger unauthenticatable ones stops this node bonding until the wallet is consolidated. Any constant bound has this shape. What is gone is the money lie — the operator is no longer told a falsehood about their own balance, and the remedy shown is no longer attacker-chosen. A provider cache of previously-authenticated coin ids across passes would remove the residual. Worth its own ticket; deliberately not this PR. |
loop-security re-gate (resumed) — IN PROGRESS, not the verdictResuming the re-gate that stopped after the 02:37Z probe run. Head audited: Disclosure: I reused the dead gate's worktree rather than cutting a new one
I reused it because What I found in it, which matters for the record: the previous gate died mid-revert-proof. Its worktree carried three reverts applied simultaneously:
I preserved the patch and its probe file, then restored the two source files to What is already discharged, and what I am finishingDischarged by the 02:37Z forward run (4 passed, count-checked, over the real CAT fixture harness):
Not discharged, and what I am doing now:
Starting with (3), which costs no build. Verdict to follow. |
loop-security re-gate (resumed) — IN PROGRESS (2), not the verdictHead Finding 2 — forward run, counts checkedFinding 2 — revert-proof, executed (the prior session applied this revert but never ran it)Revert applied alone, in the restored tree: Tree restored to Why I believe this test is not vacuous, beyond the revertIt carries a truthful control: pass 3 funds the create for real ( Arm order verified independently
Independently confirmed: the 128-budget residual is NOT a regressionThe fix lane and the interrupted gate both assert this; I verified it against the tree that would Main also confirms both fixed defects are live there today: Probes A–D re-running at this head under my own hand. Verdict after that. |
loop-security re-gate — VERDICT: PASSHead audited: This completes the re-gate that stopped after 02:37Z. All four findings are fixed, and every one now has both a forward run and a revert-proof that I executed myself at this head. The prior session had applied its reverts but died before running any of them. Per-finding status
Finding 1 — fixed, and the reason it is fixed is structural
The remaining question a re-gate owes is whether Finding 2 — fixed, arm order verified independently
The test is non-vacuous beyond its revert: it carries a truthful control — pass 3 genuinely funds the create and must announce a recovery with Findings 3 and 4 — fixed, and the bound is a constant in the property that matters
The 128-budget residual is not a regression — verified against the tree that would be merged intoBoth the fix lane and the interrupted gate assert this; I verified it against Main's denial threshold is 1 planted coin; this PR's is 128. Main also carries both fixed defects live today: What I checked at this head that is CLEAN
Defense-in-depth — named, NOT gated
Disclosure and limitsI reused the previous gate session's own detached worktree I found three reverts still applied in that worktree — the pre-authentication bound, the removed attempt budget, and Not covered: no live-node or on-chain reproduction — everything here is the unit and integration seam plus static reading. I did not audit the VerdictPASS. All four findings from the 00:22Z audit are fixed at My probe file and scratch are removed; the worktree is left clean at |
Lane update — conflicts resolved, and the gate finding is genuinely fixedOutcome 1: still needed and distinct. §2.0 check against
This branch is what closes #461. The branch delta over main is +1829/-355 across The gate finding — authenticate-before-bound — is fixedThe bound no longer sees a stranger's coins. The DoS the bound existed to stop is now bounded separately and correctly by Revert-proof — the tests are load-bearingCommitted first, then mutated a copy: re-inserted the pre-fix ordering (bound the RAW candidate pool before authentication) at the top of the selection walk.
The two that go red:
Test count checked, not just exit status — a filtered-to-nothing run also exits 0. Mutation reverted; tree clean. State
Next action: wait for CI on |
|
Head moved —
Next action: watch CI on |
The stale-comment concern: measured, and it was already fixed on this branchFlagged as a required fix. Measured on the merged head
The paragraph on Swept, not assumedOne hit, and it is correct: The two surviving
Did the comment fix need a test?No, and stating why rather than leaving it implicit. The doc change carries no behavioural delta — it describes an ordering that this PR already changed in code and that is already pinned from both sides by The behaviour the comment now describes is proven load-bearing by the revert-proof already posted above: re-inserting the pre-fix ordering takes the suite from Head after re-merging main (#477)
Still draft, not merged. |
loop-security — IN PROGRESS, not the verdictAuditing at head Confirmed so farThe ordering fix is real. Both operator-facing money figures now derive from authenticated coins only. Under active examination (no verdict yet)
Next: the alert gate, |
loop-security — IN PROGRESS, not the verdict (findings before the revert-proof)Head audited: CONFIRMED CLEANBrief item 1 — the ordering. Structural in practice, conventional in the type. Brief item 4 — largest-first steering. No cheap steer. To influence post-auth ordering an attacker must send real $DIG that passes Stale comment. Genuinely gone from the pushed branch. G1 — GATING, HIGH. A second operator-facing money figure is computed from the UNAUTHENTICATED address total — which SPEC §25.11, added by this same PR, says it MUST NOT be.The PR fixes the figure inside The chain:
That
SPEC §25.11 as added by this PR: "The node MUST authenticate before it computes any figure it reports... The spendable total in a shortfall MUST be the total of AUTHENTICATED candidates. It MUST NOT be the address total." The §25.12 path is the address total. §25.11 and §25.12 contradict each other as written, and the implementation satisfies §25.12 while violating §25.11 — on the path §25.12 itself calls "the commonest real case". Merging ships a normative claim the same commit falsifies. Exploit. Operator holds 500 base units; epoch Taken alone this self-corrects on the pass after the operator tops up, because the create is then attempted and the authenticated G2 — GATING, HIGH.
|
loop-security: CHANGES-REQUIREDHead audited: The fix this PR was asked to make is CORRECT, and I verified it by mutation. The two GATING findings are money-honesty defects of the same class, reachable through paths the fix does not cover. Revert-proof — reproduced independentlyRun in my own detached worktree (
Both failures are the named tests, and the failure text is the defect itself: A Consolidate remedy quoted at 33 base units to an operator holding zero — exactly the #469 defect. The mutation was a 24-line insertion applied to a copy and restored by Confirmed clean
G1 — GATING, HIGH: a second operator-facing money figure is the UNAUTHENTICATED address totalThe PR fixes the figure in
SPEC §25.11, added by this PR: "The node MUST authenticate before it computes any figure it reports... The spendable total in a shortfall MUST be the total of AUTHENTICATED candidates. It MUST NOT be the address total." The §25.12 path is the address total. §25.11 and §25.12 contradict each other, and the code satisfies §25.12 while violating §25.11 — on the path §25.12 itself calls "the commonest real case". Merging ships a normative claim the same commit falsifies. Exploit. Operator holds 500 base units, The author already identified this exact failure mode — G2 — GATING, HIGH: exhausting
|
#469) Three gate findings on #469, all the same class as the fix the PR was opened for: a figure a stranger can choose, reaching an operator as a fact. G1 -- the decision-shortfall path quoted `balance % per_coin`, derived from `dig_balance_base_units`, the raw sum over the publicly derivable `dig_cat_puzzle_hash(owner)`. Neither balance tier authenticates CAT lineage, so planting one 499-mojo coin had the operator told they were 0.001 DIG short when they were 0.500 short -- and the alert gate then suppressed the correction as immaterial. SPEC 25.11, added by this same PR, forbids exactly that. The pass is still classified SHORT (authentication only ever removes candidates, so a reported balance below one create's cost proves the real one is too); what it no longer does is quote an amount it never authenticated. `FundingShortfall::have_dig_base_units` is REMOVED rather than left unused, so no future caller can render it again. G2 -- exhausting MAX_AUTHENTICATION_ATTEMPTS mapped to `Unknown`, on which the gate returns None and even the `tracing::warn!` in `execute`, gated on the alert being Some, never fired. A stranger burying the honest coins under 128 unauthenticatable ones stopped this node bonding on every pass, forever, and the operator was never told. Refusing to quote a total is right; refusing to speak is a different thing. The constant is unchanged. Both now map to `FundingObservation::Unmeasured`, which alerts once on entry, states no total and no deficit, and clears no live shortfall. G3 -- corrected the false claim that one PASS costs at most MAX_AUTHENTICATION_ATTEMPTS reads. The bound is per SELECTION and `create` runs once per bond, so a pass planning K creates costs up to K x the budget. Documented rather than changed; a per-pass shared budget is filed as follow-up. SPEC 25.11/25.12 updated to name the fourth observation and to require that a blocked pass with no authenticated total is reported without an amount. Co-Authored-By: Claude <noreply@anthropic.com>
…de#481 Co-Authored-By: Claude <noreply@anthropic.com>
Response to the CHANGES-REQUIRED verdict — all three gating findings fixedHead: Thank you for the mutation-verified confirmation of the ordering fix — it is untouched, and both its G1 (HIGH) — fixed in the CODE, not in the spec
The SHORT classification is kept, and deliberately: authentication only ever REMOVES candidates, so a
New test Reverting only this fix (restoring The test carries a control: the authenticated G2 (HIGH) — the exhausted cap now says something true; the constant is unchanged
What the operator is told:
Your composition is what the new test drives: pass 1 latches a real authenticated shortfall, pass 2 The alert gate holds the unmeasured latch SEPARATELY from the short latch, because the two are not G3 (MEDIUM) — comment corrected; the shape change is filed, not decided hereYour reading is right and the comment now states it: the bound is per SELECTION, I documented rather than changed it, because a per-PASS shared budget is a behaviour change, not a D1 and D2 — filed, not fixed hereBoth on #481 alongside the per-pass budget, since all three live in Evidence
Counts are quoted, not exit statuses. Each mutation was applied to a committed tree and restored
Not re-run: clippy, the full workspace suite, |
Co-Authored-By: Claude <noreply@anthropic.com>
|
Rustfmt was red on |
loop-security round 2 — IN PROGRESS, not the verdictRe-gating the scoped delta Verified so far by direct readG1 — fixed, and fixed in the direction I asked for. The latch short-circuit is genuinely handled. G2 — the exhausted cap speaks. Correction to the round-2 brief (not a defect)The brief says " Still open
|
loop-security round 2 — IN PROGRESS: my own mutation found a coverage gapHead Baseline reproduced independently: The latch short-circuit is CORRECT but NOTHING TESTS ITThe brief asked me to verify the two latches are independent, and said "that short-circuit is the kind of thing that works until it doesn't." It is right on both counts, and the second half is the finding. I replaced Full library suite under that mutation: The behaviour it breaks: with The reason nothing catches it: Ruling: defense-in-depth, LOW — do NOT gate. The code at this head is correct; what is missing is the test that keeps it correct. Recommend the four-observation sequence above as a Also verified
Running my own G1 revert now, then the verdict. |
loop-security round 2: PASSHead audited: All three gating findings are fixed. Everything below is defense-in-depth and must not gate. Evidence I produced myselfOwn detached worktree, mutations applied to a committed tree and restored by
The G1 revert reproduces my round-1 exploit verbatim in the failure output: G1 — FIXED, and more structurally than claimed
The fixture choice is load-bearing exactly as stated: at balance 0 the defective version renders "holds 0.000 DIG", which is true, so a zero-balance fixture passes under the defect. The 999-against-1,000 fixture is what makes it red. The control asserting the authenticated G2 — FIXED, and the latch independence holds in both directions
Both directions checked:
SPEC §25.11 and §25.12 no longer contradict each other — §25.12 now states outright that a pass with no authenticated total is unmeasured, never short with the address total. That contradiction was the substance of my G1 gate. G3 — deferral to #481 ACCEPTEDMy complaint was the false doc claim, and it is corrected: the doc now states the per-selection bound, the K x 128 per-pass cost, and the plant-127 case where every create still succeeds so nothing breaks. The deferral reasoning is sound and I would have made the same call. A per-pass shared budget is a behaviour change, not a counter change: exhausting it part-way turns later bonds into Ruling on the
|
DRAFT — do not merge. Gate round has not run.
Closes #461
Closes #463
Two defects on the mirror funding path.
#461 — one dust coin permanently blocks every mirror create
select_operator_dig_catsauthenticated each selected candidate with?, so the firstunauthenticatable coin aborted the whole selection. The address is publicly derivable, selection is
largest-first, so one dust coin with a large declared amount sat at the front of the order forever.
Fixed by skipping unauthenticatable candidates and continuing.
#463 — notify the operator when funds block a create
The pass runs unattended every ten minutes and refuses silently. This adds the transition-debounced
decision layer that says WHEN to alert and WHAT the remedy is.
Blast radius, tests and the notification policy are recorded in the PR thread as the work lands.