Skip to content

feat(app): gate rewards-clawback-* strings on a proven viewer authority - #404

Merged
MichaelTaylor3d merged 6 commits into
mainfrom
loop/3281-clawback-authority
Sep 10, 2026
Merged

feat(app): gate rewards-clawback-* strings on a proven viewer authority#404
MichaelTaylor3d merged 6 commits into
mainfrom
loop/3281-clawback-authority

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Summary

Refs #3281. Builds ClawbackAuthority: a witness constructible only from a wallet-key-derived ViewerPuzzleHash that is byte-equal to a commitment's own clawback_puzzle_hash, and the SOLE producer of the four finished rewards-clawback-* sentences (ProvenClawback::open). The gate takes the CAPABILITY and derives the comparand itself, so a caller cannot forge it by supplying both compared values in one line — the class of defect pane.rs's WarningsShown (pane.rs:646-682) left open, which is unchanged and out of scope here.

What shipped

  • New crates/dig-app-core/src/rewards/clawback.rs:
    • ViewerPuzzleHash([Bytes32]) — only constructor from_wallet_key; no Copy/Clone/Default/PartialEq/From/from_bytes.
    • ClawbackAuthority::prove(&ViewerPuzzleHash, &RewardDistributorCommitment) -> Option<Self> — private matched: Bytes32; no Copy/Clone/Default.
    • ProvenClawback::open(ClawbackAuthority, &RewardDistributorCommitment) -> Option<Self> — consumes the witness by value, returns the four already-formatted Strings. Refuses the whole window (None) on a checked_sub underflow rather than showing a stand-in zero forfeited figure.
    • Two compile_fail doctests on ClawbackAuthority: copy::CLAWBACK_CONFIRM_BODY unreachable from outside the crate; a consumed witness cannot be reused.
  • copy.rs's four CLAWBACK_* Msg constants narrow pub -> pub(super). ALL_KEYS byte-identical, so the 14-locale completeness/forbidden-phrase sweeps still walk them. No .ftl file touched.
  • pane.rs's test-only string-literal scanner (function_body/string_literals) moved to a shared rewards::test_scan module so clawback's key-isolation test reuses the ONE implementation instead of writing a second (plan step 6).
  • Workspace root Cargo.toml 15.5.1 -> 15.5.2.
  • No clawback affordance ships — not even disabled. Six tabs unchanged.

Blast radius

Checked by grep (gitnexus was NOT-INDEXED in this worktree at dispatch): rewards/mod.rs (new pub mod clawback; + test_scan line), rewards/copy.rs (visibility narrow only — grepped for external callers of the four consts outside rewards/: none), rewards/pane.rs (test-helper move only, behaviour-preserving). No other module in the crate references the touched symbols.

Test plan

  • CI: Rustfmt, Clippy, Test + coverage (>=80% lines, gated), Check version increment, Lint commit messages, Headless build (--no-default-features)
  • New tests in clawback.rs: no_module_outside_clawback_names_a_clawback_key, the_guard_itself_trips_on_a_planted_key, from_wallet_key_matches_the_independently_derived_curry_tree_hash, a_one_bit_flipped_commitment_hash_proves_nothing, rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing, checked_sub_underflow_refuses_the_whole_window, plus 2 compile_fail doctests.
  • Untouched and still green: commitment_has_exactly_the_four_spec_fields_and_no_more (wire.rs), tab_id_all_is_still_the_six_labels, activity_tab_emits_zero_action_rows.

DO NOT MERGE — gate round in progress. Parent lane owns the merge.

🤖 Generated with Claude Code

MichaelTaylor3d and others added 3 commits September 10, 2026 01:48
Adds ClawbackAuthority: a witness constructible only from a wallet-key-
derived ViewerPuzzleHash that is byte-equal to a commitment's own
clawback_puzzle_hash, and the sole producer of the four finished
rewards-clawback-* sentences via ProvenClawback::open. The gate takes
the CAPABILITY and derives the comparand itself, closing the class of
forgeable witness pane.rs:WarningsShown left open (that type is
unchanged; out of scope).

- New crates/dig-app-core/src/rewards/clawback.rs: ViewerPuzzleHash
  (single constructor, no Copy/Clone/Default/PartialEq/From),
  ClawbackAuthority::prove, ProvenClawback::open (consumes the witness
  by value; refuses the whole window on a checked_sub underflow rather
  than showing a stand-in zero).
- copy.rs's four CLAWBACK_* Msg constants narrow to pub(super);
  ALL_KEYS untouched so the 14-locale/forbidden-phrase sweeps still
  walk them. No .ftl file touched; no affordance ships.
- Moved pane.rs's test-only string-literal scanner to a shared
  rewards::test_scan module so clawback's key-isolation test reuses it
  instead of duplicating it.
- Workspace root Cargo.toml 15.5.1 -> 15.5.2.

Refs #3281

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The version-bump commit updated Cargo.toml's workspace.package.version
but left the three path-member entries in Cargo.lock (dig-app,
dig-app-core, diga) pinned at 15.5.1, which every CI job's --locked
cargo invocation refuses to reconcile.

Refs #3281

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… private copy consts

Two CI root causes on #404, no behaviour change.

Cause A (Clippy, Test + coverage, both Native confirmers): when the string-literal
extractor moved out of `pane.rs` into `rewards::test_scan` for reuse by
`rewards::clawback`, the `use` landed in `mod tests` while both call sites live in
`mod rewards_sections_tests` -- so line 75 was an unused import and lines 588-589
were E0425. The import now sits in the module that calls it; there is still exactly
ONE extractor (`rewards/test_scan.rs`).

Cause B (Doc-link hygiene): three rustdoc failures, all doc text.
  1-2. Intra-doc links into the `#[cfg(test)]` module cannot resolve under
       `cargo doc` (broken_intra_doc_links); the two test names are now plain
       backticks, not links.
  3.   Four `ProvenClawback` field docs linked `copy::CLAWBACK_*`, which this
       ticket deliberately downgraded to `pub(super)`
       (private_intra_doc_links). The DOC is fixed, not the visibility: the four
       constants stay `pub(super)` and no `pub` re-export, alias or accessor was
       added -- that downgrade is the security gate this PR exists to add.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CI finish — 5 red checks, two root causes, no design change

Head 7e2b6b1d. Diff is 8 insertions / 8 deletions across two files, all of it an import move and doc text.

Cause A (Clippy, Test + coverage, both Native confirmers — 4 of 5). When the string-literal
extractor moved out of pane.rs into rewards::test_scan for reuse by clawback.rs, the use landed in
pane.rs's mod tests while both call sites live in mod rewards_sections_tests — so pane.rs:75 was an
unused import and pane.rs:588-589 were E0425, one defect reported as two. The import now sits in the
module that calls it. Still exactly ONE extractor, at
crates/dig-app-core/src/rewards/test_scan.rs (function_body, string_literals); no second copy was
written.

Cause B (Doc-link hygiene (rustdoc)). Three problems, all doc text:

      1. [tests::…] links into the #[cfg(test)] module can never resolve — cargo doc builds without
        cfg(test), so those are broken_intra_doc_links permanently, not typos. Both test names are now plain
        backticks.
  1. Four ProvenClawback field docs linked copy::CLAWBACK_*, which this PR deliberately narrowed to
    pub(super) — that is private_intra_doc_links firing correctly. The doc was fixed, not the
    visibility.
    All four constants remain pub(super) (copy.rs:53,59,63,65) and no pub re-export,
    alias or accessor of them was added anywhere in crates/ — restoring pub would have greened CI while
    deleting the gate this PR exists to add.

Acceptance still intact (from the run's own log, not from a rollup): 3132 tests run, 3132 passed,
4 skipped; 5 doctests passed, including both compile_fail proofs
(clawback.rs line 89 = copy::CLAWBACK_CONFIRM_BODY unreachable outside the crate, line 96 = the
consumed witness cannot be reused). no_module_outside_clawback_names_a_clawback_key,
the_guard_itself_trips_on_a_planted_key,
from_wallet_key_matches_the_independently_derived_curry_tree_hash,
rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing,
commitment_has_exactly_the_four_spec_fields_and_no_more, tab_id_all_is_still_the_six_labels,
activity_tab_emits_zero_action_rows and sentence_builders_carry_no_hardcoded_english_literal each
ran and passed. clawback.rs is at 100% line coverage; workspace total 92.20% regions.

One CI-infrastructure note, not a code red. Native confirmer (windows-latest) on attempt 1 shows as
cancelled: all six of its functional steps passed (cargo clippy -D warnings, cargo test, and
cargo doc with the same deny-flags, for both dig-app-core and the dig-app shell — last step finished
at 12:07:19), and the job was then killed by timeout-minutes: 60 during Post Cache cargo + target,
the 1.7 GB rust-cache upload. The job logged No cache found at start, so it was a fully cold build:
58.9 min of steps against a 60 min cap. Nothing in this branch's code is implicated, and the fix is a CI
policy call in .github/workflows/ci.yml (raise the cap, or cache less), which is outside this lane's
file set. The job has been re-run.

Still a DRAFT; the parent lane owns the merge.

🤖 Generated with Claude Code

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

ADVERSARIAL GATE (third leg) — CHANGES-REQUIRED

Head attacked: 7e2b6b1d9b59dd9a44385de3a46b78863e4f1f73. Read-only; nothing edited, nothing merged.

Attack thesis carried in from #403: a number can be correctly typed, read-only, verb-free, perfectly
formatted and false about WHOSE money it is.
Applied to every figure, hash and sentence this diff can
put in front of a person, plus the round-4 question — does the fix reintroduce its own class?

It does, once, structurally. One merge blocker; two required-before-a-renderer follow-ups; four of the
six named attacks come back clean.


F1 (MERGE-BLOCKING) — ProvenClawback is forgeable from any crate: the "SOLE producer" property is not compiler-held

crates/dig-app-core/src/rewards/clawback.rs:132-142

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenClawback {
    pub confirm_title: String,
    pub confirm_body: String,
    pub withdraw_button: String,
    pub keep_button: String,
}

pub struct, in pub mod clawback, in pub mod rewards (lib.rs:107), all four fields pub, no
private field and no #[non_exhaustive]. A struct literal therefore constructs one from anywhere,
including outside the crate
, with no ClawbackAuthority in existence:

let forged = dig_app_core::rewards::clawback::ProvenClawback {
    // any amounts, any hash — see F2 for why the Msg is still reachable
    confirm_body: dig_app_core::i18n::Msg::new("rewards-clawback-confirm-body").with(&args),
    confirm_title: String::new(),
    withdraw_button: "Withdraw 500 $DIG".into(),
    keep_button: String::new(),
};

And because every field is pub, a legitimately obtained ProvenClawback is mutable in place — a
renderer can keep confirm_body ("{returned_amount} returns to this wallet") and overwrite
withdraw_button with a different figure.

The witness itself is airtight: ViewerPuzzleHash.0 and ClawbackAuthority.matched are private to the
clawback module, RewardDistributorCommitment's fields are pub(crate) (wire.rs:101-106) so
prove is not even callable cross-crate, and no
From/Into/Default/Clone/Copy/Deserialize/from_bytes/unsafe/test-only constructor exists
on either type. The gate holds up to the last type — and then the product type, which is what a UI
actually holds, has no gate at all.

Three doc sentences assert the property the compiler does not hold:

  • clawback.rs:3-4 — "the SOLE producer of the four finished rewards-clawback-* sentences"
  • clawback.rs:129-131 — "produced ONLY by [Self::open]"
  • copy.rs:47-49 — "the finished, money-bound sentence is reachable ONLY through
    ProvenClawback::open, which requires a ClawbackAuthority no caller can forge"

This is exactly #403 round 4: the fix's own doc claims a property its own shape gives away.

Money/authority consequence. The deliverable is a custody boundary enforced by types. As shipped,
the next lane wiring the UI can construct or mutate the confirm window's text without ever proving the
viewer controls clawback_puzzle_hash — and will reasonably believe the type forbids that, because the
doc says so twice. Nothing renders today (zero callers outside the module, verified by grep), so no
figure is currently false in front of a person; the blocker is that the property is claimed, will be
relied on downstream, and is absent.

Fix (small). Make the four fields private and add four &str accessors (confirm_title(), …).
Keep Clone/PartialEq — finished text, not a capability, and the doc is right about that. Then
ProvenClawback::open really is the sole producer and clawback.rs:129-131 becomes true.
#[non_exhaustive] alone is not sufficient: it blocks the cross-crate literal but leaves the
in-crate literal and, more importantly, leaves the fields publicly writable.

F2 (follow-up ticket; requires a one-sentence doc correction in this PR) — "unreachable from outside the crate" is false about the value

clawback.rs:86 — "CLAWBACK_CONFIRM_BODY is pub(super) inside rewards — unreachable from outside
the crate, where this doctest runs".

The compile_fail doctest at clawback.rs:89-91 is genuine about the path. The value is still
public: copy.rs:144-155's pub const ALL_KEYS: &[Msg] carries all four narrowed Msgs by value,
Msg is pub with pub fn with/text (i18n/mod.rs:145-181), and Msg::new is a pub const fn
taking any &'static str. So copy::ALL_KEYS[9].with(&args) — or simply
Msg::new("rewards-clawback-confirm-body") — renders the real 14-locale sentence with arbitrary
amounts and an arbitrary clawback_ph_short, from any crate.

That hatch is not closable while Msg::new is public, and it should not be: the honest framing is
the one the module doc already uses for the sibling-file hop (clawback.rs:33-38), which correctly
refuses to write "unreachable outside the gate" and points at the source scan instead. Line 86 is the
one sentence that overstates, and it overstates in the direction of a custody guarantee. Correct it to
say the path is unreachable while ALL_KEYS/Msg::new keep the key reachable. That disclosure
currently lives only in the PR body, and a note the next reader never arrives at is not a disclosure.

Residual (c) is also understated: rewards/ holds nine files; the scan (clawback.rs:263-269)
lists five. Unlisted are mod.rs, wire.rs and the new test_scan.rsmod.rs being the material
one, since a pub use clawback::… / pub use copy::… re-export added there would widen reach without
tripping either the scan or the compiler. A gate over an enumeration can only check the enumeration.
The durable fix is a directory-completeness assertion (assert the scanned set equals the .rs files in
rewards/ minus copy.rs/clawback.rs) rather than five more include_str! lines. Follow-up ticket.

F3 (follow-up ticket, must land before ANY renderer) — one raw Unix timestamp painted as both an epoch index and a start date

clawback.rs:171let epoch = commitment.epoch_start.to_string(); — then fed to both
{$epoch_index} (:178, :181) and {$epoch_start_date} (:182).

In all 14 locales that renders (en, en.ftl:16-17): "Withdraw your commitment for epoch
1767225600?"
and "You committed 1.0 $DIG to epoch 1767225600, which starts 1767225600."

Both substitutions are false about their own subject: 1767225600 is not an epoch index, and it is not
a date. The consequence is subject-identity, not cosmetic — this identifier is the only thing in the
window that says which of a wallet's commitments is being withdrawn, and the withdraw button beside
it names a real amount. A user with two commitments cannot tell them apart, and the sentence asserts a
start date that was never formatted.

The comment at :166-170 self-classifies this as "a known display gap, not a security one". I do not
accept that classification, and the diff refutes it one screen up: :156-158 refuses the whole
window
rather than paint a stand-in zero for forfeited_amount. The same discipline applied to a
false identifier means open refuses until a real ordinal/date formatter exists — a Fluent placeable
cannot be omitted, so "render the raw integer" is the only alternative to refusing. Not merge-blocking
because nothing renders, but the code comment must not describe it as a display gap, and no renderer
may land on top of it.


Clean under attack

  • Attack 2 — the acceptance test is NOT vacuous.
    rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing
    (clawback.rs:317-348) genuinely goes RED if the == in prove (:125) is deleted: with
    then_some made unconditional, arm (b) assert!(prove(&viewer, &strangers_commitment).is_none())
    (:347) fails. Arm (a) alone would survive that deletion — the stranger arm is the load-bearing one,
    worth knowing, but it is present. The expected hash is derived independently
    (independently_derived_root_puzzle_hash, :224-235) through a second implementation over
    chia_bls::master_to_wallet_unhardened + StandardArgs::curry_tree_hash, not borrowed back from
    from_wallet_key; :296-300 proves the two agree. Assertions are on owned String fields of
    proven, so #3253's dropped-local/transmute UB shape is absent — and test_scan.rs:32-48 carries
    the de-unsafed extractor verbatim, comment and all. The fixture starts from a real BIP-39 mnemonic
    (:213-218) with entropy 0x7c deliberately distinct from dig-account's 0x42 golden vector, so it
    is neither re-deriving a pinned value nor starting from a state production cannot reach. Both
    compile_fail doctests DO run in CI (ci.yml:177, cargo test --doc --workspace --all-features).
    Minor: neither pins an error code — compile_fail passes on any compile error, and the second
    doctest's hidden fixture() -> … { todo!() } (:97-100) is exactly the kind of body that can start
    failing for an unrelated reason while still reporting success. Add compile_fail,E0603 and
    compile_fail,E0382. Non-blocking.
  • Attack 4 — the four-field decision holds, element AND wrapper. Nothing in the diff recomputes a
    share. forfeited = rewards_base_units.checked_sub(recoverable_base_units) (:156-158) subtracts two
    wire fields; no withdrawal_share_bps, no 9000, no / 10_000, no compiled-in constant anywhere in
    clawback.rs — SPEC §2.6 clause 2 respected by name. wire.rs is untouched and
    RewardDistributorCommitment still carries exactly four fields (wire.rs:101-106). Checking the
    wrapper specifically, per feat(app): reward distributor management pane (#3253) #403's reappearing trap: ProvenClawback (:132-142) carries four
    finished strings and no money type at all — no share, no bps, no partial figure — and each of
    slot_amount/returned_amount/forfeited_amount traces to a wire field or a checked_sub of two.
    The wrapper does not prescribe the banned defect.
  • Attack 5 — absence does not paint as a reassuring zero. checked_sub returns None and open
    refuses the whole window (:156-158), asserted by checked_sub_underflow_refuses_the_whole_window
    (:353-365). Swept the other direction too: RewardDistributorCommitment has no serde derive
    and no #[serde(default)], so there is no missing-field-decodes-as-0 path, and a genuine
    recoverable_base_units == 0 is a real chain value (everything forfeited), not an absence — "0 $DIG"
    is true there. amount_with_unit (amount.rs:173) keeps figure and unit inseparable.
  • Attack 6 — §12.5 clause 7 and the Q6 ruling hold. A non-matching commitment yields None from
    prove and, structurally, no ProvenClawback can exist without an authority to consume, so no
    string is produced at all
    — no partial window, no "hidden slot" placeholder, and no count anywhere
    in the diff, so no enumeration oracle. Nothing in the new copy or code exposes a never-admitted vs
    evicted distinction; no .ftl file was touched.
  • pane.rs (:596-601) is a verbatim helper move plus an import — behaviour-preserving, and it removes
    the second copy rather than adding one.

Verdict

CHANGES-REQUIRED at 7e2b6b1d. F1 is merge-blocking — private the four ProvenClawback fields
and add accessors, so the "sole producer" sentence this diff writes three times becomes true. F2's doc
correction at clawback.rs:86 should land in the same push
(one sentence; it is a custody
overstatement, and #403 round 3 established that the retraction belongs where the reader arrives, not in
a PR body). F2's enumeration-completeness assertion and F3's epoch identifier are follow-up tickets — F3
gated so no renderer lands on top of it. The two compile_fail error codes are a nit; take them or
leave them.

The witness design itself is right, and right for the reason it claims: prove takes the capability and
reads the comparand out of the record, so a caller can supply neither compared value. The failure is one
type further downstream than the gate was built.

KG: a-type-level-gate-can-be-airtight-up-to-its-product-type-and-a-pub-field-gives-the-whole-property-away

🤖 Generated with Claude Code

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

SECURITY audit — IN PROGRESS, not the verdict

Head audited: 7e2b6b1d9b59dd9a44385de3a46b78863e4f1f73. Read-only, fresh context.

Two findings so far, both LIVE. Posting as formed; verdict follows in a separate comment.


FINDING 1 — LIVE, gating. The witness is never bound to the commitment it was proved against.

crates/dig-app-core/src/rewards/clawback.rs:120-126 and crates/dig-app-core/src/rewards/clawback.rs:152-155

prove returns ClawbackAuthority { matched: viewer.0 } — it carries the hash, and nothing about the identity of the commitment it was proved against. open then takes a second, independent commitment parameter and never re-compares authority.matched against commitment.clawback_puzzle_hash.

So the two commitments need not be the same one. Concrete exploit, in-crate, two lines:

let viewer = ViewerPuzzleHash::from_wallet_key(&my_key);
let a = ClawbackAuthority::prove(&viewer, &my_own_slot).unwrap(); // matches: legitimate
let p = ProvenClawback::open(a, &strangers_slot).unwrap();        // renders the STRANGER's slot

p.confirm_body then renders, from en.ftl:17:

You committed <stranger's 1,000,000 $DIG> to epoch …. <stranger's 900,000 $DIG> returns to this wallet. … This is authorized by the key recorded when you committed — <the viewer's OWN hash> — and nothing else can authorize it.

Every figure is the stranger's. The one viewer-true value is the hash. This is the predecessor's total_paid_out_base_units defect precisely — correctly typed, read-only, verb-free, perfectly formatted, and false about whose money it is — except that here the deliberate and otherwise-correct choice at clawback.rs:164 to render authority.matched rather than commitment.clawback_puzzle_hash makes the forgery more convincing, because the audit hash shown genuinely is the viewer's own.

The doc comment asserts the property the compiler does not hold. clawback.rs:78-82:

A copyable witness could be spent against two different commitments' worth of confirm text without a second proveProvenClawback::open consumes this type by value for exactly that reason: one proof, one confirm window.

Non-Copy prevents one proof covering two commitments. It does not prevent one proof covering one wrong commitment, which is strictly worse. The compile_fail doctest at clawback.rs:96-106 proves only the move — and its own text writes open(authority, &commitment) twice, displaying the two-parameter seam that is the hole.

Remedy shape (not prescriptive): have prove hand back an authority that carries the commitment — e.g. ClawbackAuthority<'a> { matched: Bytes32, commitment: &'a RewardDistributorCommitment } — and let open(authority) take no second commitment. Then only one commitment is ever in play and the splice is not expressible. Re-verifying the equality inside open and returning None on mismatch also closes the live hole, but leaves the shape forgeable by a later edit.


FINDING 2 — LIVE gate-integrity, gating. pub const ALL_KEYS re-exports all four narrowed Msg by value, and copy.rs claims the opposite 95 lines above.

crates/dig-app-core/src/rewards/copy.rs:47-49 (the claim) vs crates/dig-app-core/src/rewards/copy.rs:144-155 (the hatch)

copy.rs:48 states the finished sentence is

reachable ONLY through super::clawback::ProvenClawback::open, which requires a super::clawback::ClawbackAuthority no caller can forge.

copy.rs:144-155 then publishes the same four values in pub const ALL_KEYS: &[Msg], and Msg::with is pub (crates/dig-app-core/src/i18n/mod.rs:173). From anywhere, including outside the crate:

dig_app_core::rewards::copy::ALL_KEYS[8]  // == CLAWBACK_CONFIRM_BODY
    .with(&Args::new()
        .text("returned_amount", "1,000,000 $DIG")
        .text("clawback_ph_short", "0xdead…beef")
        /* … */);

Full money-bound confirm body, attacker-chosen figures, no witness, no wallet key.

This defeats both guards at once, which is why it matters more than the disclosed Msg::new hop:

  • it names no forbidden string literal and no forbidden const identifier, so no_module_outside_clawback_names_a_clawback_key (clawback.rs:243-278) is structurally unable to see it;
  • the compile_fail doctest at clawback.rs:88-92 tests only the copy::CLAWBACK_CONFIRM_BODY path, so it stays green while the identical value is public under another name.

Closing it costs nothing. ALL_KEYS is referenced only at copy.rs:192 and copy.rs:265, both inside copy.rs's own #[cfg(test)] module. pub(super) or pub(crate) breaks no caller. At minimum copy.rs:47-49 must retract to what the compiler actually holds — the precedent pane.rs:646-658 set in this same module, and which clawback.rs's module doc correctly honours for the sibling-module hop but not for this one.

Separately, Msg::new("rewards-clawback-confirm-body") is publicly constructible (i18n/mod.rs:151). That one I rate defence-in-depth, not gating: it is not closable without changing Msg, and the source scan covers the five siblings. But it is the second reason the word "ONLY" at copy.rs:48 is wrong.

Minor hardening in the same area: both compile_fail doctests are unpinned. compile_fail,E0382 (move) and compile_fail,E0603 (privacy) would stop them going green later for a name or privacy error unrelated to the property claimed.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

SECURITY GATE VERDICT: CHANGES-REQUIRED

Head audited: 7e2b6b1d9b59dd9a44385de3a46b78863e4f1f73
Merge-base: d34f387c2cc3ab5b2064ac3634ef882dd15ec263
Read-only, fresh context. CI at this head is 11/11 green including Test + coverage and Doc-link hygiene (rustdoc) — finding 1 below is live underneath that green check-set.

Gating (both in the comment above)

  1. clawback.rs:120-126 + clawback.rs:152-155 — the witness is never bound to the commitment it was proved against. prove(viewer, A) yields an authority carrying only the hash; open(authority, B) takes a second independent commitment and never re-checks it. open(prove(&viewer, &mine), &strangers_slot) renders the stranger's slot_amount, returned_amount and forfeited_amount under "You committed …", "… returns to this wallet", beside the viewer's own genuine hash. LIVE. Doc at clawback.rs:78-82 asserts a containment the compiler does not hold.
  2. copy.rs:47-49 vs copy.rs:144-155pub const ALL_KEYS re-exports all four narrowed Msg by value. ALL_KEYS[8].with(…) renders the full confirm body from outside the crate with arbitrary figures and no witness, invisible to both the source-scan guard and the compile_fail doctest. ALL_KEYS is used only by copy.rs's own test module (copy.rs:192, copy.rs:265), so narrowing costs nothing. LIVE.

Non-gating

  1. clawback.rs:171 — raw Unix timestamp rendered for both epoch_index and epoch_start_date. en.ftl:17 reads "You committed X to epoch 1767225600, which starts 1767225600." Honestly disclosed at clawback.rs:166-170 and no surface renders it yet, so not gating — but a money-moving confirm window whose commitment identifier is unreadable cannot reach a user. Recommend a real ordinal/date formatter as an acceptance condition on the wiring ticket.
  2. SPEC §2.6 clause 5's distinction is unrepresentable. Nothing in clawback.rs/wire.rs can distinguish "nothing was committed" (a legitimate empty list) from "the commitments could not be read". Not this PR's job — nothing is wired — but it is forced at wiring time. Recommend a ticket.
  3. Unpinned compile_fail doctests (clawback.rs:88-92, clawback.rs:96-106). Pin E0603 / E0382 so neither can go green later for a name or privacy error unrelated to the claimed property.

Areas clear, with why

  • 1. Forge the witness — CLEAR. ViewerPuzzleHash(Bytes32)'s field is module-private and from_wallet_key (clawback.rs:71) is its only constructor; ClawbackAuthority.matched is module-private and prove its only constructor. Neither derives Clone/Copy/Default/DeserializeDebug only. No from_bytes, no pub field, no Serialize, no unsafe/transmute anywhere in the file, and no #[cfg(test)] constructor that mints either from raw bytes: the fixture goes through a real BIP-39 mnemonic into WalletKey::from_seed (clawback.rs:204-212). The rejected prove(viewer_ph, commitment_ph) shape is genuinely absent, not renamed — prove reads the comparand out of &RewardDistributorCommitment at clawback.rs:124. Derivation provenance is proven against a second independent longhand derivation (clawback.rs:224-235), not the code agreeing with itself. The capability is built soundly; finding 1 is that it is then spent against an unbound subject.
  • 3. The subject — hash CLEAR, amounts NOT. clawback.rs:164 correctly reads authority.matched, never commitment.clawback_puzzle_hash. The rendered hash can only ever be the viewer's. The rendered amounts are unbound — finding 1.
  • 4. Arithmetic and refusal — CLEAR. checked_sub(…)? at clawback.rs:156-158 refuses the whole window; no unwrap_or(0), no saturating_sub, no reassuring-zero path. No share is recomputed from a compiled-in constant: SPEC §2.6 clause 1 places rewards_base_units * withdrawal_share_bps / 10_000 on the responder, and withdrawal_share_bps appears in this crate only inside prose (wire.rs:83, client.rs:13, copy.rs:24) — never in an expression. forfeited = rewards − recoverable uses two wire fields and no constant.
  • 5. Enumeration — CLEAR. A non-matching commitment yields None from prove, and ProvenClawback is unconstructible without an authority to consume, so a stranger's slot produces no greyed row, no "not yours" sentence and no string at all. open is strictly per-slot with no aggregate and no count, so there is no ownership-enumeration oracle against a distributor's funders. Nothing in the diff touches entry admission or eviction (SPEC §12.5 clause 7).
  • 6. Key handling — CLEAR. from_wallet_key borrows &WalletKey and retains only key.puzzle_hash(), a public derived value the standing invariant test itself uses the same way (wallet/no_user_key_on_wire.rs:141). No key or seed material is stored, cloned into a long-lived struct, serialized, or reachable through Debug — both Debug impls print a Bytes32 puzzle hash only. No logging call of any kind in clawback.rs.

Scope

Audited at 7e2b6b1d: rewards/clawback.rs, rewards/copy.rs, rewards/mod.rs, rewards/pane.rs (diff only), rewards/test_scan.rs, rewards/wire.rs, i18n/mod.rs (Msg/Args visibility), amount.rs (short_asset_id_str), all 14 i18n/*.ftl clawback strings, and dig-rewards-coin SPEC §2.6 clauses 1-6.

Not covered: pane.rs's pre-existing WarningsShown witness (unchanged by this diff, out of scope per the module doc); the Cargo.lock/version bump beyond confirming it is a version-only change; runtime behaviour of the unwired surface; the non-English locale bodies were checked for placeable presence, not translation fidelity.

No shared checkout was mutated. D:/worktrees/dig-app-3281 was read-only throughout; git identity untouched.

… ALL_KEYS

Salvages a session-cap-killed dirty edit that half-applied S1's shape change
(ClawbackAuthority gained a commitment field but its constructor never set it,
and ProvenClawback::open still took a second, independent commitment parameter
-- worse than either end state, since the splice remained expressible).

- S1: ClawbackAuthority::prove now stores the matched commitment by value;
  ProvenClawback::open takes exactly one argument and reads every figure from
  authority.commitment, so the S1 splice (open(prove(&viewer,&mine).unwrap(),
  &strangers_slot)) is unrepresentable, not merely unchecked. Added a third
  compile_fail,E0061 doctest proving the second parameter no longer exists.
- F1: ProvenClawback's four fields are private with &str accessors;
  #[non_exhaustive] was considered and rejected (leaves fields writable).
  Corrected the doc comments that claimed the compiler already held this.
- S2: copy::ALL_KEYS is private and #[cfg(test)] (its only two callers are
  copy.rs's own test module; a non-test private const with no non-test
  caller is dead code under --no-default-features -D warnings). Corrected
  the "no caller can forge" / "ONLY reachable" overclaims to state
  reach-narrowing, since Msg::new + a literal key string still renders the
  sentence from any crate.
- F2: no_module_outside_clawback_names_a_clawback_key now enumerates
  src/rewards/ at test time via std::fs::read_dir instead of a hardcoded
  five-file list, so mod.rs, wire.rs and test_scan.rs (previously uncovered)
  are scanned and a tenth file is covered automatically.
- F3: corrected the doc classifying the raw-timestamp epoch_index/
  epoch_start_date collision as "a known display gap, not a security one" --
  it is the only identifier naming which commitment is being withdrawn and is
  not yet trustworthy. No wire field invented; the code fix is a follow-up
  ticket per the ruling.

dig_ecosystem#3281

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3281-clawback-authority branch from 5f71420 to fd3c69f Compare September 10, 2026 16:25
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

SECURITY GATE VERDICT: PASS

Head audited: fd3c69f95021388f6613f502cc09ad0f7af4c61e (fresh audit, no prior verdict carried forward).

Prior two gating findings — both verified closed, not just claimed

S1 (witness proved a hash but not its subject). ClawbackAuthority now carries the matched
RewardDistributorCommitment by value (clawback.rs:155-163), and ProvenClawback::open(authority: ClawbackAuthority) -> Option<Self> takes exactly one argument (clawback.rs:230). Read the body: every
figure (slot_amount, returned_amount, forfeited_amount, clawback_ph_short) is read from
authority.commitment / authority.matched, never a second caller-supplied record. open(prove(&viewer, &mine).unwrap(), &strangers_slot) is now a wrong-arg-count compile error, asserted by the E0061
compile_fail doctest at clawback.rs:89-99 (present, and it names the right error code). Searched the
crate for any second function that could reintroduce a two-source path (from_bytes, From, a public
setter on commitment) — none exists. Closed.

S2 (ALL_KEYS re-exported Msg by value, public). copy.rs:167ALL_KEYS is now
#[cfg(test)] + private (no pub), so it cannot be named from another crate at all, let alone indexed.
Confirmed its only two callers are the module's own completeness/forbidden-phrase test functions
(grepped the crate — no other file names ALL_KEYS). Confirmed the 14-locale/render sweeps still
exercise it, since those tests are unchanged and still compile against the same slice literal (just no
longer pub). Closed.

Adversarial F1 (ProvenClawback public mutable fields). clawback.rs:202-210 — all four fields are
private; accessors confirm_title()/confirm_body()/withdraw_button()/keep_button() return &str
borrowed from &self, cannot outlive or escape the value. No pub field, no From, no Default, no
builder function, no #[non_exhaustive]-only half-measure. Clone/PartialEq/Eq are still derived,
which is fine for a finished, immutable rendered value — cloning doesn't reopen mutability, since the
fields stay private in the clone too. No post-proof in-place mutation is possible from outside the
module (no &mut accessor exists). Closed.

Round-3 attack on the new shape (per the brief's "one type further out" thesis) — clean

  • ClawbackAuthority carrying RewardDistributorCommitment by value: RewardDistributorCommitment's
    own fields are pub(crate) (wire.rs:104-109, unchanged by this PR), so nothing new is readable
    cross-crate through ClawbackAuthority's Debug impl beyond what Debug on a pub(crate)-field
    struct already prints in-crate. ClawbackAuthority derives Debug only — no Clone/Copy — so the
    witness itself still cannot be duplicated for a second confirm window (verified via the E0382
    compile_fail doctest at clawback.rs:110-121, reused-witness case). The commitment is captured by
    value at prove() time (clawback.rs:159, commitment: *commitment) — a deliberate snapshot, not a
    live reference, so there's no stale-vs-live divergence hazard introduced here; it's the same
    single-read-then-render shape the rest of the pane already uses, and matches SPEC's per-slot (not
    aggregate, not polled) rendering model.
  • &str accessors: return borrows scoped to &self; cannot escape past the ProvenClawback's own
    lifetime. No 'static leak, no Box::leak, no raw pointer path.
  • from_wallet_key (clawback.rs:81-83): takes &WalletKey, calls key.puzzle_hash(), returns only a
    Bytes32. No field of WalletKey is stored, cloned, logged, or serialized anywhere in this diff; the
    key parameter is not held past the call. ViewerPuzzleHash's own Debug derive prints only the
    derived Bytes32, never key material. WalletKey itself is untouched by this PR (external
    dig_account type) — out of scope, no new exposure created.
  • Arithmetic: forfeited_base_units = commitment.rewards_base_units.checked_sub(recoverable_base_units)?
    (clawback.rs:245-248) — None on underflow refuses the WHOLE window (verified by
    checked_sub_underflow_refuses_the_whole_window, present and passing per CI). No path constructs a
    fallback/reassuring 0. No withdrawal_share_bps or compiled-in split constant appears anywhere in
    this diff — recoverable_base_units is read verbatim off the wire commitment, matching SPEC §2.6
    clause 2's prohibition (the type's own doc names the prohibition explicitly, wire.rs:82-86).
  • Enumeration: ClawbackAuthority::prove returns Option and a non-match yields None with nothing
    else observable — no partial ProvenClawback, no count, no "not yours" branch anywhere in this file or
    its callers (there ARE no callers yet outside tests — see below). No greyed-row/hidden-count oracle
    exists because nothing renders a list of commitments in this PR at all.

Scope note (not a finding)

ClawbackAuthority/ProvenClawback have no call site outside #[cfg(test)] yet — pane.rs does not
invoke clawback:: at all (grepped the crate for ClawbackAuthority|ProvenClawback|clawback:: outside
clawback.rs/test_scan.rs: only doc-comment cross-references in copy.rs). This PR is the
foundation/gate only, consistent with RewardDistributorCommitment's own doc calling itself "inert in
this pass." Nothing to exploit at a live UI surface yet; the property under audit is that the type CANNOT
be misused once it is wired, which holds.

Residual tickets — confirmed honest, not re-filed

  • dig_ecosystem#3289 (epoch_index fed the raw Unix timestamp) matches clawback.rs:171-183's own
    comment verbatim, including the corrected framing ("NOT merely a display gap"). Open, not duplicated
    here.
  • dig_ecosystem#3290 ("nothing committed" vs "could not be read" unrepresentable) matches the current
    reality — no reading-state enum for commitments exists in this diff; still inert. Open, not duplicated.

Neither ticket's defect is reachable in this diff (no renderer exists yet for either), so correctly
non-blocking.

Verified independently, not trusted from the commit message

  • Re-read clawback.rs, copy.rs, wire.rs, mod.rs, pane.rs's diff and test_scan.rs in full from
    git objects at fd3c69f9, not from the PR description.
    Confirmed the transmute-UB test helper (dig_ecosystem#3253 adversarial finding 2) stays fixed in its
    new home (test_scan.rs:30 — owned Strings, no unsafe).
  • Confirmed CI is green on this diff (Clippy, Test+coverage, Doc-link hygiene, Rustfmt, Headless build,
    version/phase/lane guards all pass; only Native confirmer (windows-latest) is pending, the same
    slow-cold-cache pattern noted non-blocking on the prior head, not implicated by this diff's code).

What I did not cover

Did not re-audit pane.rs's unrelated warning-block/status sentences (WARNING_*, STATUS_*) — outside
this PR's diff and outside the clawback subject. Did not run the test suite myself; relied on CI's own
green run plus reading the test bodies to confirm they assert the right thing (not just that they exist).

KG: dig_ecosystem#3281 — round 2 of a "fix opens the next seam one type further out" pattern
(ClawbackAuthority folding the commitment in by value, private fields + accessors) closed a witness/
subject-binding class cleanly on the second attempt; worth a wiki/dig pattern entry alongside the
total_paid_out_base_units 250x predecessor if not already written.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial re-gate (third leg) — head fd3c69f9 — CHANGES-REQUIRED (two doc sentences; no code change needed)

Fresh read, prior verdict on 7e2b6b1d discarded. Read-only; nothing edited, committed or pushed.

The restructure holds. open taking exactly one argument makes the S1 splice unrepresentable rather than re-checked, the fields are private with &str accessors, ALL_KEYS is #[cfg(test)]-private, and the scan enumerates src/rewards/ with a named-file non-vacuity floor. Both surviving findings are claims stronger than what the compiler holds — the same class as rounds 1-3, now living in prose rather than in a type.

F1 (blocking) — crates/dig-app-core/src/rewards/wire.rs:88: a clause this very diff falsified

/// This type is inert in this pass: nothing constructs, reads or paints from it.

At this head clawback.rs:170 constructs a RewardDistributorCommitment into the witness and ProvenClawback::open reads all four fields and paints three of them as $DIG amounts. The sentence was true when written and went false by merge. Money consequence: the next reader of the wire type is told nothing reads it, so they will change a field's meaning without looking at the custody gate that now renders it. One-sentence fix.

F2 (blocking) — crates/dig-app-core/src/rewards/clawback.rs:3-5: "real amounts" claims provenance the witness does not carry

//! ... the four finished rewards-clawback-* sentences bound to a commitment's **real amounts** and hash.

What prove establishes is hash control, not record provenance. RewardDistributorCommitment's fields are pub(crate) (wire.rs:105-108), so any module in dig-app-core can write the literal
RewardDistributorCommitment { clawback_puzzle_hash: key.puzzle_hash().into(), rewards_base_units: 999_999_000, recoverable_base_units: 0, epoch_start: .. }
dig_account::WalletKey::puzzle_hash() is public, so the viewer side is trivially satisfied — prove returns Some, and open renders "You committed 999,999 $DIG … returns to this wallet" beside the viewer's genuine hash. Airtight per the witness's actual property; false about the money. Nothing binds the commitment to a chain read, because no chain read exists yet (client.rs does not adopt dig.listRewardDistributorCommitments). The honest words are "the commitment it was proved against", and the # What this does NOT prove section needs a third bullet: the witness proves control of the hash in a record, never that the record came from the chain; provenance arrives with the RPC seam. This is the round-3 version of the class the brief predicted — one type further out: hash bound, subject bound, origin unbound.

F3 (follow-up, not blocking) — the two known-false shapes are described but not citable

clawback.rs:252-266 documents the raw-timestamp-for-{$epoch_index} defect fully and honestly but calls it only "a follow-up ticket" — dig_ecosystem#3289 appears nowhere in the crate; grep '3290\|clause 5' across crates/ is empty, so SPEC §2.6 clause 5's two unrepresentable absences have no note where the next reader arrives (wire.rs's commitment doc). Add both numbers in-code.

Attacks that did not survive (each checked, each clean)

  1. Stale/divergent held commitmentmatched and commitment are assigned in one expression from the two compared values (clawback.rs:167-171); the field is private with no accessor and no &mut, and the type is not Clone, so there is no path where matched != commitment.clawback_puzzle_hash. Cross-crate Debug on ClawbackAuthority would print the pub(crate) figures, but an external crate cannot obtain an authority: it cannot construct a commitment (private fields, no constructor) and prove needs one. Snapshot-vs-displayed-record staleness is latent only — the gate has zero callers today; worth one doc line when it is wired, not now.
  2. Acceptance test vacuity after the restructurerendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing (clawback.rs:458-495) arm (b) is still a live runtime assertion (prove(&viewer, &strangers_commitment).is_none()), not a compile error: deleting the == in prove makes prove infallible and that assert fires. Arm (a) alone would pass under the defect; arm (b) is what carries it. Non-vacuous. Note the "verified red by hand" claim in the file is the author's, not mine — I verified the property structurally, not by mutating and running.
  3. §2.6 clause 2 in the wrapper — the wrapper is the witness, and it carries the commitment whole, by value; ProvenClawback is finished display text only, so there is no incomplete money type prescribing a recompute. commitment_has_exactly_the_four_spec_fields_and_no_more still has no ...
  4. Absence painting as zero — forfeited is checked_sub(...)? and refuses the whole window, with checked_sub_underflow_refuses_the_whole_window covering it; no other $DIG-labelled figure has a zero fallback path.
  5. §12.5 clause 7 / Q6 — no count, no "hidden slots" figure, no never-admitted-vs-evicted signal anywhere in the diff; a non-matching commitment yields None and no string is produced at all.

CI is green (Clippy, rustfmt, doc-link, headless --no-default-features, tests+coverage; windows confirmer still running).

Verdict: CHANGES-REQUIRED — F1 and F2 only, both prose, no logic change. Re-gate is a diff read, not a full pass. F3 lands as ticket edits.

KG: pattern/a-witness-can-bind-the-hash-and-the-subject-and-still-not-bind-the-origin

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

CORRECTNESS GATE (L2 review, first run on this PR) - PASS

Head reviewed: fd3c69f95021388f6613f502cc09ad0f7af4c61e. Fresh context, read every changed line from git objects at this head (not the PR description), read-only in D:/worktrees/dig-app-3281. No prior correctness/L2 review has ever run on #404 -- this is a full review of the whole diff against merge-base d34f387c2cc3ab5b2064ac3634ef882dd15ec263, not a delta review.

This PR already carries an independent adversarial round (loop-decider, CHANGES-REQUIRED at 7e2b6b1d, two findings) and an independent security gate (loop-security, CHANGES-REQUIRED at 7e2b6b1d, then PASS at this exact head fd3c69f9). I re-verified their closed findings myself rather than trusting the transcripts, plus ran the checks assigned to this gate specifically.

Acceptance criteria -- checked one by one

  • Witness airtight, subject bound (S1/round-2 fix). ClawbackAuthority { matched: Bytes32, commitment: RewardDistributorCommitment } (clawback.rs:155-164), both fields private, only constructor is prove(&ViewerPuzzleHash, &RewardDistributorCommitment) -> Option<Self> (clawback.rs:171-180). ProvenClawback::open(authority: ClawbackAuthority) -> Option<Self> takes exactly one argument (clawback.rs:249) -- the S1 splice open(prove(&viewer,&mine).unwrap(), &strangers_slot) is a compile error (E0061), not a runtime re-check. Every figure inside open reads from authority.commitment/authority.matched only -- no second commitment in scope to diverge from. Confirmed by reading the body, not the comment.
  • ProvenClawback fields private (F1 fix). clawback.rs:202-211 -- all four fields private, &str accessors only (clawback.rs:215-234), no pub field, no From/Default/builder/#[cfg(test)] forge constructor anywhere in the file. Clone/PartialEq/Eq stay (finished text, not a capability) -- correctly reasoned in the doc, and true: cloning doesn't reopen the private fields.
  • ALL_KEYS private (S2 fix). copy.rs:163-164 -- #[cfg(test)] const ALL_KEYS (no pub). Grepped the crate: only callers are copy.rs's own two test functions (copy.rs:211, copy.rs:285). Cannot be named from outside the crate, or even from a non-test build inside it -- closes the sibling-forgery hole cleanly.
  • copy::CLAWBACK_* narrowed to pub(super). copy.rs:60,66,70,72. no_module_outside_clawback_names_a_clawback_key enumerates src/rewards/ via std::fs::read_dir (clawback.rs:380-413), not a hardcoded list -- verified by ls: the directory holds cadence.rs, clawback.rs, client.rs, copy.rs, mod.rs, pane.rs, reading.rs, tab_placement.rs, test_scan.rs, wire.rs (10 files), 2 excluded by name (clawback.rs, copy.rs -- both justified: self-referential/defining), leaving 8 scanned by the real enumeration. The must_be_scanned assertion only requires 4 of those 8 by name as a floor against the enumeration silently returning nothing (a moved CARGO_MANIFEST_DIR) -- the other 4 (cadence.rs, client.rs, reading.rs, tab_placement.rs) are still covered by the loop itself, just not asserted by name; that's an acceptable non-vacuity floor, not a coverage gap. the_guard_itself_trips_on_a_planted_key (clawback.rs:432-435) proves the extractor itself is live.
  • Wire stays 4 fields. wire.rs:104-109 untouched, still exactly epoch_start, clawback_puzzle_hash, rewards_base_units, recoverable_base_units. No withdrawal_share_bps, no /10_000, no hand division anywhere in clawback.rs -- forfeited_base_units = rewards_base_units.checked_sub(recoverable_base_units)? (clawback.rs:251-253), None refuses the whole window, never a stand-in zero -- confirmed by checked_sub_underflow_refuses_the_whole_window (clawback.rs:496-509), which constructs an inconsistent commitment (recoverable > rewards) and asserts open(...).is_none().
  • Money honesty. Every rendered figure (slot_amount, returned_amount, forfeited_amount) goes through amount_with_unit(Asset::DIG, ...) (clawback.rs:277-279) -- no raw integer, no /1_000, no hand-rolled divisor anywhere in the diff. Asset::DIG/ticker() are pre-existing, untouched infrastructure outside this diff's blast radius.
  • Hardcoded English (historical blind spot check). Grepped clawback.rs and copy.rs for any pub/pub(super) function returning a literal English sentence outside the Msg/fluent path -- none found; all four rendered strings go through copy::CLAWBACK_* -> Msg::with/.text() into the existing 14-locale catalog, confirmed present in all 14 .ftl files (grep -l rewards-clawback-confirm-title *.ftl = 14). No .ftl file is touched by this PR, matching the claim.
  • Doc honesty. Scanned for overclaim phrasing ("no caller can forge", "unreachable"): none remains in clawback.rs/copy.rs at this head. clawback.rs:7 explicitly states the narrower true claim ("narrower than 'the sole producer of those sentences'"), and copy.rs:47-59 states the Msg::new/literal-key hatch honestly rather than claiming unreachability. This is the corrected language from the adversarial/security rounds, verified present, not just asserted fixed.
  • compile_fail doctests pin error codes. clawback.rs:118 = compile_fail,E0061 (wrong arg count), clawback.rs:137 = compile_fail,E0603 (private path), clawback.rs:144 = compile_fail,E0382 (use-after-move). All three name the specific code, not a bare compile_fail. CI's Doc-link hygiene / doctest run is green at this head.
  • Non-vacuous acceptance test. rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing (clawback.rs:461-492): expected hash comes from independently_derived_root_puzzle_hash() (clawback.rs:328-339), a second, independent implementation via chia_bls::master_to_wallet_unhardened + StandardArgs::curry_tree_hash -- not borrowed back from from_wallet_key under test. Assertions are on owned Strings returned by the accessors (proven.confirm_body() etc.), not a &str borrowed from a dropped local -- confirmed test_scan.rs:32-48 (the moved extractor) still returns owned Strings, no unsafe/transmute. Reasoned through by hand: deleting the viewer.0 == commitment_ph equality in prove (making it unconditionally Some) would make the stranger arm's assert!(ClawbackAuthority::prove(&viewer, &strangers_commitment).is_none()) (clawback.rs:491) fail -- the test does go red on the exact defect it exists to catch.
  • Version bump. Cargo.toml/Cargo.lock 15.5.1 -> 15.5.2, workspace-root file, matches "Check version increment" green.
  • Residual tickets honestly documented, not re-filed. dig_ecosystem#3289 (raw epoch timestamp) matches the comment at clawback.rs:261-274 verbatim including the corrected "NOT merely a display gap" framing. dig_ecosystem#3290 ("nothing committed" vs "could not be read") -- no reading-state enum exists in this diff, consistent with the ticket being still-open and unaddressed here. Neither re-filed in this review.
  • Untouched-and-passing set (commitment_has_exactly_the_four_spec_fields_and_no_more, tab_id_all_is_still_the_six_labels, activity_tab_emits_zero_action_rows, the five warning blocks) -- confirmed via git diff --stat these files/tests are not touched by this diff; CI reports them passing.
  • Blast radius. Grepped the crate for ClawbackAuthority|ProvenClawback|clawback:: outside clawback.rs/copy.rs/test_scan.rs -- zero hits. Nothing renders this yet, matching the PR's own claim. pane.rs's diff is a verbatim test-helper move (function_body/string_literals -> test_scan.rs) plus one use line -- behaviour-preserving, confirmed by reading the diff line-for-line.

CI

11 required/guard checks green by name at fd3c69f9: Rustfmt, Clippy, Test + coverage (>=80% lines, gated), Check version increment, Lint commit messages, Headless build, Doc-link hygiene, Phase-stamp guard, Lane-scratch guard, plus Native confirmer (macos-latest). Only Native confirmer (windows-latest) is pending (not in the required set per the dispatch brief).

What I did not do

Did not re-run the test suite myself locally (CI's own green Test + coverage run, read test bodies to confirm they assert the right thing rather than just exist, and independently re-derived what the stranger-arm deletion would do by hand). Did not re-audit pane.rs's pre-existing WarningsShown/status-copy shape -- out of this diff's scope, called out by the PR's own module doc as a separate, unresolved defect class. Did not re-verify WalletKey::puzzle_hash()'s derivation math beyond confirming the test's independent second derivation agrees with it (from_wallet_key_matches_the_independently_derived_curry_tree_hash) -- dig-account internals are outside this crate's diff.

No new findings from this gate. Both prior gating rounds are verified closed at this head, not just claimed closed.

Verdict: PASS
Head: fd3c69f95021388f6613f502cc09ad0f7af4c61e

KG: a-type-level-gate-can-be-airtight-up-to-its-product-type-and-a-pub-field-gives-the-whole-property-away (confirms the prior adversarial-gate KG entry -- closed correctly on the second attempt, verified independently rather than trusted)

…1 created

- wire.rs: RewardDistributorCommitment's doc claimed "nothing constructs,
  reads or paints from it" -- false since the S1 fix: ClawbackAuthority::prove
  now constructs it into the witness and ProvenClawback::open reads three of
  its fields. Corrected.
- clawback.rs module doc: names the untracked gap this module's "bound to a
  commitment" claim does NOT cover -- record PROVENANCE (parsed chain read vs.
  in-crate struct literal) is not established by `prove`, tracked as
  dig_ecosystem#3294.
- F3's promised follow-up ticket is now named: dig_ecosystem#3289 (the
  epoch_index/epoch_start_date raw-timestamp gap).
- Names dig_ecosystem#3290 for the brief's SPEC §2.6 clause 5 note ("nothing
  committed" vs. "could not be read" is unrepresentable in these types today)
  next to the related wire-gap comment it sits beside, doc-only, no field
  added.

dig_ecosystem#3281

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider (adversarial confirm of my own F1/F2/F3) — CHANGES-REQUIRED

Head judged: a22507467249c227ebbed7434fbc50d04d799a44. Prior verdict was CHANGES-REQUIRED on
fd3c69f9. I re-judged prose only; I did not re-audit the gate mechanics that reviewer and security
passed at fd3c69f9, and I confirmed independently that the delta is comments-only.

F1 — wire.rs:88 "this type is inert" → STILL OPEN, for a new reason

The retraction itself is correct. ClawbackAuthority::prove (crates/dig-app-core/src/rewards/clawback.rs:184-193)
copies the commitment by value into the witness (commitment: *commitment), and ProvenClawback::open
(clawback.rs:263-322) reads rewards_base_units, recoverable_base_units and their checked_sub
difference and paints all three through amount_with_unit(Asset::DIG, …). So the old "nothing
constructs, reads or paints from it" is properly withdrawn, and the new "the next reader must not
assume no custody gate reads this type" is true at this head.

The new "What is still true" clause is where the fix reintroduces the defect it was fixing.
wire.rs:96-97:

and nothing in dig-node serves the RPC yet either (PRs #593/#594 open, unmerged)

Both are merged: DIG-Network/dig-node#593 merged 2026-09-09T12:26:12Z, #594 merged
2026-09-10T06:32:19Z — before this head. The conclusion survives (I checked:
ListRewardDistributorCommitments appears nowhere in dig-node, and #594's file list is
crates/dig-node-service/src/rewards_claim/*, a peer-side claim loop, not an RPC handler) — but the
sentence presents a falsified fact as the evidence for the security-relevant claim "never a parsed
chain read". F1 existed precisely because a clause went false by a change made elsewhere; this clause
is already false by a merge that happened elsewhere, on the day of the fix. Same defect, different
words.

Required: state the checked fact, not the ticket state — e.g. "both merged (#593 2026-09-09,
#594 2026-09-10), and neither serves the method: no ListRewardDistributorCommitments handler exists
in dig-node". A fact a reader can re-verify in the code, rather than a PR state that expires.

Fold in one nit while you are in the sentence: "every value of this type in the running program
today
is an in-crate struct literal (fixture or otherwise)". There are no values in the running
program at all — the only construction sites are clawback.rs:365 and clawback.rs:524 (both inside
#[cfg(test)]) and a todo!() doctest at clawback.rs:136. The universal is vacuously true, but it
implies production constructs commitments today; "every value that exists today lives in this crate's
own tests and fixtures" is the true and stronger statement.

F2 — clawback.rs:3-5 "real amounts" → CLOSED

clawback.rs:1-8 now binds the sentences to "the amounts carried by the commitment
ClawbackAuthority::prove was called against, and to the hash it matched", and states the non-claim
about provenance explicitly. That matches the code exactly: open reads only authority.commitment
and authority.matched, and prove compares a ViewerPuzzleHash derived from a real WalletKey
against commitment.clawback_puzzle_hash. The third What this does NOT prove bullet
(clawback.rs:53-63) names ORIGIN, cites dig_ecosystem#3294, and says in terms that it is not
closed by a visibility change or another doc sentence. Nothing here reads as "the outward march of
this defect class has finished": reach-narrowing-not-unreachability is restated, and three open
tickets are named.

F3 — the three tickets → CLOSED

All three land where the next reader arrives, and all three exist and are OPEN:

  • #3289clawback.rs:278 and :288, at the epoch render site.
  • #3290clawback.rs:292-299, beside the same render site.
  • #3294clawback.rs:60 (module doc, What this does NOT prove) and wire.rs:99.

The SPEC characterisation is right, and stronger than the comment claims. §2.6 clause 2 bans the
compiled-in epoch length by name: "epoch_seconds is echoed for the same reason … a caller
derives an epoch's end as epoch_start + epoch_seconds … and MUST NOT hardcode 604_800". So
"deriving an index from a compiled-in epoch length is the SPEC §2.6-clause-2 shape" is literally
correct, not an analogy. §2.6 clause 5's "nothing committed" vs "could not be read" matches #3290's
description. #3294 is not presented as doc- or visibility-closable.

My own sibling-clause sweep of wire.rs — one finding, above

Verified rather than trusted, clause by clause:

  • wire.rs:33-35 RewardCounters "the real transport … is not wired yet … a constructor with no
    caller is dead code today" — TRUE. The only RewardCounters construction sites are
    pane.rs:296, reading.rs:155 and wire.rs:156, all after a #[cfg(test)] at pane.rs:276,
    reading.rs:136 and wire.rs:125 respectively. rewards/mod.rs:9-10 agrees ("the real transport
    is not wired yet").
  • wire.rs:53-56 RewardDistributorStatusRecord docs — TRUE, reading is still the only place the
    record becomes prose.
  • wire.rs:104-108 the pub(crate) rationale, "unreachable from any client method" — TRUE.
    client.rs:9-20 documents that dig.listRewardDistributorCommitments is deliberately absent from
    RewardsClient, and clawback reaches the type by an in-crate struct literal, not a client method.
  • wire.rs:82-86 "SPEC §2.6 clause 2 forbids BY NAME recomputing a share from a compiled-in
    constant" — TRUE, verbatim clause 2.
  • wire.rs:96-97 — the one falsified clause, above.

Advisory only, no change required: wire.rs:85-86 "This type carries the chain's own already-computed
answer instead" sits a little oddly beside the new :97 "every value … is an in-crate struct literal".
Both are true (one is field semantics, one is today's population), and :93 already reconciles them.

#[cfg(test)] ALL_KEYS — no false doc, sweep still exercised

copy.rs:152-162 describes it accurately for a non-test build: it says #[cfg(test)], names its two
callers (every_copy_key_renders_in_the_active_language, no_rewards_copy_contains_a_forbidden_phrase),
and explains that a plain private const with no non-test caller would trip -D warnings' dead-code
lint in a --no-default-features release build. clawback.rs:12's "is now private" understates rather
than overstates. No doc anywhere claims ALL_KEYS feeds the 14-locale sweep — copy.rs:206-209
explicitly disclaims it.

The 14-locale sweep is genuinely exercised and does not depend on ALL_KEYS:
i18n/tests.rs:86 every_locale_carries_every_key_and_no_more is driven by
every_msg_key_in_source() (i18n/tests.rs:21), a source scan for Msg::new keys, cross-checked
against catalog_ids(lang) over the 14 embedded catalogs (i18n/catalog.rs:1). The CLAWBACK_*
consts are still Msg::new("rewards-clawback-…") literals in source, so gating ALL_KEYS to test
builds removed nothing from that sweep.

One line to green

Rewrite wire.rs:96-97 so the "still true" clause rests on a re-verifiable code fact (no
ListRewardDistributorCommitments handler in dig-node) instead of two merged PR numbers, and take
the "in the running program today" nit in the same hunk. Comments-only again; nothing else in this
PR is in question.

KG: a-true-doc-clause-goes-false-the-moment-its-subject-ships — extended: the clause can be born
false, when the fix cites a PR state as evidence and that PR merged the same day. Cite the code fact,
never the ticket state.

🤖 Generated with Claude Code

…cation

wire.rs's RewardDistributorCommitment doc cited dig-node#593/#594 as
"open, unmerged" -- both had merged before this head, reintroducing the
exact staleness pattern already banned on #402 (cite a durable version or
code fact, never another repo's PR state). Replace it with a dig-app-
internal fact instead: no constructor of this type anywhere in this crate
sits outside #[cfg(test)] code (verified: clawback.rs:364,524 and
wire.rs:180 are all under #[cfg(test)] mod tests). Also reworded "every
value ... in the running program today" so it no longer implies a
production value exists -- it does not, only test fixtures do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider re-gate (round 4, doc-honesty only) -- PASS

Head judged: 77b3d9fd2c24f3644e90a3e365ceaf85372604fa. Read-only; scope limited to the new prose at crates/dig-app-core/src/rewards/wire.rs:88-100. F2/F3 not revisited (confirmed closed at a2250746).

F1: CLOSED

The born-false citation (#593/#594 open, unmerged) is gone. Every fact the clause now rests on is verifiable inside dig-app's own source at this head.

The three cited facts

  1. RewardsClient does not adopt dig.listRewardDistributorCommitments -- TRUE, durable. rewards/client.rs:53-60 declares exactly three methods (list_distributors, prover_status, distributor); the only occurrences of the method name in the crate are explanatory doc comments (client.rs:9,20, mod.rs:8, wire.rs:76,93, clawback.rs:60). In-crate fact, falsifiable only by an edit in the same crate.

  2. "no constructor of this type anywhere in this crate sits outside #[cfg(test)] code" -- TRUE, durable. Construction sites: rewards/clawback.rs:365 and :524, both inside the #[cfg(test)] mod tests opened at clawback.rs:328; rewards/wire.rs:179, inside the #[cfg(test)] module at wire.rs:124. No others.

  3. "this type has no production value at this head, only test fixtures" -- TRUE, durable, and correctly weaker than the phrasing it replaced. Verified transitively: the type derives only Debug, Clone, Copy, PartialEq, Eq (wire.rs:108) -- no Deserialize, no Default -- so a value can only originate at a constructor, and all three are test-only. ClawbackAuthority::prove (clawback.rs:185-194) copies a caller-supplied &RewardDistributorCommitment (commitment: *commitment), which is not an origin; and it has no caller outside clawback.rs's own test module (the prove( hit in profile_melt/mod.rs:380 is an unrelated local fn declared at :384).

The doctest stub does NOT falsify fact 2

clawback.rs:136 is # fn strangers_commitment() -> ...RewardDistributorCommitment { todo!() }. Two independent reasons: (a) it declares a return type and its body is todo!() -- it constructs no value; (b) it sits in a ```compile_fail,E0061 block, which by contract never compiles successfully, and a doctest compiles as a separate crate against dig_app_core rather than "in this crate". The sentence is exact as written.

No external-PR-state or release-state citation survives in wire.rs

Swept for the shape, not just the old patterns. What remains: wire.rs:4 and :77 cite dig-rpc-protocol v0.11.0 -- a shipped, published version, which is precisely the remedy recorded on #402, not the banned shape; wire.rs:34 ("not wired yet") and :105 ("nobody is using yet") are claims about this crate's own code. No assertion anywhere in wire.rs about another repo's PR status, unmerged branch, unserved method, or unreleased version.

Does the fix carry the class forward one more hop?

No. The three prior rounds each rested on a fact owned by another repo or another timeline; all three facts now live in crates/dig-app-core/src/rewards/** and go false only by an edit in the same crate -- the same diff a reviewer is already reading. The one residual sensitivity, recorded rather than blocking: fact 3 goes false the moment a production caller of ClawbackAuthority::prove appears. That is the intended, locally detectable failure mode, and #3294 already owns closing it.

Logic is byte-identical to fd3c69f9 (delta is 4/5 lines, zero non-comment) which loop-reviewer and loop-security both passed. No code change requested. PASS -- merge-ready on this verdict.

KG: a-born-false-citation-is-fixed-by-citing-an-in-crate-code-fact-not-a-better-external-fact

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 10, 2026 17:19
@MichaelTaylor3d
MichaelTaylor3d merged commit 9c85d14 into main Sep 10, 2026
11 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/3281-clawback-authority branch September 10, 2026 17:19
Sign up for free to 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.

1 participant