Skip to content

fix(server): persist rejection code for tx-dedup replay#30

Merged
grrowl merged 1 commit into
mainfrom
fix/replay-rejection-code
Jul 22, 2026
Merged

fix(server): persist rejection code for tx-dedup replay#30
grrowl merged 1 commit into
mainfrom
fix/replay-rejection-code

Conversation

@grrowl

@grrowl grrowl commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Fixes #21

Problem

#rejectTx sends { code, message } on the live socket but recordTx persisted only the message. A client retrying the same txId (dedup replay) got the reason message with no machine-readable code, so code-based error handling broke on exactly the retry path dedup exists for.

Design decision

Issue #21's option 1: persist the code alongside the message (rather than re-running authorize/validation on retry, which would change dedup semantics).

  • _sync_seen_tx gains a nullable error_code column. The table is CREATE TABLE IF NOT EXISTS, so already-deployed DOs wake with the old shape; initSchema now runs a pragma-gated ALTER TABLE ... ADD COLUMN (idempotent, safe to re-run on every wake). Pre-migration rows replay code-less, matching what those clients were originally sent.
  • #replayReceipt shapes the replayed rejected frame identically to the original: { code, message } when a code was recorded, { message } otherwise — a client cannot tell a replay from the first send.

Tests

  • tests/error-paths.test.ts: new test — a coded (VALIDATION) rejection retried with the same txId replays the identical code (fails on main); the existing no-code retry test now also asserts the replay stays code-less.
  • tests/schema-evolution.test.ts: new test — a DO with the pre-error_code table shape upgrades in place on wake, idempotently, with old rows readable and new rows carrying the code.

Full suite: 195/195 passing.

Adversarial review (codex gpt-5.5)

One Medium finding: two concurrent duplicate frames can both pass lookupTx before either records (async authorize/command execute can yield), and the loser then sends its locally computed outcome rather than the stored one. This race is pre-existing on main and orthogonal to this fix — the diff changes what the dedup record contains, not when it is written or read. Left as a follow-up candidate rather than widening a bug-fix PR into a dedup-semantics change. No migration bugs found; all recordTx call sites confirmed updated.

🤖 Generated with Claude Code

A rejected tx's machine-readable error code was sent on the live socket
but never recorded, so a client retrying the same txId replayed the
reason message with no code — breaking code-based handling on exactly
the retry path dedup exists for.

_sync_seen_tx gains an error_code column; initSchema adds it in place
(pragma-gated ALTER) so already-deployed DOs upgrade on wake. The
replayed rejected frame is now shaped identically to the original:
{ code, message } when a code was recorded, { message } otherwise.

Fixes #21

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@grrowl
grrowl force-pushed the fix/replay-rejection-code branch from 339ce6c to d0cd524 Compare July 22, 2026 04:09
@grrowl
grrowl merged commit 0316024 into main Jul 22, 2026
1 check passed
@grrowl
grrowl deleted the fix/replay-rejection-code branch July 22, 2026 04: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.

Rejection code is dropped on tx-dedup replay

1 participant