Repository navigation
Record history no longer shows a field before the patch that first wrote it - #3578
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request refactors historical record reconstruction (getRecordAtTime and reconstructForward) to accurately handle fields first written by newer patches, ensuring that fields are omitted or restored to their correct historical values (such as null or 0) based on exact integer arithmetic reversals. It also improves handling of incomplete history, pruned base puts, and corrupt entries, backed by comprehensive unit tests. The feedback suggests that when encountering a corrupt entry with an undefined type during a rebuild, the code should log a warning and skip the entry rather than breaking the loop, to prevent aborting commits or wedging subscriptions.
getRecordAtTime on a live record trusted the reverse walk for every field: a plain field first written by a newer patch kept its future value in the earlier snapshot, and a counter a newer add established reversed to 0 (or 0n) instead of being absent or null. Every key a reversed patch wrote, add keys included, is now settled by the forward rebuild at the timestamp; a key a complete rebuild lacks is removed. reconstructForward reports incomplete history (a pruned link, a corrupt entry, an entry with no body) through an optional out-object, and over incomplete history a key keeps its best-effort reversed value. add.reverse also reverses a bigint delta folded into a number field instead of throwing. Refs #1413 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-history-reverse-absent-fields
An add-only integer counter now keeps its reversed value when it is a nonzero integer: only an add that established its field from absent or null reverses to 0, and walking such a counter back to its base made a lagging snapshot read its whole retained history. Fractional deltas still go to the forward rebuild, which is exact where reversal leaves a float residue. An entry with no readable body now restarts the partial rebuild, so a value written before it can no longer settle a key the entry may have changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-history-reverse-absent-fields
…ct history A rebuild that crosses an out-of-order write merged on an additionalAuditRefs branch no longer counts as complete: the single-link walks never visit that write, so a field it lacks may still have existed. A counter reversal is only trusted while every value it reverses from is a safe integer. A newer patch with no readable body is crossed like a put instead of reversed as a no-op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-history-reverse-absent-fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-history-reverse-absent-fields
…ed time When the forward rebuild finds no put or patch at or before the requested time over complete history (an invalidate after a delete), keys only a newer patch wrote were left with their reversed value instead of removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3578-24d708e74dce-7b158545
… fields An invalidate stores only a stub of projected and indexed fields, so a forward rebuild that crosses one cannot prove another field absent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3578-24d708e74dce-7b158545
…tubs A newer invalidate replaced the record with a stub, so the reverse walk now rebuilds from history like it does across a put; the partial forward rebuild stops at an invalidate instead of filling fields from writes before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3578-24d708e74dce-7b158545
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3578-24d708e74dce-7b158545
The plain set's value is unrelated to what the counter held before it, so subtracting older deltas from it invented a value (or threw converting a fractional delta to BigInt). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: late-review-kriszyp_harper_3578-24d708e74dce-7b158545
kriszyp
force-pushed
the
fix/history-absent-fields-at-time
branch
from
October 27, 2026 02:01
79fca78 to
bae3b06
Compare
kriszyp
marked this pull request as ready for review
October 27, 2026 14:02
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
Record history showed fields from the future. For a record that only received patches after some point, history reads (
getHistoryOfRecord,getHistory,read_audit_log, subscription replay) put a field first written by a later patch into every earlier snapshot. A counter created by a lateraddshowed0instead of being absent ornull. A later put then made the leaked field vanish from the same snapshot.🤔 Is this fix wanted at this size?
As specified, yes. It is the remaining plain-field and counter part of #1413, confined to
resources/crdt.ts. It merges cleanly with the open #3504, which reworks the eviction path in the same file.💡 Solution
⚖️ Alternatives
🤔 Is the remaining forward-walk cost acceptable?
A counter that reverses to
0, or has a fractional delta, still walks back to its last put: n+2 audit reads, about 12–15 ms at 1,000 patches (RocksDB, loaded box). Plain fields already pay this on main. A depth cap would make old history best effort instead. Follow-up: #3569.🔧 Changes
resources/crdt.ts:applyReversesends plain sets and inexact adds to the rebuild.reconstructForwardreports incomplete history through apartialout-object: a corrupt entry, a merged branch, or an entry with no readable body, which also restarts the replay. Aninvalidateends the partial rebuild's history: its stub holds only projected and indexed fields.getRecordAtTimerebuilds across a newer patch with no readable body or a newer invalidate (a stub cannot be reversed from), skips older adds under a newer plain set (subtracting from the set's value invented one, or threw onBigInt(2.5)), rebuilds across a newer patch with no readable body and removes fields a complete rebuild lacks.add.reverseno longer throws on a bigint delta folded into a number field.resources/DESIGN.md: the exactness rule and its limits.🤔 Should a newer invalidate force a rebuild from history?
A snapshot older than an invalidate is now rebuilt from history, as across a put, not reversed from the stub. With pruned history that snapshot is absent instead of partial. Follow-up: #3614.
🤔 Should a coerced counter keep its original type?
A numeric string or boolean that a later
addcoerced comes back as its number ('3'reads as3). Recovering the type means rebuilding every counter from history. Changing this later is local to the trust check.✅ Verification
crdt.test.js(mock store),auditLog.test.js(real tables on RocksDB and LMDB, throughgetHistoryOfRecord,getHistoryandreadAuditLog) andsubscriptionSuperseded.test.js. 18 fail on anorigin/mainbuild on both engines. The other 10 each fail when their guard is removed. At bae3b06,crdt,auditLogandsubscriptionSupersededpass on both engines (RocksDB 198, LMDB 195).test:unit:resources: RocksDB 4919 passing, LMDB 3730 passing. The 1 and 3 failures also fail on the base build (socket?.off,setReplicationConfirmation).test:unit:mainandtest:integration:alldid not finish locally (overloaded box); CI runs them.New tests
nullrestored; a counter already0kept; record that began with a patch; delete bound; pruned base keeps unsettled values; writes before a body-less entry not trusted; fractional delta; unsafe-integer total; out-of-order merged branch; newer body-less patch; integer counter reads only two entries; body-less and corrupt base entries; bigint delta on a number field; prototype-named field; null rebuild after a delete; invalidate in range (absence, older writes, base-less add); newer invalidate; older add under a newer plain set.randomAccessFieldstable;nullrestored; pruned base.includeSuperseded.PR QA: Does record history stay free of future fields when two nodes patch the same record concurrently? — queued @ c776194, qa-pr-harper-3578-c776194
Docs: none needed. History now matches its documented meaning.
Refs #1413
🤖 Generated by Claude Opus 5.5 (Claude Code dev-agent); posted via @kriszyp.
Release target: main — fixes history reconstruction on main; no release-line customer need recorded
Related PRs: #3504 overlaps (same file; clean trial merge), 2 others independent
Complexity: complicated
Deferred findings
6148b771cf8b67ffminor performance —resources/crdt.ts— add-only counters with a zero or fractional reversed result force a forward audit walk per getRecordAtTime — filed Record history rebuilds each patch entry from scratch, so listing a record's history costs n² audit reads #3569b98f1ab64e1fc0d4minor data-integrity —resources/crdt.ts— out-of-order write merged after the timestamp is in the live image but never reversed — filed getRecordAtTime misses out-of-order writes merged after the timestamp, and skips the fill on a history with no put or patch #3570de4d2ec8c10da23aminor data-integrity —resources/crdt.ts— whole-record rebuild treats a corrupt sentinel as start of history and a bodiless put as an empty base — filed Whole-record history rebuild treats a corrupt or body-less audit entry as complete history #3565Late Grok findings
5 surviving (highest major) — Grok reviewed 24d708e
resources/crdt.ts:237— When a patch's body can't be read (a bodyless mint, the warned path atresources/auditStore.ts:1373-1386), onlypartial.truncatedis set. The fields from before that patch stay onpriorRecord, and the fill atresources/crdt.ts:367copies them over the correct reversed value. Example: put `{cresources/crdt.ts:137—applyReversenow adds every key tounknowns, including keys changed only by CRDTaddoperations; main put only plain-set keys there. So a history read that crosses only add patches now runsreconstructForwardback to the nearest put on every read.getRecordAtTimeruns once per partial audiresources/crdt.ts:363— WhenreconstructForwardreturnsnullover complete history (for example the entry atauditTimeis aninvalidateand the one before it is a delete), theif (priorRecord)guard skips the new delete for absent keys. The reversed best-effort values stay on the returned record when they shouldresources/crdt.ts:206— The check that treats an entry read with no type as truncated history only runs whenpartialis set. A whole-record rebuild still treats a corrupt entry as the start of history. This was deferred earlier underwhole-record-rebuild-corrupt-entry-as-history-start.resources/crdt.ts:134— New comments repeat one rationale in three places (:134-136,:236,:348-351) and narrate the code (:206,:238-239).Not standing: 1 dropped-factually-wrong. Grok ran 23m (23m from launch to report); the fold adjudication (
cursor-grok@24d708e74dce-7b158545, claude) took 1m.Late Grok findings
6 surviving (highest major) — Grok reviewed b4ee076
resources/crdt.ts:361— The fix only partly restores the fast path. An add whose reversed result is 0, or any add with a fractional delta (resources/crdt.ts:144-146), still sendsgetRecordAtTimeon a forward audit walk back to the record's base. That function runs once per audit entry during history scans (`resources/aresources/crdt.ts:361— The new trust gate only checks the final reversed value. It never checks the stored value before reversal or the delta. Two ways to get a wrong snapshot:resources/crdt.ts:378— The new delete for keys missing from a complete rebuild can drop a field that an out-of-order replicated patch wrote on anadditionalAuditRefsbranch. The forward walk follows a single previous-link chain (resources/crdt.ts:228-230,resources/crdt.ts:258-277), so it never collects a write thatresources/crdt.ts:330— A patch newer than the requested time with no readable body reverses as a no-op. The keys it wrote stay out ofunknowns, so their newer values leak into the snapshot. This change handles unreadable bodies in the forward replay (resources/crdt.ts:240-245) but not here in the reverse walk. Alreadyresources/crdt.ts:134— Added comments:resources/crdt.ts:378— When the rebuild returns null, the absent-key delete is skipped. The author declined to change this (a null rebuild means there is no put or patch in range to prove the key was absent). Deferred.Not standing: 1 dropped-factually-wrong, 1 accepted-known. Grok ran 23m (23m from launch to report); the fold adjudication (
cursor-grok@b4ee076b7eba-7c26db1d, claude) took 2m.Origin — the dispatch brief this PR was written from
Residue of #1413 in getRecordAtTime (resources/crdt.ts): the unknowns fill only overwrites when Object.hasOwn(priorRecord, key), so a plain field absent at the target time keeps its future value; add.reverse subtracts unconditionally, so a counter first established by an add becomes 0 instead of absent (or null when seeded null). Open PR #3504 rewrites reconstructForward in the same file: base on it if it has merged, otherwise coordinate. applyReverse adds add keys to unknowns; reconstructForward reports whether it hit a pruned link (expose the internal truncation); on a complete reconstruction delete keys absent from priorRecord; on a truncated one keep the best-effort value.
Acceptance
Tests on rocksdb and lmdb: a plain field first written after t2 is absent at t2; a counter established by a later add is absent at t2 and restores null if seeded null; a pruned base keeps the best-effort value. Show each new test failing on origin/main sources (forced rebuild) before the fix.
Dispatch: task
harper-history-reverse-absent-fields· queued by finding-triage/wave-40 · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=10; full=2 @ bae3b06
Review-Attention: deep ~25m (critical: crdt.ts; decisions: delete-absent-on-complete-history, trust-nonzero-integer-reversal, invalidate-policy-split, newer-invalidate-routes-to-rebuild) @ bae3b06