Skip to content

Stop out-of-order patches on audit:false tables from resurrecting deleted records or applying increments twice - #3506

Merged
kriszyp merged 14 commits into
mainfrom
fix/noaudit-patch-tie-null-dup-guards
Oct 28, 2026
Merged

kriszyp merged 14 commits into
mainfrom
fix/noaudit-patch-tie-null-dup-guards

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 23, 2026 •

Copy link
Copy Markdown
Member
A frantic mailroom worker pins duplicate envelopes to a red-string corkboard

⊙ Problem

On audit: false tables, a patch older than or tied with the stored record skipped the checks the audited path makes. A patch older than a delete or a publish brought the record back, indexed. A re-delivered patch applied its add again. On RocksDB, a put or patch after a publish to the same key in one transaction was dropped.

🤔 Is the requirement right as specified?

Mostly. The task also asked LMDB to keep the head's version after a merge (#3388). That version is also LMDB's optimistic-lock token, so keeping it loses concurrent writes.

💡 Solution

For an older or tied write on an audit: false table:

  • A write is skipped if it ties the head, the head has no record (delete, publish), or the head already lists it.
  • A merge that applies a commutative op records its identity, so a re-delivery is skipped.
  • A tie left by a relocate or publish in the same transaction, before or after the write, is ordered against the committed head on RocksDB: newer applies, older merges only its ops, equal is a re-delivery.

Fixes #3385. Refs #3388.

⚖️ Alternatives

Rejected alternatives
  • Keep the head's version on LMDB (Math.max): LMDBTransaction.commit checks ifVersion(key, entry.version), so keeping the version lets a write staged before the merge commit over it.
  • Return null from remainingAgainstRecord for a missing record: the delete rule is Table policy (already in belowAuditFloor), and the helper cannot detect duplicates.

🤔 Should a same-origin tie with a different log key count as a duplicate?

Yes. An audit: false head stores no log key, so a re-delivery and a distinct same-version write look identical.

🤔 Should an older patch over a publish-only head be dropped?

Here it is: without a log, a publish-only head looks like delete-then-publish. The cost: a patch arriving first creates the record.

🤔 Should receipts get their own record field?

They reuse receivedIdentityRefs, avoiding a format change; old receipts can age out of the bounded list.

🤔 Should the out-of-order arm avoid its extra walk and allocation?

No: the walk is bounded by one transaction's same-key writes, and the allocation is on the rare merge path.

🔧 Changes

✅ Verification

New test cases
  • Older patch after a delete: no record, no index entry, blob reclaimed.
  • Older patch after a publish to an absent key: no record, no index entry.
  • Newer patch after a delete still recreates the record (control).
  • Delete then patch in one transaction still applies (control).
  • Publish then patch, publish then put, publish to an absent key then patch, all in one transaction, still apply.
  • A replicated patch and publish, in either order, apply over an older head, merge only their ops under a newer head, and apply once on re-delivery (RocksDB).
  • Tie re-delivery of an add applies once.
  • Out-of-order re-delivery applies once, including two merges each re-delivered (RocksDB).
  • Same-timestamp patches from two origins both merge (RocksDB).
  • A re-delivered two-patch transaction applies each patch once.
  • The merge keeps the head version, so older patches and deletes still lose (RocksDB).
  • Receipts are stored per origin and log key, and a plain-key merge adds none of its own (RocksDB).
  • Applied writes sharing a record version but not a log key both merge (RocksDB).
  • A write staged before an out-of-order merge commits does not overwrite it (both engines; guards against the LMDB Math.max change).

🤖 Generated with Claude Code

🤖 Generated by Anthropic Claude (Opus 5.5); posted via @kriszyp.

Release target: v5.4 — extends #3411's audit:false branch, which is on v5.4 only

Related PRs: #3469 overlaps (adjacent full-update branch; expect a textual conflict), #3499 overlaps (merged; same-transaction publish/relocate tie, audited path; rebased onto it), #3411 overlaps (merged; this extends its audit:false branch), #3476 overlaps (merged; committed-head duplicate skip, audited path), #3510 overlaps (merged; minted local log keys and the received-identity list the receipts now use; rebased onto it), #3423 overlaps (merged; replication confirmation note in DESIGN.md; rebased onto it), #3576 overlaps (merged; distinct origins never tie, so the tie skip is same-origin only; rebased onto it), #3529 overlaps (merged; added followsAppliedWrite, reused here; rebased onto it), 20 others independent
Complexity: complicated
Framing-Verdict: chosen-approach-sound

Deferred findings

  • e88fb92adebeea8b minor data-integrity — resources/Table.ts — chained same-txn out-of-order plain-field patch dropped against prior staged record — NOT FILED
  • 367176ab4120fb41 minor data-integrity — resources/Table.ts — in-order write on audit:false drops receivedIdentityRefs, so re-delivery of an earlier commutative merge double-applies — NOT FILED
  • e06173778072dd87 minor data-integrity — unitTests/resources/noAuditOutOfOrderPatch.test.js — LMDB drops patch before same-txn publish; new tests skip LMDB — NOT FILED
  • 9cef1ac0381dadf8 minor data-integrity — resources/Table.ts — an older replicated publish rewinds the head version below a newer committed write — NOT FILED
  • 3001c44f2fdb2aa6 minor data-integrity — resources/Table.ts — re-delivered [publish K, patch K] re-applies the patch's commutative ops on LMDB — NOT FILED
  • de38970ada68f5aa minor data-integrity — unitTests/resources/noAuditOutOfOrderPatch.test.js — dedup and publish arms skipped on LMDB — NOT FILED
  • c2574982c64bc159 minor data-integrity — resources/Table.ts — LMDB merge stores the older version; a later older-than-head write compares in-order — NOT FILED
Origin — the dispatch brief this PR was written from

Fixes #3385 and #3388 plus two untracked siblings, all in the audit:false patch branch of Table._writeUpdate (resources/Table.ts ~5535-5547), which after #3411 is remainingAgainstRecord(...), skip if nothing remains, set write.superseded, with no tie, null-head or duplicate guard (the audited and belowAuditFloor branches have all three, e.g. ~5518-5528). remainingAgainstRecord returns the whole update for a null record and always keeps op keys, so: (D1, #3385) an old patch past a delete tombstone recreates an id: undefined record in primary and secondary indexes and leaks blobs (also via Table.publish null entries on trackDeletes:false tables); (D2) an exact-timestamp re-delivery double-applies add; (D3) an out-of-order duplicate re-applies because existingEntry.additionalAuditRefs is never consulted, and writeCommit (~5714) wipes the list; (D4, #3388, LMDB) writeCommit uses Math.max(txnTime, existing version) only on RocksDB (~5694), so LMDB rewinds the head version. Skip the write (write.skipped = true; return writeCommit(false)) when the txn ties the existing version, the existing record is null, or additionalAuditRefs already holds this (txnTime, nodeId); when a partial merge applies, carry additionalAuditRefs forward and add this write's ref as the belowAuditFloor branch does; use the Math.max version rule on LMDB too. Draft PR #3469 edits the adjacent audited block; expect a textual conflict.

Acceptance

Dual-engine tests on an audit:false table: old patch after delete does not recreate the row (no index entry, no leaked blob); tie re-delivery of an add applies once; out-of-order duplicate add applies once; LMDB head version never decreases. Controls: in-order and genuine out-of-order patches still merge. Shown failing on origin/main.

Dispatch: task harper-noaudit-patch-tie-null-dup-guards · queued by finding-triage/wave-39 · ran by claude/opus/xhigh · worker kzyp-xps-1

Late Grok findings

11 surviving (highest major) — Grok reviewed 9759ff4
  • major — resources/Table.ts:5847 — Source-applied audit: false transaction [patch K count+=4, message K] (seed count 10 at v50, frame at v100): the message saves eagerly and restamps K to v100; the deferred patch reloads that entry, ties with it, and the tie exception checks only earlier writes (priorWritesSkipped), not later o
  • minor — resources/Table.ts:5862 — existingRecord == null treats a publish-only head like a delete tombstone. In-order delivery (patch T50, then publish T100) keeps the record; out-of-order delivery leaves no record; the audited walk creates it. Peers end up with different data, and the test at `unitTests/resources/noAuditOutOfOrde
  • minor — resources/Table.ts:5861 — precedesExisting === 0 skips on record-version equality alone. A distinct same-origin write at version 100 / log key 110 against a head at version 100 / log key 100 loses its count += 4
  • minor — resources/Table.ts:5995 — In-order writes on audit: false carry no receipts forward. So: T50 out-of-order add (receipt stored), then T150 in-order patch, then re-delivery of T50 applies the add a second time (count 15 → 18)
  • minor — resources/Table.ts:10319 — priorWritesSkipped walks the whole predecessor chain, and every skipped same-key write calls it again (resources/Table.ts:5847, resources/Table.ts:5864). Re-delivering N same-key writes costs N(N−1)/2 inspections
  • minor — resources/Table.ts:5880 — commutativeOpsOf builds a full projection whose only use is a truthiness test, and the receipts are sliced before anyone knows a push will happen. Both are per-patch allocations on the out-of-order audit: false branch
  • minor — resources/Table.ts:8372 — A re-delivered [publish K, patch K] transaction applies the patch's commutative ops again (deferred: noaudit-publish-then-patch-redelivery-double-apply)
  • nit — resources/Table.ts:5880 — On LMDB, a chained same-transaction patch rebuilds receipts from the pre-transaction entry and drops the receipt the earlier patch staged. No double-apply follows today because the LMDB merge stores the record at txnTime
  • nit — resources/Table.ts:5847 — The empty else if branch only works because incrementalUpdateToApply stays unset; an explicit assignment would survive later edits
  • nit — resources/DESIGN.md:146 — The receipt note does not say what happens to unaudited receipts once auditing is turned on (the audited check reads them only under identityIsNotKey), or mention the encoder-budget limit
  • nit — unitTests/resources/noAuditOutOfOrderPatch.test.js:16 — Comment nits, all in one: "as on main" at test line 16 is addressed to the reviewer, and the null-guard narration at resources/Table.ts:5858-5859 restates the condition below it. The applyFrom comment at test line 132 is borderline

Not standing: 2 separate-issue. Grok ran 15m (15m from launch to report); the fold adjudication (cursor-grok@9759ff40a9c6-9aee4a61, claude) took 2m.

Late Grok findings

1 surviving (highest minor) — Grok reviewed e58cfa9
  • minor — resources/Table.ts:5892 — on an audit: false table, the next in-order write throws away the receipt from a commutative out-of-order merge. A re-delivery of that merge then applies its ops a second time (count 10 → 13 → 16)

Not standing: 1 separate-issue. Grok ran 12m (12m from launch to report); the fold adjudication (cursor-grok@e58cfa93ee41-9901dfce, claude) took 1m.

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; blocked=claude(fallback)(out-of-budget); declined=muse; rounds=20; full=7 @ a6212d0

Review-Attention: study ~15m (critical: Table.ts; decisions: noaudit-tie-is-duplicate, null-head-skip-includes-publish, receipts-on-record-best-effort, lmdb-arms-deferred) @ a6212d0

@kriszyp kriszyp added this to the v5.4 milestone Oct 23, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the out-of-order patch handling on tables with auditing disabled (audit: false). It ensures that older patches do not resurrect deleted records or materialize records over newer publishes, correctly handles program-order ties when earlier writes restamped the head without staging, and tracks merged write identities in additionalAuditRefs to recognize re-delivered patches. Corresponding documentation in DESIGN.md and comprehensive unit tests in noAuditOutOfOrderPatch.test.js have been added. I have no feedback to provide as there are no review comments to assess.

kriszyp and others added 14 commits October 27, 2026 13:22
…eted records or re-applying increments

On an audit:false table, a patch older than (or tied with) the stored record went through
remainingAgainstRecord with no further checks, so:
- a patch older than a delete tombstone or a publish to an absent key recreated the record
  (indexed, with its blobs kept);
- a re-delivery at the head's own version re-applied its `add` ops;
- a re-delivered out-of-order patch re-applied its ops, because the record's
  additionalAuditRefs were neither consulted nor carried forward.

The branch now skips a tie, a head holding no record, and a write whose (txnTime, nodeId) the
head already lists; a merge carries the head's refs forward and adds its own identity.
resolveAuditHead ignores refs on an unaudited record, since they address no log entry.

LMDB still stores a merge at the merging write's older version (#3388): its entry version is
also the ifVersion token LMDBTransaction commits on, so keeping the head's version lets a write
staged against the pre-merge entry overwrite the merge. A test pins that.

Fixes #3385
Refs #3388

Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s by the received log key

A publish or relocate restamps the head without staging it, so on RocksDB a patch that follows
one in the same transaction ties the head (or meets a publish's null value) and the new guards
dropped it. They now apply only when every earlier write to the key in the transaction was
skipped, as the audited head-tie check does.

The identity folded into an unaudited record is the received log key — the origin's log key for
an apply, txnTime for a local write — so two applied writes sharing a record version stay
distinct. resolveAuditHead no longer ignores refs on unaudited tables: a publish stores real
log-key refs there.

Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ogram order

A publish or relocate restamps the head without staging it, so on RocksDB a later write to the
key in the same transaction ties the head. On an audit:false table that tie sent a put to the
"loses" branch and a patch through remainingAgainstRecord, dropping the put and every plain
field of the patch. Such a tie now applies as an in-order write; the audited walk already
ordered it by log key.

Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive op

A re-delivered out-of-order patch can only double-apply its commutative ops; the plain keys it
merged are held by the record afterwards and remainingAgainstRecord drops them. Receipts are walked
as log keys once auditing is turned on (a subscribe does that), each a transaction-log miss of
0.3-1 ms on a 60k-entry log here, so they are kept to the merges they protect.

Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
arms still use it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
…ocksDB

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
…log-key list

Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…es the key after it

A source-applied patch is deferred to commit, while a publish or relocate of
the same key saves when it is added and restamps the head at the origin's id
and version. The patch then tied that head with no earlier unskipped write, so
the no-audit branch dropped it as a re-delivery (count 10 instead of 14). The
program-order check now also applies the tie when the committed head
(identityBase) does not tie; a re-delivered transaction still ties it and skips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
…t away from it

The previous commit applied the tie in program order whenever the committed
head did not tie, including a committed head that is strictly newer: an older
replicated patch then overwrote its plain fields. Compare once against the
committed head (identityBase) and carry the result: older head applies in
program order, newer head takes the ordinary out-of-order merge, a tie skips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
…ocksDB

A publish or relocate before a replicated patch is an unskipped predecessor,
so the tie went straight to program order and an older source transaction
overwrote a newer head's plain fields; a re-delivered [publish, patch] also
re-applied its increment. On RocksDB the tie is now always decided against
identityBase's committed head, wherever the publish sits. LMDB commits pass no
committedBy, so identityBase cannot see past the restamp there and LMDB keeps
the predecessor exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
#3529 added the same predecessor walk under that name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
@kriszyp
kriszyp merged commit ce229d2 into main Oct 28, 2026
104 of 105 checks passed
@kriszyp
kriszyp deleted the fix/noaudit-patch-tie-null-dup-guards branch October 28, 2026 14:51
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.

audit:false: an out-of-order patch older than a delete tombstone resurrects the deleted record

1 participant