feat(mirror): construct the bond-state observation (#412 step 7) - #420
feat(mirror): construct the bond-state observation (#412 step 7)#420MichaelTaylor3d wants to merge 7 commits into
Conversation
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>
|
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 Measured against merged
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 |
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 hadstalled on the 600s silence watchdog, not died — and pushed
63c83d5, 457 new lines ofmirror/lifecycle.rsfor the step-7 wiring work, with three more files dirty in its worktree. Asquash-merge of #419 would have carried that ungated code into
mainunder a verdict that never sawit.
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) plusmain's ownchore(release): v0.172.0. No content was rewritten and nothing was force-pushed; the lane's commits are untouchedand its later PR will simply see these as ancestors.
Gate round RETURNED at
c649ce9.loop-reviewerPASS, on executed mutation probes ratherthan a read: removing
dig-downloadstill turns the anchor guard red atdependency_tree.rs:68; amanifest 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 withexactly the lie it guards.
loop-securityreturned CHANGES-REQUIRED on one gating item, nowcleared.
The cleared item, recorded because it is the interesting one. The doc on
dig_balance_base_unitspromisedNonefor "an unsynced replica that cannot answer for thisaddress". The function does not have that guarantee.
balance_for_addressreturnsErr(NotSynced)only from the
Source::Fallbackarm with no live fallback; an in-scope-but-behind replica returnsOk(WalletBalanceResult { synced: false, .. }), so this returnsSomeof 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-codessynced: falsefor answers that are perfectly good. The corrected doc names the residue instead ofsilencing it.
The tracked
.step7-wipscratch marker is dropped in the same commit; a squash would have landed iton
main.Three non-gating findings — attacker-driven
held_mirrorstruncation that under-reports LOCKEDmoney, 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::observeturns four already-gathered readings — thecapsules on disk, the mirror coins on chain, this node's own open creates, and the $DIG it can
spend — into the
BondObservationthatcontrol.mirror.bondStatespages, so the surface answersreal rows instead of
unknown { reason: "chain_unreadable" }.observeis a pure function over values. It takes noMirrorEffects, so it holds neither thecreatenor thereclaimhalf of the lifecycle: a read surface reachable from the token-gatedcontrol 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 secondderivation 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 hereprecisely because taking it is step 8's act.
locked_dig_base_unitsis summed over the whole chain observation, including coins beingreclaimed, 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_hashderives this node's public puzzle hash without everconstructing a
WalletSigner. The distinction is held by the return type, not by a convention.WalletBackend::dig_balance_base_unitsreads spendable $DIG at that hash.Noneis UNKNOWN,never zero — the two render as opposite claims (
deferred{balance_unreadable}vs anout-of-funds
unfundedalarm), 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::MAXas exactlyu64::MAX, the largest possible confident wrong number on a fundingdecision.
What does NOT ship, and why — the DHT half is blocked UPSTREAM
The
unverified_mirror_coin_idattach with epoch-rollover re-announce is not reachable from thisbranch, and cannot be made reachable by a version edit here. Measured against
index.crates.io:dig-downloaddig-dht ^0.13dig-peer-selectordig-dht ^0.13dig-dht^0.13and0.15are semver-incompatible on a0.xline, so declaringdig-dht = "0.15"resolvestwo dig-dht lines while cargo prints success, and dig-dht values cross from
dig-node-coreintoboth consumers. The lock confirms the current resolution is
dig-dht 0.13.0.ProviderRecord::unverified_mirror_coin_idlives in dig-dht 0.15 (dig-dht#23), and a repo-wide grepfor 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
impactwas 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 thefallback, and which is stated here rather than implied.
split_by_provenance— visibility widenedfn→pub(super). Widening adds callers andremoves 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, noexisting callers.
Two red required checks, both fixed
Check version increment.maintook 0.172.0 while this branch was open. Bumped to 0.173.0— minor, because step 7 adds a capability. There is no
package.jsonin this repo, soCargo.tomlis the only file carrying a version.
Test + coverage—the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build.This one was a diagnosis, and the answer is that
dig-downloadnever left the production tree.The guard panicked on
expect("dig-download is a production dependency of this crate")— anOptionthat came backNone, so the lookup failed; it never observed a violation. Cause,measured at byte level:
2614d07csaved that manifest with CRLF line endings. Not one declaration changed — 1147 of its1175 changed lines were line-ending churn, the real dependency delta being 28 lines. But
dependency_tree.rsreads the manifest throughinclude_str!and searches it for a section headerbracketed by newlines, which CRLF bytes never match. The lookup's
unwrap_or(0)then turned thatfailed search into a silent fallback to offset 0, so the guard searched
[package], found nodig-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
expectwas not deleted, loosened, or given a fallback, and theassertion it makes is unchanged:
rather than about how a file happened to be saved;
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 publishedversion, verified against
index.crates.iowith aUser-Agentheader:dig-download0.21.0,dig-peer-selector0.10.0,dig-nat0.21.0,dig-peer0.13.0,dig-rpc-protocol0.10.2,dig-mirror-coin0.7.0,dig-node-control-interface0.27.0,dig-mirror-collateral0.3.0,chia-query0.19.0, and the wholechia-*set on 0.36.x.dig-gossipis a git dep and is not oncrates.io, so it was compared against its
mainrather than the registry: pinned rev1a339166equals the
maintip.dig-dhtis 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, andgreen 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.rsdied before it ever compiled, so its test fixtures were writtenagainst remembered shapes. Three compile errors, all in the test module and none in
observeitself, were fixed against the published types. Both
BondState::Bondedassertions were rewrittento
matches!rather than weakened, and one now pins the payload — coin id, epoch, and theamount 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.