Record a write-ahead audit retention floor, raised before every prune (infrastructure for #2448) - #2458
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements an audit retention floor mechanism to track the oldest retained audit history time, ensuring consumers resuming incremental audit-log consumption can reliably detect if their history has been pruned. The floor is raised before any pruning occurs across all prune paths, with updates made to ResourceBridge.ts, Table.ts, auditStore.ts, and databases.ts, alongside new comprehensive unit tests and design documentation. The review feedback suggests returning 0 immediately in Table.ts's deleteHistory when using RocksDB to avoid unnecessary iteration, and using strict assertions (assert.strictEqual and assert.deepStrictEqual) in the unit tests to prevent type-coercion bugs.
|
Docs companion: HarperFast/documentation#660. |
Per gemini's review on #2458. `assert.deepEqual` on a scalar was the wrong tool regardless, and the other two compare numbers where a string-vs-number would have been masked. AGENTS.md's house style is plain `assert` with no `node:assert/strict` import, which this keeps — it calls the strict methods directly, as that guidance allows for checks that need them. The other review comment (early-return from deleteHistory on RocksDB) is declined and filed as #2469: the suggested fix would disable the cleanupDeletedRecords branch, which does real work on RocksDB and has a RocksDB-only regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
Caught in review on #2458. The merge with #2338 kept the `last-removed` marker and said so in its own message and in the `AUDIT_FLOOR_KEY` comment, but this prose was not updated with them. DESIGN.md is the section index AGENTS.md points contributors at, so calling an actively written and actively tested marker "retired" is exactly the sentence that would get it deleted — silently reverting #2338's hardened write path and its five tests. Says what is true instead: both markers are live, why they coexist, and why the floor needed its own key rather than reusing that one. Also splits the trust-marker bullet, which had grown two unrelated claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The full-scope review round I ran to audit this function as a whole produced no coverage — the Codex leg was SIGKILLed on timeout and the fallback ran out of budget, so there is no receipt behind it. Rather than re-run and hope, here is the audit done by hand, so the concurrency and failure reasoning is checkable without re-deriving it. Audit-floor failure matrixWritten because three consecutive fail-opens in
Record state × operation
Cutoff domain
Write outcome × engine
Known gaps, stated rather than papered over
Why this exists: three consecutive fail-opens in |
kriszyp
left a comment
There was a problem hiding this comment.
I think we want to ensure that this uses the same mechanism for determining complete catch-up, as replication uses for determining base copy (should be exactly the same condition/path).
I'm also wondering if this could be more succinctly/narrowly handled by having a subscribe() return a flag indicating the start time preceded the audit retention window (try to subscribe, fail if it can't reliably subscribe, less prone to race conditions).
🤖 Reviewed with Codex
| // so take whichever is later. Bounded by what survives, and RocksTransactionLogStore.getKeys() is | ||
| // unimplemented, so there this reduces to Date.now(); an accepted limitation of stamping rather | ||
| // than leaving a floorless store permanently unknown, which would make every upgraded deployment | ||
| // fail closed forever. |
There was a problem hiding this comment.
Legacy bootstrap can certify a selectively pruned gap — Severity: blocker. A floorless LMDB store from an older version may already have an audit entry removed by table-specific deleteHistory that is newer than every surviving key. After clock rollback, max(Date.now(), newest) can remain below that missing entry, so a persisted cursor above this epoch passes the resume check and silently skips history. The simplest safe fix is to store Infinity for an unmarked store until a full resync or database generation records a trustworthy epoch.
—
Reviewed a184017
There was a problem hiding this comment.
Confirmed, and reachable — thank you, this is the sharpest form of the bootstrap objection so far. deleteHistory(endTime) removes one table's entries below endTime out of the shared log, so a table whose entries were the newest and all fell below that bound leaves the newest surviving key being a sibling's older entry. getKeys({reverse:true, limit:1}) therefore bounds what survives, not what existed, and a clock rolled back to between the survivor and the removed entries stamps an epoch below them. A cursor in that window then resumes over the gap. Four conditions have to coincide, but none of them are exotic.
What I have changed is the comment, which claimed more than the guard delivers — it now says the bound narrows the rollback hole rather than closing it, and why nothing in surviving state can distinguish the case (25bfa1a).
What I have not done is take the recommended fix, because AUDIT_FLOOR_UNKNOWN for every unmarked store is the option Dawson already ruled against — the residual question from an earlier round was exactly stamp, or preserve an unknown floor for any store with no record, and the cost of not stamping is that every upgraded deployment fails closed forever, for all consumers, permanently, since the floor is monotonic and Infinity is its top. That trade is recorded as decision 4 in the PR body. Your finding raises the price of that ruling and deserves to be weighed against it rather than settled by me, so I have escalated it to him rather than resolving this thread.
One option neither the ruling nor this comment considers, if it helps the decision: the missing ingredient is any persisted artifact of the pre-rollback clock, and the database file's mtime is one. max(Date.now(), newest surviving key, mtimeMs) would cover your scenario, since the removed entry was written before the last write to the file. It fails only when a copy resets mtimes, and its error direction is safe (too high costs one resync; too low degrades to today's bound). Narrows, still does not close.
🤖 Addressed by Claude Code
There was a problem hiding this comment.
Following up with a severity question rather than a fix, and it is genuinely a question — your call, not something I want to assume.
The case for re-targeting this at #2448 instead of this PR. As of 2a0d9ba00 the accessor promises nothing: it is explicitly diagnostic, and cursor >= floor is stated in the JSDoc, DESIGN.md, the docs page and the PR body as not a certificate that resuming is safe. Nothing in the product reads the floor — Table.subscribe does not consume it; that is #2448. So there is no code path today in which a wrong bootstrap epoch produces wrong behavior. It is a latent metadata bug, and #2448 is what makes it live.
That is now recorded structurally rather than promised: #2448 is marked blocked by #2451, with your finding and Kris's both written in as gating criteria, and #2451 carries a new acceptance criterion that came directly out of your comment — a database that reads unknown must be able to earn a real floor back — because AUDIT_FLOOR_UNKNOWN turns out to be absorbing (raiseAuditFloor cannot lift it, establishAuditFloor skips any existing record), which makes the generation stamp the only route back to a finite floor.
The strongest argument against me, which I would rather raise than have you find. Deferring is not free, because the bootstrap runs at first open by the new version, not when #2448 lands. Ship this and any affected store has the suspect epoch persisted immediately, months before anything reads it. A later release cannot repair it either, because nothing distinguishes a bootstrap-stamped floor from a legitimately-pruned one.
Which suggests a third option, if you would rather not wave this through. That last objection is fixable cheaply and additively: have establishAuditFloor also record the bootstrap epoch under its own key. The 8-byte floor format and its strict decode stay untouched, nothing reads the new key in this PR, and a later release can then identify exactly the suspect population — floor still equal to its bootstrap value, never raised by a real prune — and repair it. That converts "defer and hope" into "defer with a repair path," which I think is the actual disagreement here.
So, three ways I am happy to go, in my order of preference:
- Add the bootstrap-provenance key now (~10 lines plus a test), and re-target the epoch fix itself at restore_backup rolls a database back in time with no epoch, so audit cursors, record versions, and replication sequence state all read as valid afterwards #2451. Deferral becomes repairable.
- Re-target as-is, on the grounds that nothing acts on the floor until Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 and Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 cannot land without restore_backup rolls a database back in time with no epoch, so audit cursors, record versions, and replication sequence state all read as valid afterwards #2451.
- Add the mtime term now —
max(Date.now(), newest surviving key, mtimeMs)covers your scenario, since the removed entry was written before the last write to the file. Narrows, does not close, and fails when a copy resets mtimes.
Tell me which and I will implement it, or hold the PR if you would rather none of them. Not resolving this thread either way — that is yours to close.
🤖 Addressed by Claude Code
There was a problem hiding this comment.
Implemented option 1 — fbb96f673. Dawson's call was to take it, so the epoch fix itself stays with #2451 and this PR makes the deferral repairable rather than permanent.
establishAuditFloor now writes the epoch under Symbol.for('audit-floor-bootstrap') before the floor, stamps the floor from what that record holds, and never touches the record again. The reading a later release gets:
| Floor | Bootstrap record | Reading |
|---|---|---|
finite, === bootstrap |
present | still the unverified guess — suspect, repair by raising it |
finite, > bootstrap |
present | a real prune raised it; earned, not guessed |
| finite | absent | provenance write was lost — suspect, so this fails closed |
AUDIT_FLOOR_UNKNOWN |
either | already fail-closed; nothing to repair |
Absent reads as suspect because repair means raising a floor, which is always safe, so an over-broad reading costs one resync. Ordering carries the rest: a crash between the two writes leaves a record with no floor, which the next open retries because the early return tests the floor. updateAuditFloor takes the key now rather than hardcoding it, so the record gets the same verified commit as the floor instead of a second write path.
Two things worth flagging because they are the kind of decision that looks arbitrary later:
- Adoption is not bounded by the newest surviving key. Re-deriving
max(Date.now(), newest)on a store that already has a record would be more conservative, and it would also destroy the signal —floor === bootstrapis the entire basis of the table above. So the record wins, which is sound because a prune in the meantime would have written the floor the early return checks. - There is no read-the-record-first fast path, though it is the obvious optimization and I wrote it that way first. Ablating the write showed the orphan-adoption test still passing, because the initial read was silently covering for it — two routes to one value, the second untested. That is the same shape as the four fail-opens earlier in this PR, so it is now one path; everything past the early return runs once in a store's life, so the transaction it saves is not worth it.
Coverage checked by ablation on both engines rather than assuming the branch is shared: with the record write and adoption removed, the same four tests fail on RocksDB and on LMDB, and the fifth is a negative test that correctly still passes. Suites 1975 RocksDB / 1634 LMDB, zero failures.
Your original scenario is still reachable in the sense that matters — the epoch can still be wrong. What changed is that it can now be found and fixed. Leaving this thread for you to close or hold.
🤖 Addressed by Claude Code
There was a problem hiding this comment.
Correction to the table I posted above — the cross-model round at 1f5ab5b broke the middle row, and it was the load-bearing one.
floor > bootstrap ⇒ a real prune raised it; earned, not guessed does not follow. A prune raising the floor above the epoch certifies only the history that prune removed. It says nothing about history removed before tracking began, which may sit above the epoch — which is exactly the case your finding is about, since that is what the guess cannot see. Concretely, using your own scenario: a v4-era deleteHistory removes tableA up to t=1000 while sibling tableB's newest survivor is 900; the rolled-back clock stamps bootstrap=900 and floor=900; a later retention pass raises the floor to 950. A repair keyed on floor > bootstrap reads 950 > 900, calls it earned, and skips the store — leaving a consumer at cursor 970 certified over tableA's missing 950–1000. The reading would have skipped precisely the databases the record exists to find.
So the record's presence is the signal, and no comparison against the floor retires it. A store carrying the record has an unverified pre-tracking window for as long as the record exists, however far the floor has since moved; only a database generation (#2451) can retire the mark. What the recorded value is for is telling that repair how far the guess reached. Fixed in 0f479e1f6 — the contract in DESIGN.md and the docstring, since nothing reads the record yet.
Same round found a second defect in it, which matters more for your original concern: an undecodable provenance record pinned the floor to unknown permanently. The resolver skipped the write whenever a record existed, so my comment claiming "the floor stays absent, so a later open retries" could not come true — every later open failed the read back identically. A torn 4-byte write would have produced the fail-closed-forever state this whole bootstrap exists to avoid. Undecodable bytes are now overwritten, which is safe for this record and would not be for the floor, where a present record may be a deliberate AUDIT_FLOOR_UNKNOWN. I had weighed that exact asymmetry when I wrote it and chose wrong.
Your finding is unchanged by any of this: the epoch stamped here can still be below history a legacy selective prune removed. What has changed is that the store is now durably marked as carrying a guess, and the mark cannot be cleared by anything short of the generation work.
🤖 Addressed by Claude Code
|
On the review summary — the inline finding is addressed in 2a0d9ba (the API is now explicitly diagnostic; thread resolved). The two points here: "Same mechanism replication uses for determining base copy." The existing predicate is "Could Which leaves the fair question your summary implies: if 🤖 Addressed by Claude Code |
cb1kenobi on #2458: the guard is defeated by the one prune that can remove entries above every survivor. `deleteHistory(endTime)` takes one table's entries out of the shared log, so a table whose entries were the newest and all fell below endTime leaves the newest surviving key being a sibling's older entry. A clock rolled back to between the two stamps an epoch below history that is gone, and a cursor in that window resumes over the gap. The finding is right, and nothing in surviving state distinguishes that case — which is why the only alternative is the unknown sentinel for every unmarked store, the option already ruled on (stamping loses this narrow window; never stamping makes every upgraded deployment fail closed forever). So the fix here is to stop the comment implying the bound is sound: it narrows the rollback hole rather than closing it, and it covers what survives rather than what existed. The RocksDB `getKeys()` gap is named in the same place instead of trailing the sentence that overstates the guard. Comment only; no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // so take whichever is later. | ||
| // | ||
| // It narrows that hole; it does not close it, because the bound covers what SURVIVES rather than | ||
| // what existed. A legacy `deleteHistory` removes one table's entries below its endTime out of the |
There was a problem hiding this comment.
What: cb1kenobi's thread here ("Legacy bootstrap can certify a selectively pruned gap — Severity: blocker") is still open (isResolved: false) as of this push. 25bfa1af5 only rewords this comment to admit the gap — establishAuditFloor's max(Date.now(), newest surviving key) bootstrap is unchanged: a legacy deleteHistory that pruned one table's entries out of the shared log can leave the newest surviving key older than history that's actually gone, and a clock rolled back into that window still stamps an epoch that certifies a cursor over the silently-lost entries.
Why it matters: Both the MEMBER reviewer and the author-agent agree this is real and reachable ("Confirmed, and reachable... Four conditions have to coincide, but none of them are exotic"). The author-agent explicitly declined to implement a fix and escalated the stamp-vs-unknown tradeoff to a human decision-maker rather than resolving the thread. Flagging so this doesn't get lost under a "no blockers" verdict from any reviewer pass — it needs the maintainer decision the author is waiting on, not a rediscovery.
Suggested fix: None of mine to add on top of the thread — the two candidates are already on the table there (stamp AUDIT_FLOOR_UNKNOWN for every unmarked store, or the mtime-based narrowing floated in the latest reply). This is a decision, not an implementation gap.
There was a problem hiding this comment.
Accurate on all counts, and the read on 25bfa1af5 is the right one: it rewords the comment to stop the guard over-claiming, and changes no behavior. The bootstrap is unchanged and the decision is live, not lost — leaving both this thread and cb1kenobi's unresolved on purpose.
Two pieces of state worth having here so a later pass does not rediscover them:
AUDIT_FLOOR_UNKNOWNfor unmarked stores is absorbing.raiseAuditFloorcannot lift it (cutoff > Infinityis false) andestablishAuditFloorskips any existing record, so under that candidate a legacy store never earns a finite floor back by any route. The only mechanism that could is a persisted marker of when the floor-maintaining version took over, at which point the oldest surviving key becomes a sound bound — i.e. the database generation in restore_backup rolls a database back in time with no epoch, so audit cursors, record versions, and replication sequence state all read as valid afterwards #2451. So both open blockers on this PR (this one and Kris's restore/checkpoint hole) reduce to the same missing primitive.- The maintainer's current question is whether to pivot to enforcement inside
subscribe(Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448's shape, which Kris independently proposed) rather than a public accessor. That does not make the epoch trustworthy — location does not fix metadata — but it changes this trade's price: as an opt-in subscribe option, the unknown sentinel only costs the subscribers who ask for the guarantee, instead of every consumer on every upgraded deployment. That is the weighing in progress.
Nothing here blocks behavior today: after 2a0d9ba00 the accessor is diagnostic and no product path acts on the floor, so this is a latent metadata bug that becomes live when #2448 lands. Whether that makes it a blocker for this PR or for #2448 is cb1kenobi's call, not mine to assume.
🤖 Addressed by Claude Code
kriszyp
left a comment
There was a problem hiding this comment.
I think my concerns are:
- I don't think there should be any inconsistency between how we determine a valid subscription resume in replication vs how we do it with the Table.subscribe. Is there any compelling reason to create different paths? Or should these be aligned/consistent?
- I know this is an acknowledged open question, but I am not sure I followed the justification for separating this from #2448; the specific concern with separation and defining a Table.oldestRetainedAuditTime() is that we are binding ourselves to a contract with a new public API (HarperFast/documentation#66 indicates it is supposed to be publicly documented), yet this API hasn't been shown to have any external purpose, its purpose is only for 2448, AFAICT. Furthermore, that contract seems like it could be tenuous. I think we have acknowledged elsewhere that higher precision subscription resumption may involve a vector of resume timestamps (for each origin), which doesn't seem to align with a single number returning floor function in this iteration (or maybe I am missing something?).
🤖 Reviewed with Codex
|
Re-scoped to infrastructure only, per this review — Dawson's call, 2026-09-10, at 04caa8f.
Both inline findings are fixed in the same push; replies are on the threads. PR title and body are being updated to match the new scope. 🤖 Addressed by Claude Code |
… (infrastructure for #2448) Every database now records an audit retention floor — the point at or after which no prune has removed audit history — so that #2448 can make `Table.subscribe` tell a complete `startTime` catch-up from a silently truncated one. This is the write side only: the floor is recorded, verified, and raised first by every path that prunes. Nothing reads it publicly, and no resume path consumes it yet. Scope, and how it got here: the branch originally shipped `Table.oldestRetainedAuditTime()` as a public accessor with a documented contract. Per kriszyp's review it is removed (Dawson, 2026-09-10): it had no caller except #2448, a public single-number floor binds a contract a per-origin resume vector could not honor, and it created a second resume-validity path where replication already checks inside the operation (`shouldForceBaseCopyForRetention`). The check belongs inside `Table.subscribe` itself, where the floor cannot move between being read and being acted on. #2448 is to call `getAuditFloor(auditStore)` (internal, resources/auditStore.ts) from there. HarperFast/documentation #660's page is reverted in #669; #666 is closed. Mechanics - All five prune paths — the LMDB and RocksDB retention loops, the boot purge (`purgeAgedLogs`), `Table.deleteHistory`, and the bridge's whole-database `delete_transaction_logs_before` — call `raiseAuditFloor` BEFORE removing anything, monotonically, in a store transaction whose commit is verified by reading the write back. Only one path recorded anything before, and it recorded afterwards, so a crash in between left a floor certifying history already gone. - The floor lives under `Symbol.for('audit-floor')`; its PRESENCE is the trust marker. A store without one gets a one-time epoch at first open (`establishAuditFloor`: max(Date.now(), newest retained key)), recorded as a guess under `Symbol.for('audit-floor-bootstrap')` so a later database generation (#2451) can find and repair it. No comparison retires that mark: a later prune certifies only what it removed and says nothing about history a legacy prune took before tracking began. - Untrustworthy metadata (wrong length, NaN, negative) decodes to `Infinity` — unknown, fails closed, and absorbing — rather than to a number a `cursor < floor` spelling could read as safe. - `boundedAuditPruneEnd` clamps every operator-supplied bound to just above the newest key in the log before it is recorded or used as the prune's range end. `Infinity` is the extreme case; a finite far-future bound (`Date.now() * 1000`, a bare '9999999999999') is the same defect by degree — recorded verbatim it would pin the whole database's floor forever. Non-numbers pass through untouched to `raiseAuditFloor`'s rejection: `>` coerces, and a numeric string or a future Date must not become an accepted bound (kriszyp). - `delete_transaction_logs_before` validates its timestamp at its own boundary and reports a 400: `Number.parseInt` took a numeric PREFIX, so '9999999999999oops' parsed to a year-2286 bound that purged every log, and NaN/negative/-0 bounds sort ABOVE every real timestamp in the raw float64 key encoding, so `getRange({ end: NaN })` spanned the whole log. - RocksDB's `transactionSync` returns undefined on a swallowed abort; the floor write requires an explicit `true`. Contract, as stated in `getAuditFloor` and resources/DESIGN.md - Database-scoped: `cursor >= floor` (for a cursor in the audit-log key domain — `txnLogKey`, not `getHistory`'s origin version) means no prune that ran with a floor recorded removed history after the cursor. Nothing is promised below the FLOOR; `[floor, cursor)` is covered. - One exception, named rather than denied: history removed before the floor existed. The bootstrap stamps from what survives, so a legacy `deleteHistory` that removed a table's newest entries plus a clock rollback leaves the stamp below history that is gone. Only a generation closes it (#2451). - Not a generation check: `restore_backup` and RocksDB checkpoints reinstall the copy's floor (#2451). - A floor that cannot be written blocks the prune (#2486 has the full-volume analysis). Tests: unitTests/resources/auditFloor.test.js (new), auditPurge.test.js and deleteTransactionLogsBeforeRocks.test.js (extended). Every fix has a failing test verified by ablation on the engine branch where its path exists. The RocksDB purge test asserts Harper's contract — the recorded floor covers everything the purge was asked to remove — not that the native purge dropped a file, which is platform-dependent (the Linux rocksdb-js binary declines to drop an active log; macOS/Windows drop it). History: squashed from the review branch (43 commits on top of repeated merges from main) after the re-scope; the review record is on #2458. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e8c06ea to
3f629ab
Compare
From the full cross-model round at 3f629ab (codex + gemini + cursor composer + harper-domain; four in-scope findings, no blockers). - `Date.now() - auditRetention` goes negative for a retention above ~55.7 years or `Infinity` (keep logs indefinitely). A negative bound is not "nothing eligible": `raiseAuditFloor` rejects it, so every boot purge and retention pass warned and the floor was never raised on such an install. All three retention-derived bounds now go through `retentionCutoff()`, which floors at 0 — a harmless no-op pass, since nothing sits before the epoch and an existing floor is never lowered. The throw itself was protective on LMDB (a negative range end spans the whole log), which is why this clamps at the source rather than relaxing the guard. Regression in auditPurge.test.js, verified by ablation. - The `cleanup_deleted_records` tombstone sweep gated on the raw `endTime` while the audit prune and the floor used the clamped `pruneEnd`; aligned to `pruneEnd` so the three bounds agree. No change on RocksDB, where `pruneEnd === endTime`. - Two deleteHistory tests could flake on a fast LMDB run: the clamp sits at `newest + 1` when the newest key's fractional millisecond is at or past the clock, so a write in the same millisecond landed below the floor. They now wait for the clock to pass the floor. 15/15 on LMDB. - Trimmed the comments that narrated review history or attributed findings to reviewers, keeping the invariant statements. Pre-existing finding (RocksDB deleteHistory scans and reports a false entriesDeleted) is on main and goes to a separate issue. test:unit:resources: 2393 passing RocksDB (the one failure, rangeReadActivity, reproduces on pure main here), 1880 LMDB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ments From the --final cross-model round at fa58a9e (codex + gemini + harper-domain; two in-scope survivors, no blockers). - Every other assertion in auditFloor.test.js read the floor back through the handle that wrote it, and the one reopen test covered only the standalone legacy LMDB root. A floor that did not persist is re-stamped from the bootstrap epoch on the next open — at or above the newest SURVIVING key — and then certifies history a prune removed before the restart: the exact failure the floor exists to prevent. New test raises the floor on an ordinary database, closes it through `closeDatabase`, reopens it through `table()`, and asserts both the floor and the bootstrap-provenance record come back from disk unchanged, on whichever engine is running. Clean close in one process; a crash or power-loss window is not exercised. - Trimmed the comment layers that narrated review history, cited issue numbers as chronicle, gave worked far-future examples, or referenced test fixtures, at the eight sites the round named. The invariant statements stay. test:unit:resources: 2394 passing RocksDB (the one failure, rangeReadActivity, reproduces on pure main here), 1881 LMDB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… correctly From the delta round at 579d9f3 (all five legs; no blockers). - `closeDatabase` fires `close()` without awaiting it, and on LMDB that close is asynchronous — the drop path awaits the same call. The new reopen test reopened the path in the same turn, so on the LMDB leg the still-closing env could throw or hand back pre-flush bytes: the test that certifies floor durability was itself flaky there. It now hooks the root store's close to capture the promise closeDatabase discards, awaits it, and on LMDB asserts a promise was captured so a missed hook fails loudly instead of racing. 10/10 on LMDB. - The test's `bootstrapEpoch` helper decoded via `stored.slice().buffer`, which on a pooled Node Buffer is the whole pool — reading eight bytes at pool offset 0, not the record. A provenance record that failed to survive could have compared equal. Copies the bytes out first, as production's `FLOOR_BUFFER.set(stored)` does. - Compressed the remaining comment blocks the round named to their invariants, and removed two stale references in raiseAuditFloor's inline comment: the bridge no longer reaches NaN "via Number.parseInt", and there is no accessor to retire. test:unit:resources: 2394 passing RocksDB (the one failure, rangeReadActivity, reproduces on pure main here), 1881 LMDB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ned-audit-time # Conflicts: # resources/Table.ts
…st-retained-audit-time # Conflicts: # resources/Table.ts
cb1kenobi
left a comment
There was a problem hiding this comment.
Reviewed the full diff at 167c67f for correctness, concurrency, engine parity, and test coverage, and found no new blocking issues on changed lines. The write-ahead ordering holds because every prune path records the clamped bound as its floor and then uses that same value as its range end, so nothing can be removed that the floor does not cover. Earlier blocking findings (string/Date coercion through the clamp, phantom commits on floorless stores, far-future bounds pinning the floor) are all fixed with tests. Remaining open threads are scope and contract decisions for the maintainers, not defects.
—
Reviewed 167c67f
kriszyp
left a comment
There was a problem hiding this comment.
I think this looks good, nice work. I am filing an issue for the follow-up work of using a shared function between this subscription and replication handler for determining the audit/txn log floor.
🤖 Reviewed with Codex
Records a write-ahead audit retention floor for every database — the point at or after which no prune has removed audit history — so that
Table.subscribecan tell a completestartTimecatch-up from a silently truncated one (#2448). This PR is the write side only: the floor is recorded, verified, and raised first by every path that prunes. Nothing reads it publicly.Re-scoped 2026-09-10, per kriszyp's review. The branch originally shipped
Table.oldestRetainedAuditTime()as a public accessor with a documented contract. That accessor is removed. Three reasons, all his: it had no caller except #2448; a public single-number floor binds a contract that a per-origin resume vector could not honor; and it created a second resume-validity path where replication already checks inside the operation (shouldForceBaseCopyForRetention). So the check belongs insideTable.subscribeitself — one path, and the only shape where the floor cannot move between being read and being acted on. Nothing consumes the floor in this PR — no resume path reads it, and silently truncated catch-up is unchanged until #2448 callsgetAuditFloor(auditStore)(internal,resources/auditStore.ts) from insidesubscribe. HarperFast/documentation#666 is closed and the section #660 added is being removed; consumer-facing docs land with #2448.The gap this is infrastructure for is live today:
Table.subscribe'sstartTimereplay begins wherever the audit log now begins, and MQTT durable sessions hand it a persisted per-topicstartTimeon every resume, so a client offline longer thanlogging.auditRetentionloses messages — QoS 1/2 included — with no signal at all.Making the floor trustworthy was most of the work. All five paths that prune audit history now raise the floor before removing anything, monotonically, in a store transaction whose commit is actually verified; only one path did before, and it recorded afterwards, so a crash in between left a floor certifying history that was already gone. The floor lives under a new key whose presence is the trust marker: a store without one has retention history that cannot be accounted for, so it gets a one-time resync epoch rather than a permissive baseline. Untrustworthy metadata resolves to
Infinityrather than to a number, so a consumer spelling the check ascursor < floorcannot read corrupt bytes as safe.Two fail-open paths this uncovered are worth naming, because neither depends on the floor existing. A prune bound of
NaN, a negative, or-0was accepted by the range — audit keys are raw float64, so those values set the sign bit or the quiet-NaN pattern and sort above every real timestamp, makinggetRange({ start: 1, end: NaN })span the whole log.delete_transaction_logs_beforereached exactly that throughNumber.parseInton a non-numerictimestamp(and'9999999999999oops'parsed to a year-2286 bound that purged everything), so it now validates at its own boundary and reports a 400, and the bound guard itself throws rather than declining silently. A finite bound above everything reachable —Infinity,Date.now() * 1000, a bare'9999999999999'— is the same defect by degree: recorded verbatim, it pins the whole database's floor in the far future forever, since a floor only rises.boundedAuditPruneEndclamps every operator-supplied bound to just above the newest key in the log, and the prune uses that same clamped value as its range end, so it cannot remove an entry the floor does not cover. And RocksDB'stransactionSyncreturnsundefinedon a swallowed abort rather than throwing; the floor write requires an explicittrue.The branch originally retired
getLastRemoved/updateLastRemoved, which had no consumers and did not work: both read through the audit store's value decoder, so on LMDB the raw eight float bytes decoded as an audit entry and the function returned a stale module global — measured, it stored1234567.5and returned1— while on RocksDB the same call threw in msgpack decode. That retirement was reverted when this branch merged #2338, which hardened the marker's write path and added five tests around it. Both markers are live now and the code says why they are separate keys:last-removedrecords where the LMDB retention loop got to, after the fact, while the floor is written ahead of all five prune paths with its commit verified.For the human reviewer
Judgment calls, mirroring the decision slugs in the
Human-Review-Needfooter. Mechanics went through five cross-model rounds (three full, two delta; final adjudicated severity nit) and human review; these are the scope and contract choices that remain yours.Write side first, read side in Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 — re-scoped. Originally "ship the public primitive before its consumer," which kriszyp's review overturned: there is no public primitive now. What remains separable is that this PR touches every prune path plus bootstrap and the bridge, while Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 touches
subscribe— which also serves WebSocket/SSE reconnects andsourcedFromcaching-table subscriptions, so its failure mode for a fallen-off cursor is its own design question. Nothing here presumes how Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 answers it.One database-scoped floor, not per-table. The audit store is per-database and entries carry a
tableId, so an exact per-table floor needs a scan for that table's oldest entry. The cost:deleteHistoryon one table raises the floor for every sibling, forcing resyncs of history that still exists. Cheap to change now, costly once Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 depends on the meaning.Infinityis the unknown sentinel — internal representation now, so no longer a public-type concern. It makes bothcursor >= floorandcursor < floorfail closed, which is why it beatundefined/null/throw. It is also absorbing (a floor atInfinitynever comes down), which is why every operator bound is clamped before it can be recorded. Open (Chris's thread): with every caller now clamped, nothing can passInfinitytoraiseAuditFloor, so accepting it only preserves a footgun for a future caller; reversing to a throw is trivial.Upgrade stamps a resync epoch — decided (Dawson), and its one exception is now named in the contract. Every existing store lacking the record gets
max(Date.now(), newest retained audit key)on first open, recorded as a guess under its own key. Never stamping would make every upgraded deployment fail closed forever. The cost, per kriszyp's review, is stated rather than denied: the stamp is bounded by what survives, so a legacydeleteHistorythat removed a table's newest entries, followed by a clock rollback, stamps a floor below history that is gone, and a cursor in that window is certified over the gap. The guarantee is therefore one-directional for every prune that ran with a floor recorded, and silent about history removed before one existed. Only a database generation closes that (restore_backup rolls a database back in time with no epoch, so audit cursors, record versions, and replication sequence state all read as valid afterwards #2451); the recorded epoch is what tells that repair how far the guess reached. On RocksDBgetKeys()is unimplemented, so the clock-rollback guard reduces toDate.now()there.A floor that cannot be written blocks the prune — routed to A failed audit-floor write blocks the boot purge exactly when the disk is full, making #1115's reclamation unreachable #2486.
raiseAuditFloorthrows, and it is called first precisely so the throw stops the prune. On a full volume the thing that fails is the 8-byte floor write, so Resync re-delivery (~6.7×) + cleanup starvation balloon a far-behind node's transaction logs (compounds #1114 OOM) #1115's boot purge is skipped in exactly the condition it exists for. You cannot record that you pruned without recording — the unknown sentinel is itself a write — so the escapes are reserved headroom, an in-memory poison, or accepting an inaccurate floor. Nothing reads the floor yet, so this is reversible per call site.deleteHistoryraises unprobed. The retention loop probes for an eligible entry before raising;deleteHistoryraises to its (clamped) bound before knowing whether this table has any entry below it, so a routine per-table trim advances the database floor even when it deletes nothing. Symmetry would mean atableId-filtered scan — the per-table cost decision 2 avoids.Table.deleteHistorynow rejects non-number bounds (reject-vs-coerce-deletehistory-bounds). A numeric string,'Infinity', or aDateused to coerce through>into an accepted bound — the whole-log prune Kris caught — and now throwsInvalid audit prune bound, matching the bridge's 400. A contract tightening on a public Table static, trivially reversible by coercing through the bridge's validation instead. Nothing that worked before stops working except the coercion that was the bug: a no-arg call was always an empty range (endTimedefaults to 0), and aDatewas never a valid range key.On RocksDB the floor tracks the configured horizon, not surviving reality (
rocksdb-floor-tracks-configured-horizon). Every retention pass advances the floor to the horizon whether a log file dropped or not, because whole-file purge granularity cannot say beforehand what will drop and the floor must be written first. Cursors over entries still on disk are told to resync — the safe direction, and the deliberate one; reversible only if the native purge ever reports what it will drop. Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448's consumer must not read "floor advanced" as "history removed".Power-loss durability of the floor write on RocksDB is trusted, not enforced (
engine-commit-durability-trusted). The write-ahead guarantee rests on the floor'stransactionSyncbeing durable before the same-threadpurgeLogsunlinks files. It is crash-safe — a process death leaves OS buffers intact for both — but rocksdb-js exposes no per-commit WAL sync; the only gate is a full memtableflushSync, which its own docs describe as database-wide and stall-prone. So a power cut in that window can persist the unlink and lose the record. One flush per retention pass is cheap in frequency, not in blast radius; this wants a ruling on the engine contract before a sync is added.The reopen test wraps
root.close(test-monkey-patches-store-close).closeDatabasefire-and-forgets LMDB's asynchronousclose()while the drop path awaits the same call; the durability test captures the discarded promise rather than changingcloseDatabase. The helper still fire-and-forgets for any future caller — returning the close promise would fix it at the root, but that is outside this PR's write-side scope.One deferred limitation stated in the internal contract: copying a database's state without its history —
restore_backupreinstalls the backup's floor, and a RocksDB checkpoint copies the floor but no transaction logs — needs a database-level generation, because the same copy rolls back record versions and per-node replication sequence state too (#2451). The former check/use race limitation disappears by construction once #2448 puts the check insidesubscribe— there is then no separate read to race. In this PR there is no read at all.Verification
Storage-layer behavior, reachable from unit tests on both engines.
unitTests/resources/auditFloor.test.js(new): floor establishment and the resync epoch, the exact resume predicate atF-1/F/F+1, six shapes of untrustworthy metadata all resolving toInfinity, monotonicity, database scoping and non-leakage,NaN/-0/non-number bounds refused before anything is deleted — including numeric strings,'Infinity', and a futureDate, which>would otherwise coerce into an accepted bound (kriszyp) —deleteHistory(Infinity)and far-future finite bounds clamped rather than recorded, bootstrap provenance, a real legacy standalone audit root with close-and-reopen durability, floor-before-purge ordering observed from insidepurgeLogs, and the empty-pass no-write case. Tests read the floor throughgetAuditFloor(table.auditStore), the call Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short #2448 will make.unitTests/resources/auditPurge.test.js(extended):purgeAgedLogsrecords the floor at the same cutoff, beforepurgeLogs.unitTests/resources/deleteTransactionLogsBeforeRocks.test.js(extended): the whole-database purge advances the floor database-scoped and clamped; the bound guard refuses thirteen badtimestampshapes with a 400 while acceptingDate, numeric strings including'1e3'and'1234.5', a number,0and"0"; a far-future bound does not pin the floor.deleteHistorypath is covered on LMDB, the RocksDB-only whole-database purge on RocksDB).unitTests/resourcesat the re-scope commit: 1731 passing on RocksDB, 1252 on LMDB. The one RocksDB failure islongLivedTransactions"names a chain link reachable only through the root", an order-dependent flake of main's own: it fails in the full suite with this branch's test files excluded, and passes in isolation and on re-run.npm run lint:required0 errors,prettier --checkclean.origin/mainrepeatedly rather than rebased; theDESIGN.mdcheat-sheet conflicts were resolved as unions and verified row-by-row with an escaped-pipe-aware parser after one resolution silently reverted main's Indexed multi-value (elements) attributes return one result per matching element — duplicate records from range/contains queries #2434 row.Docs: HarperFast/documentation#660 documented the now-removed accessor and is being reverted by a follow-up; #666 (corrections to that page) is closed. Consumer-facing docs land with #2448.
Deferred to its own issue, per the review's disposition: on RocksDB,
Table.deleteHistory()still scans the whole audit range and reports a nonzeroentriesDeletedfor no-op removals. Pre-existing onmain, not this diff — #2566.History: squashed to one commit on current
mainon 2026-09-10 after the re-scope (43 commits over repeated main merges collapsed; tree verified byte-identical to the last tested tip before the force-push). The per-round review record — including the eight rounds on the docs page and the three findings that were the engine's own — is in this PR's threads and comments, not in the commit history.Complexity: complicated
Review-Coverage: authored=claude; ran=codex; declined=gemini,cursor-grok,cursor-composer,domain; rounds=18; full=1 @ 167c67f
Human-Review-Need: 4 @ 167c67f