Skip to content

feat(native-chat): add a chat-owned command receipt store - #26653

Merged
brennanb2025 merged 3 commits into
mainfrom
brennanb2025/chat-writes-own-retry-proof
Oct 9, 2026
Merged

brennanb2025 merged 3 commits into
mainfrom
brennanb2025/chat-writes-own-retry-proof

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

When you press Send in a native chat, Orca first saves a separate "I've seen this request" note in a shared retry table. Only after that does it save your message. That first step has its own rules (how old a request may be, cleanup of other callers' notes, its own disk write). It can say no even when your message itself could have been saved. That is how a user got "Orca has received too many requests in the last day. Your message was not sent."

This PR is the first step of fixing that at the root. It adds a chat-owned receipt that is saved in the same database transaction as the message (or other action) itself. A retry of the same request then finds that receipt and gets the original answer back. No separate gate sits in front of the write. Nothing uses the receipt yet; later PRs in this stack switch each kind of chat action over, then remove the old gate in one step.

What Changed

User-facing: nothing yet. This PR only adds storage and tests; no chat action reads or writes the new table.

Mechanism:

  • New table agent_session_command_receipts in the chat database on the host that runs the chat. Each row holds:

    • the request's identity: operation id, caller, chat id and method
    • its canonical hash, the same one used today to detect a changed request
    • accepted or rejected
    • a pointer to the row that holds the result (chat epoch + sequence)

    It is not a copy of the result. Only a genuine no-op answer (a cancel that stopped nothing, a queue resume that changed nothing) is stored inline, because no row exists to point at.

  • Ids are looked up the way the old ledger looks them up: rows are keyed by caller and operation id; a send's id is checked against every caller's rows, and any other action checks only its own caller's rows. So, for chat commands, the same id reused for a different action conflicts where it conflicts today. (Today's send check also sees ids used by non-chat actions such as terminal agent launch; the receipt table holds only chat commands. The cutover PR labels that difference.)

  • insertCommandReceiptIfAbsent runs only inside an open transaction (the caller's effect transaction). A second request with the same id and hash gets the existing receipt back; with a different hash it gets a conflict. A row it can't read is reported as "unreadable", never as "absent", so a damaged row can never let a retry run twice.

  • buildCommandReceiptTransaction plugs that write into the existing per-write receipt hook (JournalOperationReceipt), which already commits inside the journal's transaction. When the id already exists, it throws, so the whole write rolls back.

  • How a receipt dies: a receipt lives exactly as long as its chat record. session_id references the chat record with ON DELETE CASCADE. Chat records are updated in place (ON CONFLICT DO UPDATE), which keeps receipts, and deleting a chat record deletes them. Today nothing deletes chat records, so in practice receipts are kept for good, like the chat's journal rows; the cascade is what removes them if chat deletion is added. Receipts do not die when a rewind replaces the chat's epoch, or when a queued draft is pruned. That is why no "move the proof somewhere else before deleting" step is needed. There is no age limit, count limit or background pruning.

  • The table is created at every writable open, as queued_messages is, with no schema version bump. Older builds simply ignore it. The bump question is revisited at the cutover PR, when the old ledger stops being written.

Why

The old design commits a separate reservation, with its own admission policy, before the user's write. The bug class is that any bookkeeping transaction placed in front of the user's durable write can refuse it. Removing the count cap (#26543) fixed one symptom. Age expiry, clock-skew refusal, pruning of unrelated rows and the reservation's own storage failures remain.

The common pattern is that a command's receipt is inserted-if-absent inside the same transaction as the command's effect, and a duplicate reads the existing receipt. There is no separate admission step and no count or age budget.

Alternatives considered:

  • Put the operation id on each kind of existing row instead (messages, queued drafts, prompt answers, stop events, rewind and create records). Rejected: identity would live in many places. Every epoch replacement or draft prune would have to copy the proof elsewhere first, and checking an id across methods would mean searching every carrier. One chat-owned table makes that a single keyed lookup.
  • Reuse agent_session_operations. Rejected: terminal agent.launch still relies on its pending-claim and expiry semantics. Native chat moves off it entirely; the launch side is a separate follow-up.

Differences from the common pattern:

  • INTENDED: receipts carry the canonical request hash (the common pattern's receipt has no payload fingerprint). This keeps Orca's existing protection against the same id being reused for a changed request.
  • INTENDED: receipts also record the caller, and ids are scoped per caller except for sends, which are checked across callers. This keeps today's id scopes, which shipped clients depend on.
  • INTENDED: receipts live as long as the chat record (in practice, kept for good, as in the common pattern). A rewind keeps the chat addressable, so its retry proof must survive the rewind.
  • INTENDED: a genuine no-op answer (a cancel that stopped nothing, a queue resume that changed nothing) is stored inline in the receipt. In the common pattern every accepted command writes an event to point at; these Orca actions write no row, so there is nothing to point at.
  • INTENDED: a refusal is stored as the existing typed refusal (code, details, message), not free text, so a retry gets the same structured refusal clients already handle.
  • INTENDED: the receipt is written through the effect transaction's existing receipt hook, and an existing id throws so the whole transaction rolls back. The common pattern inserts the receipt as the transaction's first statement. Both are one transaction, so the effect and its receipt still commit or roll back together.
  • INTENDED: Stop can still act when its receipt can't be saved (to land in the Stop PR). Stopping live work must not wait on bookkeeping.
  • TEMPORARY: the old ledger stays authoritative and the new table is unused. All native chat actions switch together in the cutover PR at the end of this stack, so an id can't be accepted by one store and missed by the other.

Linked Issue

Follow-up to #26543 (removed the ledger's count caps); this stack removes the separate admission step itself.

Visual Proof

No user-visible change: this PR adds storage with no caller, so there is no before/after screen. Proof is the receipt test run at the PR head (e31475e). It shows the duplicate and conflict answers, rollback with the effect, the chat-record cascade, survival across epoch replacement, and damaged rows reading as unreadable:

pr1-receipt-tests.png

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests in src/main/native-chat/agent-session-journal/command-receipt-{schema,table,transaction}.test.ts cover:

  • table creation on fresh, v3 and v4 databases with user_version unchanged
  • a newer-schema database stays read-only and byte-identical, and its writes refuse with journal_read_only
  • first insert, duplicate, changed-hash conflict, and today's id scopes (a send and another action reusing one id conflict as they do today; separate callers' non-send actions don't)
  • malformed rows, an out-of-range timestamp and a table missing a column read as unreadable; SQLite busy and I/O errors still surface as errors
  • the chat-record foreign key: an unknown chat fails, an upsert keeps receipts, a delete cascades them
  • a receipt and its effect roll back together
  • receipts and their pointers survive epoch deletion and real epoch replacement

Run with vitest run on those three files plus journal-database.test.ts, journal-store-schema.test.ts and journal-epoch-replacement.test.ts: 6 files, 65 tests passed at e31475e. At that commit the node typecheck passed through the queue, and orca-ci-checks --no-typecheck passed 22/22. Platform: macOS. The change is SQLite inside the host process, with no path or shell behaviour, so Windows, Linux, SSH and WSL hosts run the same code on their own database.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Author: @BrennanKB5. Stack: this is PR 1 of the native-chat retry-proof stack. Later PRs switch each chat action (send and queued actions, Stop, settings and goals, create, rewind and /clear) to write its receipt in its own commit. The last one removes native chat's use of the separate ledger admission and settlement. SSH and WSL: the receipt lives in the database on the host that runs the chat; clients never keep their own copy.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Adds the agent_session_command_receipts table and an insert-if-absent
receipt write that runs inside a command's own journal transaction. A
receipt records the command's identity, canonical hash, status and a
pointer to the row holding its result; it dies with its chat record.
Nothing uses it yet: the existing operation ledger stays authoritative.
Rows are keyed by caller and operation id; a send looks across callers and
other methods look up only their caller's row, matching the existing ledger.
Bad rows and newer table shapes read as unreadable instead of throwing.
…tion id

Global lookups now take rows in insertion order, so a read and an insert
agree on which receipt answers an id regardless of the host clock. The
primary key leads with the operation id, so its own index serves both
lookups.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

Problem. Every native-chat write (send, stop, settings and the rest) first commits a separate reservation in a shared retry table, and only then saves the user's action. That reservation has its own rules: how old a request may be, clock skew, cleanup of unrelated rows, and its own disk write. Any of them can refuse the user's write even when the write itself would have succeeded. That is how a user saw "Orca has received too many requests in the last day. Your message was not sent." An earlier PR removed the count caps; the reservation step itself is still there.

User-facing change. None in this PR. It adds the storage the later PRs in this stack will use: a receipt saved in the same database transaction as the user's action, so a retry gets the original answer with no separate gate in front of the write. No chat action reads or writes it yet, and the old retry table is still the one that decides.

What review found and fixed

Review covered correctness, design and how it fits the rest of the stack, regressions and compatibility, and an independent architecture challenge. A readiness checklist ran at the end. The challenger designed its own solution from the issue before reading the code and arrived at the same shape: one receipt table, written in the action's own transaction, storing a pointer to the result rather than a copy.

  • P2, fixed in 3eb7a01. Receipt ids were not looked up the way today's retry table looks them up. Sends and other actions were kept in separate id spaces, so the same id reused for a send and then a stop would have been accepted twice. Today that is a conflict. Rows are now keyed by caller and operation id. A send's id is checked against every caller's rows, and any other action checks only its own caller's rows. Fixing this now matters because the table's key can't change once later PRs start writing rows.
  • P3, fixed.
    • A damaged row or an unexpected table shape now reads as "unreadable", never as an error and never as "absent". SQLite busy and I/O errors still surface as errors.
    • A test that could not fail now checks the sequence number the writer actually assigned.
    • Corrected wording on the transaction guard and the replay rule.
  • P3, fixed in e31475e (both caused by the first fix).
    • Lookups across callers now take rows in insertion order rather than by the host clock, so a read and an insert always agree on which receipt answers an id.
    • The primary key now starts with the operation id, so it serves both lookups and the extra index was dropped.
  • PR text. The body now says plainly that receipts are kept for as long as the chat record exists, which today means for good, since nothing deletes chat records. It also labels the remaining differences from the common pattern:
    • no-op answers stored inline;
    • refusals stored with their code and details;
    • the receipt written through the transaction's existing hook.

Deferred to later PRs in this stack (they are about switching callers over, not this storage):

  • The order in which each kind of action switches over.
  • A durable "may have happened" record for settings, goals and prompt answers before they leave the old table.
  • Fitting the transaction hook to the send path, which already uses both hook slots.
  • Result types for queued drafts, settings, rewind, /clear and create.
  • Ids used by non-chat actions such as terminal agent launch, which today's send check also sees.
  • Whether to bump the schema version at the final switch-over.

Minor: a test harness that reseeds chat records would wipe receipts. Only tests use it.

Verified

  • At e31475e:
    • the Node typecheck passed;
    • 6 test files and 65 tests passed (command-receipt-{schema,table,transaction}, journal-database, journal-store-schema, journal-epoch-replacement);
    • the repository's static checks passed 22/22.
  • Reviewers confirmed in code:
    • the chat-record foreign key and its cascade;
    • the only production chat-record writer updates rows in place;
    • receipts survive epoch replacement;
    • a receipt rolls back with its action;
    • newer databases stay read-only and byte-identical.

Not verified

  • UI QA: not applicable. This is an inert storage change with no caller and nothing a user can trigger, so an app run couldn't exercise it. No screenshots.
  • No real-host run on Windows, Linux or SSH. The code is SQLite inside each host's own process, with no path, shell or wire change.
  • CI at e31475e: every check finished. 14 passed, none failed, among them static analysis and typecheck, all 5 unit-test shards, relay integration, cross-version wire compatibility, and Windows and macOS packaging. 16 were skipped by path filters, so they did not run. They include the e2e and persistence suites, so those are unverified, not 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