Uh oh!
There was an error while loading. Please reload this page.
feat(spend-audit): node-side audit record for automated spends - #378
Conversation
…seam Adds the node-side audit record for spends made without per-transaction user approval: an append-only JSONL ledger, a journal that writes the entry BEFORE a producer can sign, and a ChainInventory seam that checks local bookkeeping against the chain. Three honesty rules are enforced by the shape rather than by convention: failures are entries; Confirmed carries its height and created coin inside the variant so it is unreachable without a confirmation; and a RecordedSpend settles itself as Unresolved on Drop so a producer that forgets cannot leave silence. FundingCoinId and TargetCoinId are distinct types so the legacy confirm-the-wrong-coin bug is a type error. Refs #376 Co-Authored-By: Claude <noreply@anthropic.com>
Read-only, local verbs over the automated-spend audit record. They reach no node on purpose: the record is a file on this machine, and a person asking what it spent is often asking because the node stopped, so an audit surface that goes dark with the node is not one. Human output and --json beside it, filterable by time range, store, kind and status. A coin id is marked as expected or as observed, never both alike; a corrupt line is reported as an INCOMPLETE record rather than folded away; and reconcile with no chain source REFUSES instead of reporting clean. Refs #376 Co-Authored-By: Claude <noreply@anthropic.com>
Adds the normative contract for the automated-spend audit record, the user-facing README section for `dign spends`, and an end-to-end test that writes through the real journal and reads the record back out of the SHIPPED dign binary across a process boundary. Bumps the workspace version to 0.160.0 (new capability, minor). Refs #376 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Gate round 1 — posting findings as they form; verdict comment follows.
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Round 1 findings 2-4 (see the earlier inline for the gating one).
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED
Head reviewed: 6b1db5f814d531e0f504a68c0562e0285446dc2d (resolved from gh pr view 378 --json headRefOid; branch loop/376-spend-audit, draft).
GitHub returns 422 on any verdict-bearing review event from the PR author's identity, so this is recorded as a comment review. The two open inline threads are what bar the merge, via required_conversation_resolution.
Ranked findings
- GATING —
spend_audit.rs:175-186/:251-256/:792.Failed { stage: Broadcast | Confirmation }is a "the money may have moved" outcome wearing a terminal "it didn't happen" label.FailureStage's own doc says a broadcast failure may still land;is_terminal()then reports it settled andreconcileaccounts for nothing, so such a spend's coin — if it landed — lands inunrecorded_on_chain, the field documented as invisible money movement with no audit entry. There is an entry; it says the opposite. This is theUnresolved-vs-Failedcollapse the type split exists to prevent, reintroduced one level down. Full remedy, the wrong fix to avoid, and a discriminating test are in the inline thread. - GATING (dig-constants standing check) —
spend_audit.rs:72.spend-audit.jsonland the status tokens are declared locally whileSPEC.md:7147-7149,7190says dig-app is a second view of the same file. Either move them todig-constantsor make §23 say the app reads throughdign spends --json— a decision this PR should make, not leave implicit. Notedig-constants = "0.11.2"is already the latest published, so §2.4b is otherwise clean. - GATING (doc/contract split) —
spend_audit.rs:201-204. "Confirmedreachable ONLY throughSpendJournal::confirmed" is untrue:SpendLog::appendispuband takes an arbitrary record. Narrow the claim, or make it true withpub(crate) fn append(nothing outside appears to need it). Kept open because it is the central honesty claim and the file is being edited for #1 anyway. - Non-gating, resolved by me —
:558-571. The documented panic-safety of theDropguard had no test. I verified it holds.
What I verified holds
Confirmedcarries its evidence inside the variant. No optimistic field exists; height andTargetCoinIdcannot be separated. Only the "ONLY through" wording overreaches (#3).FundingCoinId/TargetCoinIdcannot be interchanged. NoFrom/Into, no shared unwrapping helper — onlyDisplayon each. The legacyServerCoin.ts:347substitution is a compile error. (Both havepubtuple fields, so an explicit re-wrap is possible; that is visible by construction and I do not treat it as a defect.)Unresolvedis distinct fromFailedat the top level — separate variants,is_terminal()excludesUnresolved(:250-256),reconcileaccounts for its intended coin and lists it (:777-786), and the CLI renders its reason (spend_audit_cli.rs:162-164). The collapse is only viaFailureStage(#1).- Carry-forward is genuinely guarded. I reverted it in my own worktree (
writerebuilding coin state from the opening snapshot) and the suite went red ona_competing_spend_of_the_funding_coin_never_confirms_this_spendandan_expected_coin_is_marked_differently_from_a_confirmed_one— both discriminating, both about the coin id vanishing at settle. Two tests, not the eight the PR claims; the property is real, the count is overstated. - The
Dropguard survives a panic. Probe:catch_unwindproducer callingbegin→submitted→panic!. One record,unresolved,intended_coin_idintact. No double-panic. - The failure reason renders in the list (
spend_audit_cli.rs:155-166), inline, with its stage. - No CLI verb mutates.
SpendsCommandisList | Show | Reconcile(entrypoint.rs:423-446); the CLI touches onlyledger/query/reconcile. reconcilewith no chain source REFUSES (spend_audit_cli.rs:96-107,ExitCode::NotServing) and today always refuses, sincerun()passesNone(:48). The e2e drives the shippeddignand asserts a non-zero exit — "an unperformed check is not a pass".- Coin ids render
#observed vs~expected (:146-147), derived fromchain_reference()rather than re-derived. unreadable_linesis counted and surfaced, in both--jsonand aWARNING: … This record is INCOMPLETEline (:124-131). A corrupt line cannot silently shrink the record.- Suite: 32 spend-audit unit/CLI tests green at this head in my own worktree.
What I could NOT verify
- The structural guard has no producer to bind yet.
git grepforRecordedSpend/SpendJournaloutside the module returns nothing — no signing entry point takes&RecordedSpend, because #377 has not landed. So "a producer cannot sign without an entry" is today prospective, not enforced: the type makes the right call easy, but nothing yet is obliged to make it. Whoever gates #377 must check that the mirror-coin signing path actually takes&RecordedSpendrather than merely callingbeginbeside it. - The e2e suite (7 tests against the shipped binary) — I ran the 32 lib/CLI tests, not the e2e binary build.
ChainInventoryhas no implementation anywhere, soreconcile's non-refusing path is exercised only by test doubles.
…tcome
Failed{stage: Broadcast|Confirmation} was labelled terminal and accounted for
by nothing in reconcile. Those are exactly the stages where a signed bundle
already existed, so a broadcast-failed spend that actually landed put its coin
in unrecorded_on_chain -- the field documented as money moved with NO audit
entry -- while an entry for it sat in the file saying the opposite. That is the
Unresolved-vs-Failed distinction the type split exists to preserve, collapsed
one level down inside FailureStage.
FailureStage::money_may_have_moved is now the single place the distinction is
decided, written as an exhaustive match so a new stage cannot be added without
choosing a side. is_terminal and reconcile both ask it rather than re-listing
variants. An unknown-outcome failure now accounts for its intended coin and is
reported under ReconcileReport::unresolved, exactly as Unresolved already was.
The CLI no longer renders such a failure as a settled "it didn't happen": the
row says the outcome is UNKNOWN and the money may still have moved.
SpendLog::append is now private to the module, which makes the "Confirmed is
reachable only through SpendJournal::confirmed" invariant true rather than
merely claimed -- it took an arbitrary record and was pub.
SPEC section 23 now states that the audit file is node-private and that every
other view reads it through `dign spends --json`, so there is one reader of the
path and the CLI envelope is the published contract.
Tests: a Broadcast-based reconcile test (the existing one used Signing, the one
stage where the collapse is harmless), a both-sides pin on money_may_have_moved,
and an in-tree catch_unwind test that the Drop guard survives a panic with the
intended_coin_id intact.
Closes#376
Co-Authored-By: Claude <noreply@anthropic.com>2eda8b7 to
1e240e7CompareMichaelTaylor3d
commented
Aug 27, 2026
IN PROGRESS — not the verdict. Round-2 scoped re-gate, head Established so far (static reading; revert-proof still running): Identity / housekeeping — CLEAN. All five commits on the branch author as Finding 1 (failure-state collapse) — structurally satisfied.
Finding 3 ( Test-vacuity read — the new reconcile test is NOT vacuous by construction. Still to do: the executed revert-proof of the |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS — round-2 scoped re-gate
Head read: 1e240e71dfb3e5fcaa36481e68bb17bc84c01a96 (resolved from the remote; the force-push is
accounted for). Scope: the three round-1 findings and the two self-flagged judgement calls only.
Finding 1 — failure-state collapse: FIXED, and the revert-proof was EXECUTED
(a) spend_audit.rs:217-222 is a real exhaustive match on &self, not a matches!. Adding aFailureStage variant is a compile error at that site.
(b) No third re-listing site. Exactly three consumers, all asking the predicate:spend_audit.rs:304 (is_terminal), spend_audit.rs:863 (reconcile),spend_audit_cli.rs:159-166 (row rendering). The residualSpendStatus::Pending | SpendStatus::Failed { .. } => {} at spend_audit.rs:869 is guard-ordered
after the money-may-have-moved arm, so only Signing reaches it — that is a fallthrough, not a
re-list.
(c) spend_audit.rs:863-868 inserts intended_coin_id into accounted and pushes intoreport.unresolved — byte-for-byte the same treatment Unresolved gets at :851-856.
Revert-proof, run by me in my own worktree at this head. I deleted ONLY the reconcile arm
(spend_audit.rs:863-868), leaving is_terminal fixed, and ran the test. It failed on the
money-movement assertion, not the is_terminal half:
panicked at spend_audit.rs:1441:
assertion `left == right` failed: a broadcast-failed spend that landed has an ENTRY,
so its coin is not untracked money movement; the coin with no entry at all still is
left: ["coin-broadcast", "coin-nobody-recorded"]
right: ["coin-nobody-recorded"]
That is the load-bearing claim, confirmed by execution rather than by reading. Restored; 35/35spend_audit tests green at the restored head; worktree removed.
Test-vacuity: not vacuous. The fixture carries both controls that exclude the two nearest wrong
implementations — coin-nobody-recorded (a fix that empties unrecorded_on_chain wholesale fails)
and a Signing record asserted still-terminal (a fix that reclassifies every failure as unknown
fails). only_a_signing_failure_claims_the_money_stayed_put (:1466-1480) pins the predicate from
both sides, which is the right shape for a boolean guard.
Finding 2 — the second parser: GENUINELY GONE, not merely deprecated in prose
I checked the claim rather than accepting it. No second reader exists anywhere in the ecosystem —spend-audit / spend_audit / SPEND_AUDIT appear in zero files across dig-app, digs and
dig-updater. The "two views over one file" promise lived only in dig-node's own module doc
(spend_audit.rs:11-12) and SPEC.md:7148-7150, and both were removed before the second reader was
ever written. So the SPEC sentence is not a claim the code fails to enforce; it is a constraint
adopted at the cheapest possible moment, and there is nothing left to enforce it against.
Rejecting dig-constants is defensible on that basis: with the file node-private, the file name is
no longer a value a second repo must match. What IS the cross-repo contract — the --json envelope
and the status tokens — is now normatively specified (SPEC.md §23.2/§23.5/§23.6), so a future
dig-app view has something to conform to.
Finding 3 — SpendLog::append visibility: FIXED
spend_audit.rs:477 is now fn append, module-private. Two callers, both SpendJournal methods in
the same module (:593, :701). No caller lost; tests/spend_audit_e2e.rs goes throughSpendJournal, never append. The "Confirmed is reachable only throughSpendJournal::confirmed" invariant is now true at the crate boundary rather than asserted.
The two self-flagged calls
1. ReconcileReport::unresolved carrying two statuses — adequately documented.spend_audit.rs:792-796
states the union in rustdoc and gives the reason ("a signed bundle exists and the chain has not told
us how it ended"), and SPEC.md §23.5 states it normatively for a reimplementer. A consumer
filtering on the field is told what is in it. Accepted.
2. Confirmation classified as unknown — I agree, and would not accept the other answer. "The
chain reported it could not succeed" is one node's observation of a network it does not fully see;
reorgs and re-broadcast by another route both survive it, and a signed bundle exists either way. The
error direction is over-reporting an unknown, which costs a person one reconciliation query, versus
under-reporting, which tells them their money is safe when the node does not know. For a money-
accounting surface that asymmetry decides it. No disagreement to record.
Housekeeping
All five commits author as Michael Taylor <michael@michaeltaylor.dev> withCo-Authored-By: Claude <noreply@anthropic.com>. No fabricated identity (§3.2). The wip(audit)
collapse left a clean conventional-commit history and all four round-1 threads survived and are
resolved.
check-merge-preconditions.sh: all five required contexts present and SUCCESSby name —Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage —
with unresolvedReviewThreads=0. It reports BLOCKED solely on draft=true, which is the correct
state for a PR whose gate round had not yet returned (§2.4a). Undrafting is the orchestrator's call,
not mine.
Non-gating note (no thread opened, nothing to resolve)
SPEND_AUDIT_FILE (spend_audit.rs:83) remains pub and is reachable asdig_node_service::spend_audit::SPEND_AUDIT_FILE. Inside dig-node that is fine — the crate's own
e2e test uses it and the "node-private" property holds at the repo boundary, which is the boundary
that matters. Worth keeping in view only if dig-node-service ever gains an external consumer.
Verdict: PASS. All three round-1 findings are genuinely addressed, the load-bearing revert-proof
was executed rather than accepted, and the money-accounting property holds: a Failed entry can no
longer tell a user their money did not move at a stage where it may have.
Uh oh!
There was an error while loading. Please reload this page.
What
The node-side automated-spend audit record — the source of truth for every spend the node makes
without per-transaction user approval — and the
dignverbs that read it.The node is being given authority to sign automatically, because a recurring per-store cycle cannot be
gated on a human pressing approve. This record is what replaces authorization with accountability,
and on a headless install it is the only surface on which that automation is visible at all.
Closes#376
Epic: https://github.com/DIG-Network/dig_ecosystem/issues/3166
DRAFT — DO NOT MERGE. Handing back for the gate round.
The record's shape, and why
It models a spend, generically — mirror coins are the first producer, not the subject. A record
shaped around mirror coins grows a second shape for the second producer, and the person loses the one
property that makes automatic signing defensible: a single place where everything spent on their behalf
is visible.
id · revision · kind · purpose · authority{principal, grant} · asset · amount_mojos · fee_mojos · store_id · initiated_ms · updated_ms · status · funding_coin_ids · intended_coin_idThree shape decisions are load-bearing:
Confirmed { height, coin_id }carries its evidence inside the variant. There is no optimisticfield to fill in, so a record cannot hold a confirmation height without a confirmation.
FundingCoinIdandTargetCoinIdare distinct types. The legacywaitForConfirmationwaitedfor the funding coin to be spent (
ServerCoin.ts:347), which a competing spend satisfiesidentically while the intended coin never exists. Confirming against the wrong coin is now a compile
error rather than a code-review question.
Unresolvedis separate fromFailed. "The node signed and does not know" is not "it did nothappen" — money may well have moved, and saying
failedabout a spend that landed is the same classof lie as claiming an unconfirmed success.
Storage is append-only JSONL in the machine-wide state dir, so the daemon and the operator's
dignresolve the same file across accounts. Each line is a full snapshot at one
revision; the ledger is thefold. A terminal outcome never rewrites the line that recorded the attempt. An unparseable line is
counted and reported (
unreadable_lines), because a corrupt trail that reads as a tidy shorter one isthe same lie as a missing entry.
The structural guard
SpendJournal::begin(intent) -> RecordedSpendwrites thependingentry to disk before it returns,and
RecordedSpendhas no public constructor. A producer's signing entry point takes&RecordedSpend,so recording is the shape of the call rather than a rule each future producer must remember.
RecordedSpendalso settles itself onDrop: a producer that returns early, panics, or simply forgetsleaves
Unresolvedbehind rather than apendingrow that reads like work still in flight. A spendreaching chain with no entry is invisible money movement — that is the property this whole feature rests
on, and it is now enforced by the type rather than by discipline.
CLI surface
Read-only and local — it contacts no node, so it still answers when the node is stopped or wedged
(the
wallet export-seedprecedent). There is deliberately no verb that edits or deletes an entry.A coin id prints as
#<id>when observed on chain and~<id> (expected)when it is only the intendedresult.
reconcilewith no chain source refuses rather than reporting clean — "nothing to report"and "I could not look" are different answers.
Reconciliation seam
ChainInventory::owned_coin_ids(owner_puzzle_hash)—dig-mirror-coin'squery::listis the intendedimplementation, and the crate is being uplifted in parallel, so this is a trait today and #377 plugs in
without reshaping the record.
reconcilereportsagreed/missing_on_chain/unrecorded_on_chain/unresolved, withunrecorded_on_chainas the alarm.submittedandunresolvedentries account fortheir intended coin, so chasing an unresolved spend does not raise a false alarm about its own coin.
Blast radius checked
gitnexus/socraticode MCP tools were not exposed in this lane, so per §2.0 bound (2) the radius was
established by grep + direct read, and that is stated rather than implied.
spend_audit,spend_audit_cli) — no existing symbol's behaviour is modified.entrypoint.rs: one newCommand::Spendsvariant, one dispatch arm, oneaction()arm, one mapper.The non-exhaustive
matchinCommand::actioncaught the missing arm at compile time.CONTROL_METHODS/OWNED_CONTROL_METHODS/dispatch_controlare deliberately untouched, so the#426 CLI-parity drift test and the published-contract conformance tests keep their current radius. The
control method the app will read is a release-first change in
dig-node-control-interfaceand iscalled out below rather than smuggled in via
KNOWN_UNPUBLISHED.Cargo.tomlworkspace version +Cargo.lock(0.159.0 → 0.160.0, minor: new capability;the bump is present in BOTH files and survived the rebase).
Re-measured for the gate-fix commit
2eda8b7That commit is not additive — it changes the behaviour of existing symbols — so the radius was
re-established rather than carried over:
SpendStatus::is_terminal— callers: none outside this module today (grep acrosscrates/; theonly other
is_terminalhit isseed_export_cli.rs:88, which isstd::io::IsTerminalon stdin andentirely unrelated). Behaviour change:
FailedatBroadcast/Confirmationnow returnsfalse.reconcile— one caller,spend_audit_cli.rs:108. Behaviour change: an unknown-outcome failurenow accounts for its intended coin and appears in
unresolved.ReconcileReport's field set isunchanged, so the
--jsonshape is unchanged.SpendStatus::Failed— one external match site,spend_audit_cli.rs:159, updated in the samecommit to render the unknown-outcome qualifier.
FailureStage— no variant added, removed or renamed, so the serde representation and everyexisting
FailureStage::Signing/::Broadcastconstruction site (3 inspend_audit_cli.rs, 3 intests/spend_audit_e2e.rs) are unaffected.SpendLog::append—pub→ module-private. Verified zero callers outside the module acrosscrates/before narrowing it; the only other.append(matches areOpenOptions::append(true).status_tokens_are_stable,the_json_field_names_are_the_published_shapeand
the_json_listing_keys_are_stableall still pass untouched, which is the property that mattersfor dig-app: the remodelling changes what the node BELIEVES about a failed spend, not what it emits.
Risk: HIGH by subject (it is the money-honesty path) but the radius is contained to two modules with
no cross-repo surface. Flagged for the re-gate rather than waved through.
Verification
cargo test -p dig-node-service— 443 unit + all integration green, including 7 e2e tests.cargo clippy --workspace --all-targets --all-features -- -D warnings— exit 0, read unpiped.cargo fmt --all -- --check— clean (exit 0).git checkout):Reverting only the
Dropguard fails exactlya_dropped_spend_settles_itself_as_unresolved.Reverting only the revision carry-forward fails 3 tests —
a_competing_spend_of_the_funding_coin_never_confirms_this_spend,an_expected_coin_is_marked_differently_from_a_confirmed_one, and the newa_producer_that_panics_still_leaves_an_unresolved_entry_with_its_coin. Restored, 443 green.Correction. An earlier revision of this body claimed 8. That was wrong and is withdrawn.
The gate independently measured 2, which was correct for the tree it measured; the third is
the panic test added in
2eda8b7, whoseintended_coin_idassertion also depends on thecarry-forward. Re-measured here rather than taking either number on trust.
Broadcastremodelling, proved in both halves separately — the assertions short-circuit, soone revert only proves the first:
is_terminalcollapse → fails on "a broadcast failure may have landed, so it is anunknown to be chased, not a settled outcome".
reconcilearm → fails with the lie itself:left: ["coin-broadcast", "coin-nobody-recorded"]vsright: ["coin-nobody-recorded"].a guard that skips while unwinding (
|| std::thread::panicking()): under it the existing drop testpasses and the new one fails (
left: "submitted",right: "unresolved").tests/spend_audit_e2e.rswrites through the realSpendJournaland reads back viaCARGO_BIN_EXE_dignin a separate process, crossing real state-dirresolution, the clap surface and the
--jsonenvelope. Manually driven too —list,list --status,show,list --jsonandreconcileall render correctly against a real state dir.A local
cargo test --workspacerun hitrustc-LLVM ERROR: out of memorywhile linking thedig-walletlib test.
dig-walletis untouched by this diff and passes on its own (cargo test -p dig-wallet --lib,exit 0), so this is a local memory limit rather than a regression.
Fixture design notes
confirmation from a broken one, so a landed spend sits beside the competing-spend case.
distinguish "failures are listed" from "everything is listed".
because a one-coin fixture cannot tell the three buckets apart.
NOWconstant passed through an injected clock, never the wall clock.sinceinclusive must pass,untilexclusivemust fail.
Two defects found by the tests during this work
pendingsnapshot, so the coin ids recorded atsubmittedvanished the moment the spend settled — a terminal entry with no chain reference to check.Fixed by carrying the record forward.
("insufficient funds" is the difference between a broken node and a wallet that needs topping up).
An existing repo gate (
no_help_text_exposes_an_internal_ticket_number) also caught a ticket numberleaking into user-facing
--helptext, which is scrubbed.Deliberately not in this PR
control.spends.listmethod the app's Activity tab will read. Serving it requires publishingit in
dig-node-control-interfacefirst (release-first); adding it toKNOWN_UNPUBLISHEDwould addexactly the drift that list exists to shrink. The reader (
SpendLog::query) is already shared, so thatchange is a dispatch arm, not a second implementation.