Skip to content

Cluster record locks: amortized per-record ownership (Phase 1 of #483) - #2498

Merged
kriszyp merged 69 commits into
mainfrom
feat/record-lock-phase1
Sep 14, 2026
Merged

kriszyp merged 69 commits into
mainfrom
feat/record-lock-phase1

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 3, 2026

Copy link
Copy Markdown
Member

Phase 0 (#2462) made the rocksdb-js in-memory key lock the sole authority for table.lock(id), exclusive across one node's worker threads. This branch carries that verb across every node that replicates the database.

The Ricart–Agrawala arbitration rule is gone. table.lock(id) now runs amortized per-record
ownership: an operator-agreed home map supplied by the transport, a rendezvous-hashed home per
key, and a volatile delegation that serves repeat locks from the Phase 0 key lock with zero cluster messages. Releasing the application lock deliberately does not release the delegation — that is the amortization, and the unit suite asserts it directly: 26 locks on one key from one node, spread over 25 seconds of its own clock, cost one round.

durable commits frame deliveries one node down
Ricart–Agrawala, P=12 13 143 every cluster lock blocks
First lock on a cold key 0 2 unicast only that node's ring share
Repeat lock under a live delegation 0 0

docs/record-lock-ownership.md specifies the replacement, and is the substance of this push. Three levels at three very different
rates:

  • Home map (rare, operator-agreed, immutable) — per database, (generation, homes[]), published by an administrator and never derived from liveness. It is immutable for the life of its generation: nothing a node observes — an unreachable peer, a restart, a partition — changes it. No consensus runs at any rate. Agreement is that every node holds the same generation, checked by digest before the feature is enabled.
  • Home node (derived, free) — within a generation the arbiter for a key is a rendezvous hash over homes[]. One arbiter per key is trivially exclusive, which deletes the entire grant state
    machine: no deferral queues, no (tsR, nodeName) tiebreak, no synthesized grants, no split votes,
    no revocation protocol.
  • Delegation (volatile, the amortization) — a node asks a key's home for the exclusive right to admit critical sections on that key for a bounded time. With a live delegation lock()/unlock() are pure Phase 0: the local key lock, zero cluster messages. Releasing the application lock does not release the delegation, so a node writing the same record repeatedly pays one round and then nothing, and the delegate is in practice the last writer.

Steady-state cost goes from P+1 durable commits and P²−1 deliveries per lock to zero of each; a first lock on a cold key is two unicast messages and one RTT to the home; a handoff is one durable release plus P−1 deliveries. A node that is down blocks only its own share of the ring, until an operator publishes a new generation — that price is the design decision, not a caveat, and §4.2 of the note states it in those words.

This is close to what harper-pro#438 filed as its own "Phase 1 — single-owner delegation", and to what #483 describes in prose ("if an exclusive lock is held by one other node, the lock can be requested from that node"). The home map is deliberately its own recordLockHomes generation rather than server.shards: a shard map is a residency directive, so reusing it would couple arbitration to data placement and require sharding configuration, which one customer uses.

Product and architecture tour

What a cluster lock() promises now

Is exclusion-only the right guarantee to ship, given what it leaves for the caller to know?

Before After
Ricart–Agrawala: every participant must grant, so one unreachable peer blocks every cluster lock in the database, and each acquisition is P+1 durable commits and up to P²−1 frame deliveries — 13 and 143 at P=12. One arbiter per key. An unreachable node blocks only the keys it homes. A first lock on a cold key is one unicast round trip; a repeat lock under a live delegation is the Phase 0 key lock and zero cluster messages.

lock() guarantees that at most one node admits a critical section for a key at a time, and that a node admitted after another released cleanly has applied that node's committed writes — while the key's home still holds that handoff's dependency set.
It adds no fencing generation to conflict resolution and does not confirm locked writes to a quorum: two conflicting writes resolve exactly as they would without a lock, so a predecessor's write can still outrank its successor's, and successor freshness is not promised once the dependency set is gone. Neither limitation is crash-only; both are reachable on a clean handoff.
There is no caller-side mitigation. X-Replicate-To / confirm= is super-user-gated and a residency directive first, and it closes none of the freshness routes anyway. The normative wording is §10 of docs/record-lock-ownership.md; the API docs may not soften it (#2547).

The alternatives — fenced (generation, timestamp, origin) conflict ordering, and quorum-confirmed locked writes — are deferred to #2540 with their costs recorded. Fenced mode would change conflict resolution for every write in the database, including in deployments that never call lock(), and invert a rule Phase 0 documented and shipped, in exchange for one crash-shaped case. Both limitations ship silent: a caller whose locked write loses gets a 200, no log line and no counter. That is recorded in §10 as a choice, with the cheap lock-path detection routed to #2541.

Three levels at three rates

Where does agreement come from if no consensus runs, and what does harper-pro still owe?

Where each level lives

The only agreed state is published by an operator; everything per key is derived or volatile.

flowchart TB
    subgraph harperpro["harper-pro (owed: harper-pro#825)"]
        E["Home map<br>(generation, homes, homeIncarnation)<br>operator-published, immutable per generation"]
    end
    subgraph core["core (this PR)"]
        H["Home node per key<br>rendezvous hash over homes"]
        D["Delegation<br>exclusive right to admit K, bounded time"]
        K["Phase 0 key lock<br>rocksdb-js, process-wide"]
    end
    E -->|"transport.homeMap()"| H
    H -->|"one grant per key"| D
    D -->|"repeat lock: no message"| K
Loading

A cold lock, a repeat lock, and a handoff

  1. Cold key — Node B computes homeFor(K, members), sends one unicast requestDelegation to that home, and installs the grant with its deadline anchored at the moment it sent the request.
  2. Repeat lock — B holds a live delegation with room for the lease, so lock() takes the local key lock and sends nothing. Releasing the application lock does not release the delegation.
  3. Contention — Node C asks the same home. The home records a recall against B, replies contended, and C retries inside its own timeout.
  4. Drain — B stops admitting, waits for live admissions, then revokes every handle it admitted — including ones already unlocked whose writes are still staged — and writes a lockRelease entry carrying the full fencing token.
  5. Re-grant — the release, ordered behind B's own data writes on B's replication stream, clears the grant; the home now grants C.

homeFor is a rendezvous hash, chosen over a modulo because a membership change then re-homes only the departing member's keys — every re-homed key pays the recovery path on its next lock. Delegations run for DELEGATION_LEASE_MS (six minutes), deliberately longer than any lock lease they admit: a delegation sized to one lock has no room for the next, so every repeat lock would renew and pay a round trip. The first implementation had exactly that defect and its amortization test passed only because the fake clock never advanced inside the loop.

The exclusion argument

Which rules stop two nodes from admitting the same key, and where does core rely on the operator instead of enforcing?

One delegate per key per home. A home never holds two live grants for a key, and re-grants only after the previous delegation was recalled-and-drained or provably expired on the home's own clock plus LOCK_LEASE_SKEW_MS.
The home outwaits the delegate. Both sides measure on their own monotonic clock; no remote timestamp is ever compared against a local one. A delegate may drop a delegation early; a home may never forget a grant before its expiry.
Fencing tokens are ordered. (generation, homeIncarnation, counter), compared lexicographically. A release carries the whole token and is matched on all three, because counters restart when a home restarts.
A delegation is authority within one generation. A delegate drops a delegation whose token belongs to a superseded generation on next use, and a node refuses a map whose generation went backwards — a rollback re-mints tokens ordering below ones already issued.

Example: a delayed grant reply

B asks the home for K with a 100 ms lease. The home grants and starts its clock. The reply is delayed six seconds; meanwhile the home's grant expires and it grants K to C.

Outcome: B's deadline was anchored at send, so the reply is discarded as having outlived its own delegation and B asks again. Anchored at arrival — the first implementation — B would have installed a fresh delegation alongside C's.

Two hazards are handled by different mechanisms because they are different. An in-process transport swap (a component reload re-registering a transport) is handled by adoption: the successor coordinator takes the predecessor's delegations and grants in its constructor before the predecessor is closed, so nothing is dropped and no wait is paid. A process restart cannot be: core has no record of what its previous incarnation issued. Under the epoch design that interval was the transport's to hold — do not name this node in an epoch until a previous incarnation's delegations could have expired, or advance the epoch number — and neither half survives an immutable map, because a generation does not advance merely because a process restarted. So core enforces it: #grantableAfterMono now refuses to grant as a home until DELEGATION_LEASE_MS + skew on the monotonic clock, which counts from process start, so every lazily created table coordinator computes the same horizon. The earlier objection to doing this in core — it made the real-table suite hang for six minutes — is answered by ClusterLockTransport.grantableAfterMono, the explicit override for a caller that can prove a previous incarnation issued nothing.

Recall revokes capability, not admission

Is fencing at commit time the right place to stop a recalled delegate, rather than at unlock()?

Example: a write staged, then unlocked, then recalled

Inside an explicit transaction, B locks K, stages a write through the handle, and calls unlock(). The admission count is now zero, but the write is still uncommitted in B's transaction. A recall arrives; the home re-grants K to C.

Outcome: the delegation retained a revoker for B's handle and calls it on surrender. handle.revokeLease() expires the handle, and the commit-time fence in DatabaseTransaction rejects the staged write with 409 immediately before the native commit submits. A drain that waited only on the admission count — the first implementation — would have let B's write land after C was admitted.

The commit-time fence is the mechanism §6 depends on, and it now runs only when the transaction actually holds a lease-protected write (hasLeaseProtectedWrite, reset by clearWrites()), so a bulk transaction of plain writes in a core-only deployment pays nothing for it. Admissions carry their delegation's token, and release() and registerAdmission() take it back: a key's delegation can be replaced while a handle is open, and an untokened late unlock() would otherwise decrement the successor's count and let it be surrendered while its own callers were still inside.

Boundaries and non-goals

What is deliberately not here, and why can this not be enabled even on purpose?

Before After
LOCK_REQUEST = 9, LOCK_GRANT = 10, LOCK_RELEASE = 12; payload [key, requester, tsR]. Only LOCK_RELEASE = 12; payload [key, requester, generation, homeIncarnation, counter], validated on exact tuple length. Nibbles 9 and 10 are retired, not migrated — main has since taken 9 for eviction, which the rebase surfaced as a duplicate-key type error.

Not implemented, deliberately: the successor-freshness fence of the design note's §7 — the inherited (origin → position) dependency set on the release and the recovery barrier — is #2542, and harper-pro's operator-agreed home map is harper-pro#825. Until both land a handoff carries exclusion but not the clean-handoff freshness §2 states. The feature cannot be enabled meanwhile: core fails closed without an agreed map, and no core build supplies one. Every fail-closed path — no map, a rolled-back generation, no named homes, an unreachable home, a closed coordinator, a non-coordinating thread — rejects with a retryable 503 rather than downgrading to a node-local lock. harper-pro#822 implements the previous transport interface (participants, ownsCoordination) and pins core to a pre-rebase commit; against this head its transport does not register, so it needs the interface, the home map and the delegation server before the two can be integration-tested together.

  • DELEGATION_LEASE_MS is six minutes (MAX_LOCK_LEASE_MS + 60 s). That is the longest a key stays pinned to a crashed delegate before its home re-grants. Is that the right availability-versus-amortization point, or should it be configurable per table?
  • The restart quarantine is now on by default in core (six minutes on this node's own share of the ring), because an immutable generation leaves nothing external to hang it on. Is that the right default, or should harper-pro be expected to set grantableAfterMono on every ordinary start and leave core's bound as the failsafe?
  • §4.3 requires an operator to stop a node before publishing a generation that removes it — declaring it removed is not a fence. Is that acceptable as a runbook requirement, or does it need the leased generation capability §9 records and overrules?
  • Exclusion-only ships two silent limitations. Is a 200 with no log or counter acceptable for a lost locked write, or should the lock-path detection routed to Record locks Phase 1: rendezvous home ring, per-record delegations, and drain/recall #2541 land before enablement?
  • A single arbiter per key deletes the contention protocol, but an unreachable home blocks its share of the ring until an operator publishes a new generation — an outage bounded by response time, not by a protocol. Is that preferable to the per-key quorum voting the note rejected?

For the human reviewer

  • This push is test-only, and it is two findings wide. The single open review thread asked for the regression the follower-reports-leader-failure commit had said it did not have, and the pre-push round that reviewed that test found the previous commit's failing-writer test did not actually test containment. No production code changed.

    finding ruling
    @claude, Table.ts:2880 — the follower-reports-leader-failure contract has no regression test accepted, fixedthe follower now reports the leader's 503. The suggested shape needed one extra constraint: leaderFailure is read only on the remaining <= 0 line, and the follower's own deadline timer is armed for exactly that budget. The leader's rejection travels on microtasks and the deadline is a timer, so holding the event loop past a 1 ms budget settles the race on the leader's error with remaining already negative
    pre-push round 36, graded leg — 77ac2ddc5's failing-writer test passes whether or not the throw is contained accepted, fixed#surrender deletes the delegation before it calls the writer and the contender's rejection is caught, so both original assertions hold either way; removing the try from #writeControlSafely and rebuilding dist left it green. The home can tell: a recall that resolved is confirmed and never re-sent, a rejected one is re-sent past RECALL_RETRY_MS. 2 !== 1 without the containment
    Gemini, Table.ts:2855-2866 — implicit undefined return drops exclusion for a coalesced follower whose handle expired refuted at the anchor — the if block is followed by return retryOnRemainingBudget(); at Table.ts:2871. There is no fall-through
    Gemini, recordLock.ts:226release() sets expired, so an early unlock aborts the commit refuted, againrecordLock.ts:324-326 sets released and clears the timer; only revokeLease and #onLeaseExpire touch expired
    Gemini, Table.ts:2933 noop and Table.ts:972 logger are possibly undefined refutednoop is a hoisted function declaration at Table.ts:8075, logger is imported at Table.ts:83
    Gemini + graded leg, Table.ts:5851 — table/database retirement can orphan live coordinator authority real, deferred on reachability — filed as #2591 (P2, epic Add ability to exclusively lock a record for performing a safe operation #483). The earlier "pre-existing, with no line in this delta" reading was wrong: recordLockCoordinator.ts does not exist on main and main's Table.ts contains no lockCoordinator. What holds instead is reachability — lockCoordinator is only constructed behind a registered transport (Table.ts:5857-5866) and registerClusterLockTransport has no non-test caller in core, so nothing can be orphaned in any current build. The gap is that the drop path never calls close(), so the retiredCoordinators counter floor is never written and a same-name re-create restarts #counter below tokens peers still hold. It is testable only once harper-pro registers a transport, which is where the fix belongs
  • This push is the PR-review adjudication round: five open threads from @cb1kenobi, four fixed, one refuted. Four of them had already landed in earlier commits of this session; the fifth was still live and is fixed here, and following it turned up three more in the same surface.

    thread ruling
    commit-time fence dereferences a detached write declined, refuted at the anchorDatabaseTransaction.ts:1399 filters the holes out of this.writes on the line above the loop, inside the same if (transaction) block, and that filter is pre-existing on main (8d69f1bd1)
    delegation gate rejects any requester homes[] does not list fixed in 009cde857, by the second of the two routes offered — the contract now says homes[] names every locking node. §9's Adjacent row still read the other way and is corrected here
    generation denials spin to a 423 fixed in 009cde857generation and unknown-node both throw 503, and the terminal 423 is gated on contended alone
    a renewal does not re-arm the recall latch fixed in f20d98457, by the opposite move: a renewal is refused while the latch is set rather than clearing it, so the release already on the wire for token n stays the thing that completes the handoff
    an unrouted recall resolves as success fixed here — still live at 71d32bf6d, whose "stop swallowing the resolver error" was a different site
  • A recall that did not apply is not a confirmation, in three places. deliverDelegationRecall is core's receiving end of recallDelegation, whose contract is "resolves once the delegate has drained and stopped admitting", and #beginRecall latches recallConfirmed on any resolution and never re-sends. It returned normally when the transport-gated resolver yielded nothing — the reconnect window Table.lockCoordinator deliberately keeps the coordinator alive for — so the home denied the key to every other node for DELEGATION_LEASE_MS + skew. It rejects now. The same shape was in #beginRecall's own local branch, which assigned grant.recalling and never cleared it on a rejection; and transport-gating turned out not to be enough, because acquire refuses off the owner thread, so a delegation only ever lives on the coordinating one and a recall routed anywhere else found nothing and resolved — onDelegationRecall is ownership-gated now, like onDelegationRequest.

  • The takeover quarantine is anchored on homeIncarnation now, not on a sampled boolean — and this is the part worth your eye. Three pre-push rounds walked it down, each finding a defect in the previous round's fix, which is the honest reason to look at it rather than take the last one on trust:

    1. #ownershipHorizon was read only from #grant, and onDelegationRequest answers not-home before reaching it while this thread does not own coordination — so a non-owning interval was never observed at all, and ownership moving A→B→A left A's anchor dated from before the gap.
    2. Polling ownership on the tick fixed that but not the waiver: clearing #quarantineWaived on an observed gap never fires on a coordinator that never owned, and Table.lockCoordinator constructs one on every non-owning worker. It also kept every once-owned table in the 100 ms tick set for the life of the process.
    3. A sampled boolean cannot prove continuity at all. §5.1 already advances homeIncarnation once per coordination incarnation — that is what keeps the fencing token orderable — so a value this coordinator has not granted under is the transport stating that something else coordinated for this node, whatever the boolean said in between. ownsCoordination() stays as a weaker second signal — it catches a thread that stopped coordinating without anything else starting — so the tick lifecycle went back to what it was.

    The waiver now ends wherever ownership begins after construction, which is the one thing grantableAfterMono's attestation ("no previous incarnation of this process issued anything") cannot cover, and handOffTo carries it rather than letting a transport reload re-latch it off the new transport. handOffTo also stopped copying blanks over the successor's own reading: a predecessor with no ownership instant and no incarnation re-armed a quarantine on a node that had never stopped coordinating, and it cascaded through every later reload on the thread.

  • getNodeNameForId(…, rebuildOnMiss) reads the mapping once per window now, not once per entry. Raised in two rounds by two models, and the refutation recorded in an earlier revision of this description — "a burst costs one read, not one per entry" — holds only for ids the translation path actually minted, which it did not say. For an id this database will never resolve (a node whose mapping was purged, an origin relayed from elsewhere) the bypass cost an exportIdMapping read and unpack each, on the replicated apply thread, which is the burst the interval exists to keep off it. The bypass is kept and bounded: a rebuild inside the window is not stale, because writing the mapping calls invalidateNodeNames and drops the cache entry outright.

  • Declined this round, with reasons, so they are not re-filed.

    • A failed leader acquisition wakes every coalesced follower into its own retry — O(N) failing RPCs for one outage. Real, and deliberate: each waiter carries its own remaining budget, and propagating the leader's terminal error would hand a follower with a longer timeout a failure it might have waited past. Changing the coalescing contract under bot feedback is not a call to make in an adjudication round; it belongs to whoever settles the 423/503 boundary above.
    • The local-recall regression stubs onDelegationRecall instead of inducing a failure through it. The subject under test is #beginRecall's bookkeeping when a recall fails; the mechanism of the failure is not part of it, and the alternatives available (a throwing keyIdOf) would test a different thing while reading as if they tested this one.
    • Comment density. Trimmed where it narrated review history — the two verify: markers in §7.2 were an agent workflow instruction shipped in a committed document and are now stated as open questions against harper-pro, and the duplicated block above admittingCoordinator belonged on lockCoordinator. The rest is the orientation this repo keeps next to the code, declined for the fourth round running.
    • Refuted again at their anchors, each previously recorded: release() sets released, never expired (recordLock.ts:324-337); noop is hoisted at Table.ts:8075; writeLockControlEntry's synchronous encode cannot escape, because its only caller reaches it through #writeControlSafely's try/catch; stageWrite is not a synchronous function leaking a promise — all three callers collect its result into writePromises; decodeLockControlPayload has its own try/catch around both the unpack and decodeTuple; and Table.ts:2831's timer calls reject, it does not throw inside setTimeout.
  • The staged transition §4.3 now specifiesstage, acknowledge-or-fence, drain, activate, with every acknowledgement bound to the acknowledger's homeIncarnation and one durable activation record. That section is where a reviewer who wants to disagree with this design should start.

  • A blocking review finding on the key predicate is fixed, and its reachable set is wider than the finding said. isEncodableKey had drifted back to being tighter than ordered-binary. Too strict is not a safe direction here: the predicate gates onDelegationRequest on a key's home, so a shape it refuses gets not-home — which acquire retries every 25 ms for the caller's whole timeout and then converts to a 423 on a key nobody holds. The asymmetry is what hides it: #grantLocally never goes through onDelegationRequest, so the same table locks fine on its self-homed keys and fails only on the peer's share of the ring. decodeTuple uses the same predicate, so any lockRelease such a key does produce is dropped at every receiver.

    f2ef9375 on this branch set the rule — match the encoder — and the 50b966d1 rewrite lost it, restoring bigint but not binary, boolean or null, and adding a Number.isFinite that refuses two more shapes the encoder takes. The reviewer named three shapes; the reachable set is not the same three. checkValidId (resources/Table.ts:6694) rejects a scalar boolean, null and NaN before lock() can reach the coordinator at all — but it passes all three inside a composite key, and Id itself declares (number | string | null)[]. It also passes Infinity, which the reviewer did not name and Number.isFinite refused. So the predicate now states the one rule that covers the set without enumerating call sites, and the restored agreement test is what keeps the next rewrite from drifting off it a third time.

    Two regressions, each failing on the parent commit after a dist rebuild: the fake-cluster acquire grants a Uint8Array key homed on a peer instead of throwing "Record is locked and was not released in time", and the real-table test fails at "the home granted a Bytes primary key". The second one exists because the coordinator suite's keyIdOf is String(key) and never touches ordered-binary; it is the first test on the branch that pins binary-key identity across the wire against the production encoder.

  • One pre-push blocker this round was refuted, and it is the third time the same claim has come back. The claim: handle.release() sets expired = true, so lock(); save(); unlock(); commit() inside the lease hits the pre-submit fence and aborts. release() sets released and clears the timer; only #onLeaseExpire() and revokeLease() set expired, and isLeaseExpired() reads expired || #leaseLapsed(). The distinction is the point of a1a79a7c, and a staged write survives unlock() even when a replay re-saves it in recordLock.test.js executes exactly that sequence and asserts the write lands. Recorded here rather than only in the review log because the same finding was dropped as factually wrong in two earlier rounds.

  • And one from the same round that was factually wrong at its anchor. The claim was that a Uint8Array record id breaks the delegation and grant Maps, because msgpackr hands the receiver a new object and a Map compares objects by identity. keyIdOf is writeKeyId (resources/DatabaseTransaction.ts:513), which returns toBufferKey(key).toString('latin1') — a string, so every shape is compared by value. The finding's own premise ("the tighter predicate restricted keys to primitives") never described the old code either, since arrays were already accepted and arrays are objects. The real-table test above asserts writeKeyId(decoded.key) === writeKeyId(sent key) across the Uint8ArrayBuffer change and that the delivered release clears the grant, so this is now settled by execution rather than by reading.

  • Two review findings were declared "addressed at this head" and were not, so this push fixes them. The reviewer that filed them at 729aefd2 cleared every prior blocking thread at 14c1b551; git show 729aefd2:resources/Table.ts and :resources/recordLockCoordinator.ts show scopeViolation and #pruneAdmissions byte-identical to 14c1b551, and neither commit in between touches either function. Both original claims hold on the code:

    1. scopeViolation gated its fall-through on transport presence alone, while
      lock()'s entry guard fails closed on scopeRequested || isClusterLockRequired. Those two
      predicates disagree across an await: a cluster-scoped caller that coalesces onto an in-flight node-scoped acquisition re-checks its scope only after that wait, and a transport unregistered during it — a swap, which leaves isClusterLockRequired true — made the check answer "no violation" and handed back the leader's node-scoped handle for an explicitly cluster-scoped request. Both guards now read the same predicate, so only the implicit Phase 0 case falls through. The answer is the same 409 the identical call sequence gets without the race, not a 503, because the transaction really does hold a node-scoped lock on the record.
    2. #pruneAdmissions returned at the first admission still inside its lease. That is exact only when every admission shares one lease length; a longer-lease admission at the head hid every shorter one behind it, and — contrary to what its own comment claimed — no later call collected them either, so retention grew at the lock rate rather than with the live set. The leading-run fast path stays; an expiry sweep now runs once the map has outgrown the live set the previous sweep measured. The tradeoff is explicit and worth your eye: that sweep is O(size) synchronous work on one acquisition, in exchange for amortized O(1) per admission and a map bounded at twice the live set instead of unbounded. The sweep can only drop an admission whose own expiresMono has passed, which is the §6 property — asserted directly by the second regression. stats.revocable makes the retention observable, which the finding noted it was not.

    Only the retention half of finding 2 is fixed. The per-lock() allocation half stays deferred below, unchanged: bounding what admission records retain does not make a cached hit allocation-free, and §8 now says both things rather than only the aspiration.

  • One finding from the pre-push round on this push is fixed and is worth naming, because it is a rule this branch wrote down and then broke one surface of: warnClusterReleaseFailure latched for the life of the process, while warnOnce two files over states the rule and the reason — a latch shows an operator the first occurrence and hides a fault that persists. A transaction log that stops accepting control entries would have warned once and then gone quiet while every peer fell back to waiting out leases. The reviewer's follow-on suggestion to export warnOnce and reuse it is declined with a concrete disqualifier: recordLockCoordinator.ts computes DELEGATION_LEASE_MS from recordLock.ts's MAX_LOCK_LEASE_MS at module scope, so importing back the other way puts that read in the temporal dead zone.

  • Three findings from that round were checked and refuted, recorded here so they are not re-filed: a claimed blocker that writeLockControlEntry never commits its audit write (refuted by recordLockCluster.test.js, which waits for a lockRelease entry to appear in the real audit store and passes); a claim that the coordinator suite shares one fake clock (its header and FakeCluster.advance(name, ms) do the opposite — one node's clock moves at a time, which is why §12 asked for it); and a claim that getNodeNameForId(…, rebuildOnMiss) lets a peer force a store read per entry, which needs a node id that is permanently absent from the mapping — the first rebuild resolves any id the translation path actually minted, so a burst costs one read, not one per entry. A permanently-unresolvable id would be a replication-layer invariant break, and every such entry is already discarded and logged.

  • The guarantee decision is made (2026-09-09): exclusion-only. lock() guarantees that at most one node admits a critical section for a key at a time, and that a node admitted after another node released cleanly has already applied that node's committed writes — but only while the key's home still holds that handoff's dependency set. It adds no fencing generation to conflict resolution and does not confirm locked writes to a quorum, so two things are part of the documented contract. Neither is crash-only, and both are reachable on a completely clean handoff:

    1. a predecessor's write can outrank its successor's under LWW, because LWW compares the timestamp assigned when the write was staged and lease expiry orders admissions, not timestamps. lock() changes nothing about conflict resolution — whatever two conflicting writes would do to the record without a lock is what they do with one, silently. (1a) the predecessor's clock ran ahead and its write is still in flight; (1b) it stamped a future context.timestamp — deliberate Phase 0 behavior — then committed, replicated, drained and released cleanly, and the successor's write is the one that loses. No crash, no skew, nothing in flight;
    2. successor freshness is not promised once the dependency set is gone, because the recovery barrier only drains streams from reachable members. Three routes: the predecessor crashed, it is unreachable, or its native commit settled after the barrier was measured. Two damaged effects on the one record — the predecessor's transaction not reflected, and the successor's write computed from the stale value it read.

    There is no caller-side mitigation for (2) at all. X-Replicate-To / confirm= is super-user-gated (checkContextPermissions, resources/Table.ts:7258 — a 403 for an app caller), and for a super-user it is a residency directive first: a numeric value truncates the record's residency (getResidency, :1574) and * falls back to the database's configured replication.replicateTo. Either way a successor's barrier can satisfy over members that never held the locked write. Even with cluster-wide residency, confirmation closes none of (2)'s three routes — the late-settling commit needs fencing, so §2's invariant as written needs both deferred arms. Both limitations also ship silent — a 200, no log line, no counter — which §10 records as a deliberate choice with the cheap lock-path-only detection routed to Record locks Phase 1: rendezvous home ring, per-record delegations, and drain/recall #2541.

    The normative text is §10 of the note, which DESIGN.md points at rather than restating.

    Ten pre-push rounds ran on this push, and the first nine each found the contract text promising something the code does not. In order: crash-only; the late-settling commit filed under the wrong limitation; clean-handoff safety; the LWW mechanism sentence stated backwards; the drop-vs-fold rule splitting on the wrong operand; a whole-write loser guarantee false for a patch on disjoint fields; a per-field one false for CRDT ops; a recommendation to use X-Replicate-To: N;confirm=M that would have narrowed the record's residency and made the limitation more reachable; that the same header is super-user-gated, so it was never a caller-side mitigation in the first place; and — round 10, in code rather than in the note — that the super-user gate itself is bypassable. It converged only when the contract stopped restating engine conflict-resolution and said the thing that is actually true — lock() does not change how conflicting writes resolve, and there is no lever a caller can pull. That history is the argument for §12's schedules asserting the limitations as executable expectations rather than leaving them in
    prose: a paragraph about a conflict-resolution edge does not stay true on its own, and this one is
    going into user-facing API documentation.

    The choice also removes an exception §8 was carrying: with no fencing generation, ordinary writes keep their ungated path with nothing added, which is the property the fenced arm would have given up.

  • One earlier claim in this PR's body is withdrawn. It said a crashed holder's in-flight write is stamped older than any successor's and so resolves under LWW exactly as if the release had not overtaken it. That holds for a clean release. It does not hold for a crash: if the crashed holder's clock runs ahead, its delayed write can carry the greater timestamp and overwrite the successor. Monotonic lease expiry orders admissions, not timestamps — §7.3 is that hole, and the table above is the choice about it.

  • The freshness property this branch gets for free is the one the new transport must pay for. A grant here rides the grantor's own replication stream behind the grantor's data writes, so applying a grant implies having applied that grantor's earlier writes to the key. Unicast delegation messages lose that, and a scalar record version does not restore it — core breaks equal-version conflicts by node name, so a replica can hold a losing value at the same timestamp and pass a version ≥ V test. §7.1 replaces it with an inherited (origin → position) dependency set on the LOCK_RELEASE entry.

  • The work is decomposed and the remaining blocker is measurement. Record locks Phase 1: rendezvous home ring, per-record delegations, and drain/recall #2541 (home ring, delegations, drain and caps, plus the three inherited substrate defects below), Record locks Phase 1: successor freshness — inherited dependency sets and the recovery barrier #2542 (successor freshness), Record locks Phase 1: durable membership epoch protocol (single-decree agreement per database) harper-pro#825 (the membership epoch protocol), and Record locks: measure the Phase 1 cost baseline before the protocol change harper-pro#824 (the measurement gate, the only piece unblocked today).

  • The measurement gate comes before the protocol change, not after it. No number in the note is a benchmark; they are message counts. Acquisition latency on a real cluster, audit growth per lock, and throughput with the feature disabled are the first deliverable, and they also set the baseline the new design has to beat.

  • The pre-push review ran fifteen rounds on the code that is here. Roughly half the defects it found were introduced by an earlier round's own fix, which is a property of this state machine rather than of the review — and it is the strongest single argument for a design whose arbiter is one node instead of a quorum. That history is preserved in the commit log; the note's §9 records why quorum voting, a replicated Raft coordinator, and a server.shards-based owner map were each considered and not chosen.

  • Three defects the cross-model review found in the substrate, not in the rule being deleted. They are recorded in §11 of the note as obligations on the replacement, and are not fixed in this push — the branch does not merge with main yet and two of the three are touched by the replacement anyway. Named so they are not lost: re-registering a transport closes the current coordinator and installs an empty one without invalidating the handles the old one issued, so a successor can grant the same key with no lease time elapsed (resources/Table.ts:5474); a LockUnavailableError from coordinator construction escapes the direct receive callback, which the subscription sink already contains (resources/recordLockCoordinator.ts:908); and the commit-time lease fence scans the whole write set on every commit, including in core-only deployments that never register a transport, which contradicts the ungated-path goal (resources/DatabaseTransaction.ts:1213). The review's other four majors are inside the Ricart–Agrawala state machine and are deliberately left alone.

  • Two substrate decisions from the original design still stand and are still worth a check. Control entries carry recordId: null and the locked key in the payload, because a control entry carrying the key answers _writeUpdate's keyed dedup lookup at exactly ts_R and silently drops the holder's own first write. And node identity is the node name, never the audit nodeId: short ids are per-node, so a (ts, nodeId) order would order the same pair differently on two nodes and both would grant.

  • THE DESIGN CHANGED IN THIS PUSH, and this is the first thing to read. Round 7's planning gate returned Framing-Verdict: better-alternative-exists against the membership-epoch protocol, and on 2026-09-13 the repo owner adopted it. The epoch protocol is deleted, not deferred: §4 is now an immutable, operator-agreed home map — a recordLockHomes generation published by an administrator, agreed across peers by digest before the feature is enabled, and rendezvous-hashed over exactly as before.

    The argument in one sentence: the epoch protocol automated a decision that has a human authority. Whether an unreachable node is briefly down or permanently gone is knowledge an operator holds, and inferring it safely from timeouts costs a durable consensus subsystem — persisted promises and accepted values, ballots, renewal leases as the liveness signal, acceptor-side retirement reservations, transitive activation protection, and a restart quarantine to rebuild reservations a crash lost. All of that is gone. What it bought was unattended rehoming, and the price of not having it is stated plainly in §4.2 rather than hidden: an unavailable home's keys stay unavailable until an operator acts.

    before now
    membership change single-decree agreement over a majority operator publishes a generation; fail-closed during the change
    harper-pro owes harper-pro#825: the epoch protocol, acceptor promises persisted before ack, retirement reservations, transitive activation a config generation, a peer digest check, and the §4.3 change runbook
    core carries epoch numbers, ringVersion, rehoming, a recovery barrier for re-homed keys a generation, a monotonicity floor, and its own restart quarantine
    a dead node's keys recover when the epoch advances on its own recover when an operator publishes a new generation

    Cold acquisition is still two unicast messages and one RTT; a repeat lock under a live delegation is still zero cluster messages; an unreachable home still blocks only the keys it homes. §§5–8 are untouched.

  • The round-8 planning review found the hole the recut left, and §4.3 is the fix — look here hardest. Its blocker: publishing g+1 does not revoke a live g. A node that never receives the new generation keeps serving the old one from a copy that is internally consistent, and a one-time digest check cannot detect staleness. A and B hold g={A,B}; A partitions; the operator declares A removed and publishes g+1={B}; B drains and re-homes A's keys; A — still running — grants one of them to whoever it can still reach. Two holders under one key, and a drain measured from publication does not bound it because A never stopped.

    §4.3 is now stage → acknowledge-or-externally-fence → drain → activate, and it says the thing the first draft left implicit: declaring a node removed is not a fence; stopping it is. That is a runbook requirement on harper-pro, and it is the step the whole safety argument rests on — it is the most reasonable place to disagree with this design, so it is called out rather than buried.

    Two more of that round's findings are adopted in code: a generation below one already acted on is refused (a rollback arrives by config restore or partial publish, and re-minting under an older generation issues fencing tokens ordering below ones already handed out), keyed per (database, table) because that is the granularity tokens are actually compared at; and the routing hash is versioned by the protocol capability rather than by the generation, because a generation is operator-published and says nothing about which hash a binary implements — two binaries could accept one map and still derive different homes for a key.

    Its own alternative — leased generation capabilities that nodes renew from the control plane, so a partitioned old generation is fenced mechanically — is recorded in §9 and overruled on one fact: it makes every node's ability to lock depend on continuously reaching the control plane, so a control-plane outage longer than the capability lease stops record locking on every node, including healthy ones that agree with each other. It also reintroduces the renewal lease and clock-rate bound this revision exists to delete. What it genuinely buys is recorded rather than dismissed, and §9 names the condition under which it should be taken instead.

  • Round 9 found two blockers in that quarantine, both fixed, and the first one is the most interesting bug on this branch. Turning the quarantine on by default made two paths reachable that were dead while it defaulted to -Infinity:

    1. It bounded the wrong lifecycle. The horizon was performance.now() read as process uptime — but performance.now() and performance.timeOrigin are process-wide inside a worker thread too. A probe spawning a worker 1.5 s into a process reads now() = 1554.6, not ~0. Coordinator state is per-thread, so a replacement coordinating worker in a long-lived process would read an uptime far past any process-based horizon and grant immediately, over delegations the worker it replaced had issued. Two holders. The horizon is now anchored on COORDINATION_STARTED_MONO, taken at module scope in each thread.
    2. It answered 423 for a key nobody holds. The denial was contended, which acquire retries; the quarantine runs a full DELEGATION_LEASE_MS + skew (365 s) while MAX_LOCK_TIMEOUT_MS is 300 s, so no legal caller could wait it out — it burned the whole timeout holding the native key, then reported "Record is locked and was not released in time". It is now its own quarantine reason that converts to a retryable 503 immediately.

    Round 11 then found the fix was still anchored on the wrong instant. Module scope closed the process-vs-worker half, but a thread can take coordination ownership long after it booted — so a thread-anchored horizon reads as long elapsed in a thread that has just become the coordinator over a dead one's live delegations. The horizon now runs from construction, the earliest instant core can prove nothing else was granting under. Nothing is lost where a predecessor's authority is actually known: adopt waives it and the retirement record carries it. It also moved the generation rollback floor from per-(database, table) to per-database — a generation is published per database, so refusing a rollback database-wide is strictly stronger and costs nothing. Core's floor still lasts only as long as the coordinating thread; the durable, cross-thread half is harper-pro's.

    Two smaller ones from round 9: a transport swap landing while Table.lock() was parked on the native key lock 503'd the caller, because acquire ran on the coordinator it had captured and the swap closed it — it now hops through #authority() at entry and after the backoff, carrying the remaining wait; and the drain and remote-request races left their losing timers running for a full lease.

    One adjudicator ruling was overridden, with executable evidence. Gemini asked whether handOffTo copies the quarantine to the successor; the adjudicator dropped it as factually wrong by citing successor.#grantableAfterMono = -Infinity — which is the defect, not the refutation. Adopting live authority is evidence about what the predecessor coordinator granted, not about a previous incarnation of the thread, so a swap inside the window silently cleared the quarantine. The two horizons are now merged with max, and the regression fails on the parent with "a transport swap cleared the cold-start quarantine".

  • The restart quarantine moved into core, and that is a behaviour change worth your eye. Under the epoch design the interval was the transport's: do not name this node in an epoch until a previous incarnation's delegations could have expired, or advance the epoch number. Neither half survives an immutable map — a generation does not advance because a process restarted — so #grantableAfterMono now defaults to DELEGATION_LEASE_MS + skew on the monotonic clock (which counts from process start, so every lazily created table coordinator computes the same horizon) instead of -Infinity. The first cluster lock on a key this node homes waits out that interval after a restart; keys homed elsewhere are unaffected. The reason it was off before was that turning it on hung the real-table suite for six minutes; the answer is ClusterLockTransport.grantableAfterMono, an explicit override for a caller that can prove a previous incarnation issued nothing.

  • Round 12 caught a regression my own round-11 fix introduced, and one contract error under it. Anchoring the horizon at construction was right, but an adopting successor recomputed its own and took the max — so every transport re-registration, which a component reload triggers, rejected this node's whole home share for a full delegation lease. Adoption means the successor knows everything the predecessor knew, so it now inherits the predecessor's horizon exactly; recomputing over-quarantines and clearing it would let a swap inside the window grant over an unseen predecessor incarnation. Both directions have a regression.

    Under that sat a contract error worth a reviewer's attention because core cannot enforce it: homeIncarnation was specified as advancing once per process, while coordinator state — the delegation counter included — is per thread. A replacement coordinating worker restarts that counter at zero, so a per-process incarnation would let it re-mint tokens its predecessor issued, and a delayed release carrying one of those tokens clears a live grant. The quarantine does not cover this: it bounds overlapping delegations, not a stale release replayed later. §5.1 and the LockHomeMap contract now require the counter to advance per coordination incarnation, and §11 lists it beside §4.3's activation record as an obligation core relies on without being able to check.

  • Rebased onto main after it advanced mid-review, which is why the history moved. One conflict, in Table.ts's import block: main's #2524 (dedicated application worker threads) replaced the manageThreads.js import list while this branch had added isLockControlType to the auditStore.ts one. Resolved as the union minus getWorkerCount, which main dropped because nothing uses it. The interaction worth your eye is not textual: feat(threads): run an isolated application in a dedicated worker thread #2524 adds a new thread kind, and this branch's coordinator turns on ownsCoordination() and per-thread module state — so the registration obligation now names the dedicated application worker explicitly.

  • Rebased a second time, onto main after it advanced roughly 30 more commits. One conflict, again in Table.ts's import block, but a different one from above: main's audit-record refactor had added raiseAuditFloor and boundedAuditPruneEnd to the auditStore.ts import list, while this branch's own commit still only added isLockControlType to that same list. Resolved as the union of all three names — no logic changed, only which names the import statement pulls in. tsc --noEmit, prettier --check and oxlint are clean at the rebased head, the local recordLock* suite is 181 passing / 1 pending, and all four Unit Test CI legs (Node 22/24/26, Windows) plus the full integration and adapter matrix passed on it.

  • A control entry no longer claims the table's structure version, and that one is worth a look. writeLockControlEntry declared primaryStore.encoder.structures.length + typedStructs.length on an entry whose bytes were packed by the private control Packr, which shares none of those structures. RocksTransactionLogStore raises the per-(log, table) structure watermark from that field and flags the entry that raises it — so a surrender release landing between a structure mint and the next data write would take HAS_STRUCTURE_UPDATE and leave that write unflagged, and a receiver learning structures only from flagged entries then decodes later records against a stale set. That is harper#1348's failure class reached through the lock path. It is now zero, on a short argument: a payload with no table structures cannot advance them. The flag is consumed in harper-pro's replication, so this is not end-to-end verifiable here; the round-trip test asserts the field.

  • The release payload is now pinned against the TABLE decoder, not msgpackr's default. An outside lens argued that an integer record id in 64..127 reaches auditRecord.getValue's decoder as a bare fixint, is read as a structure header, and fails — silently dropping the release and leaving the home holding its grant for a full delegation lease. The existing wire test could not have caught it: it unpacks with the default msgpackr, which is not the production reader. The new regression writes releases for 64, 100, 127, 63, 128, [64] and 'record-64' through writeLockControlEntry and reads each back through getValue — all decode. The claim is refuted, and the gap in coverage it correctly identified is closed.

  • Two findings in the last round were claims about code the reviewer could not fully see, and both held. A 409 from the lease fence nulled the refused write out of this.writes but never called detachWrite, which also repairs the per-key chain — so a caller that catches the 409 and stages the same key again in the same transaction would take the rejected operation as its merge basis. That is harper#1968's failure class reached through a different door, and it now goes through detachWrite. Separately, applyLockControlEvent resolved the entry's author before the try/catch that exists so one entry cannot stall the apply loop; getNodeNameForId(..., rebuildOnMiss) reads the audit store, so a store error escaped the sink. The lookup is now inside the guard — §8's rule that a receive boundary settles its callers and keeps admission closed.

  • One more from the last round, recorded rather than fixed, because closing it is a wire change. A renewal reply that loses its race strands the home's grant for a full delegation lease. The home renews the token in place to counter n+1 while the raced-out delegate stays on n; the handback is refused because a delegation for the key is still held — which is the guard that closed the two-holder path — and the later surrender writes counter n, which the home's exact three-component match ignores. The grant then survives to DELEGATION_LEASE_MS + skew, every contender gets contended, and the recall carries n+1, which the delegate treats as a no-op. It is the availability half of the requestId residue already tracked as harper#2582; that issue should be read as covering both halves.

  • One more minor, recorded rather than fixed. getNodeNameForId(..., rebuildOnMiss) deliberately bypasses the 50 ms negative-cache window, because a dropped release leaves a home holding its grant for a whole delegation lease and control entries are far too rare to drive the store. The residue the last round sharpened is a node id that is permanently unresolvable — a translation gap, or a partially written mapping record — which then costs a store read and a map rebuild per entry carrying it, indefinitely. That is a replication-layer invariant break rather than a lock-path one, and adding a second throttle would re-open the case the bypass exists for; it is called out so the next reader does not have to re-derive the tradeoff.

  • One last-round blocker was a misread, and it is worth naming so it does not alarm the next reader. The claim was that the subscriber's lock-control skip uses return inside an async generator, so the first LOCK_RELEASE would terminate a subscriber's whole event stream. It is not a generator — it is the per-event listener callback passed to addSubscription, where return skips one event, which is what the two neighbouring lines (if (dropDuringReplay) return; and the reload branch's return scheduleReloadResnapshot()) do for the same reason.

  • And one open major from that round, left as an obligation rather than fixed, because the fix is routing. applyLockControlEvent dispatches a received release through admittingCoordinator, which is per-thread: on a thread that never acquired, it is undefined and the release is dropped — and dropped before applyEntry's own off-owner counter can see it, so the home holds its grant to the delegation deadline with nothing recorded. This is the same "route it to the coordinating thread" obligation already written down for the delegation RPC path (§11 and registerClusterLockTransport), applied to the replication sink; whether it can arise depends on which thread harper-pro runs that sink on, which is not decidable here.

  • One finding was refuted three rounds running, so it is recorded here to stop a fourth. The claim is that ringKeyFor's String(keyId) destroys binary-key entropy and collapses distinct keys onto one home. It cannot: keyIdOf is writeKeyId, which is toBufferKey(key).toString('latin1') (DatabaseTransaction.ts:513) — ringKeyFor is handed a latin1 string, never a Buffer, and String() on it is identity. The real-table test that grants a Bytes primary key across the wire pins it by execution.

  • Two more are open decisions rather than defects, and both are yours. lock()'s scope defaults to 'cluster', so an unqualified call silently means node-local wherever no transport is registered. An outside lens read that as a two-holder blocker — a non-owner worker taking the Phase 0 path while the owner thread runs the protocol — and the adjudicator disputed the premise on the right grounds: ownsCoordination() exists and fails closed, which only makes sense if the transport is registered on every worker. What survives is the startup window before the first registration on a worker, and since the "this database is clustered" latch is per-thread module state, core cannot close it. This push writes that down as an obligation on registerClusterLockTransport and in §11, beside the activation record and the incarnation rule. The alternative — defaulting to 'node' and making cluster opt-in — is a public API change for every existing caller, cheap now and breaking after release, which is why it is raised rather than decided. Separately, expiry work and cap budgets are per table, so process-wide grant retention is caps × tables and the tick loop scales with table count (harper#2581).

  • Findings from the round-6/7 reviews that are still deliberately not fixed, all of them Phase-1 scope rather than defects with a settled answer:

    1. Table cleanup does not close() the coordinator, so no retirement record is written, the coordinator stays in the process-wide tick set, and a same-name re-create restarts the token counter at 0. Reaching a two-holder consequence needs a drop plus a same-name re-create while remote delegations are live, but the retirement record is exactly the mechanism that exists to prevent it — this is table/database lifecycle ownership, and the fix belongs with whoever owns that teardown.
    2. A cached-delegation hit allocates an Admission plus two map entries per lock(), so §8's "zero additional protocol allocation on a cached hit" does not hold as written. Round 8 sharpened the retention arithmetic and §8 is corrected accordingly: an admission deliberately survives its own unlock(), because the write it staged can still commit and the revoker must stay reachable until the handle's lease runs out — so retention is lock rate × lease, the floor any correct implementation pays, and not a bound independent of lock rate. What #pruneAdmissions fixed is a different failure (a longer-lease admission hiding shorter ones behind it), and getting below rate × lease means carrying the revoker on the Phase 0 handle that already exists. That is the redesign harper-pro#824's numbers are for.
    3. admittingCoordinator?.registerAdmission(...) is optional-chained, so a standalone claim landing in the microtask gap after acquire() resolves returns a handle nothing can fence. Binding registration to the authority that minted the round is the right shape and changes the admission contract.
    4. ringVersion reaches no comparison, because DelegationRequest does not carry it. Resolved by the recut, not deferred. ringVersion existed to detect members[] changing within an epoch; an immutable generation cannot disagree with itself, so the field is gone and there is nothing left to compare. The related hazard the recut does not dissolve — two binaries hashing the same map differently — is answered separately, by versioning the routing hash on the protocol capability (above).

    Items 1–3 have now survived four rounds unchanged, which is why they are listed rather than iterated on: each is a scope call, not a defect with a right answer reachable from the code.

  • One new hazard the planning round raised that is not in the note: lease safety assumes a suspended process still advances performance.now(). A VM suspend or snapshot restore can pause a guest's monotonic clock, which would let a delegate resume with a deadline the home has already outwaited. §4 should either state the platform assumption explicitly or invalidate delegations when wall-clock and monotonic progress diverge.

One bug this push found in main, filed rather than fixed here

Tracing X-Replicate-To to decide whether it was a usable mitigation turned up #2546 (P1): checkContextPermissions (resources/Table.ts:7258) gates on truthiness, so X-Replicate-To: 0 skips the super-user 403 and reaches getResidency with count = 0, pinning the record to the receiving node. Any authenticated user with write permission can take a record out of replication with one header — 200 response, nothing logged, and the residency persists across updates. Pre-existing on main, unrelated to this branch, and out of scope here; the fix is != undefined on both guards.

Changes

  • docs/record-lock-ownership.md is where this push mostly lives. §4 is replaced: the epoch protocol's §§4.0–4.4 — durable acceptor state, ballots, promises, renewal leases, retirement reservations, transitive activation — are deleted, and the section is now the home map, why the operator is the authority, §4.3's staged transition, and the routing algorithm carried over unchanged. §9 moves the operator-agreed map from the rejected list to Chosen, records the epoch protocol as the rejected alternative it now is, and adds the leased-capability option with the fact that overrules it. §14 records both verdicts and rewrites the decomposition so harper-pro#825 is a config generation, a digest check and a change runbook. §8's admission-retention claim is corrected to rate × lease.
  • DESIGN.md gains the Phase 1 section — the exclusion-only contract, the three rates (home map, home, delegation), and the pointer to §10 of the note as the normative wording for anything user-facing about lock(). It states, rather than restates, what the note owns; the cold-start paragraph now says core enforces the restart quarantine rather than delegating it to the transport.
  • utility/errors/hdbError.ts adds LockUnavailableError (503, LOCK_UNAVAILABLE, retryable). It is the fail-closed answer whenever the cluster guarantee cannot be established — no agreed epoch, an unreachable home, a closed coordinator — and it exists as its own class so a caller can tell "could not promise" from the 423 "held by someone else".
  • resources/nodeIdMapping.ts adds getNodeNameForId, the inverse of the existing name→id map, so a received control entry can be attributed to the node that authored it rather than to whatever the payload claims. The inversion is cached per audit store — short ids are minted per database, so id 1 names a different node in each — invalidated wherever nameToId is written, and a cache miss re-reads at most once per 50 ms so a burst of unmapped ids cannot drive the store.
  • unitTests/resources/recordLock.test.js adds the Phase 0 regression this branch's fence rewrite needed: a write staged under a lock, then unlock()ed, must survive the commit-time write replay an open read iterator forces. Judging the re-save by isExpired() — which counts a deliberate release — made the same caller code succeed normally and throw 409 only when an iterator happened to be open; the lease alone is the right test.

Verification

  • Both tests added this round were verified by rebuilding dist with the behavior removed, not by reading. The follower regression reports ClientError: Record is locked and was not released in time (423) on the parent once the leaderFailure capture at Table.ts:2880 is deleted. The failing-writer test reports 2 !== 1 — the home re-sent the recall — once the try/catch is removed from #writeControlSafely. Both sources were restored and dist rebuilt before the suite runs below.
  • The follower regression is deterministic rather than timing-dependent: a mapless transport makes LockCoordinator.acquire throw on its first loop pass with no await before it, so every step from acquireRecordKey's uncontended tryLock to the rejection is a microtask, and V8 drains those before the timer phase. Run 12 times, 12 passes.
  • Eight regressions for this adjudication round, each verified failing on the parent by reverting only the behavioral hunks and rebuilding dist — not by editing the .ts and re-running, which proves nothing, because the unit tests load dist/. fails a recall it cannot route drives the registered transport's own onDelegationRecall after the reconnect window closes the getter, and reports "Missing expected rejection" without the fix; leaves a grant recallable when a local recall fails reports "a failed local recall was never retried (1 sent)"; re-arms the quarantine on an incarnation change the ownership poll never sampled holds ownsCoordination() at true throughout, so it fails on any poll-based anchor; and reads the store once per window for an id it will never resolve counts 26 store reads on the parent against 1. The other four are the owner→non-owner→owner sequence, the waiver on a coordinator built off the owner thread, the transport reload re-waiving it, and a recall routed to a non-owner.
  • Six regressions for the recut and for round 9's findings, each asserted against the code without its fix. The default restart quarantine is pinned at both ends of the interval against COORDINATION_STARTED_MONO, so it does not depend on how long the suite itself has been running; a generation that went backwards is rejected on acquire and on the home's inbound request path (reverting only the two call sites in the built dist flips it to "Missing expected rejection"); a transport swap must not clear the quarantine (reverting the one line flips it to "a transport swap cleared the cold-start quarantine"); a quarantined home answers 503 rather than spinning to a 423; an acquire whose captured coordinator was closed by a swap lands on the successor; and the existing generation-change and superseded-generation-across-the-await cases are retargeted onto the new contract.
  • The worker-clock premise was checked by execution, not by reading. A probe spawning a worker_threads.Worker 1.5 s into a process reports timeOrigin identical to the main thread's and performance.now() = 1554.6 in the worker — which is what makes a process-anchored quarantine the wrong bound for per-thread coordinator state.
  • Coordinator unit tests rewritten around independent per-node clocks. A single shared fake clock cannot express a home outwaiting its delegate by skew measured on two different clocks, so it would pass a coordinator that compared a remote reading against a local one. Eight of them are regressions for defects the pre-push review found, and each fails without its fix — including the two added in the last commit, for the handback that spans a transport swap and for a home granting to a node the epoch does not name.
  • Real-table cluster tests — the control entry encodes, commits, decodes and stays out of every surface that reports record activity, and does not collide with the holder's own audit entry.
  • Three regressions for the two findings this push fixes, each of which fails on the parent commit with the exact defect it names (verified by reverting only the behavioral change and rebuilding dist): the coalesced cluster follower is handed the leader's node-scoped handle instead of a 409; the mixed-lease sweep reports 201 retained admissions instead of bounded; and the surviving long admission must still be revoked at handoff, which is the property the sweep must not break.
  • npx mocha "unitTests/resources/recordLock*.test.js": 181 passing, 0 failing, 1 pending, at 68bfa46f4.
  • npm run test:unit:resources: 2608 passing, 33 pending, at 68bfa46f4, with one failure (Caching > Source throw error) that passes on its own and is the known local timing flake, not this branch. npm run test:unit:main was not re-run at this head — this round changes only unitTests/resources/, which that gate does not load; at 49ae532ba it reported 5519 passing, 2 failing, both pre-existing environment failures unrelated to this branch (gitCredentials.test.js tmpdir leak, configValidator.test.js cwd-length case). npm run test:integration -- integrationTests/resources/record-lock-concurrency.test.ts: 3/3. The rangeReadActivity read-your-writes failure recorded in an earlier revision of this description is no longer reproducing here; it was search()/query() iterators run outside the request transaction: a handler cannot see its own writes, and its scans and point reads disagree about the snapshot #2506, pre-existing on main.
  • tsc --noEmit and prettier --check clean. Rebased onto main twice since (see below); CI ran green on the latest rebase's head.

Review coverage, stated honestly

Three pre-push rounds ran on the implementation. Round 1 returned BLOCK with five paths that admit two nodes on one key, and it was right on every one; round 2 found two more. They are listed in the commit messages, but the ones worth a reviewer's attention here:

  • A delegate anchored its deadline when the grant reply arrived rather than when it sent the request, so a delayed reply outlived the home's own grant.
  • A delegation survived a generation change, so a re-homed key kept its old delegate admitting.
  • Recall waited on the admission count, which unlock() drops to zero while the caller's write is still staged — that write then landed after the successor was admitted. Recall now revokes handle capability, which is what §6 always said and the first implementation did not do.
  • A release was matched on its counter alone, and counters restart when a home restarts.
  • The amortization did not actually hold. A delegation ran for exactly the caller's lock lease, so it never had room for the next lock and every repeat lock renewed — a round trip per lock where the design promises zero. The test passed only because its fake clock never advanced inside the loop.

Round 3 was degraded and should not be read as a clean bill of health: the Codex graded leg and the Harper domain leg both timed out on this diff and the Claude fallback ran out of budget, so only Gemini returned findings. Its two blockers are fixed; its other two were wrong (release() sets released, not expired, so the commit fence still distinguishes a clean unlock from a revoked lease, and noop is declared at Table.ts:8075).

Round 6 ran the full set — Codex graded, Gemini, Cursor/Grok and the Harper domain adjudicator — and returned one blocker, which is fixed: the late-reply handback ran on the coordinator that sent the request, and handOffTo had already emptied that object's delegation map, so the "never hand back while a delegation for the key is held" guard added the commit before read an empty map after a transport swap and gave back a grant that still backed this node's live delegation. Two of its majors are fixed and four are recorded above as open. Both of Gemini's substantive findings were dropped as factually wrong by the adjudicator, and independently: release() sets released, not expired, and ringKeyFor's String(keyId) is identity because keyIdOf is writeKeyId, which already returns the ordered-binary bytes.

Round 8 was a planning round on the recut, and it did not clearFraming-Verdict: better-alternative-exists. Its blocker was right and is fixed in §4.3 (publishing a generation does not revoke a live one); two of its findings are adopted in code (the generation monotonicity floor, the capability-versioned routing hash); its own alternative is overruled in §9 with the disqualifier.

Round 9 was the full code round on the recut — graded (Codex), Gemini, Cursor/Grok and the Harper domain adjudicator, Adjudicated-Severity: blocker. Both blockers were in the quarantine this push turned on, and both are fixed above along with two minors. Three Gemini findings were dropped as factually wrong by the adjudicator — writeLockControlEntry does commit (DatabaseTransaction.ts:1099:1242, and recordLockCluster.test.js reads the landed entry back out of the real audit store), and applyEntry does bind the payload's requester to the authenticated author (recordLockCoordinator.ts:796) — and one of its drops was itself wrong, which is written up above.

Nit declined, for the third round running: the file-header comment block in recordLockCoordinator.ts is a design overview, and this repo keeps design orientation next to the code it explains (DESIGN.md is built on the same convention). The comments that narrate review history rather than an invariant are fair game and were trimmed where they added nothing; the orientation stays.

Rounds 27–31 were this adjudication round's own pre-push passes, and they are the reason the quarantine section above is written the way it is. Round 27 (codex + Gemini + Cursor/Composer + domain) found the ownership-horizon hole; round 28 (full: codex + Gemini + Cursor/Grok + domain, Adjudicated-Severity: major) found that round 27's fix left the waiver uncleared on a coordinator that never owned, and that handOffTo re-latched it; round 29 found that round 28's poll could not prove continuity at all and cost 10 ticks/s per table; round 30 found the rebuildOnMiss bypass. Round 31 found nothing new — its graded leg states "no new correctness or hot-path regression was found in this delta" — which is what stopped the loop. Rounds 29–31 ran without the domain adjudicator (pruned as narrow low-risk deltas), so the outside findings in those rounds were adjudicated here, against source, and each ruling is recorded above with the line it rests on.

Rounds 36–37 cover this test-only push (codex graded + Gemini; Cursor and the domain adjudicator were pruned as a narrow low-risk delta). Round 36 is where the failing-writer finding came from, and round 37 — run on the fix — returned no new actionable finding on the delta, its graded leg confirming the new assertion discriminates. Both rounds' outside findings were adjudicated here against source, in the table at the top. Round 37's first attempt was killed by SIGTERM at 188 s and was re-run to completion.

Round 38 is the full round on the second rebase above (codex graded + Gemini + Cursor/Composer + the Harper domain adjudicator, --timeout 3600 to give the graded leg room on a 12-file/6.4k-churn full diff). It found no new regression: the import-conflict resolution retains both mainline audit-floor helpers and the lock classifier, and code trace independently confirmed the failing-writer test still discriminates. Three findings recurred, all classified previously-adjudicated and left as-is — the table/database-teardown coordinator-orphan minor (already filed as #2591 above), the double isLockControlType classification nit on the replicated hot path, and the comment-narration nit declined since round 6. Receipt is on the pushed head, 6dd59d6b5.

That history is the argument for the §12 suite the note asks for, and for not enabling this until the measurement gate (harper-pro#824) has run against the replacement. Its before-figures half has
run: harper-pro#822 carries RECORD_LOCK_COST_BASELINE.md for Ricart–Agrawala on a 3-node loopback
mesh — repeat-lock p50 0.68 ms, 4 durable entries and ~310 B per acquisition, off-vs-absent inside noise. The repeat-lock figure is the one a live delegation has to turn into a local key lock.

Refs #483

Complexity: complicated

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

Human-Review-Need: 4 (decisions: exclusion-only-without-freshness-fence, cluster-is-the-default-scope, unregister-does-not-close-the-coordinator, staged-then-unlocked-writes-are-revoked-not-drained, per-table-delegation-caps, implementing-ahead-of-the-measurement-gate, design-note-in-repo) @ 6dd59d6

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements Phase 1 of cluster-wide record locks using a Ricart-Agrawala consensus algorithm over replicated control entries. It introduces a LockCoordinator to manage lock requests, grants, and releases, integrates these control entries into the transaction log and replication flow, and updates transaction committing to enforce monotonic lease deadlines. Feedback on the changes is minimal but highlights a critical issue in resources/Table.ts where an undefined noop reference in a promise catch block could cause a runtime ReferenceError.

Comment thread resources/Table.ts Outdated
@kriszyp
kriszyp force-pushed the feat/record-lock-phase1 branch 2 times, most recently from 5f7b709 to c4db265 Compare September 4, 2026 04:06
Base automatically changed from feat/record-lock-phase0 to main September 4, 2026 15:19
@kriszyp
kriszyp force-pushed the feat/record-lock-phase1 branch from c4db265 to f1dd961 Compare September 7, 2026 12:00
@kriszyp kriszyp changed the title Cluster-wide record locks: table.lock(id) exclusive across every replicating node (Phase 1 of #483) Cluster record locks: the Phase 1 substrate, with Ricart–Agrawala superseded by amortized ownership (#483) Sep 9, 2026
@kriszyp
kriszyp force-pushed the feat/record-lock-phase1 branch from cd3fea6 to 1deac50 Compare September 11, 2026 20:48
@kriszyp kriszyp changed the title Cluster record locks: the Phase 1 substrate, with Ricart–Agrawala superseded by amortized ownership (#483) Cluster record locks: amortized per-record ownership (Phase 1 of #483) Sep 11, 2026
kriszyp added a commit to HarperFast/harper-pro 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 marked this pull request as ready for review September 12, 2026 21:13
@kriszyp
kriszyp requested a review from cb1kenobi September 12, 2026 21:13
Comment thread resources/recordLockCoordinator.ts Outdated
@claude

This comment has been minimized.

@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.

Four places hand a key onward without revoking the handles the previous delegation admitted, so a node can still commit to a record after another node has been admitted to it. The worst is a renewal reply that arrives after the caller gave up: it writes a release for the token the home has live, clearing the grant while this node's older delegation is still admitting. A renewal and an epoch-change drop both replace or delete a delegation without calling revokeAll, so a write that was staged and then unlocked still commits past the commit fence. Separately, admission revokers are never removed on release, so they pile up for the full six-minute delegation on the repeat-lock path the design is built around.


Reviewed 1deac50

Comment thread resources/recordLockCoordinator.ts Outdated
Comment thread resources/recordLockCoordinator.ts Outdated
Comment thread resources/recordLockCoordinator.ts
Comment thread resources/recordLockCoordinator.ts Outdated
@kriszyp
kriszyp force-pushed the feat/record-lock-phase1 branch from 1deac50 to 729aefd Compare September 12, 2026 22:32

@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.

Three blocking issues remain. Transport transitions can silently weaken cluster exclusion or release a grant still in use. Admission pruning also permits memory exhaustion; preserve current authority, reject scope downgrades, and prune by expiry.


Reviewed 729aefd

Comment thread resources/Table.ts Outdated
Comment thread resources/recordLockCoordinator.ts
Comment thread resources/recordLockCoordinator.ts Outdated
kriszyp added a commit to HarperFast/harper-pro 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

@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.

Prior blocking threads on unbounded revokers, renewal dropping admissions, superseded-epoch unfencing, raced-out renewal releases, transport-swap cleanup, cluster-to-node scope downgrade, and prune-early-exit are addressed at this head. I re-traced acquire, release, applyEntry, lease fencing, and control-entry isolation against the current diff and existing discussion. No new confirmed blocking defect remains on the changed lines.


Reviewed 14c1b55

Comment thread resources/Table.ts Outdated
Comment thread resources/recordLockCoordinator.ts Outdated
kriszyp and others added 24 commits September 14, 2026 10:45
Round 13. §4.1 still said `homeIncarnation` advances "once per process start"
while §5.1 said once per coordination incarnation — the same safety-critical
contract stated two contradictory ways, which is worse than stating the weaker
one consistently. §4.1 now defers to §5.1, and DESIGN.md carries the rule with
the reason a per-process counter is unsafe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…, and contain the author lookup

Round 15.

**A 409 from the lease fence left `writesByKey` pointing at the refused write.**
`save()` nulled the operation out of `this.writes` and dropped it from
`ownedWrites`, but `detachWrite` — which exists for exactly this and also repairs
the per-key chain by walking `priorWrite` — was not called. A caller that catches
the 409 and stages the same key again in the same transaction would take the
rejected operation as its merge basis, which is harper#1968's failure class
reached through a different door. It now goes through `detachWrite`.

**The author lookup sat outside the receive boundary's guard.**
`applyLockControlEvent` wraps the coordinator call in try/catch precisely so one
entry cannot stall the apply loop, but `getNodeNameForId(..., rebuildOnMiss)`
reads the audit store and ran before that guard, so a store error escaped the
sink. §8's rule is that a receive boundary settles its callers and keeps
admission closed; the lookup is now inside it.

Both were raised as claims about code the reviewer could not fully see; both hold
against the source.

Tests: 161 passing on unitTests/resources/recordLock*.

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

Round 15's outside lens sharpened the default-scope item into a two-holder path,
and it is right about the mechanism. `clusterRequiredDatabases` is module state,
so it latches per thread: a worker that never registers a transport never fails
closed, and a default-scoped `lock()` there takes the Phase 0 node lock alone
while a peer runs the cluster protocol.

Core cannot check this — the latch has no cross-thread view — so it goes where
the other obligations core relies on without being able to verify already live:
on `registerClusterLockTransport` and in §11. Registering on every worker is also
what makes the `ownsCoordination()` 503 reachable, which is the path a non-owner
worker is supposed to take, so this is the design's own assumption written down
rather than a new requirement.

The alternative — failing closed on default scope wherever this worker has no
transport — is a public API change for every core-only `lock()` caller, and stays
an open decision in the PR body rather than something to settle here.

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

Round 15's last carried finding. `ClusterLockTransport.grantableAfterMono`'s doc
comment still described the quarantine it overrides as running "from process
start", which the anchor change made false and which contradicted both the
runtime and the design note. It now points at `#grantableAfterMono`, where the
reason neither process start nor thread start is sound already lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
The last copy of the corrected fact. DESIGN.md's cold-start paragraph still
described the quarantine as counting from process start, after the note and the
transport interface had both been fixed to say construction. It now carries the
reason neither process start nor thread start is a sound anchor, and that an
adopted successor inherits the predecessor's horizon so a transport reload costs
nothing.

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

Rebased onto main, which now creates dedicated application worker threads
(#2524). "Every worker" in the registration obligation would otherwise read as
the HTTP workers only, and a dedicated application worker that serves a lock()
without a transport is exactly the case the obligation exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…er, not msgpackr's default

An outside lens argued that an integer record id in 64..127 would reach the
receiving table's decoder as a bare fixint, be read as a structure header, and
fail to decode — silently dropping the release and leaving the home holding its
grant for a full delegation lease on a key nobody is using. The existing wire
test could not have caught that: it unpacks with the default `msgpackr`, which
is not the reader production uses.

Refuted by execution, and the test that refutes it stays. Writing a release for
64, 100, 127, 63, 128, [64] and 'record-64' through `writeLockControlEntry` and
reading each back through `auditRecord.getValue` — the production path — returns
a decoded array every time, so the decoder reads that byte range as data.

162 passing on unitTests/resources/recordLock*.

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

Round 19. `writeLockControlEntry` declared the table encoder's structure count
on an entry whose bytes were packed by the private control `Packr`, which shares
none of those structures. `RocksTransactionLogStore` raises the per-(log, table)
watermark from that field and flags the entry that raises it, so a surrender
release landing between a structure mint and the next data write would take
`HAS_STRUCTURE_UPDATE` and leave that write unflagged — a receiver learning
structures only from flagged entries then decodes later records against a stale
set, which is harper#1348's failure class reached through the lock path.

Zero is the right value and the argument is short: a payload with no table
structures cannot advance them. The existing round-trip test now asserts it, and
`controlEntries()` surfaces the field so the assertion has something to read.

Recorded rather than fixed, from the same round: a renewal reply that loses its
race strands the home's grant for a full delegation lease. The home renews the
token in place to counter n+1 while the raced-out delegate stays on n, the
handback is refused because a delegation is still held, and the later surrender
writes counter n which the home's exact three-component match ignores. That is
the availability half of the `requestId` residue in harper#2582, and closing it
is a wire change.

Tests: 162 passing on unitTests/resources/recordLock*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…agreement is a 503

Both findings are cb1kenobi's, on the PR, against the heads this session pushed.
Both are mine.

**The requester gate refused any node that is not a home.** Under the epoch
design it read `members[]` — the full membership — and meant "not a
decommissioned node". The recut renamed it to `homes[]` and §4.1 redefined that
as the arbiter set, so as written a node the operator did not designate as a home
could never obtain a cluster lock at all. Resolved by making the contract say
what the check needs: `homes[]` names every node that participates in record
locks. It is one set and not two because a home refuses a delegation to any node
the map does not name — that is what keeps a decommissioned node out — so a node
absent from it can neither home a key nor lock one, and rendezvous hashing makes
every listed node the arbiter for its own share anyway. The denial is now its own
`unknown-node` reason rather than being reported as a generation mismatch.

**`acquire()` had no branch for a generation denial.** Exactly the defect round 9
found in the quarantine denial: `capacity` and `quarantine` convert to a retryable
503 while `generation` fell through to the 25 ms retry loop, spent the caller's
whole timeout holding the native key, and reported 423 — "held by someone else" —
on a key nobody holds. `generation` and `unknown-node` now both throw
LockUnavailableError, each naming what is actually wrong.

Refuted, from the same reviewer: `this.writes[i].lockHandle` cannot see a slot
`detachWrite` nulled. `resources/DatabaseTransaction.ts:1399` filters the array
two lines above the loop, inside the same `if (transaction)` block, and that loop
is the only such read in the file.

Tests: 163 passing. The new regression asserts both 503s and fails on the parent
with "The validation function is expected to return true", verified by reverting
the gate and both branches in the built dist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
Round 21 caught the third case in the same round-9 finding I under-applied: a
`not-home` denial still spun to a 423. Rather than add a fourth reason branch,
the terminal answer now states the rule the branches were each approximating.

`not-home` is genuinely worth retrying — it means the two sides derive different
rings, and a stale map on this side converges on the next pass. What was wrong is
only the answer at the end of the wait: two maps under one generation number never
converge, and 423 tells the caller that a key nobody holds is held. So the timeout
reports 423 only when the last denial was `contended`, and otherwise reports a
retryable 503 naming what the home actually answered.

That also changes one existing case on purpose: a grant that arrived too late to
install used to end as 423. The home granted the key to *us* and the reply was
merely late, so nobody ever held it; it is now a 503 saying so.

This is the class fix for what cb1kenobi raised and what round 9's outside lens
listed in one sentence — quarantine, generation, not-home. The first was fixed in
round 9, the second in the previous commit, and this closes the set.

Tests: 164 passing on unitTests/resources/recordLock*, with a regression that
pins both halves — a permanently disagreeing ring ends 503, real contention still
ends 423.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…ve the coordinator after the wait

Round 22's full pass — gpt-5.6-sol at xhigh, gemini, cursor-grok, Harper domain
— on the head this session pushed. Two of the three are defects this branch
introduced.

**The rollback floor poisoned itself.** `#generationIsCurrent` raised the floor
on *observing* a generation, so one `homeMap()` returning a too-large number
once — a partial publish, a transport glitch — pinned it above anything the
operator ever publishes and failed every later lock on that database until the
thread restarted, for a key nobody holds. The invariant only ever needed "never
mint below a generation already minted", so the floor now rises where authority
is actually taken: a token minted as home, or a delegation installed as delegate.
The regression fails on the old shape with "No agreed record lock home map".

**`Table.lock()` used a coordinator snapshot taken before the native wait.** That
wait can run the caller's whole timeout — long enough for harper-pro to register
the transport on this worker — and the snapshot would then take the native key
alone and hand back a node-scoped handle while a peer that already had the
transport is granted the same key. It re-resolves after the wait. This is the
window half of the registration obligation; the steady-state half, a worker that
never registers at all, stays an obligation core cannot check.

**And the release-routing obligation is restated as the norm it now is.** Core's
log-delivery sink runs where `subscribeOnThisThread(applicationWorkerIndex())` is
true, which since harper#2524's dedicated application workers is routinely not
the coordinating thread — so a clean release landing there is dropped and the home
holds its grant for a full lease. §11 says that plainly rather than treating it as
an edge case.

Refuted from the same round: the follower timeout does not leak — `Promise.race`
is followed by a two-argument `.then` whose rejection handler clears it
(`Table.ts:2869`), and the timer is `unref()`ed; `noop` is hoisted at
`Table.ts:8037` (sixth raise); `rebuildOnMiss` needs a permanently unmintable node
id, which the translation path does not produce (fifth raise).

Tests: 165 passing on unitTests/resources/recordLock*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…ing test hashes what production hashes

Round 22's graded leg, gpt-5.6-sol at xhigh.

**One handoff became a recall RPC per contender poll.** `#beginRecall` cleared
`grant.recalling` as soon as the recall settled, so a contender polling the home
every 25 ms re-armed it on every pass — measured at 14 sends in 300 ms, which over
a full delegation is roughly fourteen thousand for one handoff. A recall the
delegate CONFIRMED is now never re-sent: it has stopped admitting, and the grant
clears on its release entry or on its own deadline, which is what the old comment
already said happens. A recall that FAILED is still retried, because the delegate
may come back — but on `RECALL_RETRY_MS`, not on the contender's poll interval.

**The routing test's home-selection loop hashed the wrong thing.** It ran
`homeFor(recordId, ...)` on the raw record id while production hashes
`ringKeyFor(database, table, writeKeyId(id))`, so the loop guaranteed nothing
about where the key actually homes and the mutual-home assertion below it passed
on luck. It now goes through `idHomedHere`, which exists for exactly this and
carries the comment explaining why.

Recorded as by-design, from the same round: closing a coordinator without a
successor leaves a just-issued remote grant until its own deadline. That is the
"a home may never forget a grant before its expiry" rule (§8) doing its job —
clearing it early is the unsafe direction — and the retirement record is what
carries the bound to the replacement.

Tests: 166 passing on unitTests/resources/recordLock*. The recall regression
fails on the parent with "the confirmed recall was re-sent 14 times".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…ng request is not contention

Round 23's full pass, on the head the previous commit produced. The first finding
is a hole that commit opened.

**`recallConfirmed` stopped the re-send but not the renewal.** The delegate can
confirm a recall and re-ask before its release reaches the home — the production
writer is an async log commit plus replication, which the in-memory harness
cannot reproduce because it broadcasts synchronously. `#grant`'s renewal arm only
skipped while `existing.recalling` was set, so that next `lock()` minted a fresh
token on the same grant: the pending release no longer matched it, `#beginRecall`
would never recall it again because the confirmation stood, and the contender
starved for the rest of the lease. The renewal arm now refuses on
`recallConfirmed` as well, which sends the delegate back through the normal path
and lets the handoff happen.

**A request that never answered was labelled `contended`.** `#requestRemotely`
races the RPC against the caller's whole remaining wait and returned
`reason: 'contended'` on timeout — so a home that is reachable but hung (GC, a
socket that never errors) ended as 423, "held by someone else", for a key nobody
held. It is now `reason: 'timeout'`: retried identically, but 503 at the end.
That is the same contract the previous commit drew, applied to the path that was
still breaking it.

Three existing regressions asserted 423 on exactly that raced-out path and now
assert the 503. The genuine-contention case one file over still asserts 423,
which is the check that the classification cuts where it should.

Tests: 167 passing. The renewal guard fails on the parent with "the home renewed
a delegate it had just recalled".

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

Round 24. The contract drawn in `2d0b3ac0b` held inside `LockCoordinator.acquire`
and not one layer up, where the caller actually sees it — the same symmetry gap
that let the `generation` case sit unfixed for nine rounds after its sibling was
found.

**The cluster step was skipped once the budget was gone, not merely misreported.**
`acquireRecordKey` can wait the caller's whole timeout, and `Table.lock` then
threw 423 without calling `acquire` at all. But a live delegation admits with
zero cluster messages and a self-homed key grants synchronously — neither needs
any budget — so the amortized path this design exists for was being skipped and
the caller told a key nobody holds was held. It now clamps the remaining wait at
zero and calls `acquire` anyway; only a real failure reaches the caller, with the
reason the coordinator determined.

**A grant that outlived its lease is 503 there as well.** The home granted the key
to this node and the lease elapsed before the handle could take it, so nobody held
it — which is exactly how the coordinator already classifies its own `timeout`.

The follower deadline stays 423, and now says why: a follower waits only because
another caller in this process holds or is acquiring the same key, which is the
contention the status describes.

Tests: 168 passing. The new regression pins the property the fix relies on —
`acquire(key, lease, 0)` admits from a live delegation without sending anything,
and a self-homed key grants locally on the same zero budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
…ting, and stop swallowing the resolver error

Round 24's graded pass — gpt-5.6-sol at xhigh, which finished this time only
because the run budget was raised to 3600 s. Both findings are fail-closed
violations introduced earlier in this session.

**The restart quarantine aged out before the thread was coordinating.** A
coordinator is built when a transport registers, but `ownsCoordination()` can
flip true long afterwards — a thread taking over from an owner that died. By then
the construction horizon this branch moved to two rounds ago has elapsed, so the
new owner granted immediately over delegations the previous OWNER issued. The
horizon is now the later of construction and `DELEGATION_LEASE_MS + skew` after
this coordinator was first observed to own coordination, re-armed whenever
ownership is regained, since something else was coordinating in between.
Ownership is observed at construction so a coordinator built while already
coordinating is not re-quarantined, and `handOffTo` carries it so a transport
reload stays free.

**The coordinator resolver's error was swallowed after the native wait.** The
re-resolve added two rounds ago caught and discarded it, leaving `coordinator` as
whatever it was — including undefined — so an implicit cluster lock fell through
to node-local authority while a peer could hold the same key. That error reaches
the caller now, as it does on the path before the wait, and the native key is
given back first.

Tests: 169 passing. The ownership regression fails on the construction-only
anchor with "a new owner granted over its predecessor's delegations".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
cb1kenobi's, on the PR at 0aa1505. Mine.

**An unrouted recall resolved as success, and the home latches that.**
`deliverDelegationRecall` is what `registerClusterLockTransport` wires as core's
receiving end of `recallDelegation`, whose contract is "resolves once the
delegate has drained and stopped admitting". It returned normally when
`coordinatorFor` yielded nothing and swallowed a throw from
`onDelegationRecall`. That was self-healing while every contender poll re-sent
the recall; it stopped being so when `#beginRecall` started latching
`recallConfirmed` on any resolution. Through the reconnect window the
transport-gated resolver deliberately answers undefined for — the same window
244adf6 routed releases around — the recall was dropped, the RPC replied
success, and the home denied the key to every other node for
`DELEGATION_LEASE_MS + skew`. Both now fail the recall so the home's `.catch`
arms a `RECALL_RETRY_MS` retry instead.

**The local branch latched `recalling` on a failure.** Home-is-delegate recalls
through `#beginRecall`'s own branch, which assigned `grant.recalling` and never
cleared it, so one failed local recall blocked every later one for the rest of
the delegation — the same defect the remote branch already handled. Both
branches settle identically now: clear and confirm on success, clear and arm the
retry on failure.

Also §9: with `homes[]` redefined as every locking node (009cde8), a shard map
is only a valid home set if it already names them all, which the Adjacent row
still read the other way.

Adjudicated and declined, same reviewer, recorded in the PR body: the
commit-time lease fence cannot dereference a detached write —
`DatabaseTransaction.ts:1399` filters nulls out of `this.writes` in the same
`if (transaction)` block, two lines above the only such loop, and that filter is
pre-existing on main (8d69f1b).

Tests: 171 passing in the record-lock suites, 2599 in test:unit:resources. Both
new regressions fail on the parent build — "Missing expected rejection" and "a
failed local recall was never retried (1 sent)" — verified by reverting the two
behavior hunks in dist and re-running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 27's pre-push review, all three in the surface the last two rounds
changed. The quarantine one is a two-delegate path.

**A non-owning interval was never observed, so ownership regained across one
was dated from before it.** `#ownershipHorizon` is read only from `#grant`, and
`onDelegationRequest` answers `not-home` before reaching it while this thread
does not own coordination — so A→B→A left A's `#ownedSinceMono` at its old value
and A granted immediately over the delegations B issued in between. Ownership is
polled on `tick()` now, and a coordinator ticks while it owns rather than only
while it holds state, so the gap is recorded by the first tick after it starts.
Tick granularity is enough: whatever coordinated during the gap needed a full
lease of its own before it could grant.

**A cold-start waiver covered every later takeover.** `grantableAfterMono` is
documented as "set it only where a previous incarnation of this process provably
issued nothing" — a claim about process start. It latched `#quarantineWaived`
for the coordinator's life, which also disabled the takeover horizon that exists
for a sibling thread in the SAME process. The waiver now ends at the first
observed gap in ownership, and `#grant` consults the horizon unconditionally;
an explicit future `grantableAfterMono` still applies exactly as before.

**A recall routed to a non-owner thread resolved.** `acquire` refuses off the
owner thread, so a delegation only ever lives on the coordinating one.
`deliverDelegationRecall` is transport-gated, not ownership-gated: a recall
landing on any other registered thread found no delegation, resolved, and the
home latched `recallConfirmed` and never re-sent while the real delegate kept
admitting. `onDelegationRecall` is ownership-gated now, like
`onDelegationRequest`. The token no-op stays a no-op, and a non-handoff close
still resolves — it expired and revoked everything first.

Tests: 171 in the record-lock suites, 2602 in test:unit:resources, 0 failing.
All three regressions fail on the parent build — "a regained owner granted over
the gap's delegations", "a fresh-database attestation waived a takeover too",
and a missing rejection — verified by reverting the four hunks in dist.

Not taken, recorded for the PR body: the five previously-adjudicated minors this
round repeated, and the comment-density nit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re it is lost

Round 28's pre-push review, on round 27's own fix. Mine.

**Clearing the waiver on an observed GAP never fired for a coordinator that
never owned.** `Table.lockCoordinator` constructs unconditionally, so every 503
`lock()` on a non-owning worker builds one; its `#ownedSinceMono` stays
undefined, there is no gap to observe, and the takeover kept `#quarantineWaived`
and granted immediately over the outgoing owner thread's live delegations — two
admitters for one key. The rule is now the other end of the same interval: the
waiver ends wherever ownership BEGINS after construction. The constructor sets
`#ownedSinceMono` directly, so a coordinator that has owned since it was built
keeps the waiver; every other way of acquiring ownership goes through
`#ownershipHorizon` and loses it.

**And `handOffTo` did not carry it.** The successor reads `grantableAfterMono`
off the new transport, so a component reload re-latched a waiver the predecessor
had already lost to a takeover. It rides along with the horizon and the
ownership clock now, for the same reason those do.

**The ownership poll was running at the tick rate.** Every table that ever
coordinated would have called `transport.ownsCoordination()` 10 times a second
for the life of the process, holding nothing. It is rate-limited to
`OWNERSHIP_POLL_MS` (1 s), which is still three orders of magnitude finer than
the gap it has to catch: whatever coordinated during the gap is inside its own
full-lease quarantine.

Also from the same round: the two `verify:` markers in §7.2 shipped an agent
workflow instruction in a committed design document — they are open questions
against harper-pro and now say so — and the duplicated doc block above
`admittingCoordinator` belonged on `lockCoordinator`.

The local-recall regression no longer sleeps before asserting: the stub's
rejection is awaited directly, and `#beginRecall` attaches its handlers first, so
the bookkeeping is settled without a fixed delay (AGENTS.md, harper#1138).

Tests: 174 in the record-lock suites, 2604 in test:unit:resources, 0 failing.
Both new regressions fail on the parent build — "a coordinator built off the
owner thread kept the waiver" and "a reload re-waived the quarantine the takeover
armed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on, not a poll

Round 29's pre-push review, on round 28's own fix. Mine.

**Sampling a boolean cannot prove continuity.** `ownsCoordination()` answers for
the instant it is read; nothing between two reads is covered, so ownership
alternating faster than the poll — with each sample landing inside this thread's
own interval — aliases away entirely, and both sides age out a full
`DELEGATION_LEASE_MS` and grant the same key. The signal for this already exists
in the contract: §5.1 advances `homeIncarnation` once per COORDINATION
incarnation, which is what keeps the fencing token orderable. A value this
coordinator has not granted under is the transport stating that something else
coordinated for this node, whatever the boolean said in between, so that is what
re-arms the horizon now.

`ownsCoordination()` stays as a second, weaker signal — it catches a thread that
stopped coordinating without anything else starting, which advances no
incarnation — but it is no longer what the argument rests on. **So the tick
lifecycle goes back to what it was**: a coordinator holding nothing stops
ticking. Round 28 kept every once-owned table in the 100 ms tick set for the
life of the process to keep the poll running, which at N tables was 10N empty
dispatches per second, and the incarnation check needs none of it.

**`handOffTo` was carrying blanks over the successor's own reading.** A
predecessor built on a non-owning thread, or before a map was available, has no
ownership instant and no incarnation; copying those `undefined`s onto a
successor that had just read the new transport re-armed a quarantine on a node
that never stopped coordinating. It cascaded through every later reload on the
thread. Only what the predecessor actually observed overrides now.

The fake cluster can set a node's incarnation at construction: a coordinator
that sees it change is supposed to re-arm, which is not what a test about a home
that restarted BEFORE that coordinator existed is saying.

Tests: 177 in the record-lock suites, 2605 in test:unit:resources, 0 failing.
The new regression fails on the parent build with "an ownership change no poll
saw did not re-arm the quarantine".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…th rebuilding for

Round 30's pre-push review. The finding is the third time this call has been
raised — codex round 27, Gemini round 30 — and the earlier refutation ("a burst
costs one read, not one per entry") holds only for ids the translation path
actually minted, which it did not say.

`rebuildOnMiss` bypassed `NODE_NAME_REFRESH_MS` on every call, not once per
window. For an id this database will never resolve — a node whose mapping was
purged, an origin relayed from elsewhere — a replayed run of control entries
therefore cost an `exportIdMapping` read and unpack EACH, on the replicated
apply thread, which is exactly the burst the interval exists to keep off it.

The bypass is still there and still worth its read: a just-minted id would
otherwise be dropped for up to the window, and a dropped release leaves the key's
home holding its grant until the delegation's own deadline. It is now bounded to
one read per window, because a rebuild inside the window is not stale — writing
the mapping calls `invalidateNodeNames` and drops the cache entry outright, so a
re-read can only find something a write put there.

Tests: 178 in the record-lock suites, 2606 in test:unit:resources, 0 failing.
The regression fails on the parent build with "a burst of unresolvable ids drove
26 store reads".

Refuted from the same round, all previously recorded and re-checked at this head:
`release()` does not set `expired` (`recordLock.ts:324-337`; only
`#onLeaseExpire` and `revokeLease` do, which is the whole point of the two
flags); `writeLockControlEntry`'s synchronous encode cannot escape, because its
only caller reaches it through `#writeControlSafely`'s try/catch, asserted by
"contains a throw from a failing writer rather than surfacing it"; `noop` is
hoisted at `Table.ts:8068`; and `stageWrite` is not a synchronous function
leaking a promise — all three of its callers collect its result into
`writePromises`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ollower report the leader's reason

Round 25 — the first full pass where every leg completed, `gpt-5.6-sol` at xhigh
included, and the first to come back `Adjudicated-Severity: minor`.

**`Promise.resolve(recallDelegation(...))` evaluates the call first.** A transport
that throws synchronously — not connected, bad node name — escaped `#beginRecall`
entirely: neither handler ran, so `recallRetryAfterMono` was never armed and the
next contender pass threw again immediately instead of backing off, and the throw
surfaced to the caller in place of a denial reply. This is the third instance of
this exact pattern on this branch, so rather than fix the one site I swept the
three lock files for it: `requestDelegation` is already inside a try that converts
it to 503, `onDelegationRecall` is `async` so a throw there is a rejection, and
`unlock()` throwing synchronously matches its documented synchronous contract.
This was the only uncontained one.

**A follower discarded why the leader failed.** When the leader's acquisition
rejected for a reason that is not contention — an unreachable home, a quarantine,
a map disagreement — the follower dropped that 503, retried on its own budget, and
ended on a fabricated 423 for a key nobody held. It now keeps the leader's
`LockUnavailableError` and reports it if the retries also run out. Retrying stays
right, because the condition may clear; only the final answer changes. That makes
the follower path obey the same rule as the two layers below it, which is where
this contract has now been applied three times.

Tests: 179 passing. The recall regression fails on the parent with the transport's
raw throw reaching the caller. The follower change is covered by inspection rather
than a test — driving it needs two concurrent coalescing callers plus a 503-ing
transport, and I would rather say that than imply coverage it does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
Round 26 caught that `contains a throw from a failing writer rather than
surfacing it` did no such thing: it injected two malformed apply entries and then
asserted `ok(true)`. Same class as the routing test that hashed the raw record id
— a test passing for a reason unrelated to its name.

Split into the two things it was conflating:

- `contains a malformed entry rather than surfacing it to the apply loop` keeps
  the original injections, but asserts they do not throw AND that the delegation
  table is unchanged, so a half-applied entry would fail it.
- `contains a throw from a failing writer rather than surfacing it` now makes the
  writer throw, via a `writerThrows` flag on the harness node alongside the
  existing `alive` one, and drives a real recall and surrender through it. It
  asserts the writer was actually reached and that the delegation was still given
  up.

Stated plainly because it matters for the next reader: the second test is an
OUTCOME assertion, not a fails-on-base one. Removing `#writeControlSafely`'s
catch alone does not flip it, because `#beginRecall`'s own `.catch` contains the
same path — two independent containments, so no single-line removal surfaces it.
It is still worth having: it exercises the real surrender path with a throwing
writer, which `ok(true)` did not.

Tests: 180 passing on unitTests/resources/recordLock*, and the new writer test
takes 501ms because it drives an actual recall rather than asserting a tautology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182Qf1qmKDwJG52Uuu4FKFx
The follower that coalesces onto an in-flight `lock()` keeps the leader's
`LockUnavailableError` and reports it once its own budget is gone, instead of
a 423 for a key nobody held. That landed in 95ff536 with the commit message
saying it was covered by inspection rather than a test; the review thread on
the PR asked for the regression, and it is constructible.

Two `lock()` calls on one key inside one link, a mapless transport so the
leader's round fails 503 with nothing to retry, and the follower's whole 1 ms
budget spent without yielding — the leader's rejection travels on microtasks,
so it settles the follower's race ahead of the deadline timer and leaves
`remaining <= 0`. The assertion is error identity, not a status code, so a
future refactor that fabricates a fresh 503 fails it too.

Fails on the parent with `ClientError: Record is locked and was not released
in time (423)` when the `leaderFailure` capture at `Table.ts:2880` is removed
and `dist` rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`77ac2ddc5` made the writer throw, but nothing in the test could see whether
`#writeControlSafely` contained it: `#surrender` deletes the delegation before
it calls the writer, so the `delegations === 0` assertion holds either way, and
the contender's rejection is caught. Removing the try/catch from
`#writeControlSafely` and rebuilding `dist` left the test green.

The home can tell. A recall that RESOLVED is confirmed and never re-sent; a
rejected one is re-sent once past `RECALL_RETRY_MS`. The test now advances the
home's clock past that interval and asserts the recall count to alpha is
unchanged — 2 !== 1 with the containment removed.

Raised by the graded leg of pre-push round 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the feat/record-lock-phase1 branch from 68bfa46 to 6dd59d6 Compare September 14, 2026 16:47

@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.

The newest commit is test-only and makes the failing-writer containment test actually discriminate, by advancing the home node's clock past the recall retry interval and asserting the recall count is unchanged. Without that change the assertion passed whether or not the writer's throw was contained. No new blocking problem was found on the changed lines, and everything traced is already fixed in this PR, previously refuted, or tracked in an existing ticket. Unit and integration CI were still running at capture time, so those results are unconfirmed.


Reviewed 6dd59d6

@kriszyp
kriszyp merged commit 4c353f8 into main Sep 14, 2026
50 checks passed
@kriszyp
kriszyp deleted the feat/record-lock-phase1 branch September 14, 2026 18:57
kriszyp added a commit to HarperFast/harper-pro that referenced this pull request Sep 18, 2026
…r-freshness barriers (harper-pro#825, harper#2542 inside #822) (#822)

* Pin core to the record-lock phase-1 branch head (harper#2498)

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

* Cluster-wide record locks: the harper-pro transport for core's LockCoordinator (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

* Read replication.recordLocks from the config tree, and fix two test-helper 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

* Address the pre-push review: test lifecycle, config warning, slot doc

- 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

* Install the connection-down reader in start(), not at module load

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

* Record-lock cost baseline: the numbers the enablement gate calls for

`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

* Address the pre-push review: node-side section timing, pooled hot-key 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

* Baseline: the request-minus-section gap is the commit plus request overhead, not HTTP alone

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

* Record locks: delegation transport for the amortized-ownership protocol (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

* Fix two more stale slot-13 references and a schema-list omission in DESIGN.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 3729b81f.

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

* Fail closed instead of crashing when recordLockConfig loads before boot

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>

* Pin core to harper#2498 head (operator-agreed home map)

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>

* Record locks: the delegation cost measurement, and the two handoff gaps 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 (4cd0b9d8), 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>

* Design note: operator-agreed home map (harper-pro#825 inside #822)

Core's home-map interface (harper#2498, merged) replaced the static
epoch with an operator-agreed, digest-checked, immutable-per-generation
map. This note scopes the harper-pro-side implementation to what #825's
issue text still covers after the design's round-7 revision.

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

* Design note round 2: replace ack/activate with a per-node drain timer

Round 1 of the planning review found the acknowledge/canonical-artifact
mechanism unsafe (local-only evidence can't support a cross-node check,
among other blockers). This revision replaces it with a purely local
wall-clock timer anchored at stage-receipt, removing the need for
cross-node evidence collection entirely, and fixes the storage,
digest, handshake, and incarnation-ordering issues round 1 found.

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

* Design note round 3: operator-timed activation, immediate quiesce-on-stage

Round 2 of the planning review rejected the local-timer mechanism with
concrete two-holder counterexamples (staging didn't quiesce immediately;
per-node deadlines aren't a last-node barrier; Date.now() isn't a safe
elapsed-time proof across a restart). This revision adopts round 2's own
stated resolution: stage atomically retracts the active generation (real
quiescence, not an inferred one), and a separate operator-issued activate
call, timed externally by the operator's own wait from the last stage/
fence event, promotes it. Also fixes the digest-mismatch ring-shrink bug,
centralizes the incarnation-bump gate in recordLockOwnerFor for both call
sites, and moves the digest off NODE_NAME onto a dedicated message.

No third planning round: this converges on both rounds' own explicit
recommendations rather than introducing a new mechanism.

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

* Cluster record locks: implement harper-pro#825's operator-agreed home map

harper#2498 merged with a round-7 revision that deleted the durable
membership-epoch consensus protocol #825 was originally scoped to build,
replacing it with an operator-agreed, digest-checked, immutable-per-
generation home map. This branch's static-epoch transport could not even
compile against core's new homeMap() interface. Implements what remains
of #825 after that redefinition, per two rounds of planning review:

- replication/recordLockHomes.ts: a dedicated LOCAL_ONLY system table
  (hdb_record_lock_homes), three super_user-gated operations
  (record_lock_stage_generation, record_lock_fence_external,
  record_lock_activate_generation), and pure planStage/planActivate
  decision functions. Staging atomically retracts any active generation
  in the same durable write (real quiescence, not inferred); activation
  is a separate, operator-timed call — the operator's own external wait
  is the safety mechanism, not anything the code measures, after two
  planning rounds showed a node-timed alternative reopens the two-holder
  bug the design exists to prevent. A small backstop timer is defense in
  depth only.
- replication/recordLockTransport.ts: epoch() -> homeMap(), sourced from
  a frozen per-thread cache of the durable generation, gated on both
  peer digest agreement and protocol capability (independent checks — a
  matching digest from the wrong protocol level is not enough). A digest
  mismatch fails the whole map closed, not a shrunk ring (excluding only
  the disagreeing peer is itself a two-arbiter bug). homeIncarnation now
  advances per coordination incarnation via a central gate in
  recordLockOwnerFor, not only at process start. grantableAfterMono
  (core's restart-quarantine waiver) needs the transport rebuilt at three
  independent points -- first-incarnation known, ownership conferred, and
  the active generation newly available -- since core's coordinator is
  built lazily and reads that field once, at construction.
- replication/replicationConnection.ts: a dedicated RECORD_LOCK_HOMES_DIGEST
  wire message (not a NODE_NAME resend, which has untested side effects),
  and peer-digest reconciliation centralized by (database, peer) rather
  than per connection object -- the mesh keeps a separate connection per
  direction, each with independent local state.
- protocolCapabilities.ts/recordLockRpc.ts: capability bump 2->3 and the
  epoch->generation wire rename for the interface change.
- core re-pinned to origin/main post-merge (4c353f880) per the task
  owner's instruction.

Verification: full unit suite 1121/1121 passing throughout. Integration
(recordLockCluster.test.mjs, rewritten for the new bootstrap model) --
a full clean end-to-end run was not obtained; blocked repeatedly by two
confirmed pre-existing, external causes on the machine this ran on (this
session's background processes killed by the harness's own memory-
pressure policy, and integration-testing's shared loopback-address pool
file corrupted by a non-atomic write raced with a concurrent process --
neither touched by this change). What is confirmed: a partial run after
all fixes passed all 4 real tests in the hardest suite, including 24-way
concurrent contention across 3 nodes, before being killed moving into
the next suite. See RECORD_LOCK_HOMES_DESIGN.md's "For the human
reviewer" section for the full account.

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

* Address round-1 pre-push review: 3 blockers, sender gating, digest truncation

- recordLockConfig.ts (via recordLockTransport.ts startup warning): enabling
  replication.recordLocks now logs an operator-visible warning that a
  handoff carries exclusion but not freshness until harper#2542, with the
  measured lost-update rate -- previously disclosed only in design docs
  nobody reads at enablement time.
- recordLockHomes.ts: stage/activate no longer return before this node's
  local cache refresh AND every other thread's has been confirmed via a
  bounded cross-thread ack (recordLockTransport.ts) -- a durable write
  landing was not the same fact as every grant-capable thread having
  actually stopped serving the old generation.
- recordLockHomes.ts: stage/fence/activate now serialize behind a
  per-database in-process queue (withRow), closing a read-outside-
  transaction race where a concurrent stage and fenceExternal (or two
  concurrent stages) could each read the same prior state and the second
  write silently discard the first's outcome, both reporting success.
- recordLockHomes.ts: digestOf no longer truncates generation to 32 bits
  (generation >>> 0 made generation 1 and 2**32+1 hash identically over
  the same homes).
- replicationConnection.ts: RECORD_LOCK_HOMES_DIGEST is now sender-gated
  on the peer's advertised recordLocks capability, matching this file's
  own stated sender-side gating discipline; sent once handshake actually
  establishes that, not only at the point databaseName was first known.
- recordLockTransport.ts/recordLockRpc.ts: disabled-transport refusal is
  now a real 503 (ClientError, not a plain Error with no statusCode --
  independently found by the delegation-cost bench); guarded a previously
  unguarded worker-side postMessage reply.

Unit suite 1121/1121 passing throughout. Integration re-confirmation of
this specific commit was blocked by the same pre-existing, external
causes as before (this session's OOM-killed background processes;
integration-testing's shared loopback-address pool file corrupted by a
concurrent process) -- every attempt reproduced the identical error
signature, never a functional failure. The fixes are narrow and
logically self-contained (an in-process serialization queue, an
await/ack chain on an existing message pair, a sender-side gate check,
an encoding width fix); nothing here touches the mechanism the prior
partial integration run already exercised successfully (peer digest
reconciliation, the three grantableAfterMono recreate triggers).

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

* Fix round-2 pre-push blocker: ack timeout must fail a stage/activate, not fake quiescence

The homes-changed ack protocol added in the round-1 fix resolved success on a
missing ack (dead worker, full mailbox, or a live worker whose event loop was
too busy to have applied the change yet) — indistinguishable from the exact
failure the protocol exists to catch: a thread still granting under the
retracted generation. It also gave the origin-to-main relay the same budget
as main's own per-sibling fan-out, so main could never answer in time
whenever a sibling was slow.

Make the ack fail-closed (timeout and a false ack both reject, propagating a
503 up through stageGeneration/activateGeneration) and give the relay leg a
longer budget than the leaf fan-out it waits on. Also fix stageGeneration's
and activateGeneration's noop (idempotent-retry) path to still re-notify: a
retry after a failed relay was previously silent, leaving the gap the retry
exists to close unreconciled.

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

* Fix round-3 pre-push finding: settle the armed ack entry on a postMessage throw, don't abandon it

sendHomesChangedAndWaitAck and the origin-to-main relay both already register a
pending-ack timer via waitForHomesChangedAck before attempting postMessage. On a
synchronous postMessage throw, the previous code deleted that map entry and
manufactured a second, unrelated rejected promise for the caller — leaving the
original promise's own timeout still armed with nothing subscribed to it. That
promise rejects unhandled ~2-3.5s later, and Node's default policy terminates
the process.

Settle the SAME already-armed entry (ok=false) instead: its resolver clears the
timer and rejects the one promise the caller is actually awaiting.

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

* Fix round-4 finding: the concurrent-increment test asserted freshness this phase doesn't provide

recordLockCluster.test.mjs asserted seen === [1..24] and exact convergence to
N — a successor-freshness guarantee harper#2542 has not landed yet.
RECORD_LOCK_COST_DELEGATIONS.md measures 0.05-0.13% of sections losing an
update at 3 contenders for exactly this reason, and the bench already
dropped its own equivalent assertion citing it; the integration test kept
it, so it could flake on the same race the code openly documents as
outstanding.

Assert what this phase actually guarantees instead: every admitted
increment lands in range, and every node converges to the same final value
— not that the value is N. Corrected the module docstring's matching claim
("never loses an update").

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

* Fix round-5 finding: require near-total distinctness so the test still catches exclusion failures

The prior fix (7a2ad19d) replaced the flaky exact-[1..N] assertion with a
pure range check, but a range check alone can't tell "exclusion works, one
rare freshness race" from "no exclusion at all" (24 requests all reading
the unwritten n=0 and writing 1 pass equally). Require the written values
to be almost all distinct (tolerating up to 2 collisions, well above the
documented 0.05-0.13% rate) so a genuine serialization regression still
fails the test.

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

* Format: wrap the distinctness assert.ok to satisfy prettier

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

* Docs: fix two accuracy findings from pre-push review (capability level, test-coverage overclaim)

DESIGN.md said recordLocks advertises as level 2; protocolCapabilities.ts
has been at 3 since the homeMap() redesign. Both DESIGN.md's test index and
RECORD_LOCK_HOMES_DESIGN.md's Testing section claimed integration coverage
(digest mismatch, record_lock_fence_external, restart incarnation
ordering, mid-bump persistence failure, holder-crash lease hand-over) that
recordLockCluster.test.mjs does not contain — corrected both to describe
what the suite actually covers and what's still worth adding.

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

* Fix round-7 nit: crash-recovery skip cites harper#2498, not harper#2542

The test's own comment ties the six-minute lease/configurability question
to harper#2498; harper#2542 is the separate successor-freshness fence.

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

* Design note: the harper-pro successor-freshness barrier (harper#2542)

Bump core to harper#2613 and record the transport-side design for
ClusterLockTransport.establishLockFreshness() before implementing it.

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

* Design note round 2: adopt the planning findings, fail recovery closed

Clean-handoff barriers evaluate against a published apply-visible
watermark; the null recovery marker rejects with 503 until an
append-order source head exists (the open decision, with options).

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

* Design note round 3: origin-keyed atomic publication, event-driven waiters

Adopt round 2: publish apply-visible progress per authenticated origin
in one Atomics-addressed 64-bit slot, wake waiters from the publication
instead of polling, record exact capability levels, select the disabled
transport on LMDB, and recommend a marker write for the recovery fence.

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

* Design note round 4: fences only from direct full-coverage streams

Adopt round 3: publish per-origin fences only from the origin's own
direct, full-database-coverage stream (the exclusion-origin set), in a
generation-keyed CAS-advanced atomic word bootstrapped from the durable
resume cursor; relayed and selectively-routed origins fail closed.
Recovery fence tracked as harper#2625.

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

* Design note round 5: both halves against harper#2627's barrier entry

Pin core at harper#2627 (lockBarrier, writeLockBarrier, deadlineMs).
Adopt round 4: zero-exclusion publishers poisoned on any drop, no cursor
bootstrap, barrier probes for recovery and for a clean dependency the
fence has not reached, self-origin clone baseline, capped waiters.

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

* Design note round 6: exact nonce barriers only, durable poison

Adopt round 5: drop every numeric fence (a restart reissues log keys),
prove each unsatisfied cross-origin dependency with a same-table
lockBarrier entry matched on (origin, position, nonce), poison an
(origin, table) durably on any drop or on core's terminal-apply-failure
hook (harper#2628), decide self-origin by incarnation start or an
ever-recloned flag, and bound/authorize/coalesce barrier requests.

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

* Design note round 7: record the append-order probe, adopt round 6's rest

Round 6's headline blocker (replay reorders a log by key) is disproved
by a probe on rocksdb-js 2.9.0: per-log range reads are append-ordered.
The residual resume skip is harper#2629. Adopt everything else: poison
is permanent, every self-origin dependency rejects after a reclone,
client-only coalescing, relay instead of a shared ring, failed poison
writes hold the frame, checks before state, an executable cost gate.

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

* Successor freshness: prove every cross-origin handoff with a lockBarrier

Implement ClusterLockTransport.establishLockFreshness() (harper#2613,
harper#2625/#2627): each inherited (origin, position) dependency and
each recovery marker is proven only by a lockBarrier entry the origin
commits on request, matched on (origin, position, nonce) once applied
here. Durable per-(origin, table) poison on every dropped record, an
ever-recloned flag written at clone start, a node-principal barrier
operation restricted to current members at the exact level, capability
level 4, the LMDB gate, and the exact-convergence cluster assertion.

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

* Docs: successor freshness landed, slot map, cost rows

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

* Consume core's apply-failure listener when present; wildcard poison

harper#2628's registerReplicatedApplyFailureListener is looked up at
registration and, when present, poisons the failed origin durably; an
origin poisoned with table '*' fails every table. The design note now
states the residual (terminal apply failures stay unrecorded against a
core without the hook) instead of a refusal the code did not implement.

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

* Cluster test: assert a barrier was applied and nothing was poisoned

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

* Fix pre-push review majors: poison durability, cross-thread poison, settle recheck

A failed poison write is retried on the next report instead of being
cached as recorded; a durable row is announced to every thread so the
coordinating thread's barrier refuses it; a matching barrier rechecks
poison at settle time; transport replacement settles the old barrier's
waits; release forgets the poison cache; the hole-recording closure is
one per connection rather than one per frame; JSON pair keys replace a
NUL separator.

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

* Read poison and reclone state from the store, not a per-thread cache

The hole is recorded on the socket's thread while the barrier waits on
the coordinating thread, and an announcement without acknowledgement
left a window; the drop completes only after the row is durable, so
the cold-path checks read the dbis store directly and the broadcast is
gone. A pair whose row could not be written stays marked on its thread
and is retried on the next report. The LOCAL_ONLY defense drop poisons
like the other drop kinds.

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

* Fail closed when poison state cannot be read; poison unknown-table drops

A database with no readable dbis store, or a read that throws, reports
as poisoned and recloned; the barrier answers a throwing check with 503
instead of letting it escape a settlement callback; a local-only drop
whose table is unknown poisons the whole origin.

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

* Pin core at main (harper#2627 + #2630) and consume the apply-failure listener directly

registerReplicatedApplyFailureListener lives in
core/resources/replicatedApplyFailure.ts, so the feature-detected lookup
on Table.ts would never have found it; import it, register per database,
unregister on release. The one hole class the notes called unrecorded
is now recorded.

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

* Design note: record_lock_bootstrap_generation

A local, fresh-state-only generation-1 write that derives homes from
hdb_nodes by default. The fresh-state guard (no active, no staged,
highestActedOn 0) is what makes the stage/drain/activate sequence
unnecessary: homeMap() has never answered on that node, so core has
never granted there.

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

* Add record_lock_propose_homes, a read-only home-set proposal

Planning review rejected the mutating bootstrap this replaces, on two
counterexamples now recorded in the design note: homeMap() iterates its
OWN active.homes, so a node that derived [A] checks no peers and serves
immediately — a digest cannot detect a participant omitted from the set
being digested; and a node newly added to a cluster already active at
generation 2 has an untouched row, so any "never acted here" guard
passes and it would activate its own generation 1.

What lands instead writes nothing: it returns the canonical home set
this node's hdb_nodes view suggests, the generation one past its own
floor, the digest, the current state and warnings, so an operator can
capture one list instead of typing it and still stage and activate it
everywhere through the operations that already exist. planProposal is
the pure decision, unit-tested like planStage/planActivate; membership
readers are injected from recordLockTransport because a static import of
knownNodes here is an initialization cycle.

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

* Return the §4.3 quiesce union, not just the new ring

Review finding, and a real defect in the first draft: the proposal's
warning said to stage "every node named in homes", which on a shrink
omits the node being removed — it is never staged, keeps its old active
generation, and keeps granting while the new ring grants too. The union
of the proposal with the current active and staged rings is now a
returned field, with a warning naming the leaving nodes.

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

* Say what the proposal cannot know, and what a departing node becomes

Two review findings on the advisory response: quiesce is built from the
answering node's own rings, so a node with no active generation cannot
name the ring the cluster is serving — it now says so and tells the
operator to ask a node that holds it. And a departing node, once staged,
is never activated, so it stays unable to lock; that is what leaving the
ring means, and saying it keeps it from reading as a stuck transition.

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

* Docs: the design note now states both limits the response warns about

Review finding: the note described the proposal's response before the
completeness caveat and the departing-node state were added, so the
canonical note contradicted the code.

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

* Record locks: drain by recall so a membership change is not a ~6 minute lock outage (#861)

* Drain on stage, so a membership change need not wait out the lease

stage already retracts active in its durable write, which stops NEW
grants; authority already outstanding still admits until its lease runs
out, and that is the whole reason §4.3 waits ~6 minutes before activate.
Drain it instead: stage now calls core's quiesceDelegations and returns
the result, so an orchestrator that sees empty outstanding on every node
in homes(g) union homes(g+1) can activate immediately, and falls back to
the timer only for a node that reports something left.

The drain never fails the stage — a stage that durably landed must not
report failure because the drain did, or the operator retries a
transition that already happened.

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

* Drain on the coordinating thread, outside the transition queue

Review findings on the first cut:

The drain ran on whichever thread answered the operation, while
coordinator state is per-thread — so it swept an empty registry and
reported "nothing outstanding" while the owner thread still held every
grant. It now relays to the owner through the existing record-lock relay
and treats a relay that does not arrive as an error, never as a clean
drain.

It also ran inside withRow, holding the database's transition queue for
the whole drain; it now runs after the row write, which has already
retracted active, so nothing new can be granted while it works and a
concurrent transition is not blocked behind it.

Tests now drive stageGeneration itself: the coordinating thread's result
passes through unchanged, a throwing drain becomes {error} without
failing a stage that already landed, and an unwired drain refuses rather
than reading as an empty outstanding list.

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

* Expose the one predicate an orchestrator may skip the interval on

provesQuiescence() is the single place that says what counts: a drain
that reached the coordinating thread, completed, and found nothing. An
empty outstanding list on its own does not, because core cannot sweep a
coordinator that was never built.

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

* Pin core at the ownership-based quiescence proof

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

* Pin core at the ownership-only quiescence proof

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

* Pin core at the retired-coordinator reporting fix

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

* Check every field of a drain reply positively

Review finding: provesQuiescence accepted { complete: true,
outstanding: {} } because `outstanding?.length ?? 0` is 0 for a
non-array. The value crosses a worker boundary, so a malformed reply has
to read as "not proven" rather than slipping through on a missing
length. Every field is now checked positively, with tests.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Pin core at harper main now that #2663 has merged

#861 pinned core at kris/856-quiesce-delegations' head, which the squash
merge left off main — so deleting that branch would make this PR's
submodule pointer unreachable, which has already happened once on this
branch. Point it at main, which carries the same work as 72d65fa42.

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

* Record locks: one operator call applies a home map across the cluster 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>

* Cluster record locks: serve lock() on every worker at threads.count > 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 73fc424d:
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 no…
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