Skip to content

feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL - #573

Merged
MichaelTaylor3d merged 13 commits into
developfrom
feat/570-daily-mirror-reconcile
Sep 7, 2026
Merged

feat(mirror): detect an IP change daily and reconcile mirror coins to the current advertise URL#573
MichaelTaylor3d merged 13 commits into
developfrom
feat/570-daily-mirror-reconcile

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Task

Daily reflexive-address refresh and mirror-coin URL reconcile (SPEC.md §25.13, dig-node#570).

Retargeted to develop per the standing flow — no version bump on this PR; the batch develop -> main
PR carries it. Rebased onto origin/develop at 194a0163, resolving textual conflicts against
dig-node#575 (mirror-bond coin id persistence) additively — both features add new, independent fields
to SpendIntent/SpendRecord and both compile together.

Orchestrator epic: https://github.com/DIG-Network/dig_ecosystem/issues/3203
Siblings: DIG-Network/dig-node-control-interface#52 (manual
control.mirror.reconcile, publishing as 0.34.0 — NOT in this PR), DIG-Network/dig-app#391
(manual "Reset mirrors" button — depends on the sibling above, NOT in this PR)

What already existed vs. what this PR builds

The ordinary mirror-coin lifecycle (plan/pass/runner/lifecycle/observe/spends/collateral/advertise,
~17 files) was already shipped and working (dig-node#377 family). What was missing, and what this PR
builds, is specifically the URL-reconcile primitive: detecting that this node's advertised address has
changed and bringing existing mirror coins back in line with it.

Scope: the AUTOMATIC half only (SPEC.md §25.13's D1-D4 in the locked spec at
.claude/loop/specs/3203-mirror-reconcile/).
The manual control.mirror.reconcile method (D5) is
deferred to a follow-up once dig-node-control-interface 0.34.0 is on the index — it needs a
synchronous submitted response shape this PR's daily trigger has no caller for.

The daily personal-day offset, and what a restart does

offset_secs = u64::from_be_bytes(SHA-256("dig-node/mirror-url-reconcile/personal-day/v1" || peer_id)[0..8]) mod 86_400

Derived from the node's own 32-byte peer id, never drawn, never persisted — there is no state to
lose. A restart recomputes the identical offset from the identical identity, so a node that restarts
daily still checks once per personal day, inside whichever day it restarted in — it cannot skip a day
and cannot re-roll into never checking (the failure mode a CSPRNG-drawn-and-persisted offset has, and
which an earlier iteration of this lane was built against before the spec locked). Golden-vector tested
(schedule::tests::golden_vector_sequential_peer_id, computed independently via a python3 -c one-liner
in the PR description, not from the Rust implementation itself).

Persisted state (mirror-reconcile.json, reconcile_state.rs) holds only last_completed_day, the two
most recent CONCLUSIVE observations, the last inconclusive check, and the last epoch an automatic
reconcile ran — never a coin id. Losing this file costs one day's delay, never a spend (see the module's
own doc for why that direction is the only safe one).

Which way the change detector fails

Toward missing a refresh, never toward a spurious one. Concretely:

  • A fresh gather that does not CONCLUSIVELY establish an address (dig_stun::establish needs agreement
    across independent source classes, NC-12) leaves the published reading untouched and is recorded as
    inconclusive — the node keeps advertising what it had, for up to the rest of the epoch.
  • Hysteresis requires the target to match on two conclusive observations on distinct personal days
    before anything is even considered for reconcile — the first day an address changes is never a spend
    day.
  • The epoch cap permits at most one automatic reconcile per mirror epoch — a daily-rotating address
    is therefore never auto-reconciled; it waits for the free weekly rollover instead.
  • reconcile::decide (the pure gate function) refuses on 7 of the spec's 9 named gates explicitly
    (advertise-not-publishing with the real §25.10 label, disabled, no-mirror-coins, url-unchanged,
    requirement-unknown, funds-unmeasured, insufficient-funds, reconcile-in-progress); gate 3 (chain
    observation complete) is structural — PassRunner::run only reaches the reconcile decision after its
    own chain read already succeeded; gate 6 (signer/broadcast capability) is left to the same
    effects-level handling the ordinary create/reclaim paths already use rather than a second capability
    check, since a failed attempt there costs a log line, never a spend.

The one property that outranks the rest, load-bearing and tested from both sides of its bound: the
plan is sized to the affordable prefix K before any reclaim, via plan::split_by_funds (the SAME
function the ordinary create path prices with) called with the balance augmented by exactly the stale
set's own reclaimable collateral. Exactly K coins are reclaimed; n - K are left untouched, still
bonded. gate8_insufficient_funds_bound_from_below_refuses / _bound_from_above_at_exactly_the_requirement_proceeds
pin the bound from both sides.

Scoped simplification, stated rather than hidden: gate 7/8's balance figure is the SAME unauthenticated
dig_balance_base_units the ordinary pass already uses for its own funds split (not a separately
authenticated §25.11 read). The actual money-moving spends (reclaim/create) still authenticate their
OWN candidate coins before signing, exactly as the ordinary path does — so an inflated balance figure at
sizing time can make a K estimate too generous, but cannot itself cause an unauthenticated spend; the
worst case is a wider unbonded window (§25.13.6's already-accepted residual), never a money loss.

Where a persisted coin id is re-verified against chain

It is not — by design. mirror-reconcile.json never stores a coin id at all (see the module doc in
reconcile_state.rs): the only persisted facts are day indices and URL-set snapshots. The coin ids a
reconcile actually reclaims are read FRESH from MirrorEffects::observe_bonded_urls every time (which
reuses the SAME authenticated chain scan observe_chain already performs), never from a prior day's
record — so there is no persisted-candidate-vs-chain-truth question for this feature the way dig-node#574
raised for mirror-bond ids.

Design notes

  • reconcile.rs's pure decide() returns a Directive{coin_ids, left_unaffordable, trigger} or a
    RefusalReason — it does NOT execute anything. The directive is threaded into
    PassInputs.reconcile, and the ORDINARY pass::decide/PassRunner::execute append the named coins
    as ReclaimReason::UrlStale(trigger) entries, run after NoLongerHeld/EpochEnded and before any
    create — reusing the existing reclaim-then-create ordering rather than inventing a parallel one.
  • The recreate is NEVER this pass's: PassInputs.on_chain was snapshotted before the UrlStale reclaim
    executes, so the ordinary create table still sees the coin as present this round and plans nothing for
    it. The bond is recreated automatically, by the SAME unmodified ordinary-create path, on the first
    later pass whose chain observation no longer shows the reclaimed coin — no new "recreate" code exists.
  • No new timer task: the daily due-check rides the existing 10-minute round loop in server.rs.
  • dig-node-core gains one narrow method, Node::replace_reflexive_readings, and amends the
    PeerStatus.reflexive doc (peer.rs:239's "never cleared" is now "replaced only by a conclusive
    re-gather") — dig-node-core gains no new dependency; the conclusive/inconclusive decision is made in
    dig-node-service, which already runs that verification every round.

dig-node-control-interface 0.35.0 IS NOW PUBLISHED -- D5's blocker is gone

Checked while finishing this PR: dig-node-control-interface published 0.35.0 to crates.io today
(2026-09-06), and dig-node-control-interface#52 (control.mirror.reconcile) is closed via its own
PR #53. D5 (the manual trigger) is deliberately still NOT built here -- STEPS.md's own plan allows
it as "a second PR in the same lane" rather than requiring it in this one, and wiring a live RPC
handler to WAIT for a specific round-loop pass's result needs a request/response coordination path
that does not exist yet between control.rs and spawn_mirror_passes -- a real, separate piece of
design and testing this PR does not rush under time pressure. Flagging this prominently rather than
leaving it to be rediscovered: the next lane in this family can start immediately, there is no
longer a crates.io wait.

Verify

cargo build -p dig-node-service --lib --jobs 4 -- clean, twice (once pre-rebase against main, once
post-rebase merged with dig-node#575 on develop).

cargo check -p dig-node-service --tests --jobs 4 -- clean (this is what caught every fixture bug
below; a plain build never compiles #[cfg(test)] code at all).

cargo test -p dig-node-service --lib --jobs 4 -- 894 passed; 0 failed. (First real run was
2 failed -- both MY OWN test-fixture bugs, not implementation bugs; see below.)

cargo fmt --all -- --check -- clean. cargo clippy -p dig-node-service --all-targets --all-features --jobs 4 -- -D warnings -- clean, zero findings.

Two of my own fixtures were wrong on the first real run, and both are worth stating rather than
burying
: a stale coin locked at exactly the CURRENT price always funds its own recreate on reclaim
alone (SPEC.md §25.13.5's own "common case Rᵢ = C"), so neither InsufficientFunds nor K < n is
reachable with every coin priced at PER_COIN -- I had written both fixtures that way. Fixed by
locking the stale coin(s) below the current price (the mid-epoch-margin-raise case the spec names),
the only way either property is actually exercised; verified by hand in the test comments before
rerunning.

Revert-proof, done AFTER everything above was green and committed (never on uncommitted work):
temporarily replaced the K-sizing (let k = split.affordable.len();) with let k = stale.len(); --
naming every stale coin regardless of affordability, exactly the bug this property exists to prevent.
Ran cargo test -p dig-node-service --lib mirror::reconcile:: -- exactly one test failed,
names_only_the_affordable_prefix_when_funds_are_short, for exactly the right reason (left: ["c1", "c2"], right: ["c1"] -- both coins named instead of the affordable prefix). Restored via
git checkout -- reconcile.rs (safe: the correct version was already committed). Working tree
confirmed byte-identical to HEAD afterward (git diff empty).

Known pre-existing issue, NOT introduced by this PR, NOT fixed here: Cargo.lock still carries
three dig-constants lines (0.10.1/0.11.2/0.13.1) -- flagged in the locked spec itself as "a §2.4b
finding for the orchestrator, not for this spec." Left alone to keep this diff's blast radius to what
it actually changed.

This diff spends -- it reclaims real mirror-coin collateral on a schedule nobody presses a button for.
Blast radius checked: ReclaimReason, PassInputs, PassContext, MirrorEffects, SpendIntent/
SpendRecord, build_reclaim, CollateralConfig -- every call site found by grep across BOTH src/
and tests/ (the first sweep missed tests/, which cargo check --tests caught) and updated, listed
across the commits on this branch. Expect the full gate.

Refs #570

Builds the automatic half of SPEC.md #25.13: a per-node personal-day
offset derived from the peer id (never drawn or persisted, so a
restart cannot skip a day), the pure stale-set + K-sizing decision
that reclaims exactly the affordable prefix before any recreate is
owed, hysteresis over two agreeing conclusive observations, and the
once-per-epoch automatic cap. Wired into the existing round loop and
audit record (reclaim_reason/trigger) with no new timer task.

Refs #570
@MichaelTaylor3d
MichaelTaylor3d changed the base branch from main to develop September 6, 2026 09:26
)

Marks the automatic-half properties SPEC.md now describes truthfully:
25.9 gains the two failure-direction bullets (fail-closed reconcile,
the bounded unbonded window), 25.10's final paragraph names 25.13 as
the one reclaim path, and 23.1 documents the audit record's new
reclaim_reason/trigger attribution.

Refs #570
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/570-daily-mirror-reconcile branch from 4affad8 to 55f8166 Compare September 6, 2026 09:29
Full normative section for the automatic detector this PR ships,
with the manual trigger (25.13.8) explicitly marked
specified-not-implemented until dig-node-control-interface 0.34.0
lands. Includes the scoped gate-7 balance reading, stated rather
than left implicit.

Refs #570
The salvaged scaffolding (DeclaredBond, observe_bonded_urls) still
pointed its doc-comments at reconcile_to_current_url, an earlier
design's function name superseded before the spec locked. The real
function is reconcile::decide.

Refs #570
… code

cargo test/check --tests compiles a surface a plain `cargo build`
never touches: pass.rs's own PassInputs literal and two integration
tests under tests/ (spend_audit_e2e, mirror_funding_reservation_expiry)
each construct SpendIntent directly and were missed by the earlier
src/-only grep sweep. Also fixes a hallucinated
CollateralRequirementResult::Known field (margin_bp_applied does not
exist; the seven real fields are epoch/protocol_version/
required_per_store_dig_base_units/stores/owners/multiplier_micros/
handicap_dig_base_units) and restores the apply_safety_margin path
pass.rs's own test needs after the function moved to plan.rs.

Refs #570
… site

tests/mirror_l1_genesis.rs (an L1-signature genesis test, outside the
earlier src/-only sweep) still called the pre-#570 4-arg
build_reclaim. Also renames a test fn to genuine snake_case --
non_snake_case is a warning cargo check tolerates but clippy -D
warnings does not.

Refs #570
…ee build_reclaim calls

The last of the pre-#570 4-arg call sites, in the signer fee-ceiling
integration test.

Refs #570
Both failed on first real run. A stale coin locked at exactly the
CURRENT price always funds its own recreate on reclaim alone --
SPEC.md 25.13.5's own "common case Rs = C" -- so neither
"insufficient funds" nor "K < n" can be expressed with every coin at
PER_COIN, whatever the rest of the wallet holds. Both fixtures now
lock the stale coin(s) below the current price (the mid-epoch
margin-raise case the spec names), which is the only way either
property is reachable. Verified by hand before rerunning: the
augmented-balance arithmetic for both bounds is written out in the
test comments.

Refs #570
Both files were edited (fixing compile errors) after the last fmt
pass and never re-verified before pushing -- caught by CI's Rustfmt
check, not by a stale local run.

Refs #570

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED @ d8f91b6

Reviewed against dig-node.SPEC.delta.md §25.13 (dig_ecosystem#3203) and STEPS.md rows D1–D4 (this PR is the automatic half only; D5 manual/control.mirror.reconcile is correctly out of scope — confirmed absent from control.rs).

Checked, and PASSING

  • Golden vector for the personal-day offset (schedule.rs:58, tests at golden_vector_sequential_peer_id / golden_vector_all_ff_peer_id_differs_from_sequential) — independently recomputed both vectors in Python against the exact tag/derivation in the spec; both match (78322, 43202).
  • Nine gates in reconcile::decide (reconcile.rs:180-245) — order matches §25.13.4 exactly; gates 3 and 6 correctly left structural rather than re-checked (documented why); 17 unit tests cover every gate individually plus the K-of-n sizing, the affordable-prefix bound from both sides, reorder-is-no-op, and future-epoch-never-stale.
  • Hysteresis and epoch cap (schedule.rs:132-156) — A,B,A correctly not stable, A,inconclusive,A correctly stable, epoch cap keyed on the epoch a reclaim actually landed (not merely attempted) — confirmed in server.rs:3094 (reconcile_url_stale_accepted > 0 gates mark_auto_reconciled).
  • Sizing before any reclaim (reconcile.rs:213-233) — reuses plan::split_by_funds with an augmented balance rather than restating the arithmetic; K=0 correctly refuses (gate 8) rather than producing an empty directive.
  • Execution order (pass.rs:265-291) — ordinary NoLongerHeld/EpochEnded reclaims from plan(), then UrlStale reclaims appended from the directive, before any create; recreate correctly deferred to a later pass (on_chain snapshot still shows the coin as present in this pass).
  • Audit fields (spend_audit.rs:397-402,440-446, spends.rs:161-166) — reclaim_reason/trigger both #[serde(default)], derived by MirrorSpends::intent, never caller-supplied.
  • mirror-reconcile.json (reconcile_state.rs) — no coin id persisted; every field #[serde(default)]; atomic write (save_to:125-132, tmp + rename); malformed/missing file falls back to "never observed", the safe direction.
  • Gate 9 / in-flight window (runner.rs:806-822) — reuses SpendRecord::reserves_funding_at (the same FUNDING_RESERVATION_WINDOW_MS funding reservations use) rather than a second window definition.
  • CI green (13 checks + 1 expected skip); test counts exceed the STEPS.md bar (17 in reconcile.rs, 20 in schedule.rs, 7 in reconcile_state.rs, all ≥ the "count the tests" bars named).
  • The dig-constants 3-line Cargo.lock finding (0.10.1/0.11.2/0.13.1) is explicitly scoped to the orchestrator by the spec delta's own closing section, not to this PR — not raised as a finding here.

MUST-FIX

1. pass.rs:280-291 / runner.rs:560 (execute) — the reclaim-order integration this PR's own acceptance bar names is untested.

STEPS.md D2 requires: "Recording-double test shows the order [NoLongerHeld…, EpochEnded…, UrlStale…, creates…]; a spend-audit.jsonl line from a test run shows "reclaim_reason":"url_stale","trigger":"daily"."

I searched the full diff and the existing test suite for any call that constructs PassInputs { reconcile: Some(_), .. } or PassContext { reconcile: Some(_), .. } — there is none. reconcile.rs's 17 tests and schedule.rs's 20 tests exercise reconcile::decide and the schedule helpers in complete isolation; nothing exercises pass::decide or PassRunner::run/execute with a real ReconcileDirective flowing through the SAME call that also has ordinary NoLongerHeld/EpochEnded reclaims and creates pending, and nothing asserts the resulting spend-audit.jsonl line carries reclaim_reason/trigger.

Test-vacuity check: revert only the append order in pass.rs:280 (e.g. push UrlStale reclaims BEFORE the ordinary ones, or interleave them into create instead of reclaim) — every existing test still passes, because none of them constructs a PassInputs with both a non-empty ordinary reclaim set AND a reconcile directive in the same call. This is money-moving code (§1.10 full-triple-gate tier) with its own named acceptance test missing.

What the fix must NOT do: adding a unit test only inside reconcile.rs or schedule.rs does not close this — the gap is specifically at the integration point (pass::decide + PassRunner::execute) where an ordinary reclaim/create decision and a reconcile directive coexist in one pass. A FakeEffects/recording double already exists elsewhere in this crate's test idiom (converge_tests.rs) — reuse that shape, not a new one.

SHOULD-FIX (ticket, not blocking)

2. schedule.rs:56-58 — doc claim contradicted by the actual call site.

The docstring on personal_day_offset_secs states: "A node with no peer_id yet... gets offset zero." The function itself has no such special case — it hashes whatever bytes it's given. The production call site (server.rs:2892-2896) passes peer_id_bytes from .unwrap_or_default(), i.e. an EMPTY byte slice when no peer id exists, not a hardcoded zero. I independently computed personal_day_offset_secs(b"") = 62789, not 0.

This is not a money-safety issue — per the spec's own reasoning, a node with no peer id "gathers no readings and cannot be part of a STUN herd, so the spreading buys nothing there" either way — but the comment asserts a behavior the code does not implement, and nothing tests the claim. Either special-case empty input to return 0 (matching the doc and SPEC.md §25.13.7.1's literal text), or correct the doc to describe what the code actually does.


Verdict: CHANGES-REQUIRED @ d8f91b6 — one MUST-FIX (missing integration test for the money-moving reclaim-order interaction STEPS.md D2 itself names as the acceptance bar), one SHOULD-FIX (doc/code mismatch, no money impact). Everything else checked — the nine gates, the golden vector, hysteresis, the epoch cap, the audit trail, and the persisted-state fallback direction — is correct and well-tested in isolation.

Comment thread crates/dig-node-service/src/mirror/pass.rs
Comment thread crates/dig-node-service/src/mirror/schedule.rs
MichaelTaylor3d added a commit that referenced this pull request Sep 7, 2026
…claims and before creates (dig-node#570)

Add PassInputs fixtures that carry a live ReconcileDirective alongside real
NoLongerHeld/EpochEnded reclaims -- nothing previously did, so moving or
dropping the UrlStale append changed nothing green (reviewer finding on
PR #573). Verified load-bearing by hand: changing the append from
reclaim.push(..) to reclaim.insert(0, ..) fails the new order assertion;
restored, green.

Also covers a directive naming a coin absent from on_chain (no panic,
nothing reclaimed) and reconcile: None (today's plan untouched).

docs(mirror): personal-day offset doc no longer claims an offset-zero special case (dig-node#570)

The code hashes the empty peer_id slice like any other input (offset
62789, not zero); a real zero special-case would make every
identity-less node fire at the same second, the herd the offset exists
to break. Doc now says the offset is still deterministic but meaningless
for such a node, which never acts on it since it gathers no readings
(SPEC.md §25.13.7.1).
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CHANGES-REQUIRED @ c559ce9

loop-security — adversarial audit of the mirror-URL reconcile auto-spend path (dig-node#570, SPEC.md §25.13). Scope: crates/dig-node-service/src/mirror/{reconcile,reconcile_state,schedule,pass,runner,plan,spends}.rs, crates/dig-node-service/src/{server,collateral,control,spend_audit}.rs, crates/dig-node-core/src/{lib,peer}.rs, SPEC.md §25.13. Lens: custody/reclaim sizing, persisted-state trust boundary, STUN/peer poisoning, fee/affordability lies, reorg, spend-audit integrity.

MUST-FIX

1. Gate 8's K-sizing formula is unsound under non-uniform stale-coin collateral — it can size a reclaim the wallet cannot actually afford to recreate, violating the PR's own stated invariant

crates/dig-node-service/src/mirror/reconcile.rs:241-256:

let reclaimable_total: u64 = stale
    .iter()
    .map(|d| d.held.collateral_dig_base_units)
    .fold(0u64, u64::saturating_add);
let augmented_balance = balance.saturating_add(reclaimable_total);
...
let split = plan::split_by_funds(&stale_bonds, augmented_balance, per_coin);

reclaimable_total sums the collateral of every coin in the stale set (all n), not just the coins that end up in the affordable prefix K. split_by_funds then computes K = floor(augmented_balance / per_coin) and takes the first K bonds in canonical (store_id, root) order — an order that has no relationship to each coin's own locked collateral. Only the collateral of the K coins actually reclaimed is ever returned to the wallet; the collateral of the n-K coins left untouched is not. When collateral differs across the stale set (the PR's own doc calls out exactly this: "the mid-epoch-margin-raise case", SPEC.md §25.13.5), the formula can report a K that is not actually fundable from what gets reclaimed.

Concrete exploit / trigger (no attacker needed — this fires under ordinary operation, exactly the case the PR's own comments name):

  • Stale set in canonical order: s1 (locked collateral R1 = 100), s2 (R2 = 100), s3 (R3 = 1000) — e.g. s1/s2 locked before a mid-epoch margin raise, s3 locked after it at the new price.
  • Current per-coin price C = 1000. Wallet balance W = 0.
  • reclaimable_total = 100 + 100 + 1000 = 1200. augmented_balance = 0 + 1200 = 1200.
  • split_by_funds reports affordable_count = floor(1200 / 1000) = 1, capped to n = 3K = 1.
  • The directive names only s1 as affordable (left_unaffordable = 2).
  • But reclaiming s1 alone returns only R1 = 100. Actual funds after the reclaim: 0 + 100 = 100, far short of the 1000 needed to recreate s1's own bond.
  • Result: the node reclaims s1 — a live, correctly-formed Refuse-gate-cleared, signed-and-broadcast spend — believing (per the gate 8 check) that the recreate is affordable, when it is not. The bond is gone; the later ordinary create pass (which uses the real, current wallet balance) finds it cannot fund the recreate and defers it, leaving the node bonded for fewer capsules than before the reconcile ran, having already paid a reclaim fee to get there.

This is precisely the outcome the module's own doc comment (reconcile.rs:12-19) calls "the invariant that outranks everything else here" and says must never happen: "a reclaim this module could not price a recreate for would leave the node holding fewer bonds than before it started, having paid to get there."

SPEC.md §25.13.5 (lines 9998-10011) documents this exact shortcut ("augments the balance by the FULL stale set's reclaimable total, not a per-k recomputation") and only claims it is "equivalent" "in the common case Rᵢ = C for every i" — it does not establish (and it is not true) that the shortcut remains a safe upper bound when collateral is non-uniform. The spec itself carries the defect the code faithfully implements.

Fix: size K as a running/incremental prefix check, not a flat total — e.g.

let mut budget = balance;
let mut k = 0;
for d in &stale {
    budget = budget.saturating_add(d.held.collateral_dig_base_units);
    if budget < per_coin { break; }
    budget -= per_coin;
    k += 1;
}

This reduces to the existing formula exactly when Rᵢ = C for every i (the common case the tests already cover), and correctly refuses/truncates when collateral is non-uniform. All of gate 8's existing fixtures pass fine on this greedy formulation too (they use uniform OLD_COLLATERAL for every stale coin, which is why the existing test suite does not catch this).

Why this is a MUST-FIX, not defense-in-depth: this is a live path to a signed, broadcast, money-moving spend (a reclaim) that the module's own gate is supposed to refuse, executing anyway, under ordinary operating conditions the PR itself names as expected (a mid-epoch margin change) — no malicious input, no poisoned peer/STUN reading required.

SHOULD-FIX (ticket, not blocking)

  • crates/dig-node-service/src/mirror/runner.rs reconcile_in_flight: an unreadable spend-audit ledger makes gate 9 assume "not in progress" (fail-open on this one gate only), same fallback direction as the pre-existing in_flight_creates. Consistent with existing code, but worth a ticket to note the epoch-cap-loss-on-crash (server.rs warning at reconcile_state.mark_auto_reconciled) and this gate 9 fallback compound: a ledger read failure plus a lost epoch-cap persist could, in principle, permit more than one automatic reconcile attempt per epoch. Bounded in practice by hysteresis and the funds check, so not a MUST-FIX.

Clear

  • Persisted mirror-reconcile.json trust boundary (reconcile_state.rs): holds no coin id, only day indices/URL sets/epoch markers; missing/malformed file falls back to Default ("never observed"), the safe direction (delays, never causes, a spend); atomic write-then-rename; every field #[serde(default)] so an older file still parses. No live issue.
  • Personal-day derivation (schedule.rs): pure function of peer_id (public, not secret), golden-vectors pinned against two independent inputs, correct floor-toward-negative-infinity day index, correct backward-clock and multi-day-forward-jump handling. A third party can time (not spoof) a node's check; timing alone cannot manufacture agreement across independent STUN source classes (unmodified advertise.rs/dig_stun::establish, NC-12), so this cannot itself cause a spurious drift observation.
  • Hysteresis + epoch cap (schedule.rs, server.rs): two-distinct-day agreement required before an automatic reconcile is even attempted; URL_RECONCILE_MAX_AUTO_PER_EPOCH = 1 enforced via last_auto_reconcile_epoch, only marked spent once at least one UrlStale reclaim actually lands (report.reconcile_url_stale_accepted > 0), not merely attempted.
  • Refusal-first shape: every one of gates 1/2/4a/4b/5/7/9 (and 8, modulo the sizing defect above) is Refuse(reason) with zero effects — decide is pure, takes no clock/chain/wallet/file access, confirmed by the exhaustive gate-order test table in reconcile.rs.
  • PassRunner::execute order: UrlStale reclaims run strictly after NoLongerHeld/EpochEnded and before any create, proven by pass.rs::a_url_stale_directive_reclaims_after_ordinary_reclaims_and_before_creates — answers the reviewer's dig-node#573 order concern, independently verified by reading the code, not merely trusting the green test.
  • Spend-audit line integrity (spend_audit.rs, spends.rs): reclaim_reason/trigger are derived from the same ReclaimReason the runner already decided to act on (build_reclaim's reason parameter, never invented at the audit layer), #[serde(default)] so old audit lines still decode. No forgeable field.
  • control.mirror.reconcile (manual trigger, D5): confirmed NOT wired in this diff — control.rs's only change is unrelated test-fixture fields. Correctly deferred per the brief; not gated here.
  • Reorg: this module reads only disk-held bonds + a fresh chain observation each pass (no persisted coin ids to go stale against a reorg); a reorg that un-confirms a UrlStale reclaim degrades exactly like an ordinary reclaim/create already does (unmodified machinery).

Scope audited

Files: SPEC.md (§25.13 block), crates/dig-node-core/src/{lib.rs,peer.rs}, crates/dig-node-service/src/{collateral.rs,control.rs,server.rs,spend_audit.rs,spend_audit_cli.rs}, crates/dig-node-service/src/mirror/{funding,lifecycle,local_bond,mod,observe,pass,plan,reconcile,reconcile_state,runner,schedule,spends,converge_tests,resolve_tests}.rs, crates/dig-node-service/tests/{mirror_fee_ceiling,mirror_funding_reservation_expiry,mirror_l1_genesis,spend_audit_e2e}.rs. Head audited: c559ce91 (verified via gh pr view --json headRefOid before starting).

Not covered: dig_stun::establish / advertise.rs's cross-source-class agreement algorithm itself (unmodified by this PR, out of scope for this gate); dig_mirror_coin::reclaim/create puzzle-level spend construction (unmodified); CI/build green state (loop-reviewer's lane).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

SHOULD-FIX from the security gate ticketed as https://github.com/DIG-Network/dig_ecosystem/issues/3222

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Fixed the gate 8 / §25.13.5 K-sizing MUST-FIX from the security review (#573 (comment)).

Commit 175304e1 on this branch.

The defect (reconcile.rs decide, gate 8): K was sized from plan::split_by_funds against a FLAT total — balance + Σ the whole stale set's collateral. Reclaims are separate, sequential spends, so a flat total can call a coin affordable that the actual spend sequence never funds once collateral differs across the stale set: stale [100, 100, 1000], balance 0, per_coin 1000 → flat total 1_200 looked like one affordable coin, but reclaiming the first coin alone only returns 100.

The fix: K is now the length of the longest prefix of stale (canonical order, unchanged) that is self-funding at every step — a running balance seeded at the pass's own funds reading, adding each coin's own collateral in turn, crediting a recreate only once the running balance reaches per_coin. Greedy, fail-closed: the walk stops at the first unaffordable coin; a later, richer coin never reorders in to rescue an earlier one. plan::split_by_funds stays out of this path (it prices an ordinary batch of independent creates, not a sequential self-funding prefix); pricing is still plan::per_coin_dig_base_units, unchanged.

Amended SPEC.md §25.13.5 to state the prefix walk instead of the flat total it previously described. Uniform-collateral behaviour (the common case, Rᵢ = C) is unchanged — the running balance never dips between coins, so K = n exactly as before.

Tests (red-first, crates/dig-node-service/src/mirror/reconcile.rs):

  • gate8_k_is_a_self_funding_prefix_not_a_flat_total
  • gate8_a_big_first_coin_funds_the_smaller_ones_behind_it
  • gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one

Verified red against the flat-total code first (2 of the 3 failed as expected, pasted below), then green after the fix. All existing gate8/directive tests pass unchanged.

---- gate8_k_is_a_self_funding_prefix_not_a_flat_total ----
  left:  Ok(ReconcileDirective { coin_ids: ["c1…"], left_unaffordable: 2, trigger: Daily })
  right: Err(InsufficientFunds { have_dig_base_units: 100, need_dig_base_units: 1000 })

---- gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one ----
  left:  Ok(ReconcileDirective { coin_ids: ["c1…", "c2…"], left_unaffordable: 0, trigger: Daily })
  right: Err(InsufficientFunds { have_dig_base_units: 100, need_dig_base_units: 500 })

cargo fmt --all -- --check clean, cargo clippy -p dig-node-service --all-targets -- -D warnings clean, cargo test -p dig-node-service --lib mirror: 258 passed (was 255), 0 failed.

Diff scope: SPEC.md (§25.13.5 sentence) + crates/dig-node-service/src/mirror/reconcile.rs only. Still draft.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

PASS @ 175304e

loop-security re-check of the MUST-FIX from #573 (comment).

Verified

Diff c559ce91..175304e1 touches exactly two files: crates/dig-node-service/src/mirror/reconcile.rs and SPEC.md §25.13.5 — no incidental changes elsewhere.

Gate 8's sizing (reconcile.rs) now walks stale (canonical order, unchanged) with a running balance seeded at W, adding each coin's own collateral and crediting K only when the running balance reaches per_coin before subtracting it, breaking at the first unfundable coin:

let mut running_balance = balance;
let mut k = 0usize;
for d in &stale {
    running_balance = running_balance.saturating_add(d.held.collateral_dig_base_units);
    if running_balance >= per_coin {
        running_balance -= per_coin;
        k += 1;
    } else {
        break;
    }
}
  • My exploit case closes. [100, 100, 1000], balance = 0, per_coin = 1000: 0+100=100 < 1000 on the first coin → loop breaks immediately, k = 0RefusalReason::InsufficientFunds. The old flat-total code (reclaimable_total = 1200, augmented_balance = 1200, split_by_fundsaffordable_count = 1) would have proceeded with K = 1, reclaiming a coin whose own collateral (100) could never fund its own recreate. This exact scenario is now reconcile.rs::gate8_k_is_a_self_funding_prefix_not_a_flat_total, and I confirm by hand it fails under the pre-fix arithmetic (traced above) and passes under the new walk.
  • No reordering. The loop iterates &stale in the same canonical (store_id, root) order computed earlier in the function (unchanged) — a later, richer coin cannot rescue an earlier unfundable one. Confirmed both by reading the loop and by the new test gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one ([100, 1000] at per_coin=500 refuses at the first coin despite s2 alone being affordable).
  • Arithmetic is saturating on every add (saturating_add); the one non-saturating subtraction (running_balance -= per_coin) is guarded by the >= per_coin check immediately above it, so no underflow path exists.
  • left_unaffordable = stale.len() - k is unchanged (still computed the same way, now against the walk's k).
  • k == 0 refusal's reported have_dig_base_units is recomputed as balance.saturating_add(stale[0].held.collateral_dig_base_units) — consistent with what running_balance held at the break, so the refusal message doesn't drift from the walk that produced it.
  • New tests, three, and I confirm each is a real regression test: the flat-total exploit case, a big-first-coin-funds-smaller-ones-behind-it case (K=2 of 3, greedy credit/debit verified by hand), and the no-reorder case above. All three exercise Rᵢ ≠ C distributions the old uniform-collateral fixtures never touched.
  • SPEC.md §25.13.5 rewritten to describe the running-balance prefix walk in place of the old flat-total claim; matches the code.

No new money path opened: pricing (per_coin) is still the same plan::per_coin_dig_base_units lookup; only the sizing loop changed, and it is strictly more conservative than the code it replaces (it can only report K ≤ what the old flat-total formula would have reported, never more).

SHOULD-FIX from the prior round is ticketed at DIG-Network/dig_ecosystem#3222 — not re-litigated here.

Scope re-checked: reconcile.rs, SPEC.md §25.13.5 diff only, per git diff c559ce91 175304e1 --stat.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED @ 175304e

Re-check after c559ce91 (test + doc fix, answers my two prior threads) and 175304e1 (gate-8 K-sizing rewrite, the security gate's MUST-FIX). Both threads verified and resolved.

c559ce91 — verified, both my threads closed

  • Order test (pass.rs, a_url_stale_directive_reclaims_after_ordinary_reclaims_and_before_creates): constructs a real PassInputs with an actual NoLongerHeld reclaim (a gone-current coin) and an actual EpochEnded reclaim (a gone-past-epoch coin) alongside a live ReconcileDirective, and asserts d.reclaim == [NoLongerHeld, EpochEnded, UrlStale] in that exact order, plus d.create still covers the stale bond (proving no early recreate in the same pass). This is exactly the integration point that was previously untested — load-bearing, confirmed.
  • Doc fix (schedule.rs:56-59): now correctly states the empty-peer-id path hashes the empty slice like any other input, matching the actual code. No more false claim.

175304e1 — the security gate's MUST-FIX, checked

Old sizing: augmented_balance = W + Σ(all stale collateral), then plan::split_by_funds over that flat total. Defect: reclaims are separate sequential spends — a coin's own reclaim only returns its own collateral, so a flat total can call a coin "affordable" that the actual spend sequence never funds when collateral varies across the stale set (mid-epoch margin change).

New sizing: a running-balance prefix walk, seeded at W, adding each coin's own collateral in canonical order and crediting k+=1 only once the running balance reaches per_coin; stops at the first coin it can't yet afford. Verified by hand:

  • [100,100,1000] @ balance 0, per_coin 1000 → old: flat total 1200 ≥ 1000, calls 1 coin affordable (wrong — reclaiming the 100-unit coin alone can't fund a 1000-unit recreate). New: 0+100=100 < 1000 → refuses at k=0. Test gate8_k_is_a_self_funding_prefix_not_a_flat_total encodes exactly this and would fail under the old code — real regression test, not vacuous.
  • [1000,100,100] @ balance 0, per_coin 500 → walk: 1000≥500 (k=1, running 500), 500+100=600≥500 (k=2, running 100), 100+100=200<500 (stop). Confirms k=2, left=1 — matches test.
  • [100,1000] @ balance 0, per_coin 500 → stops at first coin (100<500) even though coin 2 alone would fund a recreate — proves no reordering/knapsack, canonical order is fixed. Matches test.

All three arithmetic traces reproduced by hand match the test assertions. SPEC.md §25.13.5 amended consistently with the new code. Existing uniform-collateral tests (Rᵢ = C) are unaffected since the running balance never dips between coins in that case — K = n exactly as before, confirmed no regression to the common path.

This is the correct, more conservative (fail-closed) model: recreates happen in a later pass per-bond, and the reconcile-time sizing should not assume collateral from coins later in canonical order is available to fund earlier ones. Sound.

Blocking on CI, not on content

gh pr checks 573 at 175304e1: Lint commit messages FAILS175304e1's commit header is 101 chars, over commitlint's 100-char max. Mechanical, one-line fix (shorten the subject or move detail to the body). Test + coverage and several build jobs were still pending/queued at review time.

Verdict: CHANGES-REQUIRED @ 175304e — not for money-safety or test-vacuity (both prior findings are closed and the new gate-8 fix is correct and well-tested), but the commitlint check is currently failing and must go green, and Test + coverage had not yet reported, before this can merge.

…before creates (dig-node#570)

Add PassInputs fixtures that carry a live ReconcileDirective alongside real
NoLongerHeld/EpochEnded reclaims -- nothing previously did, so moving or
dropping the UrlStale append changed nothing green (reviewer finding on
PR #573). Verified load-bearing by hand: changing the append from
reclaim.push(..) to reclaim.insert(0, ..) fails the new order assertion;
restored, green.

Also covers a directive naming a coin absent from on_chain (no panic,
nothing reclaimed) and reconcile: None (today's plan untouched).

docs(mirror): personal-day offset doc no longer claims an offset-zero special case (dig-node#570)

The code hashes the empty peer_id slice like any other input (offset
62789, not zero); a real zero special-case would make every
identity-less node fire at the same second, the herd the offset exists
to break. Doc now says the offset is still deterministic but meaningless
for such a node, which never acts on it since it gathers no readings
(SPEC.md §25.13.7.1).
…flat total (dig-node#570)

Gate 8 / SPEC.md §25.13.5 sized the affordable prefix K from
plan::split_by_funds against a flat total (balance + the WHOLE stale
set's reclaimable collateral). Reclaims are separate, sequential
spends, so that flat total can call a coin affordable that the actual
spend sequence never funds when collateral differs across the stale
set: stale [100, 100, 1000] at balance 0, per_coin 1000 has a flat
total of 1_200 (one coin "affordable"), but reclaiming the first coin
alone only returns 100 -- nowhere near enough to recreate it.

Replace the flat-total split with a running-balance prefix walk over
the stale set in its existing canonical order: seed the running
balance at the pass's own funds reading, add each coin's own
collateral in turn, and credit a recreate (k += 1) only once the
running balance reaches per_coin. The walk stops at the first coin it
cannot yet afford -- a later, richer coin never reorders in to rescue
an earlier one, so "the affordable prefix" keeps naming the same coins
on every run.

plan::split_by_funds stays out of this path: it prices an ordinary
batch of independent creates and cannot express a sequential,
self-funding prefix. Pricing is unchanged (still
plan::per_coin_dig_base_units); only the sizing arithmetic moves.

Amends SPEC.md §25.13.5 to state the prefix walk instead of the flat
total it previously described. Uniform-collateral behaviour (the
common case, Rᵢ = C) is unchanged -- the running balance never dips
between coins, so K = n exactly as before.

Adds three regression tests to crates/dig-node-service/src/mirror/reconcile.rs:
- gate8_k_is_a_self_funding_prefix_not_a_flat_total
- gate8_a_big_first_coin_funds_the_smaller_ones_behind_it
- gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one

All existing gate8/directive tests (uniform-collateral fixtures) pass
unchanged. crates/dig-node-service --lib mirror: 258 passed (was 255).
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/570-daily-mirror-reconcile branch from 175304e to b4c0986 Compare September 7, 2026 04:51
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Commit headers reworded for commitlint (header-max-length): c559ce91 -> test(mirror): prove UrlStale reclaims order after ordinary reclaims, before creates (dig-node#570), 175304e1 -> fix(mirror): size K as a self-funding prefix of the stale set, not a flat total (dig-node#570); tree byte-identical (git diff 175304e1 b4c09866 empty). Gate verdicts posted at 175304e1 therefore hold for b4c09866.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASS @ b4c0986

Re-gate of the prior CHANGES-REQUIRED @ 175304e1 review, which blocked only on CI (a 101-char commit header, Test + coverage not yet reported).

1. Tree identity (corrected): the 04:52Z author claim of a byte-identical tree does NOT hold — gh api compare/175304e1...b4c09866 shows files: 4 (SPEC.md, mirror/pass.rs, mirror/reconcile.rs, mirror/schedule.rs), not files: 0. Per the brief's own fallback, I reviewed the changed content as a normal re-review rather than treating this as a pure CI re-confirmation:

  • mirror/reconcile.rs: Gate 8's sizing changed from plan::split_by_funds over a flat-total balance (W + Σ Rᵢ) to a sequential running-balance walk that treats each reclaim as a separate, sequential spend funding only the recreate behind it. This is a real correctness fix — the flat-total split could call a coin affordable that the actual reclaim-then-recreate spend sequence never funds when collateral differs across the stale set (the exact case the new test gate8_k_is_a_self_funding_prefix_not_a_flat_total exercises: [100,100,1000] @ balance 0, per_coin 1000 — flat total 1200 would pass the old split, the new walk correctly refuses at the first coin).
  • mirror/pass.rs: three new tests proving UrlStale reclaim ordering (after ordinary reclaims, before creates) and no-regression when reconcile: None.
  • SPEC.md §25.13.5: reworded to match the new sequential-walk sizing, replacing the retired flat-total description.
  • schedule.rs: doc comment only (empty-peer_id hashing behavior), no logic change.
  • Test-vacuity check: reverting reconcile.rs's walk back to the flat-total split makes gate8_k_is_a_self_funding_prefix_not_a_flat_total and gate8_a_later_big_coin_never_rescues_an_earlier_unfundable_one fail (flat total of 1200/1100 clears per_coin, so the old split would call coins affordable the new test asserts are refused) — the tests are not vacuous.
  • This is in scope: PR #573 is the dig-node#570 daily-mirror-reconcile feature; Gate 8 sizing is core to that reconcile decision path, not scope creep.

2. Checks: gh pr checks 573 at b4c09866 — 13 required checks green (Analyze ×3, Clippy, CodeQL, Lint commit messages, Release-script tests, Rustfmt, Test + coverage, build .deb ×2, build .msi, build .pkg), 1 Attach packages to the release skipped (expected, non-release). check-runs API confirms Lint commit messages and Test + coverage both completed/success (the two checks the prior review named as pending/red).

3. Commit headers: all 13 commit headlines ≤ 100 chars (max 71, e.g. fix(mirror): thread ReclaimReason through the last build_reclaim call… truncated display at 71 in the API's own field, well under the 101-char failure the prior review caught); all Conventional-Commits typed (chore/feat/docs/style/fix/test).

4. Thread state: GraphQL reviewThreads — 0 unresolved (all resolved or none open).

Verdict: PASS. The tree was not actually byte-identical to 175304e1, but the delta is a genuine, well-tested correctness fix to Gate 8's reclaim sizing plus a matching SPEC update and doc comment — not scope creep, not a regression, and covered by non-vacuous proof tests. Combined with all-green required checks and zero open threads, this clears the gate.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

PASS @ b4c0986

loop-decider — adversarial third leg (money), dig_ecosystem#3212 / dig-node#570. Head read: b4c09866 (tree identical to 175304e1, git diff empty). Lenses: gate order and zero-effect refusals, personal-day derivation, hysteresis bypass, K prefix sizing, persisted-state trust boundary, the recreate leg after a UrlStale reclaim, gate 9 in-flight, epoch-cap persistence, audit attribution. No MUST-FIX: nothing here can move $DIG outside the locked §25.13 shape, and every deviation found fails toward NOT spending.

Verified against the code (not the PR body):

  • Offset derived, never persisted: schedule.rs:76-85; golden vectors 78322 / 43202 recomputed independently (Python, this review) — match schedule.rs:172-184. personal_day_index uses div_euclid (:92-94), so day −1 is −1, not a wrap.
  • Refusal = zero effects: reconcile::decide is pure (reconcile.rs:186-280); the runner turns Err into one log line and None (runner.rs:853-861); the planner only turns an existing directive into reclaim rows (pass.rs:282-293). A coin named but absent from on_chain reclaims nothing (pass.rs:284), and reclaim refuses a coin not in the authenticated scan (lifecycle.rs:~505).
  • K is a self-funding prefix walk sized BEFORE any reclaim, canonical (store_id, root) order (reconcile.rs:144-169, :252-268); K = 0 is a refusal. Stricter than the spec's flat-total split — the safe direction.
  • mirror-reconcile.json holds no coin id (reconcile_state.rs:780-799); malformed/missing → default() → hysteresis restarts → spend delayed, never caused.
  • Hysteresis cannot be bypassed intra-day: is_stable needs two DISTINCT-day observations equal to THIS pass's target (schedule.rs:146-151); the target changes only via a CONCLUSIVE daily re-gather (server.rs:2933-2943) or bring-up, and operator_urls is read once at bring-up, so a config edit cannot make a new target stable the same day.
  • Epoch cap keyed on ACCEPTED UrlStale reclaims (runner.rs:588-596, server.rs:3094-3104) — a fully refused/rejected attempt does not consume the epoch.
  • Audit: reclaim_reason/trigger derived in MirrorSpends::intent from the runner's own ReclaimReason (spends.rs:159-167), #[serde(default)] on both SpendIntent and SpendRecord (spend_audit.rs:397-402, :440-445).

The scenario I tried hardest to break and could not — the recreate leg resurrecting the reclaimed coin. After a UrlStale reclaim confirms, the next pass sees the bond uncovered and local_bond::recheck_missing_bonds (local_bond.rs:47-58) finds the CURRENT-epoch Confirmed CREATE record naming the OLD coin id (local_bond.rs:74). If that path promoted it, the bond would read bonded forever with the collateral sitting in the wallet and no recreate — a reclaim-without-recreate the whole design forbids. It does not: recheck_bondbond_verify::chain_bond_verdictrecord.is_spent()Unbonded (bond_verify.rs:303-306) → recover_one returns None (local_bond.rs:96) → the ordinary table plans a create at THIS pass's advertised (server.rs:2967, recomputed every round and handed to NodeMirrorEffects::new at :3047), i.e. reconcile-to-CURRENT as §25.13.6 requires.


SHOULD-FIX (ticket — one issue, phase-1 §2.6; cheapest to land in this PR before undraft, ~10 lines)

1. The automatic attempt fires every ROUND, not once per personal day — doubling every healthy node's mirror chain scan forever and logging at info every 10 minutes. server.rs:2977-2991 builds reconcile_attempt = Some(..) whenever url_reconcile_enabled && is_stable(observations, advertised.urls) && epoch_cap_allows(..). From day 2 onward every healthy node's observations are [A, A] and no reconcile has ever run, so ALL three are true on EVERY 10-minute pass. Consequences: (a) runner.rs:839 calls observe_bonded_urls(), and lifecycle.rs:411-415 implements it by calling observe_chain() AGAIN — a second full dig_mirror_coin::list scan per round on every node (the comment "keeps this ONE chain read" is false, and runner.rs:832-836's "bounded to at most once per personal day" is false — the security gate's cost reading rested on that sentence); (b) runner.rs:854 logs the url_unchanged refusal at info every round — §25.13.7.3 says a same-address check logs "nothing above debug"; (c) the plan reads scan 1 and the directive scan 2, and scan 2 REPLACES self.resolved (lifecycle.rs:401-406) that reclaim later reads — different chain states within one pass. Money direction of all three: fail-safe (a coin in scan 1 only fails reclaim with a PassError and retries; a reclaim once ACCEPTED spends the cap). Fix: build the attempt only in the tick where is_due fired (carry a checked_this_tick: bool out of the is_due block at server.rs:2900), and have observe_bonded_urls read self.resolved without re-listing (or hand the runner's on_chain in). Then the runner/lifecycle comments become true and §25.13.7.3's silence holds.

2. Gate 3's label is lost. runner.rs:839 observe_bonded_urls().unwrap_or_default() turns an unreadable chain into an EMPTY set, so decide refuses as no_mirror_coins (reconcile.rs:206-208) where §25.13.4 row 3 says chain_unreadable. Zero effect either way (the pass's own observe_chain at runner.rs:436 already aborted on a failed scan), but the logged reason is wrong for the one case a second scan fails independently. Propagate the Err as its own refusal label.

3. No-peer-id offset differs from spec. server.rs:2893-2896 hashes the EMPTY slice when own_peer_id() is None; §25.13.7.1 says offset_secs = 0 and §C prints null. Harmless (such a node gathers no readings) — align code or spec text, one line either way.

Ticket body (paragraph): dig-node PR #573 landed the §25.13 daily URL reconcile with the automatic attempt evaluated on every 10-minute pass once the state is stable (server.rs reconcile_attempt), instead of once per personal day; this doubles the mirror chain scan on every healthy node (observe_bonded_urls re-runs observe_chain), logs url_unchanged at info every round against §25.13.7.3, and reads two chain states within one pass. Fix: build the ReconcileAttempt only in the tick where is_due fired, make observe_bonded_urls reuse the pass's scan, propagate a failed bonded-urls read as chain_unreadable rather than no_mirror_coins, and align the no-peer-id offset (0/null) with §25.13.7.1. Money direction of the current code is fail-safe; this is cost, log honesty and spec fidelity.

Not failed for the absence of D5 (control.mirror.reconcile) or §C's posture object — deferred by the locked STEPS.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 7, 2026 07:43
@MichaelTaylor3d
MichaelTaylor3d merged commit 93fb452 into develop Sep 7, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/570-daily-mirror-reconcile branch September 7, 2026 07:44
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

SHOULD-FIX findings of the adversarial gate tracked in #587

MichaelTaylor3d added a commit that referenced this pull request Sep 7, 2026
dig-stun 0.1 -> 0.2, chia-query 0.24.1 -> 0.24.3, dig-nat 0.21.1 -> 0.21.2,
dig-logging 0.2.1 -> 0.2.2 (fixes the --version writability probe,
dig_ecosystem#2110).

NOT bumped, reverted after breaking compilation and reported to the parent:
dig-dht 0.15 -> 0.16 (dig-download 0.22.1, pinned at latest, still requires
dig-dht ^0.15 -- no shim); dig-node-control-interface 0.33 -> 0.35 (new
required fields on MirrorBondState/MirrorBondStatesResult hit
dig-node-service/src/mirror/states.rs, owned by #573, and control_cli.rs,
outside this lane's file set).
MichaelTaylor3d added a commit that referenced this pull request Sep 7, 2026
dig-stun 0.1 -> 0.2, chia-query 0.24.1 -> 0.24.3, dig-nat 0.21.1 -> 0.21.2,
dig-logging 0.2.1 -> 0.2.2 (fixes the --version writability probe,
dig_ecosystem#2110).

NOT bumped, reverted after breaking compilation and reported to the parent:
dig-dht 0.15 -> 0.16 (dig-download 0.22.1, pinned at latest, still requires
dig-dht ^0.15 -- no shim); dig-node-control-interface 0.33 -> 0.35 (new
required fields on MirrorBondState/MirrorBondStatesResult hit
dig-node-service/src/mirror/states.rs, owned by #573, and control_cli.rs,
outside this lane's file set).
MichaelTaylor3d added a commit that referenced this pull request Sep 7, 2026
Single semver bump for the develop -> main batch: #3212 serve-path children (PR #586) + dig-node#570 daily mirror reconcile (PR #573).
MichaelTaylor3d added a commit that referenced this pull request Sep 7, 2026
…concile (#570)

Batch `develop -> main` for the second round of the develop-integration model: the #3212 serve-path lane (PR #586, 9 commits) and the daily mirror-URL reconcile (PR #573, squash `93fb4528`). Carries the single semver bump (0.254.89 -> 0.255.0) and every closing keyword, because a lane PR into `develop` closes nothing -- a keyword fires only on a merge into the default branch.

## What is in it

| commit | ticket | what |
|---|---|---|
| `93fb4528` | dig-node#570 | daily mirror-URL reconcile, automatic half D1-D4 of dig_ecosystem#3203 §25.13: derived personal-day offset, two-observation hysteresis, nine ordered gates, K sized as a **self-funding prefix** of the stale set before any reclaim (a flat total called a coin affordable that its own reclaim never funded), `submitted` never `completed`. Three PASS legs at `b4c09866`: reviewer, security, adversarial decider. SHOULD-FIX findings tracked in dig-node#587. |
| `24e2b71d` + `6d8d7fce` | dig_ecosystem#2147 | store_id/root case normalised once at `CapsuleKey::parse`; the cache delete targets the on-disk entry the held-check matched (a lower-cased path deleted nothing on a case-sensitive FS -- only the ubuntu runner saw it). |
| `abda7fbc` | dig_ecosystem#2045 | tier-0 occupancy reads the eviction-aware land ledger, not a monotonic counter. |
| `0a534314` | dig_ecosystem#3029 (F3 + F5) | profile-sync outbound budget charged in bytes (behaviour-preserving figure); `request_body` asks the announcer first. F4 (SPEC §22 disclosure paragraph) is deferred text on the ticket -- #3029 stays open for it. |
| `3ba209ff` | dig_ecosystem#2093 | melt confirmation depth: `Melted` only when the terminal spend is `MELT_CONFIRMATION_DEPTH` (32) below the peak; unavailable peak/height fails closed to `Unknown`, deletes nothing. The sibling #2090 (verify the announce signature on ingest) stays open -- decision on the ticket. |
| `1d01ba61` | dig_ecosystem#2097 | `ErrorCode::EngineWarming` (`-32002`, retryable) while the peer tier is still attaching and there is no upstream; `-32004` is a genuine miss only after the tier was consulted. |
| `899cc68f` | dig_ecosystem#2148 | window completeness derives from the bytes actually read (one stat), never `next_offset == offset` with `complete=false`. |
| `1ff0eff8` | dig_ecosystem#3212 (§2.4b) | dig-stun 0.2, chia-query 0.24.3, dig-nat 0.21.2, dig-logging 0.2.2 (the `--version` writability probe, #2110). dig-node-control-interface 0.35 and dig-dht 0.16 deferred: dig_ecosystem#3223. |
| `dfca301e` | -- | `chore(release): v0.255.0` |

## Gates

PR #586 at `899cc68f`: loop-reviewer PASS (review 5130425808), loop-security PASS (comment 5568662836). PR #573 at `b4c09866`: reviewer PASS (review 5129272285), security PASS @ 175304e (tree byte-identical), adversarial decider PASS (comment 5567083644).

## Not in this batch (still open under #3212)

#2090 verify-on-ingest half (needs a peer-key resolver via peer.rs -- next dig-node PR), #3029 F4 SPEC text, #1513 (blocked on dig-download `RangeResult::Failed`), #1903 (digs, its own lane), #382/#383/#2008/#1992/#2211 per the 18:55Z sweep; dig-pex 0.2.0 `PeerEntry.payment` adoption (#3133) is a separate PR.

Closes DIG-Network/dig_ecosystem#2147
Closes DIG-Network/dig_ecosystem#2045
Closes DIG-Network/dig_ecosystem#2093
Closes DIG-Network/dig_ecosystem#2097
Closes DIG-Network/dig_ecosystem#2148
Closes #570
Refs DIG-Network/dig_ecosystem#3212
Refs DIG-Network/dig_ecosystem#3203
Refs DIG-Network/dig_ecosystem#3029
Refs DIG-Network/dig_ecosystem#2090
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Detect an IP change DAILY and reconcile the mirror coins — per-node offset, and refuse rather than half-run

1 participant