Skip to content

feat(rewards): four-state commitments reading (#3290) - #422

Merged
MichaelTaylor3d merged 11 commits into
mainfrom
loop/3290-commitments-reading
Oct 1, 2026
Merged

MichaelTaylor3d merged 11 commits into
mainfrom
loop/3290-commitments-reading

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

SPEC.md (dig-rewards-coin) §2.6 clause 5 (byte-identical across v0.3.0/0.7.0/0.9.0):

The surface MUST distinguish "nothing is recoverable, because nothing was committed" from "the commitments could not be read."

Before this change, dig-app had no representation of either state: no commitment-collection type existed in wire.rs/clawback.rs, and ProvenClawback::open had no production caller.

What this adds

A three-state CommitmentsReading enum (crates/dig-app-core/src/rewards/wire.rs):

  • Unreadable(reason) — the chain source could not answer at all.
  • NothingCommitted { observed_at } — the distributor exists, read succeeded, zero outstanding commitment slots.
  • Committed { slots, observed_at, epoch_seconds } — one or more outstanding commitment slots.

Derived at wire.rs::commitments_reading_from_slots (pure, unit-tested directly against hand-built RewardDistributorCommitmentSlotValue values), glued to a live chain read at chain_read.rs::commitments_reading (thin wrapper over dig_rewards_coin::state::read_distributor), and rendered at clawback.rs::commitments_reading_sentence through three new fluent keys added to all 14 locale catalogs (English text copied byte-exact per dig-app#419 precedent — money copy is never machine-translated).

A chain-read failure renders as "could not be read," never as a bare zero (dig_ecosystem#3427), and never collapses into "nothing committed" (dig_ecosystem#3290 itself).

Why there is no NoDistributor state

An earlier revision had a fourth state, NoDistributor, for read_distributor(..) == Ok(None). The adversarial gate refuted it: the only production source, ControlChainSource::coin_record (chain/source.rs), answers coinById from the fallback tier with synced: false on every reply, so Ok(None) carries no warrant that the distributor does not exist. Rendering it as "no distributor exists" would be an unconfirmable claim on a money surface. It now maps to Unreadable("the chain source could not confirm the distributor exists"), a member of the closed static reason set. (ChainReadRewardsClient::distributor() has the same pre-existing defect and is filed separately.)

dig_ecosystem#3439 — no recoverable-share figure in this PR

While this PR was in progress, dig_ecosystem#3439 (priority:1-high, confirmed against a real validator) found that rewards_base_units * observed withdrawal_share_bps / 10_000 — the formula this PR originally computed off the chain-observed, curried bps — reports a nonzero recoverable amount for a commitment the chain will still refuse: a clawback against an already-started epoch is rejected by the puzzle's own compiled-in ASSERT_BEFORE_SECONDS_ABSOLUTE(epoch_start), which that arithmetic takes no account of. Offering a recoverable figure the chain will not pay is worse than offering none, at the moment a user is deciding about money (same rule as dig_ecosystem#3427).

CommittedSlot therefore carries only the raw, chain-observed fields (epoch_start, clawback_puzzle_hash, rewards_base_units) — no derived recoverable amount is computed, carried or rendered anywhere in this PR. The nothing-committed vs could-not-be-read distinction SPEC §2.6 clause 5 asks for is still exactly what's built; only the recoverable-amount derivation is withheld pending #3439.

The wire.rs:81-87 doc correction

The existing doc comment said computing recoverable_base_units in-app is "exactly the banned defect." That's imprecise: SPEC §2.6 clause 2 bans a compiled-in share (a literal or crate constant), not one derived from an observed, curried value read fresh off a chain walk — chain_read.rs's current_distributor_epoch_start already sets this precedent (dig_ecosystem#3262). The doc now states that distinction, and separately notes that an observed-and-curried bps is not automatically safe to render (dig_ecosystem#3439) — a different concern from clause 2's compiled-in ban.

Version bump

Workspace root Cargo.toml version: 15.14.8 → 15.15.0 (new capability, not a patch).

Blast radius

Touched: wire.rs, chain_read.rs, clawback.rs, copy.rs, all 14 .ftl catalogs, workspace Cargo.toml/Cargo.lock (version-only). No dependency pins changed (dig-account = "0.34", dig-rewards-coin = "0.9" untouched, already at the versions this ticket's brief specifies). No dig-rpc-protocol dependency added. No clawback control painted, no unsigned path called, src/control.rs untouched.

Tests added

  • wire.rs: empty_slots_read_as_nothing_committed_not_unreadable, nonempty_slots_read_as_committed_with_no_recoverable_figure, unreadable_and_nothing_committed_are_never_equal.
  • chain_read.rs: a_chain_source_error_makes_commitments_reading_unreadable, an_unwarranted_absent_launcher_reads_unreadable_not_no_distributor.
  • clawback.rs: unreadable_never_renders_the_same_as_nothing_committed, every_commitments_reading_variant_renders_distinct_nonempty_text.

Verified locally

cargo fmt --all -- --check clean; cargo test -p dig-app-core --lib rewards:: 186 passed. cargo clippy -D warnings not run locally; CI covers it.

Out of scope, not chased here

  • dig_ecosystem#3289 (the epoch_index display-humanization gap in clawback.rs) — pre-existing, unaffected by this change.
  • This shape also closes the #3294 forging route without waiting on #3342 for the read-only CommitmentsReading surface specifically (it constructs no RewardDistributorCommitment, so it is outside that type's forging boundary) — noted for awareness only, not widened here.

Refs DIG-Network/dig_ecosystem#3290

🤖 Generated with Claude Code

MichaelTaylor3d and others added 7 commits September 27, 2026 09:57
SPEC.md §2.6 clause 5 requires the surface distinguish "nothing is
recoverable, because nothing was committed" from "the commitments could
not be read." Neither state had any representation in dig-app.

Adds `CommitmentsReading` (Unreadable/NoDistributor/NothingCommitted/
Committed) derived in `wire.rs`, glued to a live chain read in
`chain_read.rs::commitments_reading`, and rendered in
`clawback.rs::commitments_reading_sentence` through four new fluent
keys (all 14 catalogs, byte-exact English per dig-app#419 precedent).

Per dig_ecosystem#3439 (confirmed against a real validator mid-review):
this deliberately carries NO recoverable-share figure. An earlier
revision derived `rewards_base_units * observed withdrawal_share_bps /
10_000`, which reports a nonzero recoverable amount for a commitment
the chain will still refuse (the puzzle's own compiled-in
`ASSERT_BEFORE_SECONDS_ABSOLUTE(epoch_start)`), so that derivation is
left out until #3439 closes.

Also corrects the wire.rs:81-87 doc comment, which stated SPEC bans
any in-app computation of a recoverable share; it bans only a
compiled-in one, not an observed/curried one (dig_ecosystem#3262
precedent) -- and separately notes #3439's not-yet-safe-to-render
finding.

Bumps the workspace version 15.14.8 -> 15.15.0 (new capability, not a
patch).

Refs DIG-Network/dig_ecosystem#3290

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Formatting changes only: line breaks on imports, function parameters,
and match expressions per Rust formatting standards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changed `crate::rewards::client::RewardsClient` to `super::client::RewardsClient`
in the module doc for wire.rs — module doc intra-doc links resolve one level up,
so the super:: path resolves correctly from wire.rs's perspective.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixed two more broken intra-doc links inside the commitment module's impl block:
- super::super::client::RewardsClient -> super::super::super::client::RewardsClient
- super::super::clawback::ClawbackAuthority -> super::super::super::clawback::ClawbackAuthority

These links are nested three levels deep (wire::commitment::impl), so they need
three super:: levels to resolve from wire module to rewards module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rustdoc cannot resolve intra-doc links to private modules. Converted
[\`commitment\`] references to plain backticks (\`commitment\`) throughout
the RewardDistributorCommitment doc comments, since commitment is a
private module inside wire.rs that is not part of the public API.

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

Changed super::wire::commitments_reading_from_slots to just commitments_reading_from_slots
since the function is in the same wire module, not a sibling of wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CommittedSlot's doc comment links to RewardDistributorCommitment which is
pub(crate) and not public documentation. Changed [\`RewardDistributorCommitment\`]
to plain backticks (\`RewardDistributorCommitment\`) to avoid the rustdoc
private_intra_doc_links warning.

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

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

Verdict: PASS

Head reviewed: bf9fa2a9cb12adc4379b95ed9aba3c8790ff2622

Revert-probe result (the main ask)

Ran the probe statically (no cold build available — see note at bottom) by tracing each assertion against the nearest wrong implementation rather than executing a mutation:

  • Collapsing NothingCommitted/Unreadable at the source (chain_read.rs:113-120, commitments_reading): the Err(err) => CommitmentsReading::Unreadable(err.to_string()) arm is exercised directly by chain_read.rs's new test a_chain_source_error_makes_commitments_reading_unreadable (chain_read.rs:196-207), which asserts matches!(reading, CommitmentsReading::Unreadable(_)) off a MockChainSource::fail_with(...). If that arm were changed to return NothingCommitted instead, this test fails outright — real discrimination, not vacuous. This mirrors the #420 precedent (returned-error-variant proof).
  • Collapsing the render (clawback.rs:334-353, commitments_reading_sentence): every_commitments_reading_variant_renders_distinct_nonempty_text (clawback.rs:663-687) builds all four variants and asserts pairwise assert_ne! on every rendered string. Any two arms of the match producing the same Msg/copy key collapses this test — again a real discriminator, not tautological. unreadable_never_renders_the_same_as_nothing_committed additionally asserts the unreadable sentence never contains a bare '0' character (guards #3427's "never a bare zero" rule).
  • The weakest of the seven, unreadable_and_nothing_committed_are_never_equal (wire.rs:522-527), is close to tautological given #[derive(PartialEq)] on a multi-variant enum — it would only fail if someone hand-wrote a broken PartialEq, which nobody did here. Not blocking (the two tests above already carry the real weight), but flagging so it isn't cited as the vacuity-proof on its own.

Net: the requirement (§2.6 clause 5) is guarded by tests that would actually fail under the nearest wrong implementation, both at the point of construction (chain_read.rs) and at the point of render (clawback.rs). Not vacuous.

Checked against the brief

  1. Four states distinct at the rendered surface — traced Unreadable → COMMITMENTS_UNREADABLE, NoDistributor → COMMITMENTS_NO_DISTRIBUTOR, NothingCommitted → COMMITMENTS_NOTHING_COMMITTED, Committed → COMMITMENTS_COMMITTED_SUMMARY (clawback.rs:334-353) — four distinct Fluent keys, confirmed non-colliding by the test above. Good.
  2. No derived money figure — CommittedSlot (wire.rs:352-360) carries only epoch_start, clawback_puzzle_hash, rewards_base_units. No recoverable_base_units, no withdrawal_share_bps anywhere in the new code. commitments_reading_from_slots (wire.rs:433-455) does no division/multiplication on rewards. Confirmed clean — #3439 respected.
  3. wire.rs doc correction — new text (wire.rs:82-105) correctly narrows the ban to a compiled-in bps, cites dig_ecosystem#3439 and #3262 (current_distributor_epoch_start, verified at client.rs:118-127, matches the cited precedent), and explicitly states the crate "currently derives no recoverable-share figure anywhere, on purpose" — does not imply the observed value is safe to display today. The five trailing doc-link-fix commits (1ff0f1c6..bf9fa2a9) touch only link syntax ([\crate::...`]→ ``commitment``, path depth fixes) — diffed the substance across the full range and the#3439/#3262` prose is byte-identical to what the feature commit wrote; no softening.
  4. i18n — all 14 catalogs carry all 4 new keys; confirmed byte-identical on the new lines via md5sum (not grep -c $'\r') across en/de/es/fr/hi/id/ja/ko/pt-BR/ru/tr/vi/zh-CN/zh-TW — all hash to def739ec2c61acb648a6fb58810e3902. file reports UTF-8 text, no BOM; od -c on line 20 confirms plain ASCII "The commitments..." with no stray bytes. ALL_KEYS in copy.rs:305-308 includes all four.
  5. No dig-rpc-protocol dependency — confirmed absent from Cargo.toml and Cargo.lock entirely (grep returns nothing). dig-account = "0.34" (Cargo.toml:156) and dig-rewards-coin = "0.9" (Cargo.toml:399) pins unmoved; diff shows only the version-bump hunks in Cargo.lock.
  6. Version — 15.14.8 → 15.15.0 at workspace root only; grepped the whole tree for the stale literal, zero hits remaining.

Not re-raised (per brief)

  • ProvenClawback::open's latent wire-recoverable_base_units consumer (clawback.rs:264/270/307) — still no production caller, this PR doesn't add one. Out of scope, tracked on #3439.
  • §12.5 clause 7 vs. clause 5 — confirmed not in tension; wire.rs:378-386's own doc comment states the distinction correctly.
  • no_display_can_hide_body_text_without_a_scrollbar — pre-existing flake, unrelated to this diff.

Not run

  • cargo build / cargo test -p dig-app-core --lib — started in background per the brief's allowance but did not finish compiling within this review's window (cold build); did not wait on it. All findings above are from static tracing of the diff plus a hand-traced re-derivation of the test assertions against the nearest wrong implementation, not from an executed test run. CI's own Test+coverage jobs were still running at review time (3 pending per the brief) and should be the authoritative green before merge.

No blocking findings. No inline threads opened — nothing here reaches the "fix before merge" bar.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — CHANGES-REQUIRED

Head audited: bf9fa2a9cb12adc4379b95ed9aba3c8790ff2622

Finding 1 (LIVE, blocking) — verbatim untrusted chain-source text rendered at a money decision

crates/dig-app-core/src/rewards/chain_read.rs:116

Err(err) => CommitmentsReading::Unreadable(err.to_string()),

flows straight to crates/dig-app-core/src/rewards/clawback.rs:341-342:

CommitmentsReading::Unreadable(reason) => {
    copy::COMMITMENTS_UNREADABLE.with(&Args::new().text("reason", reason.clone()))
}

and into crates/dig-app-core/i18n/en.ftl:20 (identical in all 14 catalogs):

rewards-commitments-unreadable = The commitments could not be read: { $reason }

err is dig_chainsource_interface::ChainSourceError, whose Transport(String) and
Malformed(String) variants are documented as carrying "the backend's own message, carried
verbatim for diagnostics" (dig-chainsource-interface-0.3.3/src/error.rs:22-30). The chain
source backing a ChainSource in this codebase is exactly the untrusted-peer surface this epic
has flagged before (peer-supplied data, not a value this crate computed). Its raw text is
UTF-8, unconstrained content — arbitrary length, arbitrary Unicode, no sanitization — landed
directly into a rendered sentence on the clawback pane, a money-decision surface.

This is the exact defect family this epic has already fixed twice (#3374's raw fragment,
#3289's raw timestamp) — and this crate has its own established, correct precedent right next
to it that this PR does not follow:

crates/dig-app-core/src/rewards/pane.rs:49-53:

pub enum PaneReading<T> {
    ...
    /// The call was made and failed. Carries the reason for [`PaneNote::Unreachable`].
    Unreachable(&'static str),

— a closed, curated set of static reasons, never the backend's own string. CommitmentsReading::Unreadable(String) regresses that precedent in the same crate, same money-adjacent family.

Concrete exploit: a hostile or misbehaving chain-source peer returns a Transport/Malformed
error whose message is attacker-chosen text (there is no length cap, no charset restriction, no
escaping beyond Fluent's own placeable syntax escaping). That text is displayed verbatim inside
"The commitments could not be read: {text}" on the rewards/clawback pane. This lets a peer:

  • inject misleading prose the user reads as part of the app's own claim about their money (e.g. a
    crafted string implying funds are safe/recovered, since the surrounding template still says
    "could not be read" but a long attacker string can visually dominate the sentence),
  • inject bidi-override or zero-width Unicode inside that string to reorder or hide what the user
    reads at exactly the same class of decision point flagged in Q5 for the catalogs — except here
    the channel is peer-controlled runtime data, not a reviewed catalog file, so no i18n review ever
    sees it,
  • degrade the UI with unbounded-length text (the wire-level record type this crate already ships,
    RewardDistributorStatusRecord, and ChainSourceError::TooManyRecords show this crate is aware
    hostile inputs need bounding; this string is not bounded at all).

Remedy direction (not prescriptive): map ChainSourceError to a closed, static reason set (as
PaneReading::Unreachable(&'static str) already does) before it reaches CommitmentsReading, or
otherwise strip/bound/sanitize before display. Do not render err.to_string() verbatim in
CommitmentsReading::Unreadable.

Cleared (tried to break, could not)

  1. No derived recoverable figure anywhere in the diff. CommittedSlot (wire.rs:349-357)
    carries only epoch_start, clawback_puzzle_hash, rewards_base_units. Checked the whole
    diff — commitments_reading_from_slots (wire.rs:437-458), commitments_reading
    (chain_read.rs:100-118), commitments_reading_sentence (clawback.rs:336-354), every test
    fixture — none compute rewards * withdrawal_share_bps / 10_000 or any share figure. #3439's
    fix holds.
  2. Zero-collapse-to-error, or error-collapse-to-zero: traced all four variants
    (Unreadable/NoDistributor/NothingCommitted/Committed) through
    commitments_reading_sentence; four distinct branches, four distinct copy keys, no shared
    fallthrough. wire.rs test unreadable_and_nothing_committed_are_never_equal and
    clawback.rs test every_commitments_reading_variant_renders_distinct_nonempty_text assert
    this at both layers.
  3. i18n catalogs: all 14 .ftl files byte-identical on the four new keys (diffed directly,
    od -An -tx1 on the new lines across all 14 — no BOM, no CRLF, no bidi/zero-width/homoglyph
    bytes, pure ASCII, file(1) reports plain UTF-8 text on every catalog). Placeables match
    en.ftl 1:1 (reason; none; observed_ago; slot_count+observed_ago) in every catalog.
  4. Cargo.lock: diffed against real merge-base 56fba32d — only 3 version-bump hunks (dig-app,
    dig-app-core, diga: 15.14.8→15.15.0), 8 changed lines total. dig-account still 0.34.0,
    dig-rewards-coin still 0.9.1, no dig-rpc-protocol anywhere in the lockfile or
    dig-app-core/Cargo.toml.
  5. wire.rs doc correction: cites #3439, states the crate "currently derives no
    recoverable-share figure anywhere, on purpose" and that #3439 "must close... before any
    recoverable figure is derived here again" — does not imply the observed/curried value is
    currently safe to display. The trailing doc-link-fix commits (09525dfc..bf9fa2a9) only
    touch intra-doc link syntax, not the substance of the claim.
  6. Minor non-blocking nit: wire.rs:90's doc says "chain_read.rs's
    current_distributor_epoch_start" but that function actually lives in client.rs. Cosmetic,
    not a defect worth gating on.

Not covered

  • Did not run a full workspace build (per brief's hard limit); relied on targeted
    cargo test -p dig-app-core --lib plus static tracing.
  • Did not audit dig-rewards-coin/dig-chainsource-interface internals beyond their published
    doc comments (out of repo scope for this PR).

MichaelTaylor3d and others added 2 commits September 30, 2026 17:31
…ments sentence (#3290)

Red against the err.to_string() arm: the backend text flows verbatim into
the rewards-commitments-unreadable placeable.

Refs DIG-Network/dig_ecosystem#3290

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…290)

loop-security: Unreadable carried err.to_string(), so a hostile chain-source
peer could write unbounded Unicode (bidi, zero-width) into the
rewards-commitments-unreadable sentence. Unreadable now holds a &'static str
chosen by an exhaustive match on the RewardsError variant class (same shape
as PaneReading::Unreachable). Fixtures updated; wire.rs doc now points
current_distributor_epoch_start at client.rs.

Refs DIG-Network/dig_ecosystem#3290

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

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

Verdict: PASS

Head reviewed: a27c7ab (full diff 56fba32..a27c7ab, weighted to bf9fa2a..a27c7ab). Read-only static audit; nothing built.

Finding 1 (chain-source text into the rendered sentence): CLOSED.

  • chain_read.rs unreadable_reason matches on the variant only (never Display, never the payload) and returns &'static str from a closed 3-string set. The _ arm (RewardsError is non_exhaustive) falls to a static string, so a future variant cannot leak text.
  • CommitmentsReading::Unreadable(&'static str) makes the type itself unable to carry runtime text. commitments_reading_sentence is the only renderer; its other placeables are humanize::ago and slots.len(), both numeric. No Debug formatting on any rendered path.
  • Bidi, zero-width and oversize text: none can reach the sentence; the placeable is one of three ASCII literals.

RED-first of 9b00fb8, proven statically. dig-rewards-coin 0.9.1 state.rs:78 builds ChainUnavailable(error.to_string()). error.rs:23 Display is "chain source could not answer: {0}" and error.rs:28 is "malformed chain data: {0}". dig-chainsource-interface Display includes the payload ("chain source transport error: {0}", "malformed chain data: {0}"). On the old arm Unreadable(err.to_string()) the sentence would contain "FUNDS-SAFE", U+202E and U+200B, so the test's !contains assertions fail. The test is not vacuous. It also asserts the starts_with prefix, which is correct (Fluent isolation marks sit around the placeable, after the prefix).

Other lenses.

  • Secrets, custody, crypto: none touched. commitments_reading_sentence is read-only and carries no authority. I found no production caller of commitments_reading or the sentence outside these three files.
  • Boundary: slots are copied as raw typed fields; no recoverable-share figure is derived (#3439 stays out of scope).
  • i18n: 14 locale files add 4 keys, with ALL_KEYS updated. The only placeable that could carry text is reason, and it is now static English text.
  • Dependencies: Cargo.toml only bumps the workspace version, 15.14.8 to 15.15.0.

NON-BLOCKING 1 (pre-existing, not reached by this diff; defence-in-depth, ticket it). chain_read.rs:84 Err(err) => Err(RewardsClientError(err.to_string())) carries the peer-controlled text in a pub String. This line is unchanged by the PR. I found no production consumer that renders it: pane.rs and create_card.rs do not use it, and client.rs only clones it in mocks. It becomes live the day a surface renders RewardsClientError.0. Recommend a ticket to make it closed-set or opaque, in the same shape as this fix.

NON-BLOCKING 2 (doc drift). copy.rs doc on COMMITMENTS_UNREADABLE still says "the underlying chain-read error, verbatim". That is now false and invites a regression. Reword it to "a static reason from a closed set".

Not covered: executed CI (9b00fb8's run was cancelled), runtime Fluent rendering of the 13 non-English locales (the placeable is identical in each).

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

Verdict: PASS

Head: a27c7ab (delta bf9fa2a..a27c7ab reviewed in full; whole PR 56fba32..a27c7ab re-checked). The earlier PASS at bf9fa2a is superseded by this one.

Delta

  • chain_read.rs:114-132: the Unreadable arm now calls unreadable_reason(&err). That function matches on the variant class only, with no Display and no payload. The _ arm covers the #[non_exhaustive] RewardsError. The reason set is closed at 3 static strings.
  • wire.rs:386-391 and clawback.rs:341-343: the type is now Unreadable(&'static str). The call site passes *reason. The type itself makes it impossible to carry a String of peer text.
  • wire.rs:90: the doc fix chain_read.rs -> client.rs for current_distributor_epoch_start is correct.

Traps checked

  1. Discrimination tests (clawback.rs:639-670, wire.rs:553-558): they still discriminate. The property they assert is that the Unreadable template differs from the NothingCommitted template, and that every variant renders distinct non-empty text. That depends on the Fluent message ids (rewards-commitments-unreadable vs -nothing-committed), not on the reason string. The reason is just { $reason } inside one template. They would still fail if the Unreadable arm were rendered with the NothingCommitted copy.
  2. 9b00fb8 test (chain_read.rs:235-266): it fails on the old arm, by static trace. ChainSourceError::Transport(hostile) is turned by dig-rewards-coin state.rs:78 into ChainUnavailable(error.to_string()). Old err.to_string() then gave "chain source could not answer: ". That string went into { $reason }, so the sentence contained FUNDS-SAFE, U+202E and U+200B, and the !contains asserts would fire. The test works at the decision level (reading, then sentence). It also asserts the The commitments could not be read: prefix, so a blank sentence cannot satisfy it. It covers both the Transport and Malformed source errors.
  3. i18n: the delta touches no .ftl. I re-checked the 14 catalogs at a27c7ab. The four rewards-commitments-* lines are byte-identical across all 14 (one md5), with the same keys and placeables ($reason, $observed_ago, $slot_count).
  4. Scope: recoverable_base_units u64 decoding is owned by dig_ecosystem#3446 and is out of scope. Not flagged.

Non-blocking notes (no thread)

  • state.rs:78 folds every ChainSource error into ChainUnavailable, so the "malformed answer" reason is only reachable if read_distributor itself returns Malformed (slot bookkeeping). It is correct, just narrower than the name suggests.
  • Cargo.lock pins dig-rewards-coin 0.9.1. I read 0.10.0 from the local registry for the variants. Both have ChainUnavailable and Malformed, and CI is green.

Ticket criteria

Four-state CommitmentsReading carries Unreadable vs NothingCommitted vs NoDistributor vs Committed distinctly (SPEC §2.6 clause 5). Unreadable is never rendered as nothing committed. The sentence is built from a closed set. CI is green at a27c7ab, including coverage >=80% and clippy. loop-security PASS at a27c7ab (2026-10-01T00:42:22Z).

Not run: no local build or test (static review, relying on green CI). Threads opened by me: 0. Unresolved: 0.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial gate (loop-decider, third leg) — REFUTED

Head read: a27c7ab16664d37d0e2bb98823f357b7bba8a9b9 (worktree clean at a27c7ab). dig-rewards-coin resolved by Cargo.lock: 0.9.1 (registry source).

Q1 — when does read_distributor return Ok(None)? REFUTED

  • dig-rewards-coin 0.9.1 src/state.rs:1079-1093: Ok(None) comes from exactly one place, source.coin_record(launcher_id) answering Ok(None). The crate takes the source's word for it. "Genuinely absent" holds only if the ChainSource says None only when it means it.
  • dig-app's production source does not work that way. crates/dig-app-core/src/chain/source.rs:394-418 (ControlChainSource::coin_record) is commented "DELIBERATELY UNGUARDED, and the absence is returned as an answer". coinById goes to dig-node's fallback tier, which replies synced:false on every answer (measured at source.rs:327-329), and believe_absence is skipped. source.rs:349-359 describes this as a lie that is still open.
  • Chain condition: a real distributor launcher, read through ControlChainSource while the fallback tier is lagging, pruned or does not hold the launcher. coinById returns coin: null with synced:false, then coin_record returns Ok(None), read_distributor returns Ok(None) (chain_read.rs:116), and the state becomes NoDistributor.
  • The user sees: "No distributor exists to have committed anything." (i18n/en.ftl:21, clawback.rs:344). This is a definite statement of absence, on the clawback surface, produced by a read the source itself marked as unwarranted. It belongs in Unreadable.
  • Reachability: commitments_reading has no production caller at this head; only tests call it, with MockChainSource. The hole is latent today. It goes live as soon as the read is wired to the only ChainSource dig-app ships, and this PR's doc comment (chain_read.rs:98) says that wiring is already safe.

Q2 — can an empty commitments come from a partial walk? NOT refuted

The walk stops only when coin_spend returns None (state.rs loop, "Unspent: generation tip"). If it stopped early, the tip-reserve cross-check would catch it: state.rs:1366-1377 requires the reserve to be present, unspent and equal to total_reserves, and otherwise returns Malformed. A slot spent without a matching creation is also Malformed (apply_generation). There is no pagination. The only way to get a consistent but stale snapshot is a self-consistent lagging source, and that case is stamped with that source's own peak timestamp (as of …). That is honest staleness, not truncation.

Q3 — derived money / chain text: PASS

No recoverable figure is computed or rendered. Committed renders only slot_count and observed_ago (clawback.rs:347-356, copy.rs:91-94). unreadable_reason (chain_read.rs:124-131) returns a closed &'static str set and never uses Display.

Q4 — #3446: no conflict

recoverable_base_units: u64 in wire.rs (~204, parse_from_rpc) predates this PR and is not on the commitments path. #3446 stays separate.

Minimal fix shape

Do not trust Ok(None) from read_distributor without a warrant. Either (a) give commitments_reading (and distributor(), which has the same collapse at chain_read.rs:82) a source whose launcher absence is warranted. For example, re-check through believe_absence / a synced:true tier, and map an unwarranted Ok(None) to Unreadable("the chain source could not confirm the distributor is absent"). Or (b) until such a source exists, map Ok(None) from ControlChainSource to Unreadable and keep NoDistributor for warranted sources only. Add a test with a source that answers Ok(None) + synced:false and assert it is not NoDistributor.

MichaelTaylor3d and others added 2 commits September 30, 2026 21:38
…ributor (#3290)

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

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

PASS - loop-security re-gate, head 189ac1d69e98330760c616c4ae6d28c9c05e8f50, delta a27c7ab..189ac1d (18 files, +45/-35).

  1. Outcomes: read_distributor Ok(Some) -> chain-backed NothingCommitted/Committed; Ok(None) -> Unreadable(UNCONFIRMED_ABSENCE_REASON); Err -> Unreadable(unreadable_reason). No path renders a peer-controlled absence as "nothing committed". Variant gone from the enum, so no match arm can reintroduce it.
  2. Reasons: UNCONFIRMED_ABSENCE_REASON is a &'static str const; no error Display or peer text enters it.
  3. Catalog: rewards-commitments-no-distributor removed from all 14 .ftl, COMMITMENTS_NO_DISTRIBUTOR removed from copy.rs and ALL_KEYS; repo grep finds no remaining reference (remaining "no distributor" hits are unrelated pre-existing store-pane tests). rewards-commitments-unreadable still present with its $reason placeable.
  4. No new derived money figure.

Out of scope, not flagged: ChainReadRewardsClient::distributor() collapse (dig_ecosystem#3447). Not re-run: cargo tests (read-only audit; implementer measured 186 pass).

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

PASS at head 189ac1d (delta a27c7ab..189ac1d).

  1. No NoDistributor / rewards-commitments-no-distributor left (variant, copy.rs const + ALL_KEYS, clawback arm, tests). Only a pre-existing, unrelated test name in store_rewards_tests.rs:164.
  2. 14 .ftl catalogs: identical key sets (verified by sorted key-hash, one distinct hash).
  3. Test an_unwarranted_absent_launcher_reads_unreadable_not_no_distributor discriminates: under the old mapping the reading is NoDistributor, so the matches!(Unreadable) assert fails; the string-replace comparison is a secondary check, not the sole proof.
  4. Doc comments true, except one stale non-gating nit (copy.rs:74 "Four states"), posted inline and resolved.
  5. CI at 189ac1d: Test+coverage, Clippy, Rustfmt, headless, doc-link, version, lint, macOS confirmer pass; Windows confirmer was still pending at review time.

chain_read.rs ChainReadRewardsClient::distributor() collapse is out of scope (dig_ecosystem#3447).

Comment thread crates/dig-app-core/src/rewards/copy.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Adversarial re-verify (loop-decider) — NOT-REFUTED at 189ac1d

Head read: 189ac1d69e98330760c616c4ae6d28c9c05e8f50. I checked only the delta a27c7ab1..189ac1d6.

  • The hole is closed. chain_read.rs:119 maps Ok(None) to Unreadable(UNCONFIRMED_ABSENCE_REASON). The reason is a static &str (chain_read.rs:126-127), so no chain-source text reaches the sentence. NoDistributor no longer exists anywhere: grep -rn "NoDistributor\|no-distributor" crates finds no matches (enum, copy.rs, ALL_KEYS, the 14 catalogs). Err still goes through the closed unreadable_reason set.
  • No confident state is left for an unwarranted absence. NothingCommitted and Committed can only come from Ok(Some(snapshot)). My Q2 finding on the partial walk is unchanged: the tip-reserve cross-check returns Malformed, which renders as Unreadable.
  • The test discriminates. an_unwarranted_absent_launcher_reads_unreadable_not_no_distributor uses an empty MockChainSource, whose coin_record returns Ok(None). It asserts matches!(.., Unreadable(_)), which fails on any other mapping, and assert_ne! against the NothingCommitted sentence. The sentence-class assert_eq! adds no extra discrimination, but it does no harm.
  • distributor() still has the same collapse (chain_read.rs:82). That is out of scope here and tracked as dig_ecosystem#3447.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review October 1, 2026 05:08
@MichaelTaylor3d
MichaelTaylor3d merged commit 5a97587 into main Oct 1, 2026
11 of 12 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/3290-commitments-reading branch October 1, 2026 05:09
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