Uh oh!
There was an error while loading. Please reload this page.
feat(spends): declare control.spends.list, the sanctioned reader for the spend audit record - #31
Conversation
…ed-spend audit record dig-node PR#378 settled that the node's automated-spend audit record is node-private: every other view reads it through the node rather than by opening the file. That decision is what makes this method load-bearing -- without it dig-app's Activity tab has no legitimate route to the record, and the pressure to re-parse a growing JSONL format directly comes straight back. The wire shape is read from dig-node's RecordedSpend rather than invented, and it preserves the three properties the record cannot afford to lose: * the failure STAGE, because only a signing failure means the money definitely did not move -- a broadcast or confirmation failure is an unknown outcome. A catalog exposing a bare "failed" would leave the app structurally unable to tell a person the truth about their money; * `unresolved` as a first-class state distinct from `failed`, so "the node signed and does not know" is never reported as "it did not happen"; * the count of entries the node could not parse, so an audit trail that lost rows can never read as a tidy shorter one. Pages are bounded from the first declaration and state their own truncation via `complete`, so a caller can always tell "there are no more spends" from "we stopped telling you". An out-of-range page size is refused rather than clamped. Adds -32048 SPEND_AUDIT_UNREADABLE in a free slot of the -3204x band this crate owns, so a record that could not be read at all is never an empty page. Closes#30 Co-Authored-By: Claude <noreply@anthropic.com>
e1d7274 to
2e5c6b6CompareMichaelTaylor3d
commented
Aug 27, 2026
IN PROGRESS — not the verdict. Gate round on head Cleared so far (each measured, not assumed):
One defect found, detail in an inline thread. Still to finish: running the suite, and re-checking one claim in the PR body about the node-side reader. |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED (recorded as a comment review: GitHub 422s a verdict-bearing event from the PR author's own identity). One gating finding, posted as an inline thread on SPEC.md so it is durable and blocks merge under required_conversation_resolution. Everything else on the brief cleared and is evidenced in the interim comment above.
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d
commented
Aug 27, 2026
CHANGES-REQUIREDHead reviewed: One gating finding, one non-gating note. Everything else on the brief cleared, with evidence. Gating
Non-gating (posted separately and resolved by me, so it does not block)
Verified, each measured rather than accepted from the PR body
Test-vacuity gateSuite green at head: 160 unit + 9 doc. I did not take the PR body's mutation table on trust — I re-ran the highest-stakes one in my own worktree (
These name the property (does the wire still distinguish two failure stages) rather than an outcome, and the nearest wrong implementation fails them. Not false greens. What is good here, said plainlyThe stage/unresolved distinction is carried in the type system rather than in prose, decided in one exhaustive |
MichaelTaylor3d
commented
Aug 27, 2026
NON-GATING — resolved by me, does not block merge. Recorded so the follow-on node lane is not surprised. The PR body's "Deliberately not in this PR" section says of the node-side implementation: "The reader it needs ( Measured against dig-node Nothing here is wrong with the contract, and the ordering it depends on is already correct on the node side ( No change requested in this PR. The one thing worth carrying forward: whoever implements it should compute |
…de rows A blank line between the -32047 and -32048 rows terminated the Markdown table, so -32048 rendered as a paragraph rather than a row. SPEC.md's own normative sentence forbids minting a -3204x code "not declared in the table above", so the document contradicted the code this crate mints. Guard it the way method names are already guarded: every ControlErrorCode row is located inside a parsed table BLOCK, with its number and symbol pinned together, so neither a stray newline nor a mismatched symbol can pass. A substring check on the number would have passed the defect. Co-Authored-By: Claude <noreply@anthropic.com>
Fixed at The blank line is gone. The guard: Revert-proofed both ways, committed first, reverted from a file copy (never
One judgement stated rather than assumed: the guard holds 161 unit + 9 doc tests green; PR body corrected: it no longer calls the node side "a dispatch arm rather than a second implementation" -- it now states that Still DRAFT, not merged. (An earlier revision of this comment was mangled by shell backtick expansion; this is the intended text.) |
Uh oh!
There was an error while loading. Please reload this page.
What
Declares
control.spends.list— the sanctioned reader for dig-node's automated-spend auditrecord — plus its params, result types, handler seam, error code and SPEC/README coverage.
Closes#30
Epic: https://github.com/DIG-Network/dig_ecosystem/issues/3166
Node side: DIG-Network/dig-node#378 (the record itself)
Consumer: DIG-Network/dig-app#289 (the Activity tab)
DRAFT — handing back for the gate round. Do not merge.
Why this method has to exist
dig-node PR#378 settled that the audit record is node-private:
SpendLog::appendwas mademodule-private and its SPEC §23 says every other view reads the record through the node. That is
exactly what makes this ticket load-bearing — without a control method, dig-app has no legitimate
route to the record, and the pressure to re-parse a growing append-only JSONL format directly comes
straight back. A second parser on that format is the drift this avoids.
The shape, read from dig-node rather than invented
Taken from
crates/dig-node-service/src/spend_audit.rsat PR#378's head (1e240e7), not frommemory. Three properties are carried across deliberately:
{"state":"failed","stage":"signing"|"broadcast"|"confirmation"}.Only
signingmeans the money definitely did not move — no signed bundle ever existed. Abroadcast or confirmation failure happens after a valid bundle exists, and a rejection this node
saw does not bind a network it does not fully observe. dig-node has collapsed and re-fixed this
twice; a catalog exposing a bare
failedwould make the collapse permanent and leave the appstructurally unable to tell a person the truth about their money.
SpendFailureStage::money_may_have_moved()is the single place the distinction is decided, and itis an exhaustive
matchso a new stage is a compile error rather than a guess.unresolvedis a first-class state, not an error and not a failure. "The node signed and doesnot know how it ended" is a real answer the schema can express.
SpendOutcome::outcome_is_unknown()groups it with the broadcast/confirmation failures — the rowsa person still has to chase — while deliberately excluding
pending/submitted, which areoutcomes that have not happened yet rather than ones we lost track of.
unreadable_linesrides on the wire. A trail that lost entries to corruption and reads as atidy shorter one is the same lie as a missing entry. Documented as a whole-record count, because a
corrupt entry has no parsed timestamp or id and can be attributed to no page.
Bounding, stated rather than inferred
limit(default 50, max 500) with anafter_idcursor, matchingcontrol.wallet.coinsByParent'sestablished idiom.
complete: boolis required on the wire — a caller must never infercompleteness from
spends.len() < limit, because a matching set that is an exact multiple of thepage size makes the last full page indistinguishable from a truncated one. Spelled
completeratherthan
truncatedso the value a missing/defaulted field falls back to is the safe one. Anout-of-range
limitis refused, not clamped: clamping hands back a cursor for a position thecaller never asked about.
One deliberate departure from the node's internal record
amount_mojos/fee_mojosare decimal strings, where dig-node's in-memory record usesu64.The full
u64range does not survive a JSON number through an f64 parser, and dig-app is TypeScript.This crate already made that choice for
WalletArrivalRecord::amountfor the same reason. The fileformat is node-private (the whole premise of this ticket), so the wire is free to be the safer shape.
Read-only, and said so normatively
The trait doc and SPEC §4.2d both state that a conforming node MUST NOT let this call initiate, sign,
retry, cancel or amend a spend, and MUST NOT expose a method that edits or deletes an entry — a
record that can be edited accounts for nothing. Token-gated although it is a read, by the catalog's
existing rule: the caller names no identifier, so the answer is this node's own history
(
control.wallet.arrivalsis gated for the same reason). DeclaredRouting::Owned, matching wherethe record lives.
Error code —
-32048 SPEND_AUDIT_UNREADABLEVerified free before minting, both sides: this crate's
ControlErrorCode::ALLends at-32047,and
grep -rn "\-32045\|\-32048\|\-32049"across dig-node'scrates/finds no minted code (only theband-guard's own range literal).
-32045is also free — it was vacated by #28 and left alone ratherthan reused, since re-using a number a shipped build once carried is how
-32044became ambiguous.A record that could not be read at all is this error, never an empty page: "nothing to report" and
"I could not look" demand opposite reactions and the first is the one a person stops investigating on.
A record that was never written is not this — it is an honest empty page, because a node that has
never spent automatically is the ordinary case.
On #27 — that collision is already resolved and #27 can close. #28 moved the reservation codes off
-32044/-32045onto-32046/-32047, so dig-node's shippedWALLET_NODE_SPEND_DISABLEDkeeps-32044uncontested. dig-node's own guard(
meta.rs:1217 every_wallet_band_code_dig_node_mints_is_declared_in_the_shared_catalogue) nowcross-checks the two catalogues by number and symbol. Noted there rather than changed here.
Blast radius checked
gitnexus analyze+impact --direction upstreamrun per worktree (C:\tmp\worktrees\dnci-30),per §2.0:
ControlHandler(added a required method)MockNodein this crate's ownkats.rsControlMethod(added a variant)ControlErrorCode(added a variant)No HIGH/CRITICAL risk. The per-worktree index cannot see other repos, so the cross-repo radius was
measured by grep and is stated rather than implied:
dig-node-service,dig-wallet) anddig-app (
dig-app-core), all declaringdig-node-control-interface = "0.21".^0.21cannot resolve to
0.22(0.x minors are semver-incompatible), so no existing consumer isaffected until it deliberately bumps.
grep -rn "ControlHandler" dig-node/cratesfinds zero implementations of this trait — the onlyhits are
windows_service::service_control_handler, unrelated. So the new required trait methodbreaks no implementor that exists today; dig-node acquires the obligation when it adopts 0.22
and implements the method, which is the intended release-first order.
dig_ecosystemSYSTEM.mdsweep: the control catalog is referenced there generically; nointeraction shape changed for any existing method, so nothing there goes stale.
detect_changes()is an MCP tool and is not exposed by the gitnexus CLI in this lane (gitnexus --helplists no such command), so the equivalent check wasgit diff --stat origin/main HEAD:10 files, all expected —
src/{method,params,results,traits,error,kats}.rs,SPEC.md,README.md,Cargo.toml,Cargo.lock. Stated rather than skipped.SemVer — MINOR (0.21.0 → 0.22.0)
Additive: a new method, new params/result types, a new error code, a new handler method. No wire name,
field name, field meaning or numeric code changed. This is exactly what
SPEC.md§7.1 calls a minor("adding a method/code is an additive MINOR change"), and
ControlMethod/ControlErrorCodeare#[non_exhaustive]so downstream matches already carry a wildcard arm.Stated honestly rather than waved through: adding a required method to a public trait is a
compile-time break for any implementor. It is minor here because (a) the crate's own published policy
says so, (b) the same shape shipped as a minor for
wallet_coins_by_parent, and (c) it is measuredabove that there are zero implementors anywhere in the ecosystem today. It is a compile break for
nobody, not a compile break we are choosing to ignore.
§2.4b — dependency freshness
This crate declares no
dig-*and nochia-*dependencies at all (serde,serde_json,async-trait,semver,futuresonly), so there is nothing in scope for the touch-a-crate bumprule. Stated explicitly so a reader does not have to re-derive that it was checked.
Verification
cargo test— 160 unit + 9 doc, all green (6 new tests).cargo clippy --all-targets --all-features -- -D warnings— exit 0.cargo fmt --all -- --check— clean.Load-bearing proof — five mutations, each committed first and reverted from a file copy
Never
git checkouton uncommitted work.#[serde(skip)]the failurestage(flatten to a barefailed)a_broadcast_failure_is_not_the_same_answer_as_a_signing_failure+ the golden vectorunresolvedinto thefailedfilter bucketan_unresolved_spend_is_not_returned_as_a_failurecompletefrom the page's own lengtha_truncated_spend_page_and_a_final_one_are_told_apart_only_by_completeunreadable_lines: 0unreadable_entries_are_reported_on_every_pagethe_page_bound_is_refused_from_above_and_accepted_at_the_boundA FALSE GREEN I found and fixed, reported rather than quietly corrected
The first version of the stage test passed against the flatten mutation. The fixture gave the two
failing spends different reasons ("mempool rejected the bundle" vs "insufficient funds"), so the
assert_ne!on the serialized status was satisfied by the reason, and dropping the stage from thewire entirely went unnoticed. Both rows now carry the identical reason
(
AUDIT_FAILURE_REASON), so the stage is the only field that can distinguish them, and the testadditionally pins
stageon the wire and across a decode. The mutation now fails as it should. Theconstant carries a comment recording why it must stay shared.
Other fixture-design notes
unresolved one. A fixture of nothing-but-failures cannot tell "failures are listed" from
"everything is listed", nor a client that reads the status from one that renders every row alike.
rows and only
completedistinguishes them. A three-row fixture would let a length inference pass.SPENDS_LIST_MAX_LIMITitself must be accepted and+1must be refused. A bound tested only from below can only confirm itself.amount_mojosin the fixture is9007199254740993— deliberately above 2^53, taken from theJSON/f64 limit rather than picked for looking large. A fixture under that boundary could not
demonstrate why the field is a string.
AUDIT_BASE_MSconstant, never a wall clock: a fixture whosetimestamps drift with the run cannot pin an order.
AUDIT_UNREADABLEis non-zero, because a zero would pass identically against an implementationthat never reports corruption at all.
cursorandchain_referenceseparately —nullis meaningful onboth, so serde's default
Optionhandling would let a truncated payload decode into a confident"there is nothing to resume from" / "there is nothing to look up".
Docs (§4.2 / §4.3)
SPEC.md— catalog row,AutomatedSpend+SpendOutcomefield definitions in §4.1, a newnormative §4.2d, and the
-32048row with the band-ownership sentence widened to match.README.md— catalog row.the_spec_and_readme_name_every_catalogued_methodgate enforces both; it failedfirst and drove these edits.
Deliberately not in this PR
SpendLog::queryalready exists and is shared with the CLI, so the READER is not re-derived -- butit does not yet answer this wire:
SpendQuerycarries noafter_id, andSpendLog::querycannotreport
complete. Adopting 0.22.0 therefore means extending that query type and its caller, notadding a dispatch arm over an unchanged reader. Stated plainly so dig-node's lane sizes the work
from the measurement rather than from this body.
control.spends.show/.reconcilepair.dignhas both locally; the app's Activity tabneeds the list, and §2.6 says ship the thinnest path that a person can actually use. Adding them
later is additive.
Title shortened to 91 chars; commitlint caps the header at 100 and the PR title is what a squash-merge commits.