Skip to content

OR-Map tombstone GC: epoch machinery (dark prune) + re-admission gate + CI fixes [SPEC-342b/342c]#109

Merged
ivkan merged 57 commits into
mainfrom
fix/sf-342b-ormap-marker-enumeration
Jul 9, 2026
Merged

OR-Map tombstone GC: epoch machinery (dark prune) + re-admission gate + CI fixes [SPEC-342b/342c]#109
ivkan merged 57 commits into
mainfrom
fix/sf-342b-ormap-marker-enumeration

Conversation

@ivkan

@ivkan ivkan commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

55 commits closing two SPEC-342 children plus CI repairs. Everything here went through the full SpecFlow lifecycle locally (per-spec: fresh-context audits, cross-vendor /xask design checkpoints, implementation, fresh-context reviews with /xreview gates — 3 review cycles for 342b, 2 for 342c).

SPEC-342b — server-authoritative epoch machinery, prune DARK by construction

  • Monotonic epoch counter + RAM epoch→tags index stamped at OR_REMOVE apply; PruneSafety::is_epoch_prune_eligible (fleet-wide-MIN LWM fold) + call-site prune_bound conjunction.
  • Prune cannot fire in this PR: durable_epoch_watermark ≡ 0 until SPEC-342j wires the real byte-durability watermark (gate-before-activation: 342j also depends on 342c, so no prune-without-gate window ever exists).
  • Covering-epoch conveyance makes the 342e confirmed-apply ACK loop live end-to-end; client-side cross-map min-barrier (per-map coverage registry over a pre-ACK consistent held-set snapshot; fail-closed on enumeration failure; push-event epochs routed through the barrier; eager __sys__:{name}:ormap markers + legacy backfill for add-only stores; cursor keyspace bumped to _v2 to orphan pre-barrier inflated cursors).

SPEC-342c — forgotten-client RE-ADMISSION gate

  • Pre-apply gate on handle_ormap_push_diff keyed by server-authenticated (principal, deviceId) (342i), gating records AND the inbound tombstone union, held under the 342d per-key single-writer gate→commit (TOCTOU close).
  • Sync-path closures: covering_epoch/delivered_conn gated for forgotten/regressed connections (a forgotten client can no longer re-admit itself via sync-init); regressed replicas get an authoritative REPLACE resync + client pending-oplog HLC discard; op-path defence-in-depth gate.

CI (nightly reds the repo has been emailing about)

  • RUSTSEC-2026-0204: crossbeam-epoch 0.9.18 → 0.9.20 (advisory published Jul 6 — the sole run-level failure of every nightly since Jul 7).
  • Vector Performance Gate has never passed on schedule: cargo runs bench binaries with CWD = the package dir, so --json-output results-vec-*.json landed in packages/server-rust/ while the validate step (bash -e + jq) read the workspace root → exit 2. All harness output paths now anchored to $GITHUB_WORKSPACE (perf gate had the same latent bug).
  • Perf-gate timeout 15 → 30 min: every nightly Jun 28–Jul 6 died as a timeout cancellation before reaching the scenarios.

Test evidence (local, full gates at each review)

  • server-rust lib: 1591 passed / 0 failed / 1 ignored (re-run on the final lock bump)
  • client Jest: 716/716 · adapters 47/47 · core 2074/2074
  • clippy -D warnings / fmt / prettier clean; pnpm test:sim green at spec gates

Notes for reviewers

  • Wire changes are additive-only (Option fields; old clients degrade gracefully — documented per spec).
  • .specflow/ is local-only and not part of this PR.

ivkan added 30 commits July 5, 2026 13:45
…rait surface

R1 confirm/relocate decision: RELOCATE tombstone_frontier.rs core-rust -> server-rust.
Dependency-direction check (verified live): nothing in core-rust consumes the
contract; the sole implementer is server-side (epoch counter, per-client frontier,
reconnect gate). server-rust is the correct home; the wire tombstone shape stays
Vec<String> in core-rust.

Finalized trait/type surface (types/traits ONLY, no bodies):
- Add pub(crate) sub-trait CausalFrontierRanking: CausalFrontier carrying
  tracked_cursors() -> Vec<(ClientId, Epoch)>, kept OFF the general CausalFrontier
  so a bare &CausalFrontier holder cannot compute a subset-MIN prune path; the
  point-lookup client_cursor() is not on the surface. pub(crate) encodes
  'only the RAM-pressure forget-driver ranks' in the visibility system.
  Return shape is Vec (object-safe) so the downstream forget-driver may use
  &dyn dispatch.
- Declare PruneSafety: CausalFrontier (supertrait EXPOSES low_water_mark;
  fleet-wide-MIN stays doc-enforced/reviewer-checkable, not type-enforced).
- gate_decision_holds_at_commit consumes token by value; GateToken drops Clone
  (not Copy) for a genuine single-use/linear token. Re-admission safety rests on
  the commit-time re-check, not token unforgeability (impl-note for the gate child).
- GateToken fields kept pub with an accepted-forgeability doc note (forgeability
  is not load-bearing); avoids a constructor body that would violate types-only.
- confirm_apply/is_tracked doc-contracts state tracking is implicit via first
  confirm_apply, 'unknown == forgotten' collapses first-connect and
  forgotten-reconnect into the same gated path, and no track_client/register
  method exists or is needed.
- MaxRetention/GateToken remain serde-free (server-internal metadata).

cargo build/fmt/clippy (-D warnings) green for both crates.
- New dedicated module service/domain/key_writer.rs (NOT tombstone_frontier.rs,
  which stays traits/types-only per SPEC-342a)
- DashMap<(map_name, key), Arc<tokio::sync::Mutex<()>>> registry; acquire()
  returns an owned RAII guard (OwnedMutexGuard) that can be held across
  .await points and is not tied to the registry's own lifetime
- No eviction (default lifecycle per R5 option (a)): entries only grow via
  or_insert_with, documented as an accepted operational memory concern;
  naive eviction would race with a waiter holding a live Arc clone
- Co-located tests prove (a) concurrent acquisitions on the same key
  serialize a read-then-write RMW with no lost update, (b) different keys
  do not block each other, (c) the registry never evicts (AC14)
…d-vs-add race)

CrdtService::apply_single_op's OR_ADD branch performed an unlocked
store.get -> merge -> store.put read-modify-write. Two concurrent OR_ADDs
on the same key could each read the pre-mutation OrMap state and race to
put, with the second put silently clobbering the first's merge and
dropping an add.

- CrdtService gains an internal key_writer: Arc<KeyWriterRegistry> field,
  constructed in new() so existing call sites are unaffected
- Acquire the per-key guard right before the store.get -> merge -> store.put
  region (crdt.rs OR_ADD branch) and hold it through the single store.put
  merge-commit
- Scope is OR_ADD only: the identical OR_REMOVE RMW is explicitly left
  unlocked here (342b's responsibility) -- a partial deployment closes
  add-vs-add only, not add-vs-remove interleaving
- New concurrent_or_adds_on_same_key_lose_no_update test drives N concurrent
  OR_ADDs through the real apply path and asserts all survive; verified to
  fail (6/20 survivors) with the lock temporarily removed, confirming the
  test catches the real race
…mit put

Drop the OR_ADD per-key writer guard the instant store.put returns rather
than at end-of-arm scope, so the critical region is exactly store.get ->
store.put. The payload construction that follows only clones owned locals
and touches no shared store state; holding the lock across it would
needlessly serialize unrelated writers to the same key. Hardens the
documented get..put invariant at the code level and removes a latent
footgun if post-put .await work is ever added to the arm.

Surfaced by the cross-vendor /xreview (glm-5.2) as a HIGH; refuted as a
live bug (no post-put await existed) but applied as defensive tightening.
…invariant

Fold the xreview-surfaced latent footgun (a future second CrdtService over the
same store would mint distinct per-key mutexes and reopen the SPEC-333b race)
into a WHY-comment on the key_writer field. Comment-only; no logic change.
…en, AuthAck.deviceId/deviceToken) + Zod parity

- AuthMessage gains optional deviceToken (additive; old clients unaffected; Default intentionally not derived — required token field)
- AuthAckData gains optional deviceId + deviceToken (returned only on mint/rotate)
- TS Zod parity in base-schemas.ts + client-message-schemas.ts
…R/fencing

- device_identity.rs: present-or-mint credential store over shared MapDataStore under
  reserved namespace; injective length-prefixed (principal,deviceId) key; SHA-256 hash-at-rest;
  constant-time verify (subtle); fail-open-to-new-identity mint; revoke; frontier_client_id helper
- connection.rs: ConnectionMetadata.device_id (one-shot); ConnectionRegistry device-ownership map
  with atomic TAKEOVER (DashMap shard-locked swap), is_current_owner fencing primitive, release on
  disconnect only-if-still-owner; module registered via #[path] to hold the <=5-file cap
- fix collateral AuthMessage/ConnectionMetadata struct literals for the new fields (tests/benches)
…O_AUTH)

- shared bind_device_identity helper: present-or-mint, one-shot device_id bind, TAKEOVER + close displaced; fail-open (never blocks auth)
- JWT mode: Ok(principal) arm runs present-or-mint, returns deviceId/deviceToken in AUTH_ACK (one-shot is structural — single Phase-1 bind then break 'auth)
- NO_AUTH mode: opportunistic Phase-2 Auth intercepted at WS layer under sentinel namespace, replies AUTH_ACK, never enters data-plane dispatch; explicit one-shot guard drops repeat AUTH; JWT Phase-2 re-auth stays unsupported (dropped)
…dential

- Load persisted deviceToken/deviceId from meta storage (memoized read) and
  present deviceToken on the AUTH message so the server rebinds device identity
- NO_AUTH connect now sends an opportunistic AUTH{token:''} for present-or-mint,
  with the existing grace timer as legacy fallback (never anonymous-auth deadlock)
- Thread AuthAckMessage into handleAuthAck; persist minted/rotated credential,
  keep existing token when the ack omits deviceToken (plain re-bind)
- Legacy-server inference: proceed auth-optional on a non-AUTH_ACK first message
  or grace timeout, degraded-to-legacy without a deviceId
…ence

- New SyncEngineDeviceCredential suite: mint-on-AUTH_ACK persisted and
  re-presented on reconnect, re-bind keeps existing token, legacy paths
  (no AUTH_ACK / non-AUTH_ACK first) proceed without a device identity
- Update auth-optional + SyncEngine connection tests for the new NO_AUTH
  opportunistic AUTH{token:''} present-or-mint behavior
…s, # Errors, Debug fields)

- backtick NO_AUTH in doc comments (clippy::doc_markdown)
- checked casts in now_millis/mint (u128->u64, u64->i64 via try_from)
- # Errors sections on present_or_mint/revoke (clippy::missing_errors_doc)
- ConnectionRegistry Debug impl includes new device_ownership/conn_identity fields
…l-collision (xreview)

- cross-vendor review: a JWT sub literally equal to the content sentinel collided with the NO_AUTH namespace (length prefix can't disambiguate identical content)
- replace content sentinel with a structural leading tag (a=authenticated principal, n=no-auth) that principal content can never forge; unify credential-store + frontier/ownership keying on one identity_key
- present_or_mint/mint/revoke now take principal_id: Option<&str>; drop NO_AUTH_SENTINEL const
- add noauth_namespace_cannot_be_forged_by_any_principal_content test
… fix

- New additive DeviceHelloMessage (base.rs) + DeviceAckData (client_events.rs) structs
  and DEVICE_HELLO/DEVICE_ACK Message enum variants; orthogonal to AUTH so a JWT server
  silently drops a token-less client's device presentation instead of AUTH_FAIL+teardown.
- Zod parity: DeviceHelloMessageSchema, DeviceAckMessageSchema + discriminated-union members.
- Fix review C1: add device_token:None to the 3 AuthMessage test literals in base.rs
  (workspace clippy --all-targets was red; -p topgun-server alone never compiled that target).
… claim liveness

- websocket.rs: replace the opportunistic empty-token AUTH interception with a
  Phase-2 DEVICE_HELLO handler (both modes; present-or-mint -> DEVICE_ACK; one-shot
  guard; never enters data-plane dispatch). Phase 1 untouched, so a JWT server keeps
  silently dropping a token-less client's DEVICE_HELLO instead of AUTH_FAIL+teardown.
- device_identity.rs: hand-written redacting Debug on DeviceBinding (raw secret can no
  longer leak via {:?}); tracing-capture test asserts the secret/token never hit logs
  on mint or verify (AC3); plus Debug-redaction unit test.
- connection.rs: M6 post-insert liveness re-check in claim_device_ownership so a
  self-disconnect interleaving cannot orphan ownership to a dead connection; regression test.
- classify.rs: route DEVICE_HELLO (client->server) / DEVICE_ACK (server->client) as
  non-dispatchable handshake frames.
…not empty-token AUTH

- Token-less connect now sends a dedicated DEVICE_HELLO frame (sendDeviceHello) instead
  of an empty-token AUTH — the latter is AUTH_FAIL'd + disconnected by a real JWT server,
  breaking the 'connect first, supply token later' flow (review C2). A JWT server silently
  drops DEVICE_HELLO (non-AUTH) and sends AUTH_REQUIRED, so the client parks and Case 3
  is preserved.
- handleDeviceAck persists deviceId/deviceToken from DEVICE_ACK and completes the
  connection auth-optional; credentialed clients keep bundling deviceToken on the real AUTH.
- sendAuth no longer emits an empty-token AUTH; legacy inference keys off DEVICE_ACK.
- Route DEVICE_ACK through ClientMessageHandlers; tests updated to the DeviceHello flow.
…egression)

- Drives a real TopGunClient + real JWT server (mocks don't validate JWTs, which is
  what hid the original regression): a token-less client survives (parks in
  AUTHENTICATING, not torn down) and authenticates after setAuthToken (Case 3).
- Raw-ws guarantee: DEVICE_HELLO to a JWT server is not AUTH_FAILed, the socket stays
  OPEN, and a subsequent valid AUTH authenticates on the same connection.
…diff

- Backtick NO_AUTH in DeviceHello/DeviceAck doc comments.
- claim_device_ownership consumes its String param (move into forward insert; keep a
  clone for the liveness re-check) — fixes needless_pass_by_value.
- M3 tracing-capture visitor uses write! instead of push_str(&format!(..)).
…it (xreview HIGH)

Cross-vendor review found the liveness re-check broke TAKEOVER fencing: when a DEAD
connection's claim displaced a LIVE owner A, the re-check removed the key entirely
(orphaning it to nobody) AND returned Some(A), so the caller tore down the live owner
for a takeover that never happened. Now: if the dead claim displaced a prior owner,
atomically restore it (get_mut holds the shard write-lock across the check-and-swap,
guarding a racing newer takeover); report None so the caller does not cancel the prior
owner. Regression test claim_by_disconnected_connection_restores_displaced_live_owner.
…cant entry

The M6 restore branch used get_mut, which no-ops when a concurrent remove()
already deleted device_ownership[K] (its remove_if(owner==id) matched), so a
displaced LIVE prior owner was never restored -- orphaned to nobody, failing
is_current_owner forever (its cursor ACKs would be rejected). Extract the
reconciliation into reconcile_dead_claim() and make the forward-ownership update
atomic via DashMap Entry: the Vacant arm re-inserts the displaced live owner;
Occupied-by-us restores; a newer takeover is left untouched. Deterministic
regression tests for all three arms (the Vacant arm is the closed bug).
If ensureDeviceCredentialLoaded is slow, the grace timer can complete the
connection auth-optional before sendDeviceHello's DEVICE_ACK returns. handleDeviceAck
now persists the identity unconditionally but only re-drives handleAuthAck while
deviceAckPending is still set, so a late DEVICE_ACK cannot bounce CONNECTED -> SYNCING.
…view HIGH)

The Vacant-restore fix over-corrected: prev (the displaced prior owner) can itself
disconnect between the claim and reconcile, so its own remove() already ran and will
never run again. Unconditionally re-inserting it pinned a permanently-stuck dead owner
that is_current_owner affirms forever. Liveness-check prev before restoring in both
arms; if no live prior owner exists, leave the identity ownerless. Regression test
reconcile_dead_claim_does_not_restore_a_dead_prior_owner.
- ClientApplyAckMessage { cursor: u64 } via serde_number integer helper; NO
  identity field on the wire (server derives (principal, deviceId) from the
  authenticated connection)
- ClientApplyAck enum variant on Message (#[serde(tag="type")] owns the
  discriminant, rename CLIENT_APPLY_ACK)
- mechanical exhaustive-match arm in classify.rs (non-dispatchable control frame,
  handled at the websocket layer; zero-cascade additive-variant consequence)
- round-trip + integer-wire-format + single-type-discriminant tests
…on (G2)

- TombstoneFrontier (tombstone_frontier_impl.rs): durable per-device confirmed-
  apply cursor store; advance rule new = max(stored, min(claimed, delivered_conn,
  current_max_epoch)); loss-conservative, delivered-clamped, globally-bounded,
  monotone; inclusive fencepost; LWM = MIN across tracked; settable/injectable
  current_max_epoch + per-connection delivered (default at Wave 2); regression-
  claim gate (never rolls stored back); best-effort redb persistence in a NEW
  additive keyspace + rehydration
- websocket.rs: route CLIENT_APPLY_ACK -> confirm_apply, identity derived from
  ConnectionMetadata.principal + .device_id (never the wire), rejected unless
  is_current_owner (342i fencing); rehydrate persisted cursor into the frontier
  on connection establishment BEFORE any ACK; drop per-connection delivered state
  on disconnect
- AppState.frontier wired over the shared data store (module.rs, topgun_server.rs)
- 14 frontier unit/persistence tests: dropped-ACK, replay/reorder, global-bound,
  delivered-clamp, cross-principal, two-devices-LWM-min, fencepost-inclusive,
  regression-gate, cursor-survives-restart, reconnect-rehydrates-before-ACK
…mit (G3)

- ClientApplyAckMessageSchema (CLIENT_APPLY_ACK, cursor: number) in packages/core
  + added to the discriminated MessageSchema union (cross-language parity)
- SyncEngine emits CLIENT_APPLY_ACK ONLY after the applied server batch is durably
  committed to IndexedDB (apply-not-receive): emitConfirmedApply runs after the
  applyServerEvent await chain resolves; cumulative-monotonic (non-advancing epoch
  not re-sent); forward-compatible epochOfServerEvent (0 until prune-wiring stamps
  epochs on the delta wire -> inert, not incorrect)
- tests: emit-after-durable-commit ordering, cursor monotonicity, epoch-less inert
… x2)

- forget_client now async + deletes the durable redb cursor, so a forget is
  durable (forgotten -> unknown -> full resync). Left as-was, rehydrate on
  reconnect silently re-tracked a forgotten client at its stale cursor and
  dropped the low-water-mark below an already-pruned watermark -> resurrection.
- low_water_mark vacuous case returns 0 (prune nothing) instead of
  current_max_epoch (default u64::MAX). Rehydration is lazy, so an empty
  post-restart frontier means 'no client reconnected yet', not 'no client to
  protect' -> the old value licensed pruning tombstones a not-yet-reconnected
  laggard still needs. Conservative direction, matches the spec philosophy.
- Regression tests: forget_client_deletes_durable_cursor_so_rehydrate_is_noop,
  empty_frontier_lwm_is_zero_prunes_nothing.
…view LOW x2)

- emitConfirmedApply advances lastAckedEpoch ONLY on a successful sendMessage
  (returns bool, never throws). A failed send previously advanced the cursor
  anyway, permanently skipping that epoch's ACK (monotone: a lower epoch is
  never re-sent) -> server cursor stuck.
- setAuthToken resets lastAckedEpoch to 0: a new token may name a different
  principal whose server-side per-device cursor is independent; without the
  reset the new identity's ACKs are suppressed by the prior identity's epoch.
- Tests: failed-ACK-send-does-not-advance-and-retries, setAuthToken-resets-cursor.
- add covering_epoch: Option<u64> to ORMapSyncRespRoot/Leaf/DiffResponse
  payloads (camelCase, skip_serializing_if/default, serde_number decode) —
  wire tombstones: Vec<String> byte layout unchanged
- Zod parity (coveringEpoch, optional non-negative int) in sync-schemas
- roundtrip + omitted-when-none + integer-not-float tests
ivkan added 25 commits July 7, 2026 18:44
…-safety

- server-authoritative monotonic epoch counter stamped from the op sequence
  (epoch<->seq lockstep, EPOCH_WIDTH ops/epoch), never from client tag millis
- RAM-only epoch->tags / epoch->max_seq index (pure cache); TombstoneRef
- PruneSafety::is_epoch_prune_eligible folds over low_water_mark ONLY (342a
  contract); reserved epoch-0 sentinel rejected at trait level
- drain_prunable applies the call-site conjunction (LWM && durable watermark),
  iterates ACTUAL index keys (never 0..=max); durable_epoch_watermark returns
  constant 0 in this child (dark by construction) with test-only injection
- feeds current_max_epoch ACK bound; current_epoch = covering epoch
- unit tests: AC2 server-authoritative stamping, epoch<->seq lockstep,
  AC3(i) one-behind-client pins fleet-wide, AC3(ii) watermark conjunct
  load-bearing, AC3a dark-by-construction (first epoch>=1, sentinel-0 safe)
- CrdtService gains .with_frontier() / .with_key_writer() builders (shared
  frontier + per-key writer, new() call sites unaffected)
- OR_REMOVE apply: take the per-key writer around the RMW, stamp each
  genuinely-new tombstone with the current server epoch, run the wholesale
  prune sweep after apply (dark: watermark 0 -> drains nothing)
- delete the 'grows unbounded' stopgap comment
- prune_epoch_tombstones helper (shared with sync.rs): drops drained tags from
  storage RAM+redb under the per-key writer
- AC4 test: prune wired into the OR write path (injected watermark drops the
  epoch's tombstone; a higher pinned epoch survives)
- SyncService gains .with_frontier(frontier, key_writer) builder
- OR-Map root/leaf/diff responses convey the covering epoch (current max
  stamped epoch) and mark it delivered on the connection for the 342e ACK clamp
  — including the empty-diff root, for empty-diff liveness
- run the wholesale prune sweep at OR-Map leaf-handler entry (dark: no-op in
  production; drops from storage under the shared per-key writer when active)
- AC4 test: prune wired into the SYNC leaf; AC3b server half: covering epoch
  conveyed + marked delivered on ORMapSyncInit
- construct the causal frontier + shared KeyWriterRegistry once in
  build_services and thread the SAME instances into CrdtService (.with_frontier
  / .with_key_writer), SyncService (.with_frontier) and AppState — no second
  frontier (dual-router STOP-rule satisfied: sole production CrdtService::new)
- TOPGUN_EPOCH_WIDTH env (default 1000), surfaced in a startup config log line
- ORMapSyncHandler learns coveringEpoch from OR-Map sync responses and confirms
  it after durable apply via onCoveringEpochApplied -> emitConfirmedApply
- empty diff (root matches) ACKs the covering epoch so an up-to-date client
  still advances its cursor; root mismatch defers the ACK until leaves/diff
  are applied
- ORMapCoveringEpoch tests: empty-diff ACK, mismatch defers, leaf/diff ACK,
  missing/zero epoch never ACKed
…d-drop (xreview HIGH x2 / MED x2)

Cross-vendor xreview (glm-5.2) findings on the sf-342b diff, all latent
(bite when SPEC-342j activates the watermark), fixed now:

- HIGH (TOCTOU): all five covering-epoch conveyance sites in sync.rs read
  the epoch AFTER computing the root/entries — a concurrent OR_REMOVE
  stamped in between made the client ACK an epoch whose tombstones it never
  received. Epoch now read BEFORE the data at every site; WHY-comment on the
  helper makes epoch-before-data a stated load-bearing ordering.
- HIGH (orphaned index entries): drain_prunable destroyed epoch_tags entries
  before the storage drop succeeded; a get/put error silently orphaned the
  tag un-prunable forever. drain now returns (Epoch, TombstoneRef);
  prune_epoch_tombstones re-inserts via restore_tombstone_ref on ANY
  failure; failure logs escalated debug -> warn. Regression test:
  restore round-trips through a second drain.
- MED (342a contract deviation): is_epoch_prune_eligible used inclusive >=;
  the LOCKED contract says 'advanced PAST epoch' (strict). Inclusive also
  admitted a still-accumulating-epoch prune trace (covering epoch =
  current_epoch, still open at width > 1). Now strict >; tests updated,
  new pin test: LWM == epoch NOT eligible, LWM == epoch+1 eligible.
- MED (perf): drain evaluated the LWM fold per epoch key on every OR_REMOVE
  and SYNC-leaf request even in dark mode. Early-return on watermark == 0
  (production dark fast-path) + cheap watermark conjunct short-circuits
  the fold.
- MED (test-only injector): set_durable_epoch_watermark now #[cfg(test)] —
  production code structurally cannot activate the prune early.
…llback (xreview LOW x1)

An invalid or zero TOPGUN_EPOCH_WIDTH silently fell back to the default,
leaving the operator believing their setting took effect. The rejected value
and the default now surface in a boot-visible tracing::warn (project
precedent: config surprises must be visible at startup).
… + mocks

The client-side cross-map covering-epoch min-barrier fix needs to enumerate
persisted-but-not-yet-opened OR-Map stores (the reserved
`__sys__:{mapName}:tombstones` meta-key convention) to build a consistent
held-map snapshot. Add getAllMetaKeys() to IStorageAdapter, mirroring
getAllKeys() for the meta namespace, and implement it in IDBAdapter,
EncryptedStorageAdapter (plaintext passthrough — key names are never
encrypted), and mcp-server's InMemoryStorageAdapter.

Update every test mock/fake IStorageAdapter implementation across
client, adapters, and adapter-better-auth so the interface addition
does not silently degrade the barrier in test environments (same
pattern SPEC-321 used for commitWrite/deleteOp).
The covering-epoch/confirmed-apply cursor is GLOBAL across all OR-Maps,
but the client was confirming it off a SINGLE map's sync completion.
Device B holding maps `tags` and `other_map` could ACK a covering epoch
conveyed by `tags`'s sync response alone, even though `other_map` (in
the same epoch window) had not synced on that connection yet — dark
today (durable_epoch_watermark constant 0) but a live resurrection
vector the moment SPEC-342j activates the real watermark.

Fix (per the cross-vendor-locked design):
- SyncEngine now tracks per-OR-Map coverage (`orMapCoverage`) and ACKs
  only `min(coverage[m] for m in heldOrMaps)`, monotone — a held map
  with no completed sync this connection contributes 0, stalling the
  ACK rather than over-claiming.
- `heldOrMaps` is a consistent snapshot (`computeHeldOrMapNames`) taken
  ONCE per connection, before the first sync-init message goes out:
  the union of open ORMap instances and persisted-but-not-yet-opened
  stores (enumerated via the new getAllMetaKeys storage API).
- `startMerkleSync` now instantiates + restores every persisted OR-Map
  in the held set (`instantiateAndRestoreOrMap`) so an unopened store
  actually gets synced instead of permanently stalling the barrier.
- `registerMap` late-joins a map opened after the snapshot with
  coverage 0, so it blocks further advance without retroactively
  narrowing or widening an already-computed barrier.
- `ORMapSyncHandler.onCoveringEpochApplied` now reports the mapName
  alongside the epoch so SyncEngine can fold coverage per map.

`handleServerEvent`/`handleServerBatchEvent` (live-push epoch, still
dead wire-wise) intentionally keep calling emitConfirmedApply directly
rather than routing through the new barrier, documented at
epochOfServerEvent to avoid silently reintroducing the same gap once
that path is wired.

Tests: multi-map interleaving, persisted-but-not-instantiated map
enumeration, empty held-set, late-opened map, and per-connection
snapshot/coverage reset (SyncEngine.test.ts); handler-level mapName
propagation (ORMapCoveringEpoch.test.ts).
…cursors

Companion to the client-side cross-map ACK min-barrier fix. Before that
fix a client's device-wide confirmed-apply cursor could be inflated by
a single OR-Map's sync completion while other held maps lagged behind.
Any such inflated cursor is already durably persisted server-side
(redb, CURSOR_MAP) and would otherwise survive a clean restart straight
into SPEC-342j's prune activation.

Version-bump the reserved cursor keyspace
(_topgun_tombstone_cursors -> _topgun_tombstone_cursors_v2) so a
pre-bump row is never read back — orphaned, not migrated; reclaimed
later by 342f's TTL sweep. Safe today because pruning is dark
(durable_epoch_watermark constant 0): a "loss" here degrades to the
already-accepted unknown -> forgotten -> full-resync path.

No client-side persisted-cursor version bump is needed: the client's
lastAckedEpoch was verified to be purely in-memory (reset every
connection), so there is no durable client-side row to poison.

Test: a cursor written under the retired pre-bump keyspace name is
never rehydrated into a fresh frontier over the same store.
…iew HIGH x2 / LOW x1)

- Held-set enumeration failure now fail-closes ALL covering-epoch ACKs for
  the connection (heldSetIncomplete) instead of silently running the
  min-barrier over a partial in-memory-only universe
- Server push events (single + batch) route their epoch through
  applyMapCoverage's cross-map min-barrier instead of calling
  emitConfirmedApply directly; batch-wide highest-epoch shortcut removed
- IDBAdapter.getAllMetaKeys throws (never a silent []) when the database is
  unavailable, feeding the fail-closed path
- Late-join registerMap WHY-comment: emitted ACK stands only because a
  genuinely-new map is empty-at-open; the missed-persisted-store sibling is
  closed by fail-closed enumeration
- Apply-not-receive tests rewritten to the barrier-era contract (OR-Map +
  driven held-set snapshot); 2 new regression tests (enumeration failure,
  push-path barrier routing)
…ly enumeration gap)

Review v2 Major: the covering-epoch held-set snapshot enumerated persisted
OR-Maps only via the :tombstones meta-key, which add-only maps never write, so a
lazily-opened add-only store was invisible to the barrier and the device ACK
could advance past its un-received tombstones (cross-map resurrection vector,
latent until SPEC-342j activates the prune).

- Eager `__sys__:{name}:ormap` existence marker at BOTH durable-write seams:
  transactionally in recordOperation's commitWrite (local writes — verified NOT
  routed through the persist helpers) and marker-first in persistORMapKey/
  persistORMapTombstones (server-origin applies). Once per session.
- One-time legacy-store backfill inside computeHeldOrMapNames (gated by a durable
  done-flag): scans ALL kv keys, attributes OR-Maps by records-array shape,
  stamps markers; done-flag set only on success so a throw fail-closes the
  connection (heldSetIncomplete) and retries next connection. Correctness, not
  optional: the _v2 cursor reset gives a fresh server cursor no conservative
  fallback, so an unmarked legacy add-only map would build inflated ACKs.
- Enumeration matches :ormap OR :tombstones; registerMap WHY-comment corrected
  (the 'every persisted store is in the snapshot' premise is now actually true).

Tests: THE REGRESSION (legacy add-only store discovered + blocks device ACK) +
local/server marker seams + marker-first ordering + backfill discriminator/
done-flag + backfill-failure fail-closed. Client 709/709, adapters 47/47,
mcp 109/109.

xask (glm-5.2) drove the seam correction: local write path bypasses the persist
helpers (Critical) -> marker at both seams; marker-first ordering; scan-all-keys;
inherit TODO-577 :-prefix ambiguity; scale-check confirmed backfill is correctness.
…ackfill CAN hide a real map (Review v3 xreview minor)

The backfill's :-prefix note conflated two distinct collision cases. The
colon-in-VALUE case (LWW array value under a first-colon-split name) is
over-conservative (adds a phantom, coverage 0). The colon-in-NAME case
(map name contains ':') splits to the wrong prefix, never stamps the real
map's marker, and CAN hide it from the held-set snapshot -> ACK inflation
once the durability watermark activates. Documented as the real
resurrection sub-case tracked in TODO-577.
…wire fields

- TombstoneFrontier: is_forgotten (unknown==forgotten OR cursor lag > K),
  is_protection_active (gate goes live with 342j watermark — gate-before-activation),
  set_forget_lag_epochs + DEFAULT_FORGET_LAG_EPOCHS, gate_decision_holds_at_commit
  inherent wrapper; extend the commit-time re-check to be lag-aware (not is_tracked-only)
- ORMapSyncInit.claimed_epoch (client cursor for regressed-replica detection) +
  ORMapSyncRespRootPayload.full_resync (server->client REPLACE signal), both additive
- update construction sites (classify + soak bench)
…g + REPLACE routing (sync.rs)

- handle_ormap_push_diff: connection-keyed pre-apply gate fires BEFORE merge,
  gating inbound tombstone union AND records; per-key single-writer held from the
  gate re-check through store.put (gate->commit TOCTOU close, R5); commit-time
  re-check via lag-aware gate_decision_holds_at_commit (R13 mid-batch discipline);
  fail-closed on absent frontier/key_writer (R12); verbatim-timestamp accept-as-
  untrusted (R11); third prune site wired, dark by construction (R2)
- resolve_client_id off server-authenticated (principal, deviceId) metadata (G9)
- covering_epoch gated: no eager set_delivered for a forgotten/regressed client (R9)
  across all three OR-Map sync handlers; deferred to resync completion (R8)
- sync_gated: forgotten/unknown/regressed detection + is_regressed on sync-init;
  handle_ormap_sync_init routes a gated client to full-snapshot REPLACE (full_resync)
  without advancing delivered_conn / rolling back the stored cursor (R6/R7)
- op_path_gated: drop a forgotten client's OR-bearing op on the direct-connection
  write path (handle_client_op + handle_op_batch), keyed by the server-authenticated
  (principal, deviceId) identity. Non-load-bearing / dark until 342j: the OR_ADD apply
  regenerates the tag, so a same-tag op-path resurrection is vacuous; the load-bearing
  surface is ORMapPushDiff. Inert for HTTP/anonymous/system (no connection)
…h closures

- ac5: forgotten/unknown client push blocked before merge (protection active)
- ac6: tracked client push merges under the per-key gate->commit span
- ac17: unknown identity (no device) rejected fail-closed
- ac4: third prune site in push-diff is dark by construction (drops zero epochs)
- ac7: forgotten client sync-init routed to full-snapshot REPLACE (full_resync)
- ac8: regressed replica routed, stored cursor not rolled back, delivered not advanced
- ac10: forgotten sync-init does not advance delivered_conn (covering_epoch gating)
…C discard

- ORMapSyncHandler: full_resync root routing -> discard local + pull snapshot,
  covering-epoch NOT confirmed at REPLACE-root time (deferred to post-snapshot);
  sendSyncInit reports claimedEpoch (confirmed-apply cursor) for regressed detection
- SyncEngine.replaceOrMapFromSnapshot: clear materialized OR-Map (memory + durable
  keys + tombstones), discard pending OR ops whose HLC precedes the snapshot
  boundary (subsumed), keep at-or-after ops for re-drive through the gated push;
  wire onFullResync + getClaimedEpoch into the ORMapSyncHandler config
- tests: fullResync REPLACE routing + claimedEpoch (handler), AC15 HLC-boundary
  oplog discard (SyncEngine); update offline-durability handler config
- cargo fmt on sync.rs/crdt.rs; clippy: doc backticks, let-else, too_many_lines allow
- prettier/eslint on client REPLACE files + tests
replaceOrMapFromSnapshot swallowed deleteOp failures and could splice a
subsumed pending op from memory while it survived on disk (saveOpLog does
NOT persist the opLog — deleteOp is the only durable removal), so on
restart the op reloaded and re-drove through the gated write path,
resurrecting the removed value once its tombstone was pruned.

- Delete durably FIRST, then splice from memory (never the reverse); do
  not swallow deleteOp failure — an unresolved rejection aborts REPLACE so
  the op stays in BOTH memory and disk, and the resync is retried.
- Treat a non-numeric subsumed op id as a HARD failure (abort), not a
  silent skip; op ids are always numeric (IndexedDB autoincrement).
- Drop the misleading idsToDelete>0 => saveOpLog() (a no-op for ops).
- Tests: a failed durable delete aborts (op not dropped-from-memory-kept-
  on-disk); a non-numeric id aborts.
epoch_at_gate was constructed at the push-diff gate but never read: the
commit-time re-check (gate_decision_holds_at_commit) does a LIVE is_forgotten
re-read of the client against the CURRENT epoch, never comparing a snapshotted
gate-time epoch. Drop the dead field + its construction, and correct the
GateToken docs to state the freshness signal is read live at commit.

- tombstone_frontier.rs: remove epoch_at_gate; doc now says the token carries
  only client identity, freshness read live at commit (TOCTOU semantics
  unchanged).
- sync.rs: drop the epoch_at_gate initializer at the gate construction site.
…init None claim

Two push-diff/sync re-admission gaps in the SPEC-342c gate:

FIX 1 (push-diff gate): the active gate admitted a push iff !is_forgotten,
which does NOT satisfy R10 point-1 — a regressed/not-yet-admitted connection
that flushes a queued push BEFORE completing its resync has a high STORED
cursor (is_forgotten=false) and was wrongly admitted, resurrecting adds whose
tombstones were pruned. Now also require delivered_conn > 0 (the post-resync
admission signal, advanced only on snapshot-resync completion) inside the
is_protection_active branch; a None connection_id fails closed.

FIX 2 (sync-INIT only): a client omitting claimed_epoch (None) evaded the
regressed check and was eagerly re-admitted. Force gated=true when
claimed_epoch.is_none() AND protection is active, at the sync-init call site
ONLY — the merkle-bucket / diff continuations still pass None as post-init
reads and keep plain sync_gated semantics.

- Tests: push held while delivered_conn==0 then merges after admission;
  a None claim under active protection forces full-resync even for a tracked
  client. ac10 tracked arm now reports its real claimed_epoch.
… on REPLACE

The persistedKeys removal loop swallowed storageAdapter.remove failures
(.catch + log + continue), unlike the pending-oplog discard which aborts.
A transient remove failure left an orphan record on disk after its tombstone
was cleared -> on restart the orphan re-materializes with no suppressing
tombstone -> merkle re-push -> resurrection once the server prunes the
tombstone. Remove durably FIRST (fail-closed, no swallow) and BEFORE
map.clear(), so an abort leaves both memory and disk intact for a clean retry.

Regression test: a materialized-key remove failure aborts the REPLACE with
in-memory state preserved.
The merkle-bucket and diff-request handlers passed claimed_epoch=None to
sync_gated, so is_regressed could not re-run there. A regressed replica
(stale-high stored cursor -> is_forgotten=false) mid-resync had its
delivered_conn eagerly advanced by covering_epoch, re-enabling admission
before the snapshot landed (R8/R9 violation). Add sync_gated_continuation
which also gates on the not-yet-admitted delivered==0 signal (the same signal
the push-diff gate uses); dark by construction.

Regression test: a not-yet-admitted regressed connection's merkle-bucket does
not advance delivered_conn under active protection.
- deps: bump crossbeam-epoch 0.9.18 -> 0.9.20 (RUSTSEC-2026-0204, published
  2026-07-06 — the sole run-level failure of every nightly since Jul 7)
- ci: anchor all load-harness --json-output paths to $GITHUB_WORKSPACE —
  cargo runs bench binaries with CWD = the package dir, so results landed in
  packages/server-rust/ while the validate steps (bash -e + jq) and
  upload-artifact read from the workspace root; under bash -e the first jq on
  the missing file exits 2, so the vector gate has never passed on schedule
- ci: perf-gate timeout 15 -> 30 min (cold-cache harness build alone can eat
  15; every nightly Jun 28..Jul 6 died as a timeout cancellation)
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploying topgun with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0882159
Status: ✅  Deploy successful!
Preview URL: https://56384800.topgun-f45.pages.dev
Branch Preview URL: https://fix-sf-342b-ormap-marker-enu.topgun-f45.pages.dev

View logs

ivkan added 2 commits July 9, 2026 22:37
…ters + prettier

- tests/integration-rust/helpers/memory-storage.ts, tests/doc-tests/helpers/
  memory-adapter.ts, examples/sync-lab/src/lib/memory-storage.ts implement
  IStorageAdapter but were missed by the getAllMetaKeys interface addition —
  broke Build/doc-tests/integration/vitest CI jobs at compile time
- prettier normalization on 2 files that predate this branch (format:check
  gate was red on them)
…ites)

Four integration test files define their own inline IStorageAdapter
implementations missed by both previous cascade passes — the suites failed
at ts-jest compile time (mcp-server, live-query-limit-clamp, reconnect,
cluster-routing).
@ivkan ivkan merged commit 68e1236 into main Jul 9, 2026
20 checks passed
@ivkan ivkan deleted the fix/sf-342b-ormap-marker-enumeration branch July 9, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant