Skip to content

feat(mirror): construct the bond-state observation (#412 step 7) - #420

Closed
MichaelTaylor3d wants to merge 7 commits into
mainfrom
loop/412-step7-observation-gated
Closed

feat(mirror): construct the bond-state observation (#412 step 7)#420
MichaelTaylor3d wants to merge 7 commits into
mainfrom
loop/412-step7-observation-gated

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

This PR is the GATED half of #419, split onto its own branch. #419 itself is not mergeable.

While the gate round was returning, the step-7 lane resumed on loop/412-step7-observation — it had
stalled on the 600s silence watchdog, not died — and pushed 63c83d5, 457 new lines of
mirror/lifecycle.rs
for the step-7 wiring work, with three more files dirty in its worktree. A
squash-merge of #419 would have carried that ungated code into main under a verdict that never saw
it.

So the branch stays with the live lane and the reviewed content ships from here. This branch is
66f637c8 — exactly the tree both gates audited (c649ce9) plus main's own chore(release): v0.172.0. No content was rewritten and nothing was force-pushed; the lane's commits are untouched
and its later PR will simply see these as ancestors.


Gate round RETURNED at c649ce9. loop-reviewer PASS, on executed mutation probes rather
than a read: removing dig-download still turns the anchor guard red at dependency_tree.rs:68; a
manifest rewritten with 591 genuine CRLF pairs now yields 4 passed and no panic; truncating the
locked-total fold and inserting .or(Some(0)) on the balance each make their own test fail with
exactly the lie it guards. loop-security returned CHANGES-REQUIRED on one gating item, now
cleared
.

The cleared item, recorded because it is the interesting one. The doc on
dig_balance_base_units promised None for "an unsynced replica that cannot answer for this
address". The function does not have that guarantee. balance_for_address returns Err(NotSynced)
only from the Source::Fallback arm with no live fallback; an in-scope-but-behind replica returns
Ok(WalletBalanceResult { synced: false, .. }), so this returns Some of a possibly-stale figure.
Authority for an address and currency with the chain are independent questions, and only the first
can fail the read. It is latent today — zero production callers — but step 8 would have called it on
a funding decision because that sentence said the case was handled, which makes it a born-false
claim on a money path in the commit that writes it.

The remedy is documentation, and that is the whole fix: the naive code version
(if !read.synced { None }) is wrong in the other direction, because the fallback arm hard-codes
synced: false for answers that are perfectly good. The corrected doc names the residue instead of
silencing it.

The tracked .step7-wip scratch marker is dropped in the same commit; a squash would have landed it
on main.

Three non-gating findings — attacker-driven held_mirrors truncation that under-reports LOCKED
money, the unbounded cost step 8 would put behind a paired token, and a second mnemonic-unseal route
— are all preconditions for step 8 and are logged on
#412 (comment) rather than filed as new
tickets.

Step 7 of #412 (MVP epic
https://github.com/DIG-Network/dig_ecosystem/issues/3166).

This PR closes nothing — step 8 stays on #412.

What ships

The observation half of step 7. mirror::observe turns four already-gathered readings — the
capsules on disk, the mirror coins on chain, this node's own open creates, and the $DIG it can
spend — into the BondObservation that control.mirror.bondStates pages, so the surface answers
real rows instead of unknown { reason: "chain_unreadable" }.

observe is a pure function over values. It takes no MirrorEffects, so it holds neither the
create nor the reclaim half of the lifecycle: a read surface reachable from the token-gated
control plane has no unattended spend for a later edit to reach, because no spend capability is
constructed on its path. That is a property of the module, not a rule about it.

States come from pass::decide — the same pure decision a real pass takes — rather than a second
derivation written for the read path. A second derivation is a second answer to "is this bond
covered", and the two drift in the direction nobody tests. decide's plan half is discarded here
precisely because taking it is step 8's act.

locked_dig_base_units is summed over the whole chain observation, including coins being
reclaimed, and read from each coin's own amount rather than from this epoch's requirement. Summing
the plan instead would omit every coin the plan leaves alone — most of them on a healthy node — and
report locked money as free, the one direction a money figure must never be wrong in.

Two supporting reads, both keyless:

  • dig_wallet::operator_puzzle_hash derives this node's public puzzle hash without ever
    constructing a WalletSigner. The distinction is held by the return type, not by a convention.
  • WalletBackend::dig_balance_base_units reads spendable $DIG at that hash. None is UNKNOWN,
    never zero
    — the two render as opposite claims (deferred{balance_unreadable} vs an
    out-of-funds unfunded alarm), which is the dig-app#300 conflation this surface exists to remove.
    The cast is narrowed rather than saturated, because a saturating cast reports a balance above
    u64::MAX as exactly u64::MAX, the largest possible confident wrong number on a funding
    decision.

What does NOT ship, and why — the DHT half is blocked UPSTREAM

The unverified_mirror_coin_id attach with epoch-rollover re-announce is not reachable from this
branch
, and cannot be made reachable by a version edit here. Measured against index.crates.io:

crate latest published requires
dig-download 0.21.0 dig-dht ^0.13
dig-peer-selector 0.10.0 dig-dht ^0.13
dig-dht 0.15.0

^0.13 and 0.15 are semver-incompatible on a 0.x line, so declaring dig-dht = "0.15" resolves
two dig-dht lines while cargo prints success, and dig-dht values cross from dig-node-core into
both consumers. The lock confirms the current resolution is dig-dht 0.13.0.
ProviderRecord::unverified_mirror_coin_id lives in dig-dht 0.15 (dig-dht#23), and a repo-wide grep
for that identifier returns zero hits in this tree. Bridging the two lines with a shim is the
byte-drift class this repo does not do.

Resume condition: dig-download AND dig-peer-selector both release against dig-dht ^0.15.

Blast radius

impact was unavailable in this lane, so the radius was established from the resolved lock
(Cargo.lock, cargo tree), the crates.io index, and direct reads — which §2.0 permits as the
fallback, and which is stated here rather than implied.

  • split_by_provenance — visibility widened fnpub(super). Widening adds callers and
    removes none; the one new caller is observe. Its body is unchanged.
  • manifest_section (test-only helper, dig-node-core/tests/dependency_tree.rs) — four callers,
    all inside that file, all four tests green.
  • operator_puzzle_hash, dig_balance_base_units, observe, held_mirrors — new symbols, no
    existing callers.
  • No public API of any published crate changes, so there is no consumer to migrate.

Two red required checks, both fixed

Check version increment. main took 0.172.0 while this branch was open. Bumped to 0.173.0
— minor, because step 7 adds a capability. There is no package.json in this repo, so Cargo.toml
is the only file carrying a version.

Test + coveragethe_fail_open_anchor_verifier_is_not_reachable_from_a_production_build.
This one was a diagnosis, and the answer is that dig-download never left the production tree.

The guard panicked on expect("dig-download is a production dependency of this crate") — an
Option that came back None, so the lookup failed; it never observed a violation. Cause,
measured at byte level:

crates/dig-node-core/Cargo.toml @ 60bc127  -> UTF-8 text            (LF)
crates/dig-node-core/Cargo.toml @ 2614d07c -> UTF-8 text, with CRLF

2614d07c saved that manifest with CRLF line endings. Not one declaration changed — 1147 of its
1175 changed lines were line-ending churn
, the real dependency delta being 28 lines. But
dependency_tree.rs reads the manifest through include_str! and searches it for a section header
bracketed by newlines, which CRLF bytes never match. The lookup's unwrap_or(0) then turned that
failed search into a silent fallback to offset 0, so the guard searched [package], found no
dig-download, and reported it absent. dig-download = "0.21" is on line 448 of [dependencies],
exactly where it has always been.

Fixed at the cause. The expect was not deleted, loosened, or given a fallback, and the
assertion it makes is unchanged:

  • the manifest is normalised to LF before any search, so these assertions stay about DECLARATIONS
    rather than about how a file happened to be saved;
  • the lookup now panics naming the section it could not read. A lookup that FAILED is not
    evidence about any dependency, and offset 0 makes every "X is a production dependency" assertion
    report absence with total confidence — which is precisely how this cost a diagnosis round.

Dependency freshness (§2.4b) — checked, nothing to bump

Every dig-* / chia-* declaration in the three touched crates is already at its latest published
version, verified against index.crates.io with a User-Agent header: dig-download 0.21.0,
dig-peer-selector 0.10.0, dig-nat 0.21.0, dig-peer 0.13.0, dig-rpc-protocol 0.10.2,
dig-mirror-coin 0.7.0, dig-node-control-interface 0.27.0, dig-mirror-collateral 0.3.0,
chia-query 0.19.0, and the whole chia-* set on 0.36.x. dig-gossip is a git dep and is not on
crates.io
, so it was compared against its main rather than the registry: pinned rev 1a339166
equals the main tip. dig-dht is the one held-back declaration, for the reason above.

How verified

  • cargo test -p dig-node-core --test dependency_tree — 4/4 green, including the anchor guard, and
    green for the right reason rather than by silencing.
  • cargo test -p dig-node-service --lib mirror::observe — 5/5 green.
  • cargo fmt --all --check — clean.

One salvage note for the reviewer

The lane that wrote observe.rs died before it ever compiled, so its test fixtures were written
against remembered shapes. Three compile errors, all in the test module and none in observe
itself, were fixed against the published types. Both BondState::Bonded assertions were rewritten
to matches! rather than weakened, and one now pins the payload — coin id, epoch, and the
amount that coin locks — because a bare variant check cannot see a row naming the other coin or
reporting this epoch's requirement instead of the coin's own. That assertion needed a fixture change
to be able to fail at all: every fixture coin previously shared the id "cc" * 32, so a claim about
"the coin bonding aa/11" held equally against the coin bonding bb/22.

MichaelTaylor3d and others added 7 commits August 30, 2026 04:18
Adds `mirror::observe` — a pure function over values that turns the capsules
on disk, the mirror coins on chain, this node's open creates, and its spendable
$DIG into the `BondObservation` the §25.8 surface pages.

It takes no `MirrorEffects`, so it holds no `create` and no `reclaim`: the
read surface cannot reach a spend because no spend capability is constructed
on its path. States come from `pass::decide`, the same pure decision a real
pass takes, rather than a second derivation that would drift.

Supporting reads, both keyless:

- `dig_wallet::operator_puzzle_hash` derives this node's public puzzle hash
  without ever producing a `WalletSigner`.
- `WalletBackend::dig_balance_base_units` reads spendable $DIG at that hash.
  `None` is UNKNOWN, never zero — the two render as opposite claims.

Constructing or scheduling a pass, and wiring a `MirrorSigner`, are step 8.

Refs #412
…n lookup fail loudly

`build(deps)` 2614d07 saved crates/dig-node-core/Cargo.toml with CRLF line
endings. Not one declaration changed — 1147 of its 1175 changed lines were
line-ending churn — but `dependency_tree.rs` reads that manifest through
`include_str!` and searches it for "\n[dependencies]\n", which CRLF bytes
never match.

The lookup's `unwrap_or(0)` then turned a failed search into a silent
fallback to offset 0, so the guard searched `[package]` instead, found no
`dig-download` there, and panicked claiming it was absent from the production
tree. `dig-download = "0.21"` had never moved: it is on line 448 of the
`[dependencies]` section, exactly where it was before.

Two changes, both at the cause:

- the manifest is normalised to LF before any search, so these assertions stay
  about DECLARATIONS rather than about how a file happened to be saved;
- the lookup panics naming the section it could not read. A lookup that FAILED
  is not evidence about any dependency, and offset 0 makes every "X is a
  production dependency" assertion report absence with total confidence.

The assertion itself is unchanged and no expect was loosened.

Also bumps the workspace to 0.173.0: main took 0.172.0 while this branch was
open, and step 7 adds a capability.

Refs #412
…real types

The lane that wrote `mirror::observe` died before it ever built, so its test
fixtures were written against remembered shapes rather than the published ones.
Three errors, all in the test module and none in `observe` itself:

- `CollateralRequirementResult::Known` has no `census_height`. Replaced with
  the real fields — `protocol_version`, `multiplier_micros` and
  `handicap_dig_base_units` — matching `pass::tests::known_at`, so the two
  fixtures describe the same requirement.
- `BondState::Bonded` is a struct variant carrying the coin and the amount, not
  a unit value, so it cannot be compared with `==`.

Both `Bonded` assertions were rewritten to match rather than weakened. One now
pins the PAYLOAD — the coin id, the epoch, and the 600 that coin locks — because
a bare variant check cannot see a row that names the other coin or that reports
this epoch's requirement instead of the coin's own amount, and both are the
plausible wrong answers here.

That assertion needed a fixture change to be able to fail: every fixture coin
shared the id `"cc" * 32`, so a claim about "the coin bonding aa/11" held
equally against the coin bonding bb/22. Coin ids are now distinct per
`(store, root)`.

Also applies rustfmt to `operator_puzzle_hash`.

Refs #412
The doc promised `None` for "an unsynced replica that cannot answer for
this address". `balance_for_address` returns `Err(NotSynced)` only from
the `Source::Fallback` arm with no live fallback; the ordinary
in-scope-but-behind case returns `Ok(WalletBalanceResult { synced:
false, .. })`, so this returns `Some` of a possibly-stale figure.

Authority for an address and currency with the chain are independent
questions and only the first can fail the read, so staleness was never
covered by `None`. Step 8 will call this on a funding decision because
this sentence said the case was handled; correcting the claim now costs
nothing and removes a born-false guarantee from a money path.

Notes the residue rather than silencing it: discriminating on `synced`
alone would report UNKNOWN for every fallback-served answer, since that
arm hard-codes it false.

Also drops the tracked `.step7-wip` scratch marker, which a squash would
otherwise land on main.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Closing as not planned — fully subsumed, and merging it would revert step 7.

Two lanes built #412 step 7 independently. The other one merged as
#4195df3e34fd294b2c0002eb203b51de5e8aff4fe9c.

Measured against merged main, not read:

  • git diff origin/main 66f637c -- crates/dig-node-service/src/mirror/observe.rs is empty — the
    343-line observation is byte-identical in both. Same for Cargo.lock (116), Cargo.toml (54),
    dependency_tree.rs (26) and sage/rpc.rs (36).
  • The two-dot diff origin/main → 66f637c is 47 insertions against 1,434 deletions. Every
    deletion is content this branch lacks: lifecycle.rs (811 lines), server.rs (199),
    control.rs (277), SPEC.md (63), signer.rs (10).
  • The 47 "insertions" are the pre-step-7 stub: the unknown { chain_unreadable } answer, the
    mirror_bond_observation() that returns Err unconditionally, and the SPEC paragraph saying
    "no pass is CONSTRUCTED and none is SCHEDULED". All of that is exactly what feat(mirror): construct the bond-state observation (#412 step 7) #419 replaced.

So this PR is a strict subset plus a revert. There is nothing here to salvage.

One thing worth recording rather than just closing. This PR was not a draft, and per §2.4a a
ready PR is a merge invitation to any sibling lane. A non-draft PR whose merge would silently undo a
shipped feature is the same hazard that published three crates carrying known defects on
chia-block-listener #18 — the window is only as long as the PR stays unmergeable to everyone else.
The duplication was an orchestrator dispatch error, not this lane's.

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.

1 participant