Skip to content

feat(spends): declare control.spends.list, the sanctioned reader for the spend audit record - #31

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
feat/3166-spends-list
Aug 27, 2026
Merged

feat(spends): declare control.spends.list, the sanctioned reader for the spend audit record#31
MichaelTaylor3d merged 2 commits into
mainfrom
feat/3166-spends-list

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

Declares control.spends.list — the sanctioned reader for dig-node's automated-spend audit
record — 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::append was made
module-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.rs at PR#378's head (1e240e7), not from
memory. Three properties are carried across deliberately:

  1. The failure STAGE survives.{"state":"failed","stage":"signing"|"broadcast"|"confirmation"}.
    Only signing means the money definitely did not move — no signed bundle ever existed. A
    broadcast 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 failed would make the collapse permanent and leave the app
    structurally unable to tell a person the truth about their money.
    SpendFailureStage::money_may_have_moved() is the single place the distinction is decided, and it
    is an exhaustive match so a new stage is a compile error rather than a guess.
  2. unresolved is a first-class state, not an error and not a failure. "The node signed and does
    not know how it ended" is a real answer the schema can express.
    SpendOutcome::outcome_is_unknown() groups it with the broadcast/confirmation failures — the rows
    a person still has to chase — while deliberately excluding pending/submitted, which are
    outcomes that have not happened yet rather than ones we lost track of.
  3. unreadable_lines rides on the wire. A trail that lost entries to corruption and reads as a
    tidy 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 an after_id cursor, matching control.wallet.coinsByParent's
established idiom. complete: bool is required on the wire — a caller must never infer
completeness from spends.len() < limit, because a matching set that is an exact multiple of the
page size makes the last full page indistinguishable from a truncated one. Spelled complete rather
than truncated so the value a missing/defaulted field falls back to is the safe one. An
out-of-range limit is refused, not clamped: clamping hands back a cursor for a position the
caller never asked about.

One deliberate departure from the node's internal record

amount_mojos / fee_mojos are decimal strings, where dig-node's in-memory record uses u64.
The full u64 range does not survive a JSON number through an f64 parser, and dig-app is TypeScript.
This crate already made that choice for WalletArrivalRecord::amount for the same reason. The file
format 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.arrivals is gated for the same reason). Declared Routing::Owned, matching where
the record lives.

Error code — -32048 SPEND_AUDIT_UNREADABLE

Verified free before minting, both sides: this crate's ControlErrorCode::ALL ends at -32047,
and grep -rn "\-32045\|\-32048\|\-32049" across dig-node's crates/ finds no minted code (only the
band-guard's own range literal). -32045 is also free — it was vacated by #28 and left alone rather
than reused, since re-using a number a shipped build once carried is how -32044 became 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/-32045 onto -32046/-32047, so dig-node's shipped WALLET_NODE_SPEND_DISABLED keeps
-32044 uncontested. dig-node's own guard
(meta.rs:1217 every_wallet_band_code_dig_node_mints_is_declared_in_the_shared_catalogue) now
cross-checks the two catalogues by number and symbol. Noted there rather than changed here.

Blast radius checked

gitnexus analyze + impact --direction upstream run per worktree (C:\tmp\worktrees\dnci-30),
per §2.0:

symboldirect callersrisk
ControlHandler (added a required method)1MockNode in this crate's own kats.rsLOW
ControlMethod (added a variant)0LOW
ControlErrorCode (added a variant)0LOW

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:

  • Consumers of this crate ecosystem-wide: dig-node (dig-node-service, dig-wallet) and
    dig-app (dig-app-core), all declaring dig-node-control-interface = "0.21". ^0.21
    cannot resolve to 0.22 (0.x minors are semver-incompatible), so no existing consumer is
    affected until it deliberately bumps.
  • grep -rn "ControlHandler" dig-node/crates finds zero implementations of this trait — the only
    hits are windows_service::service_control_handler, unrelated. So the new required trait method
    breaks 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.md sweep: the control catalog is referenced there generically; no
    interaction 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 --help lists no such command), so the equivalent check was git 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.

Note for anyone running gitnexus in a worktree:analyze rewrites tracked AGENTS.md,
CLAUDE.md and .gitignore. Those edits were reverted before the squash and are not in this
diff.

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/ControlErrorCode are
#[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 measured
above 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 no chia-* dependencies at all (serde, serde_json,
async-trait, semver, futures only), so there is nothing in scope for the touch-a-crate bump
rule. Stated explicitly so a reader does not have to re-derive that it was checked.

Verification

  • cargo test160 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 checkout on uncommitted work.

mutationtest that failed
#[serde(skip)] the failure stage (flatten to a bare failed)a_broadcast_failure_is_not_the_same_answer_as_a_signing_failure + the golden vector
fold unresolved into the failed filter bucketan_unresolved_spend_is_not_returned_as_a_failure
derive complete from the page's own lengtha_truncated_spend_page_and_a_final_one_are_told_apart_only_by_complete
report unreadable_lines: 0unreadable_entries_are_reported_on_every_page
clamp the page size instead of refusing itthe_page_bound_is_refused_from_above_and_accepted_at_the_bound

A 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 the
wire 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 test
additionally pins stage on the wire and across a decode. The mutation now fails as it should. The
constant carries a comment recording why it must stay shared.

Other fixture-design notes

  • An honest control. The fixture holds a confirmed spend beside the two failures and the
    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.
  • The truncation fixture is four rows paged two at a time, so both pages carry exactly two
    rows and only complete distinguishes them. A three-row fixture would let a length inference pass.
  • The page bound is pinned from both sides: SPENDS_LIST_MAX_LIMIT itself must be accepted and
    +1 must be refused. A bound tested only from below can only confirm itself.
  • amount_mojos in the fixture is 9007199254740993 — deliberately above 2^53, taken from the
    JSON/f64 limit rather than picked for looking large. A fixture under that boundary could not
    demonstrate why the field is a string.
  • Fixture time is a pinned AUDIT_BASE_MS constant, never a wall clock: a fixture whose
    timestamps drift with the run cannot pin an order.
  • AUDIT_UNREADABLE is non-zero, because a zero would pass identically against an implementation
    that never reports corruption at all.
  • The absent-key tests cover cursor and chain_reference separately — null is meaningful on
    both, so serde's default Option handling 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 + SpendOutcome field definitions in §4.1, a new
    normative §4.2d, and the -32048 row with the band-ownership sentence widened to match.
  • README.md — catalog row.
  • The crate's own the_spec_and_readme_name_every_catalogued_method gate enforces both; it failed
    first and drove these edits.

Deliberately not in this PR

  • The node-side implementation. dig-node serves this after 0.22.0 publishes (release-first).
    SpendLog::query already exists and is shared with the CLI, so the READER is not re-derived -- but
    it does not yet answer this wire: SpendQuery carries no after_id, and SpendLog::query cannot
    report complete. Adopting 0.22.0 therefore means extending that query type and its caller, not
    adding 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.
  • A control.spends.show / .reconcile pair.dign has both locally; the app's Activity tab
    needs 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.

…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>
@MichaelTaylor3dMichaelTaylor3d changed the title feat(spends): declare control.spends.list, the sanctioned reader for the automated-spend audit recordfeat(spends): declare control.spends.list, the sanctioned reader for the spend audit recordAug 27, 2026
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

IN PROGRESS — not the verdict. Gate round on head 2e5c6b68. Posting as findings resolve so they survive an interruption.

Cleared so far (each measured, not assumed):

  1. Failure-stage distinction survives the wireresults.rs declares SpendFailureStage::{Signing,Broadcast,Confirmation} internally-tagged inside SpendOutcome::Failed{stage,reason}, with money_may_have_moved() as an exhaustive match. Byte-identical in meaning to dig-node origin/main:crates/dig-node-service/src/spend_audit.rs:195FailureStage. Not flattened.
  2. unresolved is first-classSpendOutcome::Unresolved{reason} is a sibling of Failed, not a sub-case, matching SpendStatus::Unresolved at spend_audit.rs:277. outcome_is_unknown() correctly groups it with money-may-have-moved failures while excluding pending/submitted.
  3. Bounding is explicit and safe-by-defaultSPENDS_LIST_MAX_LIMIT=500, SPENDS_LIST_DEFAULT_LIMIT=50, out-of-range refused (params.rsvalidated()), and enforced in Deserialize so a node cannot forget. complete: bool is required on the wire and spelled positively, so an absent/defaulted field yields the SAFE reading. Good.
  4. Ordering matches the shipped implementation exactly — the SPEC declares DESC initiated_ms, ties by ASC id, stable across a walk. dig-node spend_audit.rs:539 sorts b.initiated_ms.cmp(&a.initiated_ms).then_with(|| a.id.cmp(&b.id)). Field-by-field the catalog's AutomatedSpend matches SpendRecord (spend_audit.rs:345); SpendKind/FundingCoinId/TargetCoinId are #[serde(transparent)] newtypes over String, so the catalog's String is the same wire shape. Asset and Authority match verbatim.
  5. Error code -32048 is genuinely free, checked on both sides — this crate's ControlErrorCode::code() ends at -32047; a grep of dig-node crates/ finds no minted -32045/-32048/-32049 (only meta.rs:1221's band range literal -32049..=-32040). #27's -32044 collision was already vacated by feat(wallet)!: move the reservation codes off -32044, which dig-node already owns #28. No new collision.
  6. Read-only is stated normatively, in three places (trait doc, SPEC.md §4.2d, method summary), including the MUST-NOT-expose-an-editing-method clause. Routing::Owned + token-gated matches the control.wallet.arrivals idiom.
  7. Commit authorshipMichael Taylor <michael@michaeltaylor.dev> with a Co-Authored-By: Claude trailer. Correct (§3.2).
  8. §2.4b — the crate declares no dig-* and no chia-* dependencies (serde, serde_json, async-trait, semver). Nothing in scope. Confirmed against Cargo.toml, not taken from the PR body.

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.

@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 (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.

Comment threadSPEC.md Outdated
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

CHANGES-REQUIRED

Head reviewed: 2e5c6b68ac06064f95607a3b02d92852a3e77bb2 (resolved from the remote at review time, not taken from the dispatch).

One gating finding, one non-gating note. Everything else on the brief cleared, with evidence.

Gating

  1. SPEC.md:932 — a blank line severs the newly-minted -32048 row from the error-code table, so it renders as a paragraph rather than a table row, while SPEC.md:935 normatively forbids minting a -3204x code "not declared in the table above." The document forbids its own new code. Fix + the required regression guard are in the inline thread; it stays open and bars merge.

Non-gating (posted separately and resolved by me, so it does not block)

  1. The PR body says the node-side reader is ready and implementation "is a dispatch arm rather than a second implementation." SpendLog::query has no cursor, so after_id is real new node work. Contract is unaffected; flagged so the follow-on lane is not surprised.

Verified, each measured rather than accepted from the PR body

#CheckResult
1Failure stage survives the wirePASSSpendOutcome::Failed{stage,reason}, money_may_have_moved() exhaustive. Proved non-vacuous by mutation (below).
2unresolved first-class, not an error, not a failurePASS — sibling variant; outcome_is_unknown() includes it and excludes pending/submitted.
3Bounded, and truncation is statedPASS — max 500 / default 50, out-of-range refused not clamped, enforced inside Deserialize; complete: bool required and spelled positively so the default reading is the safe one.
4Read-only, matching sibling idiomPASS — stated in the trait doc, SPEC.md §4.2d and the method summary, incl. MUST-NOT-expose-an-editing-method. Routing::Owned, token-gated per the wallet.arrivals rule.
5No error-code collisionPASS-32048 free in this crate (code() ends at -32047) and unminted in dig-node (grep of crates/ finds only meta.rs:1221's band literal). Checked, not assumed.
6Shape matches what dig-node SHIPPEDPASS — field-by-field against origin/main:crates/dig-node-service/src/spend_audit.rs. SpendRecord:345, SpendStatus:247, FailureStage:195, Asset:122, Authority:150 all correspond. SpendKind/FundingCoinId/TargetCoinId are #[serde(transparent)] newtypes over String, so the catalog's String is the identical wire shape. Ordering matches exactly: spend_audit.rs:539 sorts DESC initiated_ms then ASC id, which is what §4.2d declares. The u64→decimal-string departure is deliberate, documented and consistent with WalletArrivalRecord::amount.
7SemVer + SPECPASS on the bump — 0.21.0 → 0.22.0; ^0.21 cannot resolve to 0.22, so the added required trait method reaches no existing implementor. SPEC coverage is otherwise thorough; finding 1 is the exception.
8§2.4b dependency freshnessN/A, confirmed — no dig-* or chia-* deps in Cargo.toml (serde, serde_json, async-trait, semver).
Commit authorship (§3.2)PASSMichael Taylor <michael@michaeltaylor.dev>, Co-Authored-By: Claude.

Test-vacuity gate

Suite 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 (C:\tmp\worktrees\dnci31, removed after; no shared checkout touched):

  • #[serde(skip)] on SpendFailureStagedoes not compile — the enum has no Default, so the flattening mutation is blocked by the type system before any test runs. A genuine structural defence, not a test.
  • #[serde(skip_serializing)] compiles, and two tests fail: a_broadcast_failure_is_not_the_same_answer_as_a_signing_failure and spends_list_wire_vectors_are_pinned. The stage test discriminates because both failure rows share AUDIT_FAILURE_REASON (kats.rs:3955) — so stage is the only field that can tell them apart. The PR body reports finding and fixing exactly this false green itself; I confirm the fix holds.
  • a_truncated_spend_page_and_a_final_one_are_told_apart_only_by_complete is non-vacuous by fixture construction: both pages carry two rows, so any length-based inference of completeness reports them identically.

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 plainly

The stage/unresolved distinction is carried in the type system rather than in prose, decided in one exhaustive match, and cannot be flattened without a compile error. complete is spelled in the direction whose default is safe. chain_reference and cursor both use required_option so an absent key cannot decode into a confident null. The bound is declared from the first release rather than retrofitted. This is the right shape for a contract standing in for consent — the finding is a stray newline in a document, not a defect in the design.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

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 (SpendLog::query) already exists and is already shared with the CLI, so that is a dispatch arm rather than a second implementation."

Measured against dig-node origin/main, that understates the work. SpendQuery (crates/dig-node-service/src/spend_audit.rs:395) has since_ms, until_ms, store_id, kind, status, limit — and no after_id. SpendLog::query (:509) folds the whole ledger, retains matches, then truncate(n). So it can serve every filter and the page size, but it cannot resume from a cursor and has no way to report complete, both of which this contract requires on every call.

Nothing here is wrong with the contract, and the ordering it depends on is already correct on the node side (:539 sorts DESC initiated_ms then ASC id — exactly what §4.2d declares), which is the hard part. The gap is a cursor skip plus a has-more determination, and a usizeu32 narrowing on unreadable_lines.

No change requested in this PR. The one thing worth carrying forward: whoever implements it should compute complete from whether a matching row was withheld, not from records.len() == limittruncate makes those two indistinguishable at exactly the boundary this contract added complete to disambiguate.

…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>
@MichaelTaylor3d

MichaelTaylor3d commented Aug 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed at ab65276.

The blank line is gone.SPEC.md:932 deleted; cat -A on the file at head shows the -32048 row directly following -32047 with no intervening blank, so it is inside the table the normative sentence at 935 refers to. Line 935 is untouched -- the MUST still says "in the table above".

The guard: kats.rs::every_catalogued_error_code_is_a_row_of_an_error_code_table. It parses the document into TABLE BLOCKS (maximal runs of pipe-prefixed lines carrying a |---| separator; a blank line ends a block) and requires each ControlErrorCode::ALL entry to appear as a row inside one, with its number and symbol pinned in the same assertion (the row prefix | -32048 | SPEND_AUDIT_UNREADABLE |, each cell backticked in the document). A contains("-32048") check could not have caught this defect; a number-only check could not catch a mismatched symbol.

Revert-proofed both ways, committed first, reverted from a file copy (never git checkout on uncommitted work):

mutationresult
restore the blank line at 932FAILS
-32048 mapped to WALLET_READ_FAILEDFAILS

One judgement stated rather than assumed: the guard holds README.md to a weaker bar. Its error table is a pre-existing abbreviated subset that has never carried the -3204x band at all (it stops at -32032), so requiring exhaustiveness there would be a different change from this fix. What IS enforced is that any code README does list carries the correct symbol -- a wrong symbol in the short table misleads exactly its intended reader. Say the word if you want the band backfilled into README and I will do it as its own child.

161 unit + 9 doc tests green; cargo fmt --check and clippy -D warnings clean.

PR body corrected: it no longer calls the node side "a dispatch arm rather than a second implementation" -- it now states that SpendQuery has no after_id and SpendLog::query cannot report complete. The node-side work stays in dig-node.

Still DRAFT, not merged.

(An earlier revision of this comment was mangled by shell backtick expansion; this is the intended text.)

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 27, 2026 15:16
@MichaelTaylor3d
MichaelTaylor3d merged commit c9c8bf8 into mainAug 27, 2026
10 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/3166-spends-list branch August 27, 2026 15:17
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.

Add control.spends.list so dig-app can read the automated-spend audit record

1 participant

@MichaelTaylor3d