Skip to content

F-05 (High): Detect idempotency-key reuse with a different payload#5

Merged
adamburmister merged 6 commits into
mainfrom
fix/f05-idempotency-conflict
Jul 18, 2026
Merged

F-05 (High): Detect idempotency-key reuse with a different payload#5
adamburmister merged 6 commits into
mainfrom
fix/f05-idempotency-conflict

Conversation

@adamburmister

Copy link
Copy Markdown
Owner

Finding

F-05 (High) from the ledger audit: Ledger.postEntry returned the previously-stored entry on any idempotency-key match, without comparing payloads — and the dedup pre-check ran before validation. The audit demonstrated (probe P5): post sale A ($100, key k1), then sale B ($999, same key) — the second call returned sale A's entry with no error, and the ledger contains one entry. $999 of activity silently vanished.

Idempotency keys exist to make retries of the same transaction safe. When two different transactions collide on a key (recycled request ID, bad key derivation — client bugs), the correct behavior is a loud conflict (cf. Stripe's 409 idempotency_error). Failing open converts a client bug into a lost posting discovered only at reconciliation, if ever.

Fix

  • pluts_entry_keys gains payload_hash: SHA-256 fingerprint of the posted payload's business content (description, date, ordered lines of [accountId, minorUnits]), via the new exported computeEntryFingerprint(payload).
  • Ledger.postEntry now validates first (an invalid payload is invalid input, never a dedup hit — previously an invalid retry with a used key "succeeded"), then on a key hit compares fingerprints:
    • identical → return the original entry (genuine retry: network replay, DO re-execution)
    • different → throw the new IdempotencyConflictError (carries key + existingEntryId); nothing is written
  • The same comparison guards the unique-constraint race-recovery path in SqlStorageRepository.insertEntry, so two concurrent posts racing past the pre-check get the same semantics.
  • migrate() adds the column to already-provisioned databases via a PRAGMA table_info probe (ALTER TABLE isn't idempotent; table_info is an allowed pragma in DO SQL storage). Legacy rows keep the '' default, treated as "no recorded fingerprint" — retries of pre-upgrade postings keep working.
  • Repository gains getEntryKeyRecord(key); the in-memory test repository mirrors the unique-constraint semantics exactly.

Testing

Written first and watched fail: key reuse with different payload → IdempotencyConflictError with nothing written; invalid payload with a used key → ValidationError. Plus new migrate.spec.ts running real migrate() against SQLite via node:sqlite: fresh provision includes the column, legacy database gains it with '' for existing rows, idempotent double-run. Existing identical-retry and no-key tests still pass.

Full suite: 83 passed. tsc --noEmit and biome check clean.

Note: shares the test/types/node-sqlite.d.ts declaration with #4 (identical content — merges cleanly in either order).

🤖 Generated with Claude Code

The dedup path returned the previously-stored entry on any key match,
without comparing payloads — and the pre-check ran before validation.
Two different business transactions accidentally sharing a key (a
recycled request id, bad key derivation) meant the second was silently
never recorded: the caller received a well-formed Entry for a $999 sale
that does not exist in the ledger. A lost posting, discovered only at
reconciliation, if ever.

- pluts_entry_keys gains payload_hash: a SHA-256 fingerprint of the
  posted payload's business content (description, date, ordered lines
  of account id + minor units), computed via computeEntryFingerprint
- Ledger.postEntry now validates first, then compares fingerprints on a
  key hit: identical => return the original entry (genuine retry);
  different => throw the new IdempotencyConflictError (carrying the key
  and existing entry id); the same comparison guards the
  unique-constraint race-recovery path in SqlStorageRepository
- migrate() adds the column to legacy databases via a table_info probe
  (ALTER TABLE is not idempotent); legacy rows keep the '' default,
  treated as "no recorded fingerprint" so pre-upgrade retries still work
- Repository gains getEntryKeyRecord(key); in-memory repo mirrors the
  unique-constraint semantics

Audit finding F-05 (High).

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 387bd8c36b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/domain/entry.ts Outdated
adamburmister and others added 2 commits July 18, 2026 12:37
A date-less request has its date defaulted to "today" in buildEntry before
the idempotency fingerprint was computed, so retrying the identical request
after a UTC day rollover produced a different fingerprint and threw
IdempotencyConflictError instead of returning the original entry.

EntryPayload now records dateWasDefaulted, and computeEntryFingerprint
hashes null for defaulted dates — a date-less retry fingerprints identically
regardless of when it lands. Explicit dates hash as before.

Addresses PR #5 review (P1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both sides touched the repository insertEntry seam: main added
assertBalanced (F-03) and this branch added fingerprint-based idempotency.
Kept both — balance is asserted first (invalid input is never a dedup hit),
then the key check runs, mirroring Ledger.postEntry's ordering.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f5c38cfdf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/domain/ledger.ts
postEntry spread input.debits/input.credits before any validation ran, so
a structurally malformed body from untyped JS (e.g. missing credits) threw
a raw TypeError instead of the promised path-tagged ValidationError — the
validate-first ordering introduced for the idempotency fix made this
reachable on the reused-key path too. The scan now skips non-array
collections and non-string names; buildEntry's schema parse remains the
validation authority.

Addresses PR #5 review (P2).

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c074357d60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/domain/ledger.ts
adamburmister and others added 2 commits July 18, 2026 19:03
Numbers like 1e21 or 1e-7 pass z.number().finite().nonnegative() but
stringify in exponential notation, which Amount.fromMajor's digit parser
rejects with RangeError — escaping the schema transform as an uncaught
runtime error instead of the promised path-tagged ValidationError (newly
reachable on the keyed-retry path since validation now runs before the
dedup lookup). The transform now catches the parse failure and reports a
Zod issue instead.

Addresses PR #5 review (P2, transform throws).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kept both sides at the insertEntry seam: this branch's payload
fingerprint and main's storage-int bridging (F-06). Reconciled the
frozen-arrays/aliasing test combination from main (push now throws), and
re-pinned the exponential-notation amount case against main's fromMajor,
which now parses that notation — the schema transform's issue-reporting
wrapper stays as defense-in-depth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adamburmister
adamburmister merged commit 259a747 into main Jul 18, 2026
1 check passed
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