From 512905903ac3babd9821c8a179ad14f92cdd3174 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 09:07:27 -0400 Subject: [PATCH 01/13] docs(resources): the floor detects pruning, not a restored rollback Kris Zyp on HarperFast/harper#2458: the "does not report a cursor as safe when history it needed is gone" bullet is stronger than what the floor can see. Restoring a backup, or opening a RocksDB checkpoint, replaces a database's state with a copy of an earlier state and reinstalls that copy's floor, so a cursor saved after the copy point compares as safe against a floor that predates it. Scope the one-direction guarantee to retention pruning and add the rollback limit as its own bullet, so nobody reads this method as the only gate on resuming across a restore. --- reference/resources/resource-api.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 43033d4d..070b5d4d 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -253,14 +253,14 @@ All properties are optional: -The oldest point in the audit log from which an incremental catch-up is still complete — the retention floor. +The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it has definitely lost history; a cursor at or above it has not been pruned, which is not on its own a guarantee that resuming is safe (see the limits below). Subscription catch-up (`startTime`) reads the audit log, and the audit log is pruned on a retention window (`logging.auditRetention`). A consumer that saves a cursor, disconnects, and resumes past that window would otherwise receive a replay that quietly begins after the messages it missed. This method is how a consumer detects that instead: ```javascript const floor = tables.Product.oldestRetainedAuditTime(); if (cursor >= floor) { - // every change after `cursor` is still in the log; resume incrementally + // nothing after `cursor` has been pruned; resume incrementally subscription = await tables.Product.subscribe({ startTime: cursor }); } else { // history this consumer needs has been pruned; re-read the table instead @@ -276,7 +276,8 @@ Details worth knowing before relying on it: - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned below the cursor. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. This is what a database reports when its retention history cannot be accounted for — most commonly the first time it is opened by a version that records a floor, or after a migration between storage engines, which does not carry the audit log across. Consumers resync once and then get real values. -- **It errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when history it needed is gone. +- **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. +- **It is not a database-generation check, so it cannot see a rollback.** Restoring a backup, or opening a RocksDB checkpoint, replaces a database's state with a copy of an earlier state — and reinstalls that copy's floor along with it. A cursor saved after the copy point then compares as safe against a floor that predates it, even though the database no longer holds a change stream describing the rollback. Treat this method as a check for retention pruning, not as the only gate on resuming across a restore. - **It is a reading at a moment in time.** Retention can advance between this call and the `subscribe()` that follows it. The window is milliseconds against a retention window normally measured in days, and losing that race leaves you with the truncated replay you would have had anyway — but it is not a lock. - Throws if the database has no audit log at all. From 42b787708bc33cf25931aa325bf66c1669199994 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 11:18:12 -0400 Subject: [PATCH 02/13] docs(resources): fix the floor's time direction, and stop the sample implying safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s from Chris Barber on #666, both correct. The database-scoped bullet reversed the time direction: `cursor >= floor` was said to mean no entry was pruned "below the cursor", which is backwards and describes the one thing the floor does NOT promise. Entries below the cursor are older than the floor and may well be gone — that is the floor's whole purpose. What the comparison guarantees is that nothing was pruned *after* the cursor. The sample also did exactly what the prose two paragraphs below it warns against: `cursor >= floor` then subscribe, which after a restore silently resumes from a cursor newer than the restored state. There is no generation check to call yet (harper#2451), so the sample now leads with the definite conclusion — `cursor < floor` means resync — and the permissive branch carries the caveat that this is a pruning check only. Also formats the rollback limitation as a blockquote callout per gemini-code-assist, matching this file's existing `> **…**` callouts rather than the `:::caution` used elsewhere in the repo. --- reference/resources/resource-api.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 070b5d4d..c2e9b30b 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -259,25 +259,30 @@ Subscription catch-up (`startTime`) reads the audit log, and the audit log is pr ```javascript const floor = tables.Product.oldestRetainedAuditTime(); -if (cursor >= floor) { - // nothing after `cursor` has been pruned; resume incrementally - subscription = await tables.Product.subscribe({ startTime: cursor }); -} else { - // history this consumer needs has been pruned; re-read the table instead +if (cursor < floor) { + // definite: history this consumer needs has been pruned; re-read the table instead await fullResync(); +} else { + // retention has not pruned anything after `cursor`. This is a PRUNING check only — it cannot + // see a database that was restored or checkpointed (see the rollback limit below), so resume + // here only where a rollback is not a case you have to handle. + subscription = await tables.Product.subscribe({ startTime: cursor }); } ``` -The cursor is a _last-processed_ position, so a cursor exactly at the floor is safe — everything below it has already been handled. +The cursor is a _last-processed_ position, so a cursor exactly at the floor passes: everything below it has already been handled, and everything above it is still retained. Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. -- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned below the cursor. `deleteHistory()` on one table raises the floor for its siblings too. +- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. This is what a database reports when its retention history cannot be accounted for — most commonly the first time it is opened by a version that records a floor, or after a migration between storage engines, which does not carry the audit log across. Consumers resync once and then get real values. - **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. -- **It is not a database-generation check, so it cannot see a rollback.** Restoring a backup, or opening a RocksDB checkpoint, replaces a database's state with a copy of an earlier state — and reinstalls that copy's floor along with it. A cursor saved after the copy point then compares as safe against a floor that predates it, even though the database no longer holds a change stream describing the rollback. Treat this method as a check for retention pruning, not as the only gate on resuming across a restore. +- **It is not a database-generation check, so it cannot see a rollback.** + + > **Caution:** Restoring a backup, or opening a RocksDB checkpoint, replaces a database's state with a copy of an earlier state — and reinstalls that copy's floor along with it. A cursor saved after the copy point then compares as safe against a floor that predates it, even though the database no longer holds a change stream describing the rollback. Treat this method as a check for retention pruning, not as the only gate on resuming across a restore. + - **It is a reading at a moment in time.** Retention can advance between this call and the `subscribe()` that follows it. The window is milliseconds against a retention window normally measured in days, and losing that race leaves you with the truncated replay you would have had anyway — but it is not a lock. - Throws if the database has no audit log at all. From 4741a5b6fa4bce219d951608d69f4adca9493b43 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 12:02:20 -0400 Subject: [PATCH 03/13] docs(resources): a cursor below the floor must resync, not "has definitely lost history" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Barber on #666. The wording I added last round overclaimed in the mirror image of the direction bug it was fixing: an `Infinity` floor puts every cursor below it with nothing necessarily pruned, and the "errs in one direction only" bullet on the same page says the floor can ask for a resync that was not strictly necessary — which is exactly a `cursor < floor` that lost nothing. The certainty belongs to the action. Summary line and the sample's resync branch now say the history *may* have been pruned and the floor cannot certify otherwise. Same correction applied to the engine-side contract it came from, HarperFast/harper#2458. --- reference/resources/resource-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index c2e9b30b..0c67b953 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -253,14 +253,14 @@ All properties are optional: -The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it has definitely lost history; a cursor at or above it has not been pruned, which is not on its own a guarantee that resuming is safe (see the limits below). +The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it may have lost history, and the floor cannot certify otherwise; a cursor at or above it has not been pruned, which is not on its own a guarantee that resuming is safe (see the limits below). Subscription catch-up (`startTime`) reads the audit log, and the audit log is pruned on a retention window (`logging.auditRetention`). A consumer that saves a cursor, disconnects, and resumes past that window would otherwise receive a replay that quietly begins after the messages it missed. This method is how a consumer detects that instead: ```javascript const floor = tables.Product.oldestRetainedAuditTime(); if (cursor < floor) { - // definite: history this consumer needs has been pruned; re-read the table instead + // conservative: history this consumer needs may have been pruned; re-read the table instead await fullResync(); } else { // retention has not pruned anything after `cursor`. This is a PRUNING check only — it cannot From 5728d13024e4ac0f80985bb6893402b46765292b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 12:24:29 -0400 Subject: [PATCH 04/13] docs(resources): fail closed on an unset cursor, and scope the retained claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mediums from Chris Barber on #666, both introduced by my previous fix. Inverting the branch to `cursor < floor` changed what an unset or non-numeric cursor does. `undefined < floor` is false, as is any NaN comparison, so a consumer on its first run — or one whose persisted cursor failed to load — fell into the resume branch and called `subscribe({ startTime: undefined })`: live, no catch-up, no resync. The old `cursor >= floor` form failed the other way. Measured across undefined/null/NaN/"abc": only `null` resynced under the inverted form. Now `!(cursor >= floor)`, which keeps the definite conclusion first and sends everything non-comparable to resync. Carries a comment explaining the negation, so it does not get "simplified" back to the trap. Also scopes "everything above it is still retained" to "nothing above it has been pruned as of this reading", matching the two bullets below that already qualify it for restore/checkpoint rollback and for retention advancing after the call. --- reference/resources/resource-api.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 0c67b953..07688038 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -259,7 +259,9 @@ Subscription catch-up (`startTime`) reads the audit log, and the audit log is pr ```javascript const floor = tables.Product.oldestRetainedAuditTime(); -if (cursor < floor) { +// negated rather than `cursor < floor`: an unset or non-numeric cursor compares false either way, +// and this spelling sends it to the resync branch instead of starting live with no catch-up +if (!(cursor >= floor)) { // conservative: history this consumer needs may have been pruned; re-read the table instead await fullResync(); } else { @@ -270,7 +272,7 @@ if (cursor < floor) { } ``` -The cursor is a _last-processed_ position, so a cursor exactly at the floor passes: everything below it has already been handled, and everything above it is still retained. +The cursor is a _last-processed_ position, so a cursor exactly at the floor passes: everything below it has already been handled, and nothing above it has been pruned as of this reading. Details worth knowing before relying on it: From 717f42fe7dbd4180a35bce0ec3ea4d755361f1db Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 12:29:54 -0400 Subject: [PATCH 05/13] docs(resources): correct when the floor actually reports Infinity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by diffing this page against the engine-side contract, which Chris Barber's note about wording travelling between the two prompted. The bullet named the two most common causes of `Infinity` as a database's first open by a floor-recording version, and a migration between storage engines. Both are wrong, and stale from before the branch decided to stamp a starting epoch: a store with no floor record is now given `max(Date.now(), newest retained key)` the first time it is opened, including the audit-store-less result of an engine migration, so those are precisely the cases that report a real value. Verified against the engine test that asserts a fresh database's floor is finite. The real causes are a read-only database, a failed or unavailable metadata write, metadata that does not decode, and a prune that ran on a database with no floor recorded yet. Says so, and says explicitly that first open is not one of them — since "consumers resync once and then get real values" invited exactly the wrong mental model. --- reference/resources/resource-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 07688038..989850a1 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -279,7 +279,7 @@ Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. -- **`Infinity` means the floor is unknown.** Treat no cursor as safe. This is what a database reports when its retention history cannot be accounted for — most commonly the first time it is opened by a version that records a floor, or after a migration between storage engines, which does not carry the audit log across. Consumers resync once and then get real values. +- **`Infinity` means the floor is unknown.** Treat no cursor as safe. This is what a database reports when its retention history cannot be accounted for: a read-only database, a failed or unavailable metadata write, metadata that does not decode, or history pruned on a database that had no floor recorded yet. Note that a database's _first_ open is not one of these — it is given a starting floor at that point, so it reports a real value rather than `Infinity`. - **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. - **It is not a database-generation check, so it cannot see a rollback.** From d62bcb22e3434abad74631f9ccd5129c89ba908c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 12:37:30 -0400 Subject: [PATCH 06/13] docs(resources): un-contradict the Infinity causes, and restore the upgrade note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Barber on #666, both points against my previous commit. The cause list contradicted itself: it named "history pruned on a database that had no floor recorded yet" as a peer cause while the next sentence said first open always stamps a floor. Both cannot hold — a prune finds no floor only when the stamping did not land, which the read-only and failed-write cases already cover. Folded in as a consequence rather than a cause. The same edit also dropped what made the old wording actionable, and he is right that this matters more than the contradiction. The starting floor is stamped at open time, so it sits ABOVE entries the database still retains: on a node upgraded with a week of history, every pre-upgrade cursor falls below it and resyncs once with nothing pruned. Against the page's own definition of the floor as "the oldest point retention has not pruned away", that resync reads as proof the upgrade lost history — the false "audit history lost" alarm from an earlier round arriving by a different route. Split into its own bullet rather than folded into the Infinity one, because it describes a FINITE floor and has nothing to do with the unknown sentinel. That also resolves the tension with the summary definition, by saying outright that the starting floor is not derived from history. --- reference/resources/resource-api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 989850a1..068ac407 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -279,7 +279,8 @@ Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. -- **`Infinity` means the floor is unknown.** Treat no cursor as safe. This is what a database reports when its retention history cannot be accounted for: a read-only database, a failed or unavailable metadata write, metadata that does not decode, or history pruned on a database that had no floor recorded yet. Note that a database's _first_ open is not one of these — it is given a starting floor at that point, so it reports a real value rather than `Infinity`. +- **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. +- **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, cursors compare against a floor that only real prunes move. - **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. - **It is not a database-generation check, so it cannot see a rollback.** From 3037a0ff46e48e1814bccba5a18127273c12b57d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 13:54:19 -0400 Subject: [PATCH 07/13] docs(resources): stop claiming only prunes move the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "a floor that only real prunes move" implied the floor is monotonically non-decreasing and prune-caused. A restore or checkpoint reinstalls the copy's floor, which can be LOWER — the one direction that sentence ruled out — so a consumer that caches the floor and re-reads it only to see whether it rose never registers a rollback, and resumes from a cursor newer than the restored state. That is the exact silent gap the rollback caution two bullets down exists to warn about. Names the two raisers (retention, `deleteHistory()`), calls the restore an exception to the direction rather than another mover, and states the consequence outright: re-read the floor on each resume. Tied the raisers to pruning rather than listing `deleteHistory()` as an independent mover, because it only prunes on LMDB — on RocksDB `remove()` is a no-op, so it moves nothing (Table.ts:5562). "Rises when history is actually pruned" holds on both engines; a flat list would not. --- reference/resources/resource-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 068ac407..cbe7561f 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -280,7 +280,7 @@ Details worth knowing before relying on it: - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. -- **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, cursors compare against a floor that only real prunes move. +- **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it and watching for it to rise. - **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. - **It is not a database-generation check, so it cannot see a rollback.** From 38a422dc8658a43969db772633d3b2d64c56b89d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 14:17:25 -0400 Subject: [PATCH 08/13] docs(resources): stop offering a re-read as the answer to a restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3037a0ff put "re-read the floor on each resume rather than caching it and watching for it to rise" directly after the restore sentence, so it read as the mitigation for a rollback. It is not one. A restore reinstalls a LOWER floor, so a fresh read certifies at least the cursors a stale one did: cursor 600 against a floor restored 500 -> 100 passes either way. A consumer author could implement the re-read and conclude the restore hazard was handled — the overclaim shape this PR exists to remove. Scopes the advice to the reason it actually holds (retention advances between reads, so a stale floor certifies cursors the current one rejects) and rules out the rollback reading outright. --- reference/resources/resource-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index cbe7561f..0e51e43b 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -280,7 +280,7 @@ Details worth knowing before relying on it: - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. -- **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it and watching for it to rise. +- **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it — retention can advance between reads. Re-reading will not reveal a restore, though: the reinstalled lower floor still passes a cursor saved after the copy point. - **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. - **It is not a database-generation check, so it cannot see a rollback.** From 3902e7a39a64cb9271cc08a80ad14f301ee54eec Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 14:39:55 -0400 Subject: [PATCH 09/13] docs(resources): condition the one-direction guarantee on a valid cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It does not report a cursor as safe when a prune removed history that cursor needed" was stated absolutely, and the validity bullet four lines above documents a counterexample inside its own scope: a persisted `getHistory().localTime` is an origin version, so a record written with origin 900 can sit at audit key 400. Persist 900 against a floor of 500 and the check passes while the consumer's real position (400) is below the floor. No restore involved — this is pruning, which is exactly what the "Across retention pruning" qualifier admits. Conditions the claim on cursor validity and names the excluded case in the load-bearing sentence itself, so a reader lifting that sentence to restate the contract carries the condition with it. That is how the two previous overclaims travelled. Predates this branch (#660), so the fix is a correction rather than a regression, but the failure shape is the same. --- reference/resources/resource-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 0e51e43b..1e53e058 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -281,7 +281,7 @@ Details worth knowing before relying on it: - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. - **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it — retention can advance between reads. Re-reading will not reveal a restore, though: the reinstalled lower floor still passes a cursor saved after the copy point. -- **Across retention pruning it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. +- **Across retention pruning, and given a valid cursor, it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. A persisted `getHistory().localTime` is outside that guarantee, not an exception to it: being an origin version, it can overstate the consumer's real position in the audit log and so pass while that real position sits below the floor (see the validity bullet above). - **It is not a database-generation check, so it cannot see a rollback.** > **Caution:** Restoring a backup, or opening a RocksDB checkpoint, replaces a database's state with a copy of an earlier state — and reinstalls that copy's floor along with it. A cursor saved after the copy point then compares as safe against a floor that predates it, even though the database no longer holds a change stream describing the rollback. Treat this method as a check for retention pruning, not as the only gate on resuming across a restore. From 9994370e7ef0df8e5150262f0f256798afd64c9d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 15:02:26 -0400 Subject: [PATCH 10/13] docs(resources): condition the summary line, and fix the null claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from review of 3902e7a3. The summary said "a cursor at or above it has not been pruned" unconditionally, which 3902e7a3 had just conditioned four lines down. It is also the sentence a reader lifts to restate the contract — by this branch's own account, how the earlier overclaims reached the engine docstring. Now carries the same condition and says what is actually guaranteed: no history pruned AFTER a valid cursor. The sample's guard comment claimed an unset or non-numeric cursor "compares false either way". Measured: `null < 500` and `'' < 500` are both TRUE, since both coerce to 0 — so the claim is wrong for two of the five cases, not just null. That matters because the comment exists to stop the guard being simplified back to `cursor < floor`: a reader who checks it against a JSON-decoded null finds it false and discounts the whole comment. Restated as the fact that is actually load-bearing and holds for all five — neither is ever `>=` the floor. --- reference/resources/resource-api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 1e53e058..be1b72c7 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -253,14 +253,14 @@ All properties are optional: -The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it may have lost history, and the floor cannot certify otherwise; a cursor at or above it has not been pruned, which is not on its own a guarantee that resuming is safe (see the limits below). +The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it may have lost history, and the floor cannot certify otherwise; a valid cursor at or above it has not had history pruned after it, which is not on its own a guarantee that resuming is safe (see the limits below). Subscription catch-up (`startTime`) reads the audit log, and the audit log is pruned on a retention window (`logging.auditRetention`). A consumer that saves a cursor, disconnects, and resumes past that window would otherwise receive a replay that quietly begins after the messages it missed. This method is how a consumer detects that instead: ```javascript const floor = tables.Product.oldestRetainedAuditTime(); -// negated rather than `cursor < floor`: an unset or non-numeric cursor compares false either way, -// and this spelling sends it to the resync branch instead of starting live with no catch-up +// negated rather than `cursor < floor`: an unset or non-numeric cursor is never `>=` the floor, +// so this spelling sends it to the resync branch instead of starting live with no catch-up if (!(cursor >= floor)) { // conservative: history this consumer needs may have been pruned; re-read the table instead await fullResync(); From 378fb1fd8e9eabb41668ae6b76a874dd241132de Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 15:06:56 -0400 Subject: [PATCH 11/13] docs(resources): the floor is a lower bound, not the oldest entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by sweeping every guarantee-shaped sentence in this section for whether it survives being quoted alone — the property behind the last three review findings. The section's opening definition called the floor "the oldest point in the audit log that retention has not pruned away". Two of this page's own bullets contradict that: the starting floor is stamped at open time and so sits ABOVE entries the database still retains, and on RocksDB entries below the floor are routinely still on disk. The floor bounds what is retained; it does not measure it. This is the section's most liftable sentence, and unlike the previous three the engine had it right (getAuditFloor claims only that entries at or after the floor are retained, never the converse). Also conditions the database-scoped restatement of `cursor >= floor` on cursor validity, matching the summary and the one-direction bullet. Engine docstring mirrored in harper f885affef. --- reference/resources/resource-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index be1b72c7..93ac6694 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -253,7 +253,7 @@ All properties are optional: -The oldest point in the audit log that retention has not pruned away — the retention floor. A cursor below it may have lost history, and the floor cannot certify otherwise; a valid cursor at or above it has not had history pruned after it, which is not on its own a guarantee that resuming is safe (see the limits below). +The point in the audit log at or after which retention has pruned nothing — the retention floor. It is a lower bound, not a measurement of the oldest surviving entry: the log routinely still holds entries older than it. A cursor below it may have lost history, and the floor cannot certify otherwise; a valid cursor at or above it has not had history pruned after it, which is not on its own a guarantee that resuming is safe (see the limits below). Subscription catch-up (`startTime`) reads the audit log, and the audit log is pruned on a retention window (`logging.auditRetention`). A consumer that saves a cursor, disconnects, and resumes past that window would otherwise receive a replay that quietly begins after the messages it missed. This method is how a consumer detects that instead: @@ -278,7 +278,7 @@ Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. - **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. -- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. +- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so for a valid cursor, `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. - **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it — retention can advance between reads. Re-reading will not reveal a restore, though: the reinstalled lower floor still passes a cursor saved after the copy point. - **Across retention pruning, and given a valid cursor, it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. A persisted `getHistory().localTime` is outside that guarantee, not an exception to it: being an origin version, it can overstate the consumer's real position in the audit log and so pass while that real position sits below the floor (see the validity bullet above). From a2bc25e1cb05b760c52cb262f28c0fb295182417 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 15:23:22 -0400 Subject: [PATCH 12/13] docs(resources): condition the last two restatements, and fix a mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes, one from review of 378fb1fd and two my own sweep missed. Reviewed: the "exactly at the floor" gloss stated it flat. Conditioned. My keyword sweep missed it because it matched on has not/cannot/never/ guarantee/safe/only, and that sentence says "passes" and "already been handled" — a guarantee with none of those words. Re-ran the pass by reading every sentence instead of grepping, which found two more: - The sample's else-branch comment was the sole remaining unconditioned restatement, in the artifact readers copy. I left this deliberately last round, judging the qualifier noise three lines under the guard. Wrong call now that every other restatement carries the condition — the holdout is what looks authoritative. - The validity bullet still said the pruned messages sit "between" the cursor and the floor. That is the mechanism I diagnosed as backwards two rounds ago and fixed only in the one-direction bullet: they sit BELOW the floor, and the defect is the cursor overstating the consumer's position. The two bullets disagreed until now. --- reference/resources/resource-api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 93ac6694..ef972e4f 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -265,19 +265,19 @@ if (!(cursor >= floor)) { // conservative: history this consumer needs may have been pruned; re-read the table instead await fullResync(); } else { - // retention has not pruned anything after `cursor`. This is a PRUNING check only — it cannot + // retention has not pruned anything after a valid `cursor`. This is a PRUNING check only — it cannot // see a database that was restored or checkpointed (see the rollback limit below), so resume // here only where a rollback is not a case you have to handle. subscription = await tables.Product.subscribe({ startTime: cursor }); } ``` -The cursor is a _last-processed_ position, so a cursor exactly at the floor passes: everything below it has already been handled, and nothing above it has been pruned as of this reading. +The cursor is a _last-processed_ position, so a _valid_ cursor exactly at the floor passes: everything below it has already been handled, and nothing above it has been pruned as of this reading. Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. -- **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the messages between them have already been pruned, which is the silent gap this method exists to expose. +- **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the consumer's real position sits below the floor and the entries that position still needed are already gone, which is the silent gap this method exists to expose. - **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so for a valid cursor, `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. - **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it — retention can advance between reads. Re-reading will not reveal a restore, though: the reinstalled lower floor still passes a cursor saved after the copy point. From aebefbc64fd534f4e437d0139eeb0851e43baad5 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 3 Sep 2026 15:41:48 -0400 Subject: [PATCH 13/13] docs(resources): un-overclaim the getHistory gap, and rescope a disclaimer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from review of a2bc25e1. The validity bullet said a getHistory cursor passes "while ... the entries that position still needed are already gone". Sitting below the floor is not the same as pruned — line 256, added in this same PR, says the floor is a lower bound and the log routinely holds older entries, and line 283 says a stamped starting floor sits ABOVE retained entries. So on an upgraded node this asserted confirmed history loss where nothing had been pruned: the same false alarm already removed from the summary line and the resync branch. My regression from a2bc25e1, introduced while fixing the mechanism in that same sentence. The database-scoped bullet said the floor promises nothing below the CURSOR because those entries "are older than the floor". True only when cursor === floor. For cursor 900 against floor 500, `[500, 900)` is below the cursor and guaranteed unpruned — a consumer wanting that range for an uncommitted replay or a backfill was told it may well be gone. Scoped the disclaimer to the floor. All three engine-side copies of the second one had it identically; fixed in harper 1761543c6. --- reference/resources/resource-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index ef972e4f..dcb8cee4 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -277,8 +277,8 @@ The cursor is a _last-processed_ position, so a _valid_ cursor exactly at the fl Details worth knowing before relying on it: - **The time domain is the same one `startTime` uses**, so cursors compare directly with no conversion. Subscription events carry it as `localTime`. -- **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the consumer's real position sits below the floor and the entries that position still needed are already gone, which is the silent gap this method exists to expose. -- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so for a valid cursor, `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the cursor — those are older than the floor and may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. +- **Only a subscription event's `localTime` is a valid cursor here.** `getHistory()` also yields a `localTime`, but that one is the entry's origin version, which a backdated or replicated write makes differ from the audit-log position this floor describes. Do not persist `getHistory().localTime` and compare it against the floor: it can pass `cursor >= floor` while the consumer's real position sits below the floor, where the entries that position still needed may already be gone and the floor cannot certify otherwise — which is the silent gap this method exists to expose. +- **The floor is database-scoped, not per-table.** All tables in a database share one audit log, so for a valid cursor, `cursor >= floor` means no entry of _any_ table in that database was pruned _after_ the cursor. It says nothing about entries below the _floor_ — those may well be gone, which is the floor's whole purpose. `deleteHistory()` on one table raises the floor for its siblings too. - **`Infinity` means the floor is unknown.** Treat no cursor as safe. A database reports it when no floor was ever recorded successfully — it is read-only, or the metadata write failed or was unavailable — or when the recorded metadata does not decode. A prune that runs on a database with no floor records this same value, which is not a separate cause: it means the recording is what did not land. - **A database's starting floor is stamped when it is first opened, not derived from its history.** It is set at that moment, so it sits _above_ audit entries the database still retains. On a node upgraded with a week of retained history, every cursor saved before the upgrade therefore falls below it and resyncs once — with nothing having been pruned. Do not read that first resync as evidence of lost history. After it, the floor rises only when history is actually pruned — by retention, or by `deleteHistory()`. A restore or checkpoint is the exception to that direction: it replaces the floor with the copy's, which can be lower (see the rollback limit below). Re-read the floor on each resume rather than caching it — retention can advance between reads. Re-reading will not reveal a restore, though: the reinstalled lower floor still passes a cursor saved after the copy point. - **Across retention pruning, and given a valid cursor, it errs in one direction only.** The floor can ask for a resync that was not strictly necessary. It does not report a cursor as safe when a prune removed history that cursor needed. A persisted `getHistory().localTime` is outside that guarantee, not an exception to it: being an origin version, it can overstate the consumer's real position in the audit log and so pass while that real position sits below the floor (see the validity bullet above).