Skip to content

fix(cards): refuse a stale card order and recover a committed card whose reply was lost (follow-up to #882) - #903

Closed
NewWorldVenture wants to merge 10 commits into
claude/card-attempt-identity-7q6vtffrom
claude/card-stale-order-guard-7q6vtf
Closed

NewWorldVenture wants to merge 10 commits into
claude/card-attempt-identity-7q6vtffrom
claude/card-stale-order-guard-7q6vtf

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to repair B: an expected-count contract and recovery of a committed-but-unacknowledged card mirror (JIMMY-SUPPORT-CARD-RETRY-20261001). It answers the owner's independent review of #882 (5970508018).

Draft. Do not merge without coordination. Stacked on #882: base claude/card-attempt-identity-7q6vtf at 208c7e201. Claim: #811 5969137460. The actions.ts scope is issueCardAction only.

What #882 left open

  1. A stale tab. So does a retry after a lost success answer. Once the first card is mirrored, the count moves, so an identical order from a view that never refreshed is a new attempt and gets a second card.
  2. A mirror insert that committed but whose reply was lost. The parent was told "could not issue" for a live, mirrored card, and the card got no card_issued audit.

The change

  • Expected-count contract.
    • View (components/wallet/money-cards-view.tsx): every order (Virtual, issue-all, the physical dialog) sends expectedCount, the number of that child's cards of that type it shows, in every status.
    • Action: issueCardAction refuses an expectedCount that isn't a non-negative safe integer, before any read, Trust check or provider call.
    • Library: issueCard throws StaleCardOrderError before any provider call when the mirrored count differs, in either direction.
    • Answer: the action returns { ok: false, error: t('wallet.refreshToTryAgain'), stale: true }. That's an existing key, so no i18n file is touched.
    • Refresh: the view, and the dialog via onStale, re-read on the stale flag, not on the words.
    • Overlapping orders made against the same count still share fix(cards): one card order gives one card across tabs — attempt identity (repair B) #882's attempt key.
  • Read-back recovery in issueCard. After any mirror insert error, it reads the card back by stripe_card_id and adopts exactly this attempt's row:
    • After 23505, another request of this attempt inserted the row and audits it, so this request adopts it with no second audit.
    • After any other error, this insert committed with only its reply lost. The row is this order's own card: it's answered ok and audited here.
    • A failed or rejected read-back, or no row, keeps the refusal and names the card.

Results (synthetic provider; in-memory UNIQUE)

Case #882 Now
Stale identical order after the first card is mirrored (gaps 0/1/60,000 ms) 2 live cards refused as stale: 1 card, 1 row, 1 audit
The action's answer lost after mirror and audit, then a retry from the same view 2 cards refused as stale: 1 card, 1 row, 1 audit
Insert committed and its reply lost (08006) "could not issue", 0 audits answered ok with that card, 1 audit; a retry is refused as stale
Deliberate order from an up-to-date view new card new card (unchanged)
Two orders overlapping against the same count 1 card 1 card (unchanged)

Tests

  • tests/money-card-stale-order.test.ts, 32 cases. Against fix(cards): one card order gives one card across tabs — attempt identity (repair B) #882's issuing.ts and actions.ts, 24 fail; the 8 controls pass on both: overlap, count-read failures and a deliberate order.
    • The stale and lost-reply cases were committed failing first (eac32892e, 87a132998).
    • A committed-then-lost reply is simulated with a real committed row followed by a 08006 answer.
  • tests/money-card-attempt-identity.test.ts. fix(cards): one card order gives one card across tabs — attempt identity (repair B) #882's "does not re-read after a non-duplicate insert failure" is deliberately replaced by four groups:
    • read-back recovers when the exact row is there (ok, 1 audit);
    • refusal when no row is found (card named in the log);
    • refusal when the read-back errors or rejects.
  • Other suites. The characterization, money-treasury-card-actions and money-card-trust-bridge tests now send expectedCount. Every count stays exact.
  • Browser (tests/e2e/money-cards-cross-tab.spec.ts, 10 cases):
    • a stale second tab ordering through each entry point is refused, shows the answer, re-reads, and its next order carries the new count;
    • the view re-reads on the stale flag even when the text is localized, and not on a plain refusal that only reads like it;
    • each order sends that child's per-type count, in every status.
    • The other card view specs only add expectedCount to recorded inputs.

Mutants: 13/13 killed. They cover:

  • the guard removed, or one-directional;
  • validation removed;
  • the stale mapping made generic, or its flag dropped;
  • read-back only on 23505;
  • a recovered row reported as adopted (no audit), and an adopted row reported as own (double audit);
  • the view sending the total, or a family-wide, count;
  • the view or dialog not refreshing on stale, or refreshing on the words instead of the flag.

Validation on bd8543692:

Independent review of bd8543692 (read-only)

  • No blocking correctness bug. The reviewer also confirmed:
    • the page loads every card, unfiltered under family RLS, so the view's count equals the server's;
    • the read-back can only adopt this provider card's row;
    • all four placeholder locales fall back to a real translation.
  • Mutants: 19 of its 20 were killed. The survivor is equivalent: issue-all only orders for children with no cards, so both counts are 0.
  • Fixed in 1494abb04:
    • (medium) the physical dialog stayed open after a stale refusal, one click from a second card ordered against the new count. It now closes and the view re-reads.
    • Stale refusals are now logged at warn level, with ids and counts only.
    • The docstring is corrected: a first order may already have created the cardholder before a stale refusal.
  • New mutants on these fixes: 2/2 killed, 15 in all for this PR.
  • Checks after the fixes: related suites 205/205, card browser specs 92/92, tsc and lint clean.

Residuals (stated, not fixed)

  1. Orphans after the key is pruned. A card is created but never mirrored (the insert didn't commit), so the count doesn't move. After Stripe prunes the key (at least 24h), an order made against that count issues a second card and the orphan stays live and unlisted. Compensating orphans is repair E, which isn't implemented.
  2. Intentional overlapping orders collapse to one (deliberate).
    • Within a tab, A serializes them.
    • Across tabs, two up-to-date views ordering the same type at once can't be told from a duplicate, so they get one card. The second view is answered with it and shows the true count after its refresh.
  3. A rare double audit. A non-23505 insert failure that did not commit, while a concurrent request of the same attempt did, makes both requests audit.
    • A reply lost before the commit is not recovered. The insert commits after the read-back has found nothing, so the parent is told "could not issue" and no audit is written. The retry is refused as stale and refreshes, so no duplicate card results.
    • A quick second click is refused as stale. It happens when the second Virtual click lands before the first order's refresh, and the next click works.
    • A failed cards read on the page makes every order for a child with cards stale until the read recovers. This fails safe.
    • PostgREST row cap (unverified). A family with more than 1000 card rows could undercount under PostgREST's default max_rows.
  4. A stale view must refresh first. A parent whose view is stale but who wants another card refreshes first; the answer does this for them.
  5. Carried over from fix(cards): one card order gives one card across tabs — attempt identity (repair B) #882: saved provider failures (owner decision), the refusal wording for an in-flight conflict, and read-replica routing.

Not covered: live Stripe, RLS, a served browser → server → provider workflow, or production. No schema, migration, credential or provider change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS


Generated by Claude Code

claude added 8 commits October 3, 2026 12:36
… (failing)

A tab whose view is stale (it still shows N cards of a type for a child)
orders that type after another tab's card was mirrored. The mirrored count
is N+1 by then, so repair B's attempt key treats it as a new attempt and the
provider issues a second live card; nothing in the request tells it from a
deliberate order.

New sealed cases drive the real issueCardAction -> ensureCardholder ->
issueCard against the shared synthetic provider and the in-memory mirror,
with the count the view showed sent as expectedCount:

- a stale order (expectedCount N while N+1 are mirrored, N = 0 and 2, both
  types) must be refused with wallet.refreshToTryAgain: no provider create,
  no row, no audit;
- an overlapping order whose count read lands after the first card was
  mirrored is refused the same way; one held at the provider response still
  shares B's key and gets the one card (control, passes today);
- an order ahead of the mirror is refused too; every status counts;
  virtual, physical and each child are counted on their own;
- an expectedCount that is not a non-negative safe integer (negative,
  fractional, NaN, infinite, beyond the safe integers, a string, null,
  missing) is refused before any read, Trust check or provider call;
- controls passing today: a deliberate order against the up-to-date count
  issues a new card; an unreadable count is still the generic refusal.

On the current source 20 of 28 fail (the stale and validation cases); the 8
controls pass. The file does not typecheck until issueCardAction accepts
expectedCount. Test-only.

Audit JIMMY-SUPPORT-CARD-RETRY-20261001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…moved past

Repair B keys a card order by the mirrored count of the child's cards of
that type, so overlapping requests of one order share a key. A stale tab
that still shows N cards and orders after another tab's card was mirrored
reads N+1, takes a new key and gets a second live card: the server could not
tell it from a deliberate order.

The view now sends the count it showed: at the Virtual button, the issue-all
loop and the physical order dialog (passed in as a prop), expectedCount is
the number of that child's cards of that type in its cards prop, every
status. issueCardAction refuses an expectedCount that is not a non-negative
safe integer with the generic money.couldNotIssueTheCard, before the service
client, Trust or provider, and passes it to issueCard. After its count read,
issueCard throws StaleCardOrderError (an exported class) when the mirrored
count differs, in either direction, before getStripe(); B's key is
unchanged. The action maps that class, not a message, to
wallet.refreshToTryAgain, without the failure log; the four locales without
the key fall back to their base locale.

On that answer the Virtual button and the dialog show the error toast as
before and call router.refresh(), so the next order is made against what
now exists; the issue-all path already refreshes after every run. The view
recognises the answer by comparing it with its own translation of the same
key, since the action's result type is unchanged.

A parent whose view is stale but who does want another card has to refresh
first.

Audit JIMMY-SUPPORT-CARD-RETRY-20261001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…stale-order answer

Only what the expectedCount input changes is rewritten; every count stays
exact.

- Inputs to issueCardAction carry the count the sending view showed:
  0 for a first order, the up-to-date count for a deliberate later one
  (attempt identity, characterization, Treasury/card actions, Trust bridge).
- Attempt identity: the "residual" second order after the first card is
  mirrored is now refused as stale when sent against the old count (one
  card, one key); sent against the new count it is a deliberate second
  card keyed by the count. Seeded-row cases send the count of this child's
  virtual cards the seed makes (0 or 1, or 2 and 3 for the status case).
- Characterization: the remounted-view re-click (same count after the
  first card is mirrored) is now "repaired (stale guard)": refused, one
  live card. The same-millisecond case keeps its point, no clock
  collision, by sending the up-to-date count. The deliberate-orders
  control sends up-to-date counts.
- Treasury/card actions: the issueCard pin includes expectedCount (3 for
  one case, so it is forwarded, not assumed). New cases: a
  StaleCardOrderError from issueCard answers wallet.refreshToTryAgain with
  no audit, refresh or failure log; an error merely named or worded like
  it stays the generic refusal; an expectedCount of -1, 0.5, NaN,
  Infinity, '0', null or undefined is refused before service, Trust or
  provider access. The issuing mock exports a stand-in class.
- Browser (cross-tab): the stale-tab case runs for the Virtual button,
  issue-all and the physical dialog. Both tabs send expectedCount 0; the
  held answer to tab 2 is the stale refusal; tab 2 shows it, calls
  router.refresh() once, shows the card, and its next order carries 1. A
  new case pins per-child, per-type, every-status counts (1, 2, 1 where
  the child's total is 3 and the family's 4). The two "identical calls"
  cases now record the shared count.
- Pending-owner and form-ownership specs: only their exact
  issueCardAction inputs gain expectedCount.

Audit JIMMY-SUPPORT-CARD-RETRY-20261001.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
… read apart

The %j titles printed NaN, Infinity and null all as "null". Titles only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…tale-order guard

Brings in fac3583 (issueCard names a failed provider create and a
mismatched duplicate in the log), 8887c9d (cross-tab spec comments) and
208c7e2 (#876's braces allowlist and CI comment).

lib/stripe/issuing.ts and tests/money-card-attempt-identity.test.ts merged
cleanly: the expectedCount guard stays right after the count check and
before getStripe(); the provider create keeps its .catch logging.

tests/e2e/money-cards-cross-tab.spec.ts conflicted on comments only. Kept
this branch's header and the rewritten stale-tab cases (the base's comment
said a stale tab still gets a second card, which the guard ends), with the
base's "not exercised here" sentence folded into the header; took the
base's wording for the two overlapping-call comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…words

issueCardAction answers a StaleCardOrderError with stale: true beside the
translated text, and the view re-reads on that flag. Matching the text tied
the refresh to one locale's wording; a browser case pins that a refusal in
other words still re-reads with the flag and one that only reads like it
does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
…failing)

The insert commits, its reply is lost, and the order answers 'could not
issue' with no card_issued audit although the card is live and mirrored.
A lost answer to the whole action is already safe: the unrefreshed view's
retry is refused as stale. Raised in the owner's review of #882 (5970508018).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
A card mirror INSERT that reports an error other than 23505 may have
committed with only its reply lost. issueCard now reads the card back by
stripe_card_id after any insert error. Exactly this attempt's row after a
non-duplicate error is this order's own card: it is answered and audited
here, where the parent used to be told 'could not issue' for a live,
mirrored, unaudited card. A 23505 still adopts another request's row
without a second audit. Anything else, including a failed read-back, keeps
the refusal and names the card. Raised in the owner's review of #882
(5970508018).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 3, 2026 4:29pm UTC

Request Review

…vtf' into claude/card-stale-order-guard-7q6vtf
… refusals

From the independent review of #903: after a stale refusal the physical
dialog stayed open over the refreshed list, one click from a second card
ordered against the new count before the parent had seen the card that
made it stale. It now closes and the view re-reads. issueCard logs each
stale refusal (ids and counts only) at warn level, and the error's
docstring no longer claims nothing reached the provider: a first order may
already have created the cardholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS

Copy link
Copy Markdown
Owner Author

Closing again at the owner's direction to close the 85-PR backlog: this PR was in the sweep at 15:52 UTC and was reopened at 16:13 UTC. The branch is kept; nothing is merged or deleted. Please do not reopen without the owner's say-so.


Generated by Claude Code

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.

2 participants