Skip to content

Make an audit entry's field offsets independent of its previousVersion value (harper#2247) - #2499

Draft
kriszyp wants to merge 6 commits into
mainfrom
fix/audit-previous-version-presence
Draft

Make an audit entry's field offsets independent of its previousVersion value (harper#2247)#2499
kriszyp wants to merge 6 commits into
mainfrom
fix/audit-previous-version-presence

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 3, 2026

Copy link
Copy Markdown
Member

The LMDB audit entry announces its optional leading 8-byte previousVersion field with that field's
own first byte being 0x42. The writer instead emitted the field for any truthy value, so a
previousVersion outside [2**33, 2**49) was written and then skipped by every reader, leaving the
entry 8 bytes longer than the reader believed. action, nodeId, tableId, recordId and
version then all parsed from the wrong offsets, with no corruption detector on the path.

That leading-byte test is a cross-version contract, not a local convention. It is how harperdb
4.7.36's reader decodes the field, and it is how both versions' replication senders decide to strip
the field before framing an entry for the wire (encoded[0] === 66 ? 8 : 0), after which the
receiver hands the raw frame back to this same readAuditEntry. Three of the five implementations
are outside this repo. That is why presence is enforced at the value here rather than restated as a
header flag: the only word that could carry a flag is the action word, and it sits after the
optional field, so a reader cannot consult it before deciding whether to skip. Moving the header in
front would make an unmodified sender stop stripping and forward the field to a v4 reader that
parses the entry 8 bytes off — the same defect, relocated onto a shipped version.

What changed

The writer derives presence from the byte it actually encoded and uses that one boolean for both the field and the action offset, so the two can no longer
disagree. A value that cannot announce itself is rejected rather than written. Absence is now stated explicitly (undefined, null, 0) instead
of inferred from truthiness, which is what stops NaN from being silently dropped.

The reader recovers entries already written the old way, including by a v4 peer: when the first byte can begin neither an action nor a previousVersion,
the field is physically present and the header follows it. The set of bytes that can begin an action is derived from what the encoding can express, so a reserved entry type minted by a newer peer
still decodes. Recovery is gated on the LMDB container, on a candidate the superseded writer could
actually have emitted (> 1, its own guard), and on the byte at offset 8 being a legal action start.
Recovery restores the field offsets; the recovered value itself is dropped. It cannot lead with
0x42 by construction, and audit keys carry the same constraint, so it could never have addressed a
retrievable entry. A recovered entry hands a sender the bytes without the stray prefix, so the same misparse is not passed to the next hop. Anything else that cannot begin an entry now returns the existing corrupt
sentinel instead of decoding at the wrong offset.

The previousVersion <= 1 placeholder branch is gone. lmdb-js substitutes previousTime ^ 0x40 at
commit, which is 2.0 whenever no previous time was recorded — that is precisely the poisoned entry
in the byte-level capture on HarperFast/harper-pro#737decoded correctly by this reader — so the branch could not satisfy the invariant even in principle.

RocksTransactionLogStore is untouched and keeps its unconditional write, because its uint32 prelude
flag already states presence there, making its value unconstrained.

For the human reviewer

Three judgment calls worth your attention, in order.

1. The writer throws. An unrepresentable previousVersion now aborts the audit-entry encode, and
that encode runs inside a user write. I chose this over silently omitting the field because
previousVersion is the record-history back-edge, so dropping it would trade this bug's silent
mis-decode for a silently truncated history — the same class of defect. Every producer that can
reach the LMDB container was enumerated and is already in range, so the throw should be unreachable:
RecordEncoder passes a decoded localTime, which always leads with 0x42 because getTimestamp
XORs 0x40 into a byte that had to be 0x02; the other site passes a value that came back out of
readAuditEntry; and harper-pro's two call sites pass null. If you would rather this degrade than
fail, it is a two-line change.

2. PENDING_LOCAL_TIME does not throw, it drops the link. This is the one place the change can
lose something real. previousVersion === 1 is the
"previous entry has no log position yet" sentinel, and RecordEncoder feeds existingEntry.localTime
straight through on the LMDB path with no pending guard, so throwing there could abort a real write.
The entry is recorded without a back-edge and warns with the producer stack. This is the one place I
accept losing a link, because a format whose presence signal is the value's own first byte cannot
express "to be filled in at commit". The pre-push review caught this; I had originally removed the branch outright.
The review asked twice what this costs, naming the concrete case: two writes to one key inside one
transaction. I measured it rather than arguing it, and my first attempt was wrong in a way worth
recording — two separately awaited puts commit independently, so they never reach the pending path.
With both writes in one transaction the second entry carries no back-edge, and origin/main
produces the identical result on the same scenario
, because the first write of a transaction has
not published a localTime for the second to point at. So this change does not alter that path. The test in the diff pins what does matter there: neither entry misparses. I have not found a producer
that reaches the pending branch, which leaves it defensive; if one exists, it loses a link there.

3. Recovery discards the value it finds. An earlier revision preserved it, on the reasoning that
a real link should never be dropped. That was wrong in a way worth recording: RecordEncoder's
resolveRecord branch re-mints an audit entry from replacingEntry.previousVersion, so a preserved
out-of-band value flowed straight back into createAuditEntry, which now rejects it — and it does so
after the primary store.put has been issued, so the user's write fails and the record change can
commit with no audit entry. The value bought nothing anyway, since it could never address a
retrievable entry.

What this does not do. It does not fix the recurring red on harper-pro main
(replicationTopology.test.mjs, "Replicate larger v4 dataset"). In run 33709678114 every frame of
the RangeError: ... cannot be converted to a BigInt stack is under
/tmp/harperdb-legacy/node_modules/harperdb/, thrown from the legacy node's own recordId getter
inside its own audit-forwarding loop, which then closes its subscription. The mint, the misparse and
the throw all happen inside harperdb 4.7 before any v5 code runs. That trigger was addressed by
harper-pro#740; this PR closes the format hazard it left open.

Coverage gaps, stated rather than papered over. The RocksDB suite mirrors
RocksTransactionLogStore's prelude encode/decode rather than driving the real store, so it pins the
contract, not the store; the existing suites cover the real store on every audit write. No in-repo
test exercises a real replication sender or a v4 receiver, so the claim that a cleaned entry forwards
correctly is argued from the byte layout, not observed. The rejection test proves the audit value is
absent, not transactional atomicity with the primary record write.

Deliberately not fixed here. The audit key codec makes the same value-shaped inference. Its
writeKey runs on every audit key, so a warn-only guard would add per-key work without preventing an
unreadable key, and actually re-encoding keys would change key ordering and every key already
written. Worth its own issue.

Framing: Framing-Verdict: better-alternative-exists (round 1) → adopted → chosen-approach-sound
(round 2). Round 1's alternative was fail-closed enforcement at the codec boundary plus exact-action
legacy recovery; I rewrote the design around it rather than overruling.

Verification

Fails-on-base, with dist/resources/auditStore.js deleted and a full tsc before each run (the
built artifact was checked for previousVersion > 1 before the red run and for the new guard before
the green one):

origin/main source + new tests   16 passing, 19 failing
this branch      + new tests     35 passing,  0 failing

The 19 red failures are the writer-rejection cases, the legacy-recovery cases including the
harper-pro#737 hexdump, the encoded/size normalization case, and both real-LMDB cases.

The import-cycle regression test spawns a child node process that requires RecordEncoder first,
because the cycle only bites when it is the entry point and that order cannot be arranged in-process
once mocha has loaded both modules. Reproduced before the fix: under CommonJS the flag mask silently lost a bit and a valid recovery was
rejected as corrupt, decided only by load order. The mask is now built on first use, and the test spawns the child process.

Gates, run under an isolated HOME so another worktree's suite could not contend for the shared
system database (that contention, not this diff, produced the failures in my earlier runs):

leg result
unitTests/resources/auditLog.test.js 77 passing (lmdb) / 76 (rocksdb), 0 failing
test:unit:resources (rocksdb) 2043 passing, 0 failing
test:unit:resources (lmdb) 1659 passing, 2 failing
test:unit:bin (lmdb) 274 passing, 0 failing
test:unit:main 5290 passing, 1 failing

Both non-green cells are pre-existing and were confirmed against origin/main sources rather than
assumed. The two lmdb failures are subscriptionReplay's "count: empty initial state" and "count:
only other-table records exist", each a 2000ms waitFor timeout; that file passes 36/36 when run
alone on this branch, and the same two fail on origin/main in the same full-leg run. The
test:unit:main failure is configValidator's domain-socket path-length test, which resolves a
relative rootPath against process.cwd() and so fails in any deeply nested worktree.

Reader cost was measured baseline vs patched over a 2000-entry decode scan, interleaved in one
process. The result is below the measurement floor on this machine: the same binary against itself
varies by about 23% run to run, and the measured delta swings between -12.8% and +4.9% across runs.
Structurally the added work is one 256-byte table load, and it runs only for entries that carry no
previousVersion, since a 0x42 first byte takes the earlier branch.

Refs #2247

🤖 Generated with Claude Code

https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB

Review-Coverage: authored=claude; ran=codex; adjudicated=domain; declined=gemini,cursor-grok,cursor-composer; rounds=6 @ 3cb5b71

Human-Review-Need: 3 (decisions: parity-with-main-as-sufficient-evidence, pending-link-dropped-vs-deferred, throw-vs-warn-on-unrepresentable, reader-side-recovery-vs-migration, encoded-strips-prefix-silently, format-band-documented-as-permanent) @ 3cb5b71

kriszyp and others added 6 commits September 3, 2026 16:47
…n value (harper#2247)

The LMDB audit entry announces its optional leading 8-byte previousVersion field
with that field's own first byte being 0x42. The writer instead emitted the field
for any truthy value, so a previousVersion outside [2**33, 2**49) was written and
then skipped by every reader, shifting action, nodeId, tableId, recordId and
version by 8 bytes with no corruption detector on the path.

That leading-byte test is a cross-version contract, not a local convention: it is
also how harperdb 4.x's reader decodes the field and how both versions'
replication senders strip it before framing an entry for the wire. Neither of
those is in scope here, which is why presence is enforced at the value rather
than restated in a header flag.

The writer now derives presence from the byte it actually encoded, and rejects a
value that cannot announce itself instead of writing one no reader can see.
Rejecting rather than omitting is deliberate: previousVersion is the
record-history back-edge, so dropping it silently would trade a mis-decoded entry
for a silently truncated history. Absence is now stated explicitly, so a NaN is
rejected rather than falling through truthiness as "absent".

The reader recovers entries already written the old way, including by a v4 peer:
when the first byte can begin neither an action nor a previousVersion, the field
is physically present and the header follows it. Recovery is gated on the exact
action encodings the writer can emit, on a candidate the superseded writer could
actually have produced, and on the LMDB container only. A recovered entry reports
its back-edge and hands a sender the bytes without the unannounced prefix, so the
same misparse is not passed to the next hop.

The dead previousVersion <= 1 placeholder branch is removed. lmdb-js substitutes
previousTime ^ 0x40 at commit, which is 2.0 whenever no previous time was
recorded, so that branch could not satisfy the invariant even in principle. No
producer in harper or harper-pro reaches it; harperdb 4.x is what still mints it.

RocksTransactionLogStore is untouched and keeps its unconditional write, because
its uint32 prelude flag already states presence there.

Refs #2247

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB
Import cycle: KNOWN_ACTION_FLAGS read HAS_STRUCTURE_UPDATE at module scope, across
the pre-existing RecordEncoder <-> auditStore cycle. Entering through Table.ts
reaches RecordEncoder first, so that binding was uninitialized and the mask
silently lost the bit under CommonJS (reproduced: a recovery of an entry carrying
HAS_STRUCTURE_UPDATE was rejected as corrupt depending only on load order) and
would throw on the type-stripped ESM path. The mask is now built on first use,
which is the recovery branch, long after both modules are initialized.

Forwards compatibility: the legal-action-byte table was derived from the entry
types defined today, so a reserved nibble minted by a newer peer (9-15 are
documented as free) was classified as a stray previousVersion prefix and became a
corrupt sentinel, where it previously decoded every positional field and forwarded
intact. The table now comes from what the encoding can express rather than from
the current type registry. One consequence is deliberate: 0x3f is a possible
action byte, so a legacy 1.5 prefix is no longer recovered.

PENDING_LOCAL_TIME: previousVersion === 1 is the "previous entry has no log
position yet" sentinel, and RecordEncoder feeds existingEntry.localTime straight
through on the LMDB path with no pending guard, so throwing on it could abort a
user's write. A pending previous is a legitimate producer state rather than a
caller error, so the entry is now recorded without a back-edge and warns with the
producer stack. The format cannot express "to be filled in at commit" when the
presence signal is the value's own first byte.

Also: the recovery warn latches per table and no longer logs record-key bytes, and
the diagnostic on an undecodable header stops after the action byte.

Refs #2247

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB
Round 2 of the pre-push review found the interaction between two earlier
decisions. Round 1 asked that a recovered legacy value be preserved rather than
discarded, so a real link would not be lost. But RecordEncoder's resolveRecord
branch re-mints an audit entry from replacingEntry.previousVersion, so a preserved
out-of-band value flows straight back into createAuditEntry, which now rejects it
-- after the primary store.put has already been issued, failing the user's write
and potentially committing a record change with no audit entry.

The preserved value bought nothing anyway: a recovered candidate cannot lead with
0x42 by construction, and audit keys carry the same constraint, so it could never
have addressed a retrievable entry. Recovery is about field offsets, not the
value. This also removes the 2.0 special case, which was one instance of the rule
now applied generally.

Also from that round: the pending-previous branch promises not to abort the user's
write, but its own warn could, so it goes through warnContained like every other
new diagnostic; its comment no longer asserts the unverified claim that the
substitution had nothing to fill in, since TIMESTAMP_RECORD_PREVIOUS is set on the
record write for exactly that case; PREVIOUS_TIMESTAMP_PLACEHOLDER records why it
must never reach an audit value again; and 2**49 ms past the epoch is about the
year 19800, not 19857.

Refs #2247

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB
Both review rounds asked which way the PENDING_LOCAL_TIME branch actually goes,
and every existing test called the codec directly, so the branch was unproven in
both directions. This drives it from a real LMDB table write: two writes to one
key, asserting the second audit entry's previousVersion.

Observed: the second entry links back to the first entry's key, a 0x42-band
timestamp. So PENDING_LOCAL_TIME does not reach createAuditEntry from an ordinary
write -- by the time the second entry is minted, existingEntry.localTime is a
committed decoded timestamp. The pending branch is defensive rather than routine,
and the double-write case a dropped link would break keeps its back-edge.

Refs #2247

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB
The previous version of this test used two separately awaited puts, which commit
independently, so the second write saw a committed timestamp and never reached the
pending path at all. The review caught that the test exercised the already-covered
case while its comment drew a conclusion about the pending branch that the
assertion did not support. That conclusion is withdrawn.

The writes now share one transaction, which is the shape that can reach
PENDING_LOCAL_TIME. Measured there: the second entry carries no back-edge, and
origin/main produces the identical result on the same scenario, so this change
does not alter that path -- the first write of a transaction has not published a
localTime for the second to point at. What the test pins is that neither entry
misparses: recordId and type survive, which is exactly what a written-but-skipped
prefix destroys.

Refs #2247

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLaWQDpmKH9qXfMbuMbWWB

@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 addresses issue #2247 by enforcing representability constraints on the previousVersion field in the LMDB audit format, ensuring only values within the representable range [2^33, 2^49) (which lead with the 0x42 byte) are written. It also introduces a recovery mechanism in readAuditEntry to safely parse legacy entries written before these constraints were enforced, updates the storage format documentation, and adds comprehensive unit tests. There are no review comments, and I have no feedback to provide.

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