Skip to content

feat(spend-audit): node-side audit record for automated spends - #378

Merged
MichaelTaylor3d merged 5 commits into
mainfrom
loop/376-spend-audit
Aug 27, 2026
Merged

feat(spend-audit): node-side audit record for automated spends#378
MichaelTaylor3d merged 5 commits into
mainfrom
loop/376-spend-audit

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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 dign verbs 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_id

Three shape decisions are load-bearing:

  1. Confirmed { height, coin_id } carries its evidence inside the variant. There is no optimistic
    field to fill in, so a record cannot hold a confirmation height without a confirmation.
  2. FundingCoinId and TargetCoinId are distinct types. The legacy waitForConfirmation waited
    for the funding coin to be spent (ServerCoin.ts:347), which a competing spend satisfies
    identically while the intended coin never exists. Confirming against the wrong coin is now a compile
    error rather than a code-review question.
  3. Unresolved is separate from Failed. "The node signed and does not know" is not "it did not
    happen" — money may well have moved, and saying failed about a spend that landed is the same class
    of lie as claiming an unconfirmed success.

Storage is append-only JSONL in the machine-wide state dir, so the daemon and the operator's dign
resolve the same file across accounts. Each line is a full snapshot at one revision; the ledger is the
fold. 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 is
the same lie as a missing entry.

The structural guard

SpendJournal::begin(intent) -> RecordedSpend writes the pending entry to disk before it returns,
and RecordedSpend has 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.

RecordedSpend also settles itself on Drop: a producer that returns early, panics, or simply forgets
leaves Unresolved behind rather than a pending row that reads like work still in flight. A spend
reaching 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-seed precedent). There is deliberately no verb that edits or deletes an entry.

dign spends list [--since-ms --until-ms --store --kind --status --limit] [--json]
dign spends show <id> [--json]
dign spends reconcile <owner-puzzle-hash> [--json]

A coin id prints as #<id> when observed on chain and ~<id> (expected) when it is only the intended
result. reconcile with 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's query::list is the intended
implementation, and the crate is being uplifted in parallel, so this is a trait today and #377 plugs in
without reshaping the record. reconcile reports agreed / missing_on_chain / unrecorded_on_chain /
unresolved, with unrecorded_on_chain as the alarm. submitted and unresolved entries account for
their 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.

  • Two new modules (spend_audit, spend_audit_cli) — no existing symbol's behaviour is modified.
  • entrypoint.rs: one new Command::Spends variant, one dispatch arm, one action() arm, one mapper.
    The non-exhaustive match in Command::action caught the missing arm at compile time.
  • CONTROL_METHODS / OWNED_CONTROL_METHODS / dispatch_control are 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-interface and is
    called out below rather than smuggled in via KNOWN_UNPUBLISHED.
  • Cargo.toml workspace 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 2eda8b7

That 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 across crates/; the
    only other is_terminal hit is seed_export_cli.rs:88, which is std::io::IsTerminal on stdin and
    entirely unrelated). Behaviour change: Failed at Broadcast/Confirmation now returns false.
  • reconcile — one caller, spend_audit_cli.rs:108. Behaviour change: an unknown-outcome failure
    now accounts for its intended coin and appears in unresolved. ReconcileReport's field set is
    unchanged, so the --json shape is unchanged.
  • SpendStatus::Failed — one external match site, spend_audit_cli.rs:159, updated in the same
    commit to render the unknown-outcome qualifier.
  • FailureStage — no variant added, removed or renamed, so the serde representation and every
    existing FailureStage::Signing / ::Broadcast construction site (3 in spend_audit_cli.rs, 3 in
    tests/spend_audit_e2e.rs) are unaffected.
  • SpendLog::appendpub → module-private. Verified zero callers outside the module across
    crates/ before narrowing it; the only other .append( matches are OpenOptions::append(true).
  • No wire/format change.status_tokens_are_stable, the_json_field_names_are_the_published_shape
    and the_json_listing_keys_are_stable all still pass untouched, which is the property that matters
    for 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-service443 unit + all integration green, including 7 e2e tests.
  • cargo clippy --workspace --all-targets --all-features -- -D warningsexit 0, read unpiped.
  • cargo fmt --all -- --check — clean (exit 0).
  • Load-bearing proof (committed first, reverted from a file copy, never git checkout):
    • Reverting only the Drop guard fails exactly a_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 new
      a_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, whose intended_coin_id assertion also depends on the
      carry-forward. Re-measured here rather than taking either number on trust.

  • The Broadcast remodelling, proved in both halves separately — the assertions short-circuit, so
    one revert only proves the first:
    • Restoring the is_terminal collapse → fails on "a broadcast failure may have landed, so it is an
      unknown to be chased, not a settled outcome".
    • Restoring only the reconcile arm → fails with the lie itself:
      left: ["coin-broadcast", "coin-nobody-recorded"] vs right: ["coin-nobody-recorded"].
  • The panic test is not redundant with the forgetful-producer test. Nearest wrong implementation is
    a guard that skips while unwinding (|| std::thread::panicking()): under it the existing drop test
    passes and the new one fails (left: "submitted", right: "unresolved").
  • End-to-end through the shipped binary: tests/spend_audit_e2e.rs writes through the real
    SpendJournal and reads back via CARGO_BIN_EXE_dign in a separate process, crossing real state-dir
    resolution, the clap surface and the --json envelope. Manually driven too — list, list --status,
    show, list --json and reconcile all render correctly against a real state dir.

A local cargo test --workspace run hit rustc-LLVM ERROR: out of memory while linking the dig-wallet
lib test. dig-wallet is 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

  • The confirm test keeps an honest control: a fixture where nothing lands cannot tell a correct
    confirmation from a broken one, so a landed spend sits beside the competing-spend case.
  • The listing tests hold one success and one failure, because a log containing only failures cannot
    distinguish "failures are listed" from "everything is listed".
  • The reconcile test keeps three distinct coins — agreed, confirmed-but-gone, and unrecorded —
    because a one-coin fixture cannot tell the three buckets apart.
  • Fixture time is a pinned NOW constant passed through an injected clock, never the wall clock.
  • The time-window bound is pinned from both sides: since inclusive must pass, until exclusive
    must fail.

Two defects found by the tests during this work

  • Every revision was rebuilt from the opening pending snapshot, so the coin ids recorded at
    submitted vanished the moment the spend settled — a terminal entry with no chain reference to check.
    Fixed by carrying the record forward.
  • The list renderer omitted the failure reason, the single most actionable field on a failed spend
    ("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 number
leaking into user-facing --help text, which is scrubbed.

Deliberately not in this PR

MichaelTaylor3dand others added 4 commits August 26, 2026 19:50
Stub module for the node-side audit record of spends made without
per-transaction user approval (#376).
Refs #376
Co-Authored-By: Claude <noreply@anthropic.com>
…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>

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Gate round 1 — posting findings as they form; verdict comment follows.

Comment threadcrates/dig-node-service/src/spend_audit.rs

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Round 1 findings 2-4 (see the earlier inline for the gating one).

Comment threadcrates/dig-node-service/src/spend_audit.rs
Comment threadcrates/dig-node-service/src/spend_audit.rs Outdated
Comment threadcrates/dig-node-service/src/spend_audit.rs

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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

  1. 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 and reconcile accounts for nothing, so such a spend's coin — if it landed — lands in unrecorded_on_chain, the field documented as invisible money movement with no audit entry. There is an entry; it says the opposite. This is the Unresolved-vs-Failed collapse 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.
  2. GATING (dig-constants standing check) — spend_audit.rs:72.spend-audit.jsonl and the status tokens are declared locally while SPEC.md:7147-7149,7190 says dig-app is a second view of the same file. Either move them to dig-constants or make §23 say the app reads through dign spends --json — a decision this PR should make, not leave implicit. Note dig-constants = "0.11.2" is already the latest published, so §2.4b is otherwise clean.
  3. GATING (doc/contract split) — spend_audit.rs:201-204. "Confirmed reachable ONLY through SpendJournal::confirmed" is untrue: SpendLog::append is pub and takes an arbitrary record. Narrow the claim, or make it true with pub(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.
  4. Non-gating, resolved by me — :558-571. The documented panic-safety of the Drop guard had no test. I verified it holds.

What I verified holds

  • Confirmed carries its evidence inside the variant. No optimistic field exists; height and TargetCoinId cannot be separated. Only the "ONLY through" wording overreaches (#3).
  • FundingCoinId / TargetCoinId cannot be interchanged. No From/Into, no shared unwrapping helper — only Display on each. The legacy ServerCoin.ts:347 substitution is a compile error. (Both have pub tuple fields, so an explicit re-wrap is possible; that is visible by construction and I do not treat it as a defect.)
  • Unresolved is distinct from Failed at the top level — separate variants, is_terminal() excludes Unresolved (:250-256), reconcile accounts 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 via FailureStage (#1).
  • Carry-forward is genuinely guarded. I reverted it in my own worktree (write rebuilding coin state from the opening snapshot) and the suite went red on a_competing_spend_of_the_funding_coin_never_confirms_this_spend and an_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 Drop guard survives a panic. Probe: catch_unwind producer calling beginsubmittedpanic!. One record, unresolved, intended_coin_id intact. No double-panic.
  • The failure reason renders in the list (spend_audit_cli.rs:155-166), inline, with its stage.
  • No CLI verb mutates.SpendsCommand is List | Show | Reconcile (entrypoint.rs:423-446); the CLI touches only ledger/query/reconcile.
  • reconcile with no chain source REFUSES (spend_audit_cli.rs:96-107, ExitCode::NotServing) and today always refuses, since run() passes None (:48). The e2e drives the shipped dign and asserts a non-zero exit — "an unperformed check is not a pass".
  • Coin ids render # observed vs ~ expected (:146-147), derived from chain_reference() rather than re-derived.
  • unreadable_lines is counted and surfaced, in both --json and a WARNING: … This record is INCOMPLETE line (: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 grep for RecordedSpend/SpendJournal outside 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 &RecordedSpend rather than merely calling begin beside it.
  • The e2e suite (7 tests against the shipped binary) — I ran the 32 lib/CLI tests, not the e2e binary build.
  • ChainInventory has no implementation anywhere, so reconcile'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>
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

IN PROGRESS — not the verdict. Round-2 scoped re-gate, head 1e240e71dfb3e5fcaa36481e68bb17bc84c01a96.

Established so far (static reading; revert-proof still running):

Identity / housekeeping — CLEAN. All five commits on the branch author as
Michael Taylor <michael@michaeltaylor.dev>, co-authored Claude <noreply@anthropic.com>.
No fabricated identity (§3.2). The wip(audit) collapse left a conventional-commit history.

Finding 1 (failure-state collapse) — structurally satisfied.

  • spend_audit.rs:217-222 is a real exhaustive match over &self, not a matches!.
    Adding a FailureStage variant is a compile error at that site.
  • Exactly three consumer sites, and all three ask the predicate rather than re-listing:
    spend_audit.rs:304 (is_terminal), spend_audit.rs:863 (reconcile),
    spend_audit_cli.rs:163 (row rendering). Grep for FailureStage:: outside tests finds no
    fourth site that re-lists the variants.
  • reconcile at spend_audit.rs:863 inserts intended_coin_id into accounted AND pushes into
    report.unresolved, i.e. identical treatment to Unresolved. The residual
    SpendStatus::Pending | SpendStatus::Failed { .. } => {} arm at :868 is guard-ordered after it,
    so only non-money-moving failures reach it — that is not a re-list.

Finding 3 (SpendLog::append visibility) — satisfied.spend_audit.rs:477 is now fn append,
module-private. Only two callers exist, both inside SpendJournal in the same module
(:593, :701). No caller lost; the e2e test at tests/spend_audit_e2e.rs goes through
SpendJournal, not append.

Test-vacuity read — the new reconcile test is NOT vacuous by construction.
a_broadcast_failure_is_unresolved_and_accounts_for_its_intended_coin (spend_audit.rs:1400+)
carries two controls that exclude the two nearest wrong implementations: coin-nobody-recorded
(a fix that empties unrecorded_on_chain wholesale fails) and a Signing-stage record
(a fix that reclassifies every failure as unknown fails). only_a_signing_failure_claims_the_money_stayed_put
pins the predicate from both sides.

Still to do: the executed revert-proof of the reconcile arm in isolation, Finding 2 (the second
parser), and the two self-flagged judgement calls.

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

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 a
FailureStage 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 residual
SpendStatus::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 into
report.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/35
spend_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 through
SpendJournal, never append. The "Confirmed is reachable only through
SpendJournal::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> with
Co-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 as
dig_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.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 27, 2026 06:48
@MichaelTaylor3d
MichaelTaylor3d merged commit befbe68 into mainAug 27, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/376-spend-audit branch August 27, 2026 06:48
Sign up for freeto 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.

Automated-spend audit record + dign CLI commands (source of truth for unapproved spends)

1 participant

@MichaelTaylor3d