Skip to content

Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822) - #822

Merged
kriszyp merged 64 commits into
mainfrom
feat/record-lock-cluster-transport
Sep 18, 2026
Merged

kriszyp merged 64 commits into
mainfrom
feat/record-lock-cluster-transport

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Cluster record locks over the operator-agreed home map (harper-pro#825 inside #822), now with successor freshness: a handoff carries exclusion and proof that the successor has applied its predecessors' writes. Core's half is harper#2613 (lineage on the release entry), harper#2625 / PR harper#2627 (the lockBarrier fence, writeLockBarrier, deadlineMs) and harper#2628 / PR harper#2630 (the awaited apply-failure listener) — all merged; this PR's core submodule is pinned at harper main 1c312feb4. Default off, unchanged.

Why the scope changed again

harper#2613 made ClusterLockTransport.establishLockFreshness() required, so this branch stopped compiling against core. The transport half of §7 (docs/record-lock-ownership.md) is what this round adds. Six planning rounds (replication/RECORD_LOCK_FRESHNESS_DESIGN.md, revision history) each found a real hole in a numeric-watermark design; the mechanism that survived is the reviewer's own do-less from round 5: prove every unsatisfied cross-origin dependency with an exact, same-table, nonce-bearing lockBarrier entry and nothing else.

What this round adds: successor freshness

  • replication/recordLockFreshness.ts — the barrier. For each inherited (origin, position): refuse outright a non-member, a peer at another capability level, an unreplicated table, a poisoned (origin, table), an invalid position, or this node's own lineage after a reclone; otherwise register a nonce, ask the origin for a barrier, and resolve only when the exact (origin, position, nonce) entry has committed here. Recovery (null) does the same for every home-map member and returns the pairs. Every wait is bounded by min(deadlineMs, MAX_LOCK_LEASE_MS), capped per database, settled exactly once, and closed with the transport.
  • replication/recordLockRpc.ts — record_lock_barrier: node principal → current member at the exact level → table replicates → nonce → per-caller token bucket → one writeLockBarrier per request, never merged across callers.
  • replication/replicationConnection.ts — a lockBarrier record is captured at decode and reported from its frame's onCommit (relayed to the coordinating thread when applied elsewhere); every record this node drops poisons (origin, table) durably before the drop completes, and a poison write that fails holds the frame; a clone attempt writes the ever-recloned flag before its first row; the peer's exact level is recorded at handshake.
  • replication/recordLockPoison.ts — poison and the reclone flag in the database's dbis store; poison is permanent (a base copy is a put snapshot and cannot re-deliver a dropped delete).
  • replication/recordLockTransport.ts — both transports implement the method; slot 31 carries the exact level; cluster_status.recordLocks reports barrier counters and poisoned pairs; core's apply-failure listener (harper#2628) is registered per database and unregistered on release.
  • Capability level 3 → 4; replication.recordLocks resolves to false on LMDB with one error line.

The home map (harper-pro#825): why that scope changed

harper#2498 merged with ClusterLockTransport.epoch() replaced by homeMap(database) returning an operator-agreed LockHomeMap { generation, homes[], homeIncarnation } — core's own design doc for it says the map is "supplied by harper-pro; core never computes it and never advances it." There is no epoch() shape left to build the old static-epoch scaffolding against, and the static epoch was itself the design's open blocker (two nodes independently deriving different rings for the same key — two arbiters). Rather than improvise an interim shape, this branch went needs-input; the task owner's answer was to build harper-pro#825's real mechanism now, inside this PR, tracking core at current origin/main.

Because homes are now operator-agreed (never derived from hdb_nodes membership or liveness), the earlier "two rings" hazard is structurally gone: every node reads the same durably-staged/activated generation instead of computing its own view of the ring.

What the home-map transport is

Core (merged, origin/main) owns the coordinator — the home ring, the delegation table, recall-and-drain, fencing tokens, and the one control entry (lockRelease) still on the transaction log. This PR supplies everything core cannot know: the durable operator-agreed generation state, the wire, and the transport.

  • hdb_record_lock_homes (replication/recordLockHomes.ts) — a dedicated LOCAL_ONLY system table (getRecordLockHomesTable, never replicated or LWW-merged), one row per database holding {active, staged, highestActedOn, fenced[]}. Three super_user-gated operations transition it: record_lock_stage_generation atomically retracts active in the same durable write that stages g+1 (so a successful response IS quiescence evidence, not a race against a later refresh), record_lock_fence_external records an operator's external attestation that an unreachable node was stopped, and record_lock_activate_generation promotes staged → active after the operator has externally collected a stage response from every affected node and waited DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS — no node ever measures that elapsed time itself. digestOf is a length-prefixed SHA-256 over (generation, homes[]), not delimiter-joined (so ['A','B'] and ['A\0B'] can't collide) and not a truncated generation >>> 0 (so generation 1 and 2**32+1 can't collide). Same-database stage/fence/activate calls serialize behind an in-process queue (withRow) so a read this call's decision is based on can't go stale before the write.
  • homeMap(database) (replication/recordLockTransport.ts) — a frozen, per-thread cache of this node's active generation, refreshed only on change, returning undefined until this node has an active generation AND every named peer's advertised digest agrees (a mismatch fails the whole map closed, never shrinks the ring). Three independent triggers rebuild the transport object so core gets a fresh lazily-built coordinator at the instant each becomes true: the first-ever known home incarnation, ownership newly conferred, and an active generation first appearing or changing — each is a distinct restart-quarantine waiver (grantableAfterMono) or fencing-seed gap in core's coordinator that only closes if the coordinator is built after the fact, not before.
  • The digest wire (replication/replicationConnection.ts, replication/protocolCapabilities.ts) — recordLocks capability bumped to level 3 (the old static-epoch level 2 never shipped enabled); a new RECORD_LOCK_HOMES_DIGEST frame and a second shared-status-buffer slot (RECORD_LOCK_HOMES_AGREEMENT_POSITION) carry per-peer digest agreement, sent only after capability discovery so a pre-upgrade peer never receives a frame it hasn't advertised support for. Agreement is reconciled centrally by database (lastPeerDigest), independent of which of the mesh's separate per-direction connection objects last touched it.
  • A fail-closed, cross-thread change-acknowledgement protocol (replication/recordLockTransport.ts) — a stage/activate response is real quiescence evidence only if every HTTP-worker thread (not just the durable write) has applied the change; a missing or negative acknowledgement now fails the operation (503) rather than being logged and treated as success, and the origin-to-main relay carries a longer budget than the leaf fan-out it waits on. An idempotent retry (the noop decision branch) still re-notifies, so a retry after a failed relay actually reconciles the gap it exists to close.
  • requestDelegation / recallDelegation / owner relay (replication/recordLockRpc.ts) — unchanged in shape from the prior tranche: the requester's identity is the authenticated node principal, never a payload field; a relay through main that times out answers not-home, never a grant.
  • ownsCoordination(), per-database owner-worker assignment, cluster_status.recordLocks — unchanged in role; recordLockOwnerFor's async-handoff/PENDING_BUMP behavior and setRecordLockOwnership's transport-recreate-on-first-ownership are both from the prior tranche.
  • A disabled transport now answers 503, not a generic 500 (replication/recordLockTransport.ts) — createDisabledRecordLockTransport throws a ClientError(..., 503), matching the status code the rest of the transport uses for "not available yet."
  • A startup warning (replication/recordLockTransport.ts) fires when replication.recordLocks is enabled, naming harper#2542 and the measured 0.05–0.13% silent-lost-update rate — operator-visible at the point the switch takes effect, not only in this PR's own docs.
  • replication/RECORD_LOCK_HOMES_DESIGN.md (new) — the full design note, including the revision history of two rejected planning-review rounds (see below) and the "For the human reviewer" items carried into this PR body.
  • The §10 cost measurement (replication/RECORD_LOCK_COST_DELEGATIONS.md, replication/RECORD_LOCK_COST_BASELINE.md, raw runs under README, run 1, run 2, run 3, run 4 (starvation)) landed on this branch from a prior measurement PR and predates the home-map redesign; kept as-is rather than re-run (it measures against a core pin that has since moved, so its numbers are stale relative to this PR's current pin — a re-measurement is future work, not something this PR redoes).

The home-map design path (two rejected planning-review rounds)

The §4.3 stage→quiesce→drain→activate transition went through mandatory planning review three times before landing:

  • Round 1 (better-alternative-exists): rejected a "trusted orchestrator + canonical artifact" design — 4 blockers.
  • Round 2 (better-alternative-exists): rejected the local-timer reduction that followed — a purely node-timed mechanism is unsound (4 concrete counterexamples); round 2's own stated recommendation — stage retracts immediately, activate is a separate operator-timed call — is what shipped, verbatim, not a third new mechanism, so no third planning round applied.

The chosen design deliberately does not build a machine-verified barrier for the drain wait: every node checks consistency (does this activation match what I staged), never elapsed time, because a self-measured wait is unsound across a restart or clock correction. The operator's own external wait (DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS) is what makes activation safe — nothing in the code enforces it. Full history in RECORD_LOCK_HOMES_DESIGN.md.

For the human reviewer

  • Decided, not open: a home-map transition is authorized by node identity alone, and that is accepted for this release. record_lock_apply_homes is requiresSuperUser; the per-node hop it fans out on is not — executeTransition registers without it and admits any principal principalNodeName resolves. The replication socket compounds it: replicationConnection.ts dispatches as server.operation(data, {user}, !isAuthorizedNode), so runWithOperationAuthorizationBypass is on and the nominally requiresSuperUser operations are reachable that way too. One compromised node — or one super_user on any single node, through the node-identity gap listed below — can stage and then activate a map of its choosing on a peer, and the digest check narrows that rather than closing it (homeMap() iterates its own active.homes, so a node rewritten to a singleton never consults a peer, and a peer fails closed only once the changed digest reaches it). The task owner ruled to accept it here, on three facts now written into RECORD_LOCK_HOMES_DESIGN.md → "Auth for the hop": a node principal is already trusted to write replicated data on every peer; this relay does not widen the authority, because the per-node operations were already reachable that way before harper-pro#862 and the relay only adds local re-validation; and nothing shipped is exposed, since the feature is off by default and inert until a generation is activated. What would close it — an operator-delegated proof carried on the transition — is harper-pro#869, a prerequisite for recommending this feature in production and for Record locks: decide what it would take to enable replication.recordLocks by default #853's default-on question. The dispatcher's node-wide verifyPerms bypass is wider than record locks and wants its own assessment; Record locks: a home-map transition is authorized by node identity alone, with no proof an operator asked for it #869 says so rather than folding it in. An operator who sets replication.recordLocks: true is now told the caveat at startup, next to the existing enablement warning, rather than only in the design note.

  • Planning-gate recheck, round 32: Framing-Verdict: better-alternative-exists, carried rather than adopted. The pre-push CLI required a --mode plan recheck; the artifact is RECORD_LOCK_HOMES_DESIGN.md. It affirms the operator-stated map over failure-driven consensus and affirms keeping topology in harper-pro rather than core, but argues the option set missed a better implementation layer: a main-thread control-plane API with explicit topology and owner-lifecycle state, rather than HTTP workers reconstructing either. That is the same root cause as two of the carried majors (recordLockOwnerFor collapsing main/pending/none to undefined, and the proposal reader scanning raw hdb_nodes off the main thread). It is adoptable and would remove that class of finding rather than patch it — but it is multi-day work across subscriptionManager, recordLockTransport and the proposal path, so it is recorded here rather than started under a review round.

  • Round 6 of the planning gate was resolved by overrule, not cleared. Its blocker claimed replay reorders a log by key so a post-rollback barrier (key 101) precedes an inherited write (key 200). A probe on the pinned rocksdb-js 2.9.0 shows per-log range reads are append-ordered (100, 200, 101, 300 appended → start>50 yields exactly that). The framing's incarnation-qualified append sequence (a wire/storage migration) is therefore not required. What the probe did surface is filed as harper#2629: on a resume, entries below the subscriber's cursor are never delivered — a pre-existing replication hazard on clock rollback; for locks it fails closed.

  • Every hole class the design names is now recorded: harper#2628 merged (PR harper#2630) and listenForApplyFailures registers core's awaited listener per database, so a transaction core skips after a terminal apply failure poisons (origin, table) before the next event is consumed. The feature stays default-off pending the carried items below.

  • Rollback: disable replication.recordLocks everywhere and restart before downgrading; retained lockBarrier entries replay into a level-3 sink as "malformed control entry" warnings.

  • Coalescing is client-side and same-turn only, by design: a caller must match its own nonce, and a request sent before a caller's dependency was formed cannot prove it.

  • Pre-push review, this round (codex + gemini + cursor-composer + domain, full, then delta): five majors in the freshness delta were found and fixed before push — a per-frame closure allocation on ordinary replication, a failed poison write cached as recorded, per-worker poison caches invisible to the coordinating thread, no poison recheck when a barrier settled, and the old barrier outliving a transport replacement. Majors that predate this round and are carried, not fixed: a transient storage error in the one startup refreshCache (or a rejected bumpHomeIncarnation) latches homeMap() undefined until an operator re-stages; homeMap() is on the per-acquisition path and walks the home set with a getDatabases() lookup (the design's "zero-allocation hit" is the cached delegation, not the map read); a lockRelease met before NODE_NAME on a fresh socket is skipped and its cursor advanced.

  • Carried from before: RPC node identity is "authenticated user named like a node in server.nodes" (see Known gaps). Two items that were carried here are now closed rather than carried — withRow takes a node-scoped hold lock on the hdb_record_lock_homes row itself, so the per-isolate queue is no longer what excludes concurrent transitions, and harper-pro#865 / harper#2667 relay an off-owner lock() to the coordinating worker, so threads.count > 1 is served rather than 503'd.

Carried from the home-map rounds:

Decisions that are yours to make, not mine to have picked silently (full reasoning in the design note's decision ledger):

  • Ship before harper#2542 (successor freshness) lands, opt-in and explicitly warned, vs. hold enablement. Measured silent lost updates (0.05–0.13% of sections at 3 contenders) remain either way; shipping now is reversible only while no operator has built on the primitive.
  • The transition's safety rests on an external human wall-clock wait that no code measures, vs. a machine-verified barrier. A mistimed activation is a two-holder with no error anywhere. Reversing this means rebuilding the consensus mechanism the two rejected planning rounds above deliberately did away with.
  • An unacknowledged worker now 503s the whole stage/activate call, even though the durable write (which already retracted active) has landed. The operator gets an error on an operation that half-succeeded and must retry to converge. Correctness is right; whether that's the operability you want is open — making the write itself conditional on the fan-out is a protocol change, not a constant.
  • Staging still quiesces the whole database's cluster locks, not just the keys whose home is moving. The duration is no longer a fixed 365 s — stage drains and reports, so an operator with a proven-clean drain everywhere activates immediately (below) — but the window is still database-wide rather than scoped to the relocating keys. Narrowing that is a protocol change, not a tuning knob; it is the remaining lever on harper-pro#856.
  • Multi-worker support is now claimed, not warned around — both gaps that made it a decision are closed, and what is left is how much you want to trust untested breadth. withRow takes a node-scoped hold lock on the row and writes back through that same locked handle, so a stage and a fence_external on two worker isolates serialize at the storage layer rather than at a per-isolate map (unitTests/replication/recordLockHomes.test.mjs, "a transition waits for a node-scoped hold on its row taken outside withRow"); and harper-pro#865 with core harper#2667 relays an off-owner lock() acquire/release to the coordinating worker, so a non-owner worker serves rather than 503s. Every cluster fixture still runs threads.count: 1 except the harper-pro#852 suite, so the multi-worker path has unit and targeted-integration coverage, not the full matrix.
  • A departing node may or may not be activated, and the two operator paths disagree. A node in quiesce but not homes(g+1) is leaving the ring. record_lock_activate_generation accepts an activate on it — nothing in planActivate requires self ∈ homes, and neither does homeMap() — which leaves the leaver routing its own lock() to the new ring instead of answering 503. record_lock_apply_homes never sends it one (role: 'departing', activate: 'skipped'), so under that path the leaver cannot lock at all. Both are safe (a node outside homes is home to no key either way), so this is a usability contract, and RECORD_LOCK_HOMES_DESIGN.md §2 now states both rather than implying one. Picking one and making the other match is yours.
  • homeMap() re-derives peer capability and digest agreement on every call rather than reading an invalidated pointer updated on change — O(homes) shared-buffer reads plus one allocation per lock acquisition, against a design that otherwise advertises the delegation hit as zero-message and zero-allocation. Buys freshness; reversible, but touches the core-facing interface shape.

Known gaps, not fixed in this PR, code-traced not demonstrated:

  • The drain's quiescence proof has a mid-sweep hole, fix in flight in core and not yet pinned here. Core's quiesceDelegations swept one up-front snapshot of its live coordinators. The sweep awaits network recalls, so a transport replacement lands inside it: the successor adopts the predecessor's grants via handOffTo, the predecessor then closes as handed-off (which deliberately parks nothing in retiredCoordinators, because the grants are the successor's now), and the sweep sees an emptied predecessor and never the successor — { complete: true, outstanding: [] } on a database still admitting. That is exactly the result provesQuiescence() treats as authority to activate immediately, so it would become a double grant across a membership change. Found by this PR's final-artifact review. The pinned core still has it — quiesceDelegations (core/resources/recordLockCoordinator.ts:2789) still filters one up-front snapshot of liveCoordinators, and close() on a handed-off coordinator returns before parking anything in retiredCoordinators, so a successor created mid-sweep is in neither structure. No core change for it is open; it needs one (a fixpoint over the live registry). Until then, use the timed drain interval, not the drain result — provesQuiescence() is the only thing that licenses skipping the interval, and this is the way an empty sweep can still lie.
  • replication/replicationConnection.ts:5436-5441 — a lockRelease skipped because peerCapabilitiesLearned is still false on a fresh socket takes skipAuditRecord(), advancing the peer's cursor past it, so it's never redelivered — the home can't re-grant until DELEGATION_LEASE_MS elapses, worse than the neighboring comment's stated "costs a requester a 423."
  • replication/subscriptionManager.ts:771-777 (via recordLockOwnerFor) — an in-flight ownership handoff and "main owns" both surface as undefined from recordLockOwnerFor, so a subscription in flight during a handoff briefly places itself on the main thread and applies off-owner until reconcileWorkers corrects it.
  • replication/recordLockRpc.ts:142-150 — node identity for the delegation RPC is "the authenticated username appears in server.nodes," so a super_user who creates a standard user named after a peer can mint or drain that peer's delegations through the operations API.
  • replication/clusterStatus.ts:40-50 — the worker-side cluster_status wait has no deadline; a throw inside the now-async main-side status resolver leaves it pending forever and leaks the resolver entry.
  • integrationTests/cluster/recordLockCluster.test.mjs — the "§4.3 stage/activate transition" suite's inline cleanup after a 503 assertion isn't in a finally, so one real assertion failure can mask itself as two; and startNode/several fetch helpers are duplicated near-identically against recordLockCost.bench.mjs (clusterShared.mjs is where the suite already keeps shared helpers).
  • Test/integration coverage claimed in the design note is narrower than what shipped — corrected in this PR (see Changes below): digest mismatch, record_lock_fence_external, and restart/incarnation-ordering are unit-level only, not exercised against real IPC or a real cluster yet.

Changes

From the home-map rounds:

Verification

  • Unit: npm run test:unit — 1152 passing; new unitTests/replication/recordLockFreshness.test.mjs (refusals, exact-entry matching in both arrival orders, wrong nonce/origin/position, recovery across members, deadline/cap/close, 1,000 abandoned waits vanish at their deadline); recordLockTransport.test.mjs (slot 31, disabled transport rejects, real transport builds the barrier once over its own homeMap()); protocolCapabilities.test.mjs at level 4.
  • Cluster: integrationTests/cluster/recordLockCluster.test.mjs — pass 10 / fail 0 on eleven consecutive local runs, the last against harper main's core pin after merging origin/main (last against the harper main pin), with the concurrent-increment assertion restored to exact 1..N and every node at N, plus a new check that at least one barrier was applied and no pair is poisoned. Run locally with a private TMPDIR (the shared loopback-pool file race) and HARPER_INTEGRATION_TEST_STARTUP_TIMEOUT_MS=240000.
  • Not run: recordLockCost.bench.mjs — the cost doc's convergence and recovery rows now say what changed and that a re-measure is pending.

From the home-map rounds:

  • Unit: 1121/1121 passing (full npx mocha --require unitTests/unitTestSetup.cjs 'unitTests/**/*.test.mjs'), rebuilt from a clean npm ci + npm run build (0 TypeScript errors) at the pushed head.
  • Integration: partial. The three integration-level bugs found while building this design — missing grantableAfterMono on first incarnation, peer-digest reconciliation only working through one of the mesh's per-direction connection objects, and a coordinator lazily built before an active generation exists losing its restart waiver permanently — were each root-caused against a real 3-node cluster and confirmed fixed, with the hardest suite (the operator-agreed transition + N-concurrent-increment + capability-exclusion tests) passing. A full clean integration re-run against the latest pushed commits did not complete: this shared dev box's harness kills long-running background integration runs under memory-pressure policy despite free -h showing headroom, and the integration harness's shared /tmp loopback-address pool corrupts under concurrent agents on the box. Both are documented, environment-caused, not code-caused. CI is the source of truth for full integration coverage on this PR, and it is green: at 132dfcb9 every check passed, including all six Cluster Integration Tests shards, all three Integration Tests shards, unit on Node 22/24/26, and the three builds.
  • Review-thread adjudication (rounds 18–34). Eight threads were open on this PR; seven are addressed in code or conclusively answered, one is the doc contradiction above. Warranted and fixed: the barrier RPC now carries the lock's remaining deadline through sendRecordLockOperation, so a member that accepts a connection and never answers no longer pins a response waiter and a fallback socket per failed handoff; a matching re-stage now persists the union of the recorded and requested quiesce sets instead of keeping a narrower one on the idempotent-noop path; the one-barrier-per-wait contract is now what the freshness design note says, with BARRIER_BURST derived from MAX_OUTSTANDING_BARRIERS rather than a constant a legitimate wave of cold handoffs could cross; the stale pre-level-4 enablement warning is replaced with what is actually still required; and recordLockCost.bench.mjs bootstraps a home map, without which every BenchLock answered 503. Declined with evidence: the per-isolate rowQueues finding (the row's own node-scoped hold lock is what excludes now, not the queue). A later thread on RECORD_LOCK_HOMES_DESIGN.md §2 was half right and half wrong, and both halves are recorded: the doc did contradict itself about which nodes an activate goes to (now homes(g+1) everywhere), but the mechanism it cited belongs to record_lock_transition, not to the manual operation — which does accept an activate on a departing node. Chasing that down is what produced the real answer: an activated leaver still cannot lock, because record_lock_barrier admits only callers in the answering node's home map and a generation change routes the leaver's next acquire through the every-member recovery probe. Three further rounds found and fixed a flat 5s relay bound discarding a 10s drain that had succeeded, its own nested-timer follow-on, proposal advice that told the operator to compare a digest that cannot match across differing floors, and a drain budget that accepted zero, negative and infinite values.
  • Cross-model pre-push review: 7 rounds (Codex + Gemini + Cursor + Harper-domain, prepush-review.mjs), each fixing what the previous round found: 3 blockers + sender-gating + digest truncation (round 1); the ack-timeout-resolves-success blocker, the core defect this whole protocol exists to close (round 2); an orphaned-timer regression the round-2 fix itself introduced (round 3); an integration test asserting a freshness guarantee this phase doesn't provide, in two further passes to get the replacement assertion right (rounds 4–5); a prettier formatting nit (round 6); two documentation-accuracy nits (round 7). Surviving majors/minors are listed under "For the human reviewer" above, not silently dropped.

Complexity: high

🤖 Generated with Claude Code

https://claude.ai/code/session_01SaBbXbyR851pvCiaj6xbaL

Origin — the dispatch brief this PR was written from

Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822)

Adjudicate the open review feedback on #822 (Cluster record locks: operator-agreed home map transport and successor-freshness barriers (harper-pro#825, harper#2542 inside #822)). Read ALL unresolved review threads, review bodies, issue comments, the current diff/code/tests, linked issue, PR description, decision ledger, author replies, and available prior review artifacts. Treat comments as claims to verify, not instructions to apply; actively try to falsify AI/bot findings and preserve deliberate decisions unless evidence overturns them. Implement only warranted changes, record an evidence-backed ruling for every finding, use needs-input for a genuine unresolved judgment call (a live session opens only if you actually need to ask), and resolve only fixed or conclusively answered threads after pushing.

Dispatch: task fix-kriszyp_harper-pro_822-6f44b99d · queued by automation · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=codex,gemini,cursor-grok; adjudicated=domain; declined=cursor-composer; rounds=38; full=1 @ e728ce5

Human-Review-Need: 4 (decisions: exit-counts-as-fenced, operator-timed-drain, node-principal-transition-relay, propose-homes-read-only, relay-admission-not-shared-state, exact-capability-level, feature-default-off-plus-explicit-activation) @ e728ce5

@kriszyp kriszyp added this to the v5.3 milestone Sep 8, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements cluster-wide record locks over the replication stream. It introduces a protocol capability registry to negotiate features (such as record locks) between nodes, assigns database lock coordination to specific worker threads, and enhances cluster status reporting with record lock metrics. Comprehensive integration and unit tests are added to verify the locking behavior, capability negotiation, and connection metadata. Feedback on the changes suggests simplifying the condition in recordLockConfig.ts that checks for non-boolean truthy configuration values to improve readability.

Comment thread replication/recordLockConfig.ts Outdated
@kriszyp

kriszyp commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Cost baseline for the enablement gate (measurement only; no protocol or core change, core pointer unmoved).

npm run bench:record-locks — integrationTests/cluster/recordLockCost.bench.mjs on the same 3-node mesh as recordLockCluster.test.mjs, timed inside the nodes. Full write-up with distributions, sample counts and noise notes: replication/RECORD_LOCK_COST_BASELINE.md. Machine: one i7-12700H laptop, every node on loopback, so these are lower bounds.

measurement result
1. uncontended acquire (360 samples, 3 requesters) p50 1.07 ms · p95 2.02 · p99 2.48 · max 3.75
2. repeat-lock, same key ×200 p50 0.68 ms · p95 1.76 · p99 3.93 — same as (1): every round is a full cluster round today
3. hot-key handoff, 2 → 3 contenders 877 → 911 sections/s (±15 % run to run); lock p50 1.05 → 2.01 ms; counter converged exactly, 0 failed rounds
4. log cost per acquisition, P = 3 requester 2 entries / 150 B, each grantor 1 / 80 B → 4 commits, ~310 B, = P+1 (⇒ 8 deliveries = P²−1)
5. unlocked writes: off vs never-registered vs on 18.5 / 19.1 / 18.6 ms per 500 puts (p50, 30 batches each); sign flips between runs → inside noise

One thing the harness found on the way, recorded in the baseline's caveat and left for core (harper#2498): { hold: true } + save() inside a request context commits nothing until the request ends, while unlock() writes the release immediately, so a peer's grant is admitted ahead of the holder's data. Two nodes each ran 1690 hold-mode sections in 2 s and the counter settled at 1690, not 3380. The request-transaction path (LockedIncrement) has no such problem, which is why (3) uses it.

— Claude Fable 5.1

kriszyp added a commit that referenced this pull request Sep 11, 2026
…ol (static epoch)

harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.

epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.

homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.

Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.

The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.

cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.

Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.

The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.

Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.

The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.

Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.

Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.

Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6aY2ERiYM8294P2f3aoUq
@kriszyp kriszyp changed the title Cluster-wide record locks: table.lock(id) exclusive across every replicating node (harper-pro side, Phase 1 of #483) Cluster record locks: delegation transport for amortized per-record ownership (static epoch) Sep 11, 2026
@kriszyp

kriszyp commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Decided: (a) land as gated-off scaffolding. The static epoch's two-rings-during-a-membership-transition hole stays documented here and in DESIGN.md; nothing is enabled until harper-pro#825 (agreed epoch) lands. DELEGATION_LEASE_MS stays at MAX_LOCK_LEASE_MS + 60 s (360 s) by decision, so the six-minute restart hold is the accepted price for this tranche.

One merge-order note: this PR's core submodule pointer references a commit on harper's feat/record-lock-phase1. Once harper#2498 merges, bump core to the harper main commit that contains it (the branch was rebased onto 070a489d6; its last commit is 729aefd23) before merging here, or the submodule will point at a branch head that no longer advances. The core API this PR consumes (deliverDelegationRequest, deliverDelegationRecall, DelegationRequest/DelegationReply/DelegationRecall) is unchanged by the post-review fixes, so no harper-pro source change is needed for the bump.

— Claude Fable 5.1, recording Kris's ruling

kriszyp added a commit that referenced this pull request Sep 13, 2026
…ol (static epoch)

harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.

epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.

homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.

Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.

The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.

cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.

Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.

The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.

Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.

The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.

Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.

Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.

Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6aY2ERiYM8294P2f3aoUq
@kriszyp
kriszyp force-pushed the feat/record-lock-cluster-transport branch from 4cd0b9d to dd0a6ba Compare September 13, 2026 07:03
kriszyp added a commit that referenced this pull request Sep 13, 2026
…e handoff gaps it exposes

The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala
baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock
timeout; raw JSON under replication/record-lock-cost-runs.

Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the
home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms
to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to
zero.

Two results do not fit the task's stated expectations, and both are about the handoff:

- The counter no longer converges exactly. Auditing every written value shows the shortfall
  is entirely duplicate values written by two different nodes, with no holes and no failed
  requests - a successor reading a predecessor's unreplicated commit. That is the disclosed
  position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented
  (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at
  three contenders.
- A contended key is monopolized rather than shared. At two contenders the losing node
  completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout
  it fails with 423.

The bench therefore records convergence instead of asserting it: an assertion here would fail
every run while testing a guarantee this phase deliberately does not offer, and the bench
measures rather than gates.

core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push
of that branch and is four commits behind, two of which change grant and delegation holding.

Refs #824

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj
kriszyp and others added 12 commits September 13, 2026 22:44
Temporary: re-bump to the merge commit once harper#2498 lands on main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXhaW9MrWxeZr5HXysyFBM
…ordinator (harper-pro#438, W9 Phase 1)

Core (harper#2498) owns the Ricart-Agrawala protocol, writes its control entries to the
table's own transaction log and applies received ones from its replicated-event sink, in
order with the data of the batch. This adds what core cannot know:

- recordLocks capability level in the protocol registry, advertised only while
  replication.recordLocks is on; the send path skips lock control entries to a peer that
  has not advertised it (the registry's first gated frame).
- The participant set: every member of the database's replication group (explicit
  subscriptions included, direction ignored), each with the capability its own NODE_NAME
  bag asserted, kept in slot 13 of the per-(database, peer) shared status buffer; never
  learned reads as not capable.
- Per-database coordination ownership conferred by the main thread, moved only after the
  owner worker exits, with every subscription for the database placed on that worker while
  the feature is on so the coordinator applies the database's inbound entries.
- replication.recordLocks (default off): placement unchanged when off, and a cluster-scoped
  lock() on a replicated database fails closed naming the switch.
- cluster_status.recordLocks per database, with a correlated request/response so overlapping
  status calls cannot strand each other.

Depends on harper#2498 (core pinned to its head) and harper-pro#813 (merged into this branch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
…elper body reads

env.get resolves only keys registered in core's CONFIG_PARAM_MAP, so the harper-pro-only
replication.recordLocks switch read as undefined and the feature never armed; read it from
getConfigObj() instead (no core change). The cluster test's counter/controlEntries helpers
consumed the response body in an assertion message and then again via json(), and a received
control entry is applied to the coordinator rather than persisted in the receiver's log, so
the bag-less-peer test now asserts only the grant this node wrote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
- recordLockCluster.test.mjs: start nodes with allSettled so one failed start does not
  orphan the nodes that came up; thread the wait deadline's abort signal into the mesh probe.
- recordLockConfig.ts: warn when replication.recordLocks is a truthy non-boolean (a YAML 1,
  a quoted "true"), which leaves the node fail-closed, so an operator is not left believing
  the switch is on.
- knownNodes.ts: record that shared-status slot 13 now holds the record-lock capability so a
  future slot taker does not overwrite it.
- Drop one reviewer-addressing comment.

The blob-gap/analytics/schema-merge findings the review surfaced are on code inherited
through this branch's base (harper-pro#432), not this change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
knownNodes -> replicator -> recordLockTransport is an import cycle; assigning
recordLockTransport's downSinceReader while that module is mid-evaluation hit its temporal
dead zone under the unit-test import order (adding the config module's imports shifted
evaluation order enough to expose it). start() runs after every module has loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vcn5gxtbSvWXLGk4ZWFRNf
`npm run bench:record-locks` (integrationTests/cluster/recordLockCost.bench.mjs, not part of
test:integration:cluster) boots the same 3-node mesh as recordLockCluster.test.mjs and measures
uncontended and repeat-lock acquisition latency, hot-key handoff throughput with 2 and 3 contending
nodes, control entries and bytes per acquisition from each node's transaction log, and unlocked write
throughput with the feature off, unregistered, and on. Timing is in-process (fixture-record-lock-bench).

replication/RECORD_LOCK_COST_BASELINE.md records one run with its distributions, sample counts,
machine class, and which figures are noisy. No change to the lock protocol; core is not moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
… distributions, release boundary

The hot-key section time was the client's wall clock around fetch; LockedIncrement now reports the
node's own lock and lock-through-save times, and the bench pools them across contenders beside the
per-node distributions and the client round trip. Log-cost ratios divide by rounds started, so a
timed-out round's request and withdraw cannot inflate them; the after-snapshot waits until every
started round's release is in its node's log; the convergence probe threads the wait's signal.
Baseline re-recorded from a run on the updated harness.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
…erhead, not HTTP alone

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013sPwr2JJbAcbHxnhoNr5qQ
…ol (static epoch)

harper#2498 replaced Ricart-Agrawala with amortized per-record ownership, and its
ClusterLockTransport contract changed with it: core now needs epoch(), requestDelegation()
and recallDelegation(), and registerClusterLockTransport throws on a transport without
them. Against that core this branch's transport did not register at all. This is the
harper-pro half, pinned to core 1deac506d.

epoch() is STATIC in this tranche - number 1, never advanced, not agreed. Members are the
database's replication group filtered to peers that advertised the delegation level of
recordLocks, plus this node, sorted; ringVersion hashes the sorted list so two nodes with
the same set agree without a deep compare. That is the design note's section 9 "static
owner" step: enough for one arbiter per key, not enough for section 4. What a static epoch
cannot do is advance across a restart to invalidate a previous incarnation's delegations,
so core's obligation on ClusterLockTransport.epoch is met the blunt way: epoch() returns
undefined for DELEGATION_LEASE_MS + LOCK_LEASE_SKEW_MS after process start, which blocks
every cluster lock on this node for that window. That is the cost of a static epoch, and
harper-pro#825 removes it. HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS lifts it for tests.

homeIncarnation is durable and monotonic - core orders fencing tokens on it, and a random
value is identifiable but not orderable. The main thread bumps recordLockIncarnation on
this node's own hdb_nodes row once per process start (merged via ensureNode); workers read
the mirror, and epoch() withholds while it still reads 0.

Request and recall are two registered operations, record_lock_delegate and
record_lock_recall (recordLockRpc.ts), sent over this worker's live outbound subscription
session to the home when it has one - its inbound end is on the home's coordinating
worker, so the request lands where the coordinator lives - and over sendOperationToNode
otherwise. An operation that arrives on a non-owner thread is relayed through main, which
mints its own hop id (worker-minted ids collide across workers), under a 5 s bound; a
timed-out relay answers not-home, never a grant. The requester is the authenticated node
principal of the connection, never the payload; a caller that is not a known node gets
403, so a super_user cannot mint or clear a delegation through the operations API.

The recordLocks capability is now level 2 and mutually exclusive: peerSupportsRecordLocks
requires the level exactly. Level 1 was Ricart-Agrawala and never shipped enabled; a peer
still advertising it is a different arbiter, not a slower one.

cluster_status.recordLocks reports { delegations, granted, admitted, droppedOffOwner,
members } per database; members is the epoch as the owner sees it, or absent while it is
withheld.

Sync-Core cost carried by the pointer bump, stated so it is not mistaken for a lock
change: four AuditRecord.localTime reads in replicationConnection.ts follow core's rename
to txnLogKey. The branch already pinned @harperfast/rocksdb-js 2.8.0, which core now
hard-requires at load (RecordEncoder throws below it); a checkout installed before that
pin has to reinstall before any core import loads.

The crash-recovery integration case is skipped with its reason: a crashed delegate holds
its keys for up to DELEGATION_LEASE_MS (six minutes), which does not fit a test, and
whether that lease is configurable is an open question on harper#2498. The property it
covered - a home never re-grants before the delegate's deadline plus skew, on independent
clocks - is asserted in core's coordinator suite.

Two defects the first cluster run caught, both now covered by tests that fail without
the fix: the transport read its home incarnation from server.nodes, which excludes the
local node on every path, so epoch() was withheld for the life of the process
(readOwnIncarnation reads the own hdb_nodes row); and a node whose bag was suppressed
still built a ring including itself while every peer excluded it - two arbiters for one
key. epoch() now withholds unless the bag this node actually sends claims the level. The
home-side half of that guard (refuse a requester outside the member set) is filed on
harper#2541 rather than reopened in harper#2498 mid-review.

The first pre-push round (full coverage: codex, gemini, cursor-grok, domain) returned BLOCK.
Its design-level finding stands and is put to the human on the PR: with a static epoch and
locally derived membership, two nodes can hold different rings for one key during a
membership transition and each self-home it - two arbiters - and nothing short of the
agreed epoch (harper-pro#825) closes that. Its concrete findings are fixed here, each
with a test where one applies: principalNodeName trusted a payload-supplied `user.name`
as a fallback (now hdb_user only); the main-thread rpc handler was unguarded; resolveLevel
min-clamped recordLocks so a future level-3 peer resolved to 2 and passed the equality
gate (now an exact, unclamped level); epoch() rebuilt the ring on every acquisition (now
memoized for 250 ms on the injected clock); a node that never joined a mesh had no self
row so the incarnation bump spun forever and every cluster lock 503'd for the life of
the process (the counter now goes on a LOCAL_ONLY self row); the bench omitted the
restart-hold override; executeRecall reported success after a timed-out relay (now a
503); the status-view cache was keyed on `auditStore && peer`; and three DESIGN.md
statements described the previous protocol.

Round 2 was degraded (Codex and the domain leg timed out on this box), but Gemini's two
majors were real and are fixed: the recall acknowledgement compared object identity across
a postMessage structured clone, so every relayed recall would have 503'd (structural check
now); and a worker that read its home incarnation from the table before main's bump landed
would cache the previous process's value for the life of the process. Workers now never
read the table: main broadcasts the bumped value (record-lock-incarnation) the way it
confers ownership, a late-registering worker asks for it, and the worker-side setter never
moves backwards (unit test). The 5830 audit-key fallback chain also matches its sibling.

Verification: 80 unit tests (recordLockTransport + protocolCapabilities); the 3-node
cluster integration suite 7 passing, 1 skipped as above - delegation request over the
live subscription session, recall handover, 24 concurrent increments landing exactly 24
on every node, ten repeat locks writing no release entries, the LWW/409 fence, and the
bag-less peer excluded from the ring and failing its own cluster lock closed with 503.
Typecheck: 31 errors, all pre-existing environment drift (harper-pro main has 32); none
in the changed files.

Refs #438, #822, #824, #825, HarperFast/harper#483, HarperFast/harper#2498,
HarperFast/harper#2541, HarperFast/harper#2542

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

The epoch() paragraph still said the recordLocks capability is read from
slot 13 (moved to 29 during the main rebase); homeIncarnation was described
as something workers read off their own hdb_nodes row, when they only ever
adopt what main pushes over record-lock-incarnation. Also note that
recordLockIncarnation is written via ensureNode without being a declared
table attribute, so the schema list right below doesn't omit it by mistake.

Surfaced by the independent pre-push review (Cursor Grok + Harper domain
adjudication) on 3729b81.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getConfigObj() throws when no boot properties file exists yet. Every
other getConfigObj() call site in the codebase defers the call into a
function body for exactly this reason; recordLockConfig.ts read it as a
module-scoped constant at import time, so a bare mocha process (no
harperdb boot, unlike CI's own server-driven tests) crashed the whole
unit-test run the moment anything imported replicator.ts. This branch's
Unit Tests workflow never ran on GitHub Actions before this rebase (the
PR was mergeable_state: dirty, so CI skipped it) and local runs on this
box succeed only because an inherited HDB_ROOT happens to point at a
real properties file (dispatch-session leakage), masking the crash.

Catch the throw and treat it the same as "not configured": fail closed,
matching this module's own stated default.

Verified with `env -u HDB_ROOT npm run test:unit` (1094 passing) to
reproduce a from-scratch environment with no boot properties file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Companion PR HarperFast/harper#2498 is open at 71d32bf6, per the
dispatch's explicit companion-PR instruction. Not a rebase merge of
"both sides" of the gitlink -- the instruction is to take neither side
and set the pointer to this exact sha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the feat/record-lock-cluster-transport branch from 69a8c4f to 2908e08 Compare September 14, 2026 04:47
kriszyp added a commit that referenced this pull request Sep 14, 2026
…e handoff gaps it exposes

The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala
baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock
timeout; raw JSON under replication/record-lock-cost-runs.

Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the
home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms
to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to
zero.

Two results do not fit the task's stated expectations, and both are about the handoff:

- The counter no longer converges exactly. Auditing every written value shows the shortfall
  is entirely duplicate values written by two different nodes, with no holes and no failed
  requests - a successor reading a predecessor's unreplicated commit. That is the disclosed
  position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented
  (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at
  three contenders.
- A contended key is monopolized rather than shared. At two contenders the losing node
  completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout
  it fails with 423.

The bench therefore records convergence instead of asserting it: an assertion here would fail
every run while testing a guarantee this phase deliberately does not offer, and the bench
measures rather than gates.

core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push
of that branch and is four commits behind, two of which change grant and delegation holding.

Refs #824

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj
…ps it exposes (#837)

* Measure record-lock cost under the delegation protocol, and record the handoff gaps it exposes

The AFTER half of the §10 measurement gate (harper-pro#824), against the Ricart-Agrawala
baseline in RECORD_LOCK_COST_BASELINE.md. Three full runs plus a fourth past the lock
timeout; raw JSON under replication/record-lock-cost-runs.

Where §10's predictions hold, they hold clearly: a first lock splits into 0.51 ms with the
home elsewhere and 0.05 ms when this node homes the key, repeat locks collapse from 0.68 ms
to 0.01-0.02 ms, and control entries per uncontended acquisition go from 4 cluster-wide to
zero.

Two results do not fit the task's stated expectations, and both are about the handoff:

- The counter no longer converges exactly. Auditing every written value shows the shortfall
  is entirely duplicate values written by two different nodes, with no holes and no failed
  requests - a successor reading a predecessor's unreplicated commit. That is the disclosed
  position: recordLockCoordinator.ts:43 states the §7 freshness fence is unimplemented
  (harper#2542), and §14 adds that §6 step 3 settlement is too. 0.03-0.12% of sections at
  three contenders.
- A contended key is monopolized rather than shared. At two contenders the losing node
  completed one section in fifteen seconds in all three runs, and past the 30 s lock timeout
  it fails with 423.

The bench therefore records convergence instead of asserting it: an assertion here would fail
every run while testing a guarantee this phase deliberately does not offer, and the bench
measures rather than gates.

core moves to the current harper#2498 head. #822's pin was left unreachable by a force-push
of that branch and is four commits behind, two of which change grant and delegation holding.

Refs #824

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj

* Correct the starvation finding: the starved contender got zero sections, not one

The pre-push review traced `lastN` in the committed runs and found the write-up had the
order backwards. The loser's single recorded section carries the cluster's MAXIMUM written
value, so it landed after the winner's loop ended and dropped the key - not during the
contended window. In the 40 s run its first request had already failed with 423 at
DEFAULT_LOCK_TIMEOUT_MS while the holder was still running.

So over the contended window the starved contender completed zero critical sections and one
user-visible failure. That is worse than what the document claimed, and the document now says
it with the evidence.

Two harness bugs found in the same round:

- waitForAgreedCounter returned the agreed value straight to waitForCondition, which discards
  a falsy probe, so a round where every lock() answered 423 would settle on 0, be discarded,
  time out after 90 s and record agreedCounter: undefined for a cluster that did agree.
- distribution([]) produced NaN percentiles that serialize as null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj

* Claim only what the audit measures, and guard two paths it could crash on

The written-value audit shows two nodes computing n+1 from the same n. That rules out a lost
commit, but it does not by itself separate a successor admitted before applying its
predecessor's write from two nodes admitted at once - both produce the same signature, and
telling them apart needs holder intervals this bench does not record. The document now says
so and attributes the reading to core's own statement that the freshness fence is
unimplemented, rather than to these numbers.

Also: measurement 6 asserts it has a remote-home reference instead of dereferencing an absent
one, and LockStats reports unavailable coordinator stats as such rather than as an empty
object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWmasaL9vLBSSKfGnG81kj

* Clarify delegation benchmark conclusions

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Address the post-rebase review: fix a real audit bug, correct three factual overclaims

- writtenValueAudit: track every node that has written a value, not just the first,
  so a node repeating its own write after another node already wrote it is no longer
  misattributed as a cross-node duplicate. Verified against all eight committed hot-key
  rounds: every one already had repeatedCount === repeatedAcrossNodes (no value was ever
  written by the same node twice), so this does not change any reported number.
- Note the threshold/delta unit mismatch in measurement 6 (an absolute latency floor
  compared against a delta with the local lock already subtracted) and the ~500ms
  convergence-poll window's limits, without changing either's behavior blind (the
  currently-pinned core is incompatible with harper-pro's transport, so the cluster
  bench cannot actually be run right now to validate a behavior change - see below).
- RECORD_LOCK_COST_DELEGATIONS.md: record the core sha the numbers were actually
  measured at (729aefd2) and disclose that the base's own further core re-pin
  (71d32bf6) removed `epoch()` in favor of `homeMap()`, which harper-pro's transport
  does not yet implement - the committed numbers are not currently re-runnable.
  Narrow the 120s-window "real rounds (0.35ms+)" claim: runs 2 and 3's off-window
  lapses (0.196-0.239ms) are far closer to their own local-reference noise than run
  1's clean case. Replace the "abandoned instrument" paragraph with a home-per-round
  table derived from lockStats snapshots already in the committed JSON (no new
  instrumentation) - it settles most of the "why does the holder win" question.
- DESIGN.md: the disabled-transport 503 claim is wrong (a plain Error, so 500) and the
  repeat-lock range was narrower than the document it now points to actually measured.
- README.md: note that run 4's committed JSON has only one of the two expected
  measurement-6 entries.

Cosmetic, from the same round: fix the quiet-poll count in a docblock (two vs three),
drop a no-op multiplier constant, trim narrated history from two docblocks, remove an
orphaned comment, and de-duplicate a restated comment in the fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Trim two new comments to Harper's zero-narration default, record the harper-pro sha

- Move the reacquisition threshold's absolute-vs-delta bias explanation into the results
  document's §6 (where the other measurement-6 methodology notes already live) instead of
  narrating it in code; same for the agreed-counter wait's convergence caveat.
- Record the harper-pro sha the runs were measured at (4cd0b9d), not just core's; the
  prior commit only fixed the moving core reference.
- Drop the "dispatch task" tracker reference in the document - that addresses this
  session's tooling, not a reader of the PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix the §6 undercount rationale: reclassification is offline, not a re-run

The prior commit said fixing the threshold would need the bench re-run against the
now-unstartable core pin. Wrong: every tick's deltaMs is already in the committed JSON,
so reclassifying is an offline check. Verified that check against run 1's 300s row -
the naive fix (floor minus local reference) pulls two known-local ticks across the cut
as false lapses (neither on a window multiple, both inside that row's own local-reference
range) - so the real obstacle is that the floor needs a better basis than a lower number,
not that it can't be checked without a cluster. Also trims a comment that narrated the
fix it sits next to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: GPT-5 Codex <noreply@openai.com>
kriszyp and others added 2 commits September 17, 2026 09:40
… from an explicit node list (#863)

* Design note: record_lock_apply_homes, one call to apply a home map cluster-wide

Refs #862

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Add record_lock_apply_homes: one call applies a home map across the cluster

Survey every node in the operator's explicit list, refuse before staging on an
unreachable node, an unlisted ring member, a digest disagreement or a stage the
node would reject; stage everywhere over a node-principal hop that re-validates
locally; activate immediately when every node proves its drain, otherwise
report per node with a relative wait for an attested second call that covers
only what was already staged. The stage persists the quiesce set so a retry
cannot lose the old ring once active is retracted.

Refs #862

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Cluster test: judge an untouched node by its row, not by a lock a staged peer homes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Cluster test: open generation 2 explicitly before injecting the failure

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Apply review round 1: required quiesce, initiator row, staged-race recovery, hop cancellation

- record_lock_stage_generation now requires quiesce and persists it; a matching
  re-stage backfills a row that lacks one, and the survey refuses a staged row
  with none, so a manually staged node cannot hide the ring it stopped serving.
- The node taking the call reads its own row too when it is not in quiesce; a
  ring it serves that the list omits is refused like a peer's.
- Only an active disagreement is fatal; two sets staged by racing operators are
  judged per node against the target, so an explicit higher generation proceeds.
- Every hop's deadline retires the request on the wire: the live session drops
  its pending entry and sendOperationToNode closes its socket.
- The per-database apply queue releases its entry when idle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Apply review round 2: a stage must name every ring its row remembers; cancel the one-shot hop

planStage now refuses a quiesce that omits a member of the active ring, the
staged ring or the previous staged transition's participants, since the write
erases them from the row and a later survey could not see the node still
serving them. sendOperationToNode passes its timeout into the session so the
socket closes when a peer accepts and never answers. DESIGN.md index updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* DESIGN.md: four operations, not three

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

* Clear the operation timeout timer when the response arrives

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EVzk62HUFq2v4RCtG13Lq
Dispatch-Task: harper-pro-862-apply-homes

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… 1 (#865)

* Cluster record locks: serve lock() on every worker at threads.count > 1 (#852)

Cluster-scoped lock() only worked with one http worker: a request landing on
a worker that does not coordinate the database answered 503, and the row that
drives the operator-agreed home map was guarded per worker isolate. This makes
the feature usable at the default worker count.

- recordLockRpc.ts: relay a local lock() acquire/release to the coordinating
  worker over the worker-to-worker port mesh (main only broadcasts which
  thread owns each database). The admission crosses the boundary, not the
  handle; a recall on the owner fences the caller's handle over the mesh and
  the owner awaits that ack (or the handle's lease) before writing the
  release. Admissions are bound to an owner-session nonce and the
  harness-stamped origin thread, so a stale release after an ownership handoff
  cannot address another handle. Caller identity is the sender port, never a
  payload field; the messages are internal, never registered operations.
- recordLockHomes.ts: withRow now takes a process-wide node-scoped lock on the
  hdb_record_lock_homes row and writes through that locked handle, so a stage
  racing a fence_external across workers can no longer restore a retracted
  generation, and a lease lost to a storage stall fails the write.
- recordLockTransport.ts: wire acquireOnOwner/releaseOnOwner, broadcast the
  owner thread id to every worker, sum relayedAdmissions across workers (and
  main) in cluster_status, and remove the "run one http worker" warning. The
  restart-quarantine waiver (which lets a genuinely fresh node grant a key
  without waiting out a departed incarnation's lease) is cleared on the first
  handoff bump, so a successor coordinator built after ownership has already
  changed hands cannot grant while a departed worker's relayed handle can
  still commit.
- A caller worker that EXITS during an ownerless handoff counts as fenced. The
  restart quarantine does not back that up (it is read only on the home's grant
  path, so a peer-homed key renews straight back here); the process-wide native
  key lock does, since lock() takes it before the cluster admission and both the
  departed worker and any new caller are on this node. The residual teardown
  question is tracked as HarperFast/rocksdb-js#865. Recorded at the call site
  and in replication/DESIGN.md.
- Bump the core submodule to the matching harper change.

Test: new threads.count: 3 integration suite proves a lock() served on a
non-owner worker relays and succeeds, and concurrent increments stay
exclusive; core unit tests cover the remote-admission lifecycle including
fence-before-release; a transport unit test covers the waiver clearing on the
first handoff bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Pin the relay handoff guards, and drop a retracted safety claim

Review adjudication on #865.

- `broadcastOwnerlessAndWait`'s JSDoc still credited the successor's restart
  quarantine for making a worker exit safe to treat as fenced. The inline
  comment in the same function, DESIGN.md and the PR ledger all retract that in
  favour of the process-wide native key lock; a later change that trusted the
  JSDoc would reopen the two-writer window believing the gate still covered it.

- `recordLockRpc.ts` had no unit coverage at all, so the caller-side relay
  lifecycle is now pinned: an in-flight acquire fails retryably when the
  coordinating thread goes away, a grant that lands after that is handed back to
  the thread that minted it, and a release carries the session its admission was
  minted under. Both guards were verified to fail without the code that provides
  them. Naming the ACQUIRE_REPLY handler is what lets a test deliver one.

- The existing `owner-c` assertion depended on the round-robin counter starting
  at zero, which only held while this file was the first to assign an owner; it
  now asserts the invariant it is named for.

- `key` is `unknown` throughout harper-pro's relay signatures, matching
  `recordLockRpc.ts` and `establishLockFreshness` in the same file.

Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Do not confer record lock ownership on a successor that exited

Pre-push review finding on the fence wait this PR adds. `recordLockOwnerFor`
picks the successor before awaiting the incarnation bump and
`broadcastOwnerlessAndWait`, which runs as long as OWNER_FENCE_ACK_TIMEOUT_MS
and resolves a worker's own exit as a completed fence. So the wait can resolve
*because* the successor died, and `assignOwner` then confers on it.

Nothing recovers from that: `watchOwnerExit` attaches its listener inside
`assignOwner`, after the exit event it needs has already fired, so the entry is
never cleared and every relayed `lock()` for the database is routed to a dead
thread until the process restarts. Fail closed into the existing retry instead,
which re-derives over a fresh live set once the worker is back.

Before this PR the same window existed but spanned only the durable bump; the
fence wait widened it to ten seconds and made the successor's own death one of
the ways it completes.

Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Check the successor by its captured thread id, not its post-exit one

Round-2 pre-push review, confirmed here on Node v26.2.0: a Worker reports
`threadId` -1 from before its `exit` listener runs, so the previous check asked
the thread tombstone about -1, never matched, and still conferred ownership on
the dead successor. `manageThreads.addPort` captures the id for the same reason.

The unit test masked it — its fake worker kept its id after exiting. It now
models a real Worker (tombstone keyed by the live id, `threadId` already -1) and
fails against the previous check.

Also corrects the `recordLocks` capability level in DESIGN.md, which still
documented 3 while `protocolCapabilities.ts` advertises 4.

Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The caller relay's exit is not a fence; say so where the claim was made

cb1kenobi and the review bot both flagged the header comment added in 73fc424:
it says the owner writes the delegation release when "this worker exits", which
the same file contradicts twice. `revokeRemoteHandle` settles only on the
caller's REVOKE_ACK or the handle's lease timer, and `onThreadExit` keeps a
departed caller's admission to its lease precisely so a write already handed to
the engine cannot be overtaken.

The borrowed native-key-lock justification does not reach this path either: the
next holder after a recall is a PEER node, so a process-wide key lock on this
node proves nothing. Exit-counts-as-fenced belongs only to main's ownerless
handoff, where both threads are on this node — the sibling claim dropped from
`broadcastOwnerlessAndWait` earlier in this branch.

Dispatch-Task: fix-kriszyp_harper-pro_865-1f3b384a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove withRow serializes on the row lock, not on its per-isolate queue

The review bot's remaining thread was right that nothing exercised the property
`withRow` was changed for: `recordLockHomes.test.mjs` scopes itself to pure
decision logic, and the threads.count: 3 cluster suite only drives the relay. It
asked for an integration case racing stage/fence/activate across workers, which
is not what this proves -- that race is probabilistic, and a test that cannot be
shown to fail against the old code is not coverage.

The discriminating fact IS testable in one isolate: hold the same node-scoped
lock on the `hdb_record_lock_homes` row that `withRow` takes, from outside
`withRow`'s own queue, and a stage must wait for it. Verified to fail against the
pre-#852 implementation (the per-isolate promise queue with a `transaction()`
write), where the stage runs straight through and settles while the row is held.
That the lock ALSO excludes across threads is core's property; one isolate cannot
demonstrate it, and the test does not claim to.

The end-to-end multi-worker race remains a follow-up, alongside the operator
operations' integration coverage generally.

Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep the handoff's two claims honest: the fence ack, and the attempt it belongs to

Three things the pre-push review found, all on the ownership handoff path.

A worker that could not fence one of its tables still acked the fence to main.
The ack meant "every relayed handle here is dead", main confers the successor on
it, and the successor may grant a key whose old handle can still commit.
`fenceRelayedAdmissionsForDatabase` now answers whether every table fenced, and
the worker withholds the ack when one did not, so main's wait times out and the
handoff fails closed -- which is the behaviour the gate was already built for.
Main's own fence is held to the same rule inside `broadcastOwnerlessAndWait`.
Nothing reachable throws there today (the resolver is a field read and core
swallows a resolver throw before this code sees it), so this enforces the
invariant the comment was arguing for rather than fixing a live defect.

A handoff settling late could act on another attempt's state. PENDING_BUMP is not
an identity: `releaseRecordLockOwner` clears it and the next attempt re-sets it,
so a rejection arriving after a release deleted the NEW attempt's marker and
scheduled a retry that re-assigned an owner to a database ownership had been
given up on. Each attempt now carries a token, checked on both settlement paths,
and a release bumps it. Covered by a test proven to fail without it.

The relayed acquire reserved a flat 250ms of the caller's wait for the two thread
hops, so `lock(id, { timeout: 200 })` -- or any lock that spent most of a longer
timeout on the native key first -- reached the owner with `waitMs: 0` and failed
on the first contention it met. Off-owner only, which is the uniformity the relay
exists to provide. The margin is now capped at a quarter of the budget; too small
a margin costs nothing new, since the caller's own timer settles it as the same
retryable 503 and a late grant is handed back.

Also: the row lock's lease comment claimed a sub-millisecond critical section
while the `notifyChanged` fan-out runs inside the lock; the cluster suite header
claimed a cross-node recall fences a relayed handle, which neither test forces;
the transport fixture's ack comment understated that the live ack path has no
coverage; and one comment described a warning that no longer exists.

Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fence where the ack is given, and let a release cancel an armed retry

Two holes cursor-grok found in the previous commit's own fixes.

An owner's exit broadcasts ownerless TWICE: `watchOwnerExit` posts it with no
request id, then `broadcastOwnerlessAndWait` posts it again carrying the ack
request. By that second message the thread is already unowned, so the
moved-owner check fenced nothing and the worker acked a fence that never ran --
masking a failure the first message hit, which is precisely the case the ack was
just made conditional for. The handler now fences when the ack is requested,
rather than reporting on whatever the owner update happened to do; the fence is
idempotent, so the honest answer is the one from a fence that just ran. Main's
own self-fence in `broadcastOwnerlessAndWait` is explicit for the same reason.

A release could not cancel a retry that was already armed. `releaseRecordLockOwner`
returned early when no owner was recorded, which is exactly the state a FAILED
handoff leaves behind with its 10s retry pending; the timer then fired, saw
"unowned but everHadOwner", and conferred an owner on a database ownership had
been given up on. The attempt counter is now bumped before that early return, and
the timer checks it.

Neither is reachable from the unit harness: the worker handler runs only under
`parentPort`, and main's message dispatch has no test entry point, so the fence
ack has no unit coverage in either direction.

Dispatch-Task: fix-kriszyp_harper-pro_865-c2d9fa46
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover the fence ack's live arm, the one a running worker actually uses

The handoff gate's only coverage was the EXIT arm: `fakeWorker` resolved
`broadcastOwnerlessAndWait` by firing `exit` on every fence request, so a
regression that dropped the `record-lock-owner-thread-ack` route — leaving every
handoff to time out — passed the suite. The PR called that structural, on the
grounds that main's message dispatch has no test entry point. It has one:
`recordLockRpc` already names and exports `handleAcquireReply` for exactly this,
and the ack handler is the same shape.

`handleOwnerThreadAck` is now named and exported, and a test drives the gate in
both directions: a live worker that has not acked is not conferred on, and the
ack is what releases the handoff. Proven against two mutations — removing the
wait fails the first assertion, making the ack ignore its requestId fails the
second.

Proving the second one surfaced a fixture defect. `fakeWorker.once` was
last-registration-wins, so the two overlapping handoff attempts in the
superseded-handoff test registered `exit` on the same worker and the first
listener was silently dropped, leaving its arm of the wait pending on the 10s
timeout for the rest of the run. Listeners are a list now, as EventEmitter gives.

The worker side of the gate — acking only after a COMPLETE fence — stays
uncovered: that handler registers only under `parentPort`, and failing a fence
needs a table registry the unit environment does not have.

Refs #852.

Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bind a fence ack to the thread that was asked to fence

`handleOwnerThreadAck` resolved a handoff arm on the request id alone. Request
ids are a plain sequence and any thread that can post to main reaches its own
`parentPort`, so a guessed id released the gate for a worker that never fenced —
main then conferred the successor over a still-committable relayed handle, which
is the exact two-writer the gate exists to prevent.

The acker's identity now comes from the port the harness stamped, which is the
rule `recordLockRpc`'s acquire handler already states and follows; this handler
was the one place the admission path took a caller's word for it. The pending
entry carries the thread id captured while the worker is live, for the same
reason the successor's id is captured before the wait.

Raised by the round-6 planning review (finding 5). The test now also asserts a
correct request id from the wrong thread does not settle the arm, proven to fail
without the binding.

Refs #852.

Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bind a release and a revoke ack to the worker they belong to

The same rule the acquire handler states — identity comes from the port the
harness stamped, never a payload field — was unenforced at the two other
owner-side handlers, which is where it matters most.

A release carried only `OWNER_SESSION`, which is shared across every admission
this owner minted, and admission ids are a plain sequence. Any thread that
learned the session could drop a SIBLING's admission while that sibling's handle
could still commit, admitting a peer node concurrently. `OwnerAdmission` already
recorded the origin; the handler now requires it to match.

A revoke ack was worse: it settles the fence core awaits before writing
`lockRelease`, so an ack from anyone but the holder made the owner release while
the real holder could still commit — the cross-node two-writer the fence exists
to close. Pending revokes now carry the origin captured at acquire, not a late
read of the port (a caller that has exited reports -1).

Raised by the review bot on a5ebff3, which generalized the fence-ack finding to
these two sites correctly. Neither has unit coverage, for the same structural
reason the worker-side fence does not: reaching them needs the owner-side acquire
path, which needs a table registry the unit environment does not have.

Refs #852.

Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pin both owner-side deny paths, which were not structural after all

I called the two identity gates untestable because reaching them needs
`acquireForRelay` to mint a real admission. The review bot pointed out that the
coordinator can simply be stubbed, and it is right — the same correction I made
to this PR's earlier "main's dispatch has no test entry point" claim.

`handleAcquireRequest`, `handleRelease` and `handleRevokeAck` are named exports
now, the way `handleAcquireReply` already was, and `acquireForRelay` is replaced
with a stub that invokes the grant callback and hands back a synthetic round. No
table registry, no storage. Two tests then assert what the fix is for: a sibling
holding the shared `OWNER_SESSION` cannot drop another worker's admission, and a
thread that does not hold the handle cannot settle the fence core awaits before
writing `lockRelease`. Both proven to fail with either gate removed.

The revoke id comes from the request the owner actually posted rather than a
literal, since `nextRevokeId` is module-global and shared with earlier tests, and
each test claims its own database — once one has had an owner, re-claiming it
takes the handoff path and this thread would not coordinate it synchronously.

Refs #852.

Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Anchor the stubbed round to now, so the fence has one way to settle

`revokeRemoteHandle` derives the fence's fallback timer from
`mintedMono + leaseMs - performance.now()`, and `performance.now()` is ms since
process start — so the stub's `mintedMono: 0` armed that timer at 0ms as soon as
the suite had run longer than `leaseMs`. That is a second way for the fence to
settle, and not the one the wrong-thread assertion is testing.

Raised by round 9. Its specific failure does not reproduce — from a promise
continuation `setImmediate` runs in the current iteration's check phase, ahead of
a 0ms timer in the next iteration's timers phase, and the test still passed with
`leaseMs: 5` — but the dependency on process uptime is real and there is no
reason for the test to carry it.

Refs #852.

Dispatch-Task: fix-kriszyp_harper-pro_865-2c61b518
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bind an acquire reply to the thread the request was sent to

`handleAcquireReply` stored `pending.ownerThreadId` but never consulted it, and
authenticated the sender against `message.database` — a field the sender controls. Any
worker holding a port to the caller could name a database it does legitimately coordinate,
guess the sequential request id, and settle someone else's pending acquire with a round no
cluster admission backs.

This is the same invariant already stated in the acquire, release and revoke-ack handlers
(identity comes from the port the harness stamped, never the payload), applied at the
fourth site. The ownership check now reads the database off the pending request, and a
reply from any thread other than the one the request addressed is handed back to its sender
and leaves the acquire live to fail closed on its own timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Retire an owner's relayed-admission bookkeeping when it loses the database

`ownerAdmissions` was collected on release, revoke ack, an undeliverable reply and a caller
thread's exit, but not when the owner thread simply stops coordinating a database while
still running. The callers have already fenced their handles and dropped their owner
sessions by then (`clearRelaySessionsForDatabase`), so no release can ever arrive to
collect those entries and they are retained for the life of the process, one per
concurrently-relayed lock per ownership cycle.

Forget them where this thread learns it lost ownership, on the same rule as the exit path:
drop the bookkeeping, do not release — the coordinator's lease is what retires the
admission itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Deny a relayed acquire whose ownership was given up while it was minting

`handleAcquireRequest` checked `ownsDatabase` before awaiting `acquireForRelay`, so a grant
could be minted and handed out by a thread that stopped coordinating the database during
the await — after the ownership-loss sweep had already run, re-inserting the entry it just
retired, and with the successor's coordinator starting empty behind it.

Re-check after the mint and dispose exactly as an undeliverable reply does: release the
admission and answer the caller a retryable 503. Nothing was installed on the caller side,
so the release is safe here in a way it is not on the exit path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Tell one coordinator's grants from its successor's with a generation

The post-mint ownership re-check was a boolean, so a lose->regain cycle across the await
passed it: the round-robin can hand the database straight back to this thread, which builds
a fresh coordinator with empty delegation state, and the grant minted under the old one is
then replied with the same thread id and the same process-wide OWNER_SESSION. The caller's
staleOwner check cannot discriminate those, so it installs a handle no live coordinator
will revoke while the new coordinator is free to grant the same key elsewhere.

Bump a per-database generation where this thread loses coordination, capture it at the
start of the acquire, and compare after the mint. The test now drives the lose->regain
cycle, which the boolean guard does not survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Deny an unstamped port at the caller-side gates, as every other one does

`wrongSender` and `staleOwner` both short-circuited to false when the sender port carried no
`threadId`, so an unauthenticated message fell through to install a LockRound. The
`REVOKE_REQUEST` handler had the same shape and fenced a live handle on the same basis.

`pending.ownerThreadId` is a non-optional number, so the `!== undefined` clause could never
be the difference between a match and a mismatch — it only suppressed the deny. Every other
identity check in this file already denies outright on an unstamped port
(`handleAcquireRequest`, `handleRelease`, `handleRevokeAck`); these three now match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Reject an unstamped revoke outright, not by a comparison that can match

Folding the unstamped-port reject into the owner comparison left the revoke path
half-guarded: during a handoff's ownerless window the expected owner is `undefined` too, so
an unstamped port compared equal and the handler proceeded. That drives
`revokeRelayedAdmission` for an arbitrary id, and because it latches a revoke that raced the
handle's install, an admission the successor grants moments later is pre-fenced and its
holder loses the lock under it.

Explicit reject first, comparison second. The handler is a named export now so its gate has
a test, the same reason `handleAcquireReply` is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Give the unstamped-revoke test a positive control

The assertion was a negative against a stub installed on the CommonJS exports object, which
would also pass if the stub were never the thing the handler calls. The same message from
the stamped owner port now has to reach the fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch-Task: fix-kriszyp_harper-pro_865-ded21ec8

* Measure what the off-owner relay costs, and what shared state would replace

The relay's cost is the coordinating worker's event loop, not the message: an
off-owner lock() is 0.0164 ms with that worker idle and 1.008 ms at 1 ms of work
per turn, which is worse than the 0.68 ms cluster round trip delegations were
built to remove, for (N-1)/N of locks. notify() measures identical to
postMessage both idle and under load, so it is not the fix.

A local admission against a getUserSharedBuffer slot is 0.0002 ms and does not
move with owner load. It stays a follow-up rather than a replacement: a handle
is revoked after unlock() has already returned the native key, so the fence
cannot use that key lock and the revoke/ack machinery survives in full.

That same reading removes the narrowing ledger item 5 rested on, so probe it
rather than infer it. A commit handed to the engine is NOT cancelled by its
thread's termination (40 000/40 000 landed), but it keeps submission order
(0/40 000 inversions against a successor writing the same key). What is left
for rocksdb-js#865 is whether that ordering holds across threads, not whether
an abandoned commit survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdEmQwus4kj8YQVsJbWNAm

* Stop the exit-as-fence comments giving an argument that does not hold

Two of them were wrong and both were load-bearing. The restart quarantine is
read only where this node is the key's home. The native key lock only stops two
callers being inside a critical section at once: a caller that staged a write
and then unlocked has already returned the key while its write can still
commit, which is why revokeLease() fences capability rather than admission. So
the window needs no teardown anomaly, and the comments describing one understate
it.

What holds is the engine's submission ordering, which the new probe measures.
Say that instead, and narrow rocksdb-js#865 to whether the ordering is
guaranteed across threads rather than whether an abandoned commit survives.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdEmQwus4kj8YQVsJbWNAm

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review September 18, 2026 03:37
@kriszyp
kriszyp requested a review from a team as a code owner September 18, 2026 03:37
@kriszyp
kriszyp requested a review from cb1kenobi September 18, 2026 03:37

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Barrier waits honor the lock deadline, but their network operations do not. Propagate the deadline so a wedged peer cannot leak pending RPCs and sockets after every failed handoff.

—
Reviewed b67f797

Comment thread replication/recordLockFreshness.ts

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A matching re-stage ignores newly discovered participants, so an interrupted retry can forget an old-ring node and activate while it still grants. Widen or reject the stored quiesce set before treating the stage as idempotent.

—
Reviewed b67f797

Comment thread replication/recordLockHomes.ts
kriszyp and others added 4 commits September 17, 2026 22:22
…2667

Two conflicts, both mechanical:

- `core`: the branch pinned `cd56ca5f9`, the pre-merge head of harper#2667
  ("Record locks: relay a local lock() to the owner worker for threads.count > 1").
  That PR squash-merged as `1023c7d85` and the pin was left on a commit that is on
  no remote branch. Took main's pin, `72367d406` (harper main head), which contains
  the squash plus the review revisions #2667 took before merging; no harper-pro
  source change is needed for it (build clean).
- `replication/DESIGN.md`: main's #839 rewrote the clone-source-gate paragraph;
  the branch side was the older text with no record-lock content. Took main's.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven unresolved threads, each verified against the current code before acting.

Bounded the barrier RPC by the lock's own deadline (cb1kenobi, major).
`sendOperation`/`sendOperationToNode` only retire a response waiter — and only
close the per-call fallback socket — when they are given a `timeoutMs`, and the
barrier call site passed none. The deadline sweep settled the WAIT, so a member
that accepted the connection and never answered pinned a waiter (and a socket on
the fallback path) for the life of that connection, one per failed handoff.
`requestBarrier` now takes the remaining deadline and threads it through, floored
at 1ms because the fallback path reads 0 as "no bound".

Widened a recorded `quiesce` set on a matching re-stage (cb1kenobi, blocker as
filed). A re-stage naming MORE of the participant set than the first call took the
idempotent-noop path and left the narrower set durable. That set is the only record
of the ring this node stopped serving, and both later coverage checks — `planStage`'s
`uncovered` and `planSurvey`'s `unlisted` — read it, so an omitted node could go
unnamed by every later survey while it still granted under the old generation.
`planStage` now persists the union and never shrinks. Reachability is narrow (the
first record has to have been truncated, which needs a row that lost its old ring),
but the guard is a safety net and the fix is three lines.

Made the one-barrier-per-wait contract real (kriszyp). `RECORD_LOCK_FRESHNESS_DESIGN.md`
promised concurrent callers for one `(database, table, origin)` would share a request
and a nonce; the implementation issues one per wait, so `BARRIER_BURST = 400` could
refuse a legitimate wave of cold handoffs with a 429 that surfaces as a 503. Taking
the reviewer's second option: safe joining is only possible before the request is
dispatched, and dispatch is synchronous with registration, so a sound join window
means delaying every cold handoff by an event-loop turn to save on bursts. The doc now
states the implemented contract and why batching is left open, and `BARRIER_BURST` is
derived from `MAX_OUTSTANDING_BARRIERS` — a peer is refused only past what it can even
have in flight.

Replaced the stale enablement warning (kriszyp). It still told operators a handoff
loses updates pending harper#2542; level 4 establishes freshness before core admits.
It now names what is actually still required: an applied home map per database, and
every peer at the same level.

Bootstrapped the home map in the cost bench (kriszyp). Nothing locks until a generation
is staged and activated, so the three-node suite 503'd before measuring anything and the
standalone `on` arm asserted an acquire it could not get. Both now bootstrap as
`recordLockCluster.test.mjs` does; the pre-barrier and static-epoch comments are updated.

Simplified the non-boolean config check (gemini-code-assist).

The per-isolate `rowQueues` thread is already answered by the cross-isolate row lock in
`withRow` (harper-pro#852, covered by `recordLockHomes.test.mjs`); no code change.

Also in this commit: unit coverage for the request bound and the quiesce union.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y pays

Running the bench for the first time since the home-map bootstrap fix showed
measurement 4 reporting zero log cost for every uncontended acquisition and, under
contention, only `lockRelease`. `LOCK_ENTRY_TYPES` still named the level-1
`lockRequest`/`lockGrant` pair, which no longer exists, and omitted `lockBarrier`,
which is the per-cold-handoff entry the successor-freshness fence writes. That
understated the enablement cost the bench exists to measure, and let
`logSnapshotAfter`'s quiet detector call a window finished while barriers were
still landing.

Measured after the fix, 3 contenders, 3s: 294 `lockBarrier` entries at 29 B each
alongside 173 `lockRelease` at ~84 B; counters converged exactly on every run
(`lostUpdates: 0`, no repeated written value).

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… node's own generation

Two findings from the pre-push round that land inside this task's own delta.

`HARPER_TEST_RECORD_LOCK_RESTART_HOLD_MS` is read by nothing in this tree or in the
pinned core, and the comment I had just rewritten still claimed it lifted a hold. The
waiver a fresh bench node actually gets is `isFirstIncarnation`'s, which needs no env
var. Removed both.

`record_lock_propose_homes`'s leaving-node warning told the operator a departing node
"stays unable to lock until it is given its own generation". Following that advice is
the two-arbiter bug §4.1 exists to prevent: a singleton generation on the leaver makes
its own `homeMap()` home every key it is asked for, alongside the ring that just took
those keys over — `homeMap()` never requires `self` to be in `homes`. The warning now
says the leaver rejoins only through a later generation agreed and activated on every
node, and names why its own is wrong. The test asserts that clause rather than the old
phrase.

The wider contradiction the round raised with it — the per-node runbook activating
`homes(g) ∪ homes(g+1)` while `record_lock_apply_homes` skips a departing node — is a
contract decision, not a wording one, and stays for the human reviewer.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread replication/RECORD_LOCK_HOMES_DESIGN.md Outdated
@claude

claude Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp and others added 4 commits September 18, 2026 00:38
… leaver

The design note said two different things about the same manual operation. §2
issued `record_lock_activate_generation` "on every node named in
`homes(g) ∪ homes(g+1)`"; the `record_lock_apply_homes` section described the loop
it replaced as activate on "every node in `homes(g+1)`", and the
`record_lock_propose_homes` section told the operator to pass its list to both
operations "on every node". The union belongs to the drain evidence — the stage
responses the operator must collect before activating — not to the set being
activated. All three now say the same thing: stage on `quiesce`, activate on
`homes`.

Round 20 had left the underlying question for the human reviewer as a contract
decision. It is not one, and the deciding fact came out of this round's review.
Activating a departing node is mechanically accepted — `planActivate` never looks
at membership, `homeMap()` does not require `self ∈ homes` — so I first wrote it up
as a usability trade: a leaver with the agreed `g+1` would route its own `lock()`
to the new ring instead of answering 503. That is wrong. `record_lock_barrier`
admits only callers in the *answering* node's home map (`executeBarrier`'s
`isMember`), and a generation change puts the leaver's next acquire on the recovery
path, which asks every member for a barrier (`establish` with `dependencies ===
null`). Both new members refuse it 403, so the lock still fails 503 — activation
only moves the refusal from "no agreed home map" to a barrier the peers reject.
There is no upside to trade, so §2 now says plainly not to send one, with the
reason.

The review comment that raised the contradiction also claimed an operator
following §2 "always" gets refused, citing `recordLockApply.ts:510`. That guard
belongs to `record_lock_transition`, the internal relay of
`record_lock_apply_homes`, not to the manual operation §2 documents — the manual
call succeeds. The conclusion is the same; the mechanism is not, and the note
records the one that is real.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review round 31 (codex, kept by the adjudicator). `awaitRelay` bounded every
`record-lock-rpc` hop at a flat `RELAY_TIMEOUT_MS = 5s`, but one kind carries work
with its own budget: a `quiesce` hop asks the owner worker to sweep for up to
`STAGE_DRAIN_BUDGET_MS` (10s). A drain that legitimately took longer than 5s had
its relay resolve the `undefined` fallback first, so `quiesceOnOwner` threw 503,
`drainForStage` recorded the drain as unknown, and the operator fell back to the
full `DELEGATION_DRAIN_MS` wait — the wait harper-pro#856's drain exists to avoid —
on a drain that had in fact succeeded. Only reachable above one http worker, where
the operation lands off the owner.

`relayTimeoutFor` gives a `quiesce` hop its own `deadlineMs` plus slack, capped so
an absurd budget cannot pin a relay entry, and leaves every other kind on the flat
bound. Both hops use it: the worker→main relay and main's own forward to the owner.

Also in this commit, from the same round: `DESIGN.md` called the home-map
operations "Four" while listing five (`record_lock_propose_homes` was missing
entirely), omitted that `stage` returns a `quiesced` drain result — the only thing
that lets an operator skip the drain interval — and stated the relay bound as a
flat 5s.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nested relay timers (codex, minor, against the fix one commit earlier). Both
`quiesce` hops got `deadlineMs + slack`, but they are nested rather than parallel:
the caller's wait starts before main forwards, so an equal bound makes the OUTER
one fire first by however long main took to route — answering the fallback while
the owner's sweep is still inside its own budget. That is the same discarded drain
the previous commit fixed, moved one hop out. Only the forward hop is sized to the
sweep now; the caller's wait carries a second slack term, and the cap keeps that
ordering rather than clamping both to the same number.

`record_lock_propose_homes` told the operator to "compare digests across nodes
before activating". The digest is taken over `(generation, homes)` and every node
proposes its own floor plus one, so two nodes that agree on membership still differ
whenever their floors do — following that advice reads a real agreement as a
mismatch. The warning now says to compare `homes`, to pass the highest generation
seen to every node, and that the digest they agree on is the one computed from that
single list.

`STAGE_DRAIN_BUDGET_MS` accepted zero and negative values: `Number.isFinite` admits
both, and an empty environment variable reads as `0`. The failure is fail-closed,
not unsafe — core's `withDeadline` rejects immediately at `ms <= 0`, so every
delegation and grant lands in `outstanding` and the stage reports a drain it could
not do — but it silently costs the operator the full `DELEGATION_DRAIN_MS` wait on
every transition, which is exactly what harper-pro#856's drain exists to avoid. Any
value at or below zero now takes the default.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 33, against the previous commit's own fix: `> 0` alone still admits
`Infinity`, which would give `stage` an unbounded sweep rather than the 10s
reporting bound it advertises. Both guards now apply.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread replication/recordLockHomes.ts Outdated
kriszyp and others added 2 commits September 18, 2026 01:51
…ling

`Number.isFinite` alone admits a negative, which would make
`now - stagedAt < minDrainMs` never hold and silently retire the
"activated implausibly soon after staging" backstop. `>= 0` rather than
`> 0`, unlike `STAGE_DRAIN_BUDGET_MS`: zero is the value every cluster
fixture sets, so that a freshly started node can stage and activate back
to back.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y bench's clock domain

Round 35, both documentation. The `record_lock_propose_homes` warning was
corrected in the previous round but its design-note paragraph still told a script
to confirm agreement by comparing the returned digest — the exact advice that
reads a valid list as a mismatch whenever two nodes' generation floors differ.
The note now says what the code says.

`transport.bench.mjs`'s simulated shared-state admission compares a shared
`expiry` word against `performance.now()`, which is per-thread: every worker has
its own `timeOrigin`. It is sound as a cost measurement inside one origin, and
this bench is the evidence for the shared-slot fast path the relay writeup
recommends as a follow-up — so it is exactly the code someone will copy. Labeled
with what a production slot has to carry instead.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ruling on it

"Auth for the hop" recorded the mechanism — a node principal relays, each node
re-validates — and noted in passing that the replication dispatcher already
bypasses `verifyPerms` for node identities. It never said the consequence out
loud: a home-map transition is authorized by node identity ALONE, so any
principal `principalNodeName` resolves can stage and then activate a map of its
choosing on a peer, and the map is what decides which node arbitrates which key.
The digest check narrows that rather than closing it, because `homeMap()`
iterates its own `active.homes` and a node rewritten to a singleton never
consults a peer.

Ruled by the task owner on #822: accepted for this release within the existing
node-trust model. The note now states the consequence, the three facts the
ruling rests on (a node principal is already trusted to write replicated data
everywhere; this relay does not widen the authority, since the per-node
operations were already reachable that way before harper-pro#862 and the relay
only adds re-validation; nothing shipped is exposed because the feature is off
by default and inert until a generation is activated), and what would close it —
an operator-delegated proof carried on the transition, filed as harper-pro#869
and a prerequisite for recommending the feature in production.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uu8MPiSKga5Cv4E4jQVhmk
Comment thread replication/RECORD_LOCK_HOMES_DESIGN.md
…ed auth caveat

The ruling on the node-principal transition relay was to accept it and document
it. Documenting it only in `RECORD_LOCK_HOMES_DESIGN.md` is the weaker half: the
note names an operator-delegated proof as a prerequisite for production use, and
an operator who sets `replication.recordLocks: true` would never see that unless
they read the markdown first.

The startup warning right above already exists for exactly this reason — it says
what else the switch needs before a lock succeeds. This adds the caveat as a
second line rather than lengthening the first, because the two are different
kinds of thing: one is a runbook step the operator is missing, the other is a
known limitation they are accepting. Names harper-pro#869 so there is somewhere
to go.

Dispatch-Task: fix-kriszyp_harper-pro_822-6f44b99d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uu8MPiSKga5Cv4E4jQVhmk
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.

2 participants