Cluster record locks: amortized per-record ownership (Phase 1 of #483) - #2498
Conversation
There was a problem hiding this comment.
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.
5f7b709 to
c4db265
Compare
c4db265 to
f1dd961
Compare
cd3fea6 to
1deac50
Compare
…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
This comment has been minimized.
This comment has been minimized.
cb1kenobi
left a comment
There was a problem hiding this comment.
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
1deac50 to
729aefd
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
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
…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
left a comment
There was a problem hiding this comment.
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
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>
68bfa46 to
6dd59d6
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
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
…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…
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-recordownership: 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.
P=12docs/record-lock-ownership.mdspecifies the replacement, and is the substance of this push. Three levels at three very differentrates:
(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.homes[]. One arbiter per key is trivially exclusive, which deletes the entire grant statemachine: no deferral queues, no
(tsR, nodeName)tiebreak, no synthesized grants, no split votes,no revocation protocol.
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+1durable commits andP²−1deliveries 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 plusP−1deliveries. 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
recordLockHomesgeneration rather thanserver.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 nowIs exclusion-only the right guarantee to ship, given what it leaves for the caller to know?
P+1durable commits and up toP²−1frame deliveries — 13 and 143 atP=12.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 calllock(), 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"| KA cold lock, a repeat lock, and a handoff
homeFor(K, members), sends one unicastrequestDelegationto that home, and installs the grant with its deadline anchored at the moment it sent the request.lock()takes the local key lock and sends nothing. Releasing the application lock does not release the delegation.contended, and C retries inside its own timeout.lockReleaseentry carrying the full fencing token.homeForis 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 forDELEGATION_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?
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:
#grantableAfterMononow refuses to grant as a home untilDELEGATION_LEASE_MS + skewon 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 byClusterLockTransport.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 inDatabaseTransactionrejects 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 byclearWrites()), so a bulk transaction of plain writes in a core-only deployment pays nothing for it. Admissions carry their delegation's token, andrelease()andregisterAdmission()take it back: a key's delegation can be replaced while a handle is open, and an untokened lateunlock()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?
LOCK_REQUEST = 9,LOCK_GRANT = 10,LOCK_RELEASE = 12; payload[key, requester, tsR].LOCK_RELEASE = 12; payload[key, requester, generation, homeIncarnation, counter], validated on exact tuple length. Nibbles 9 and 10 are retired, not migrated —mainhas 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 pinscoreto 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_MSis 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?grantableAfterMonoon every ordinary start and leave core's bound as the failsafe?(generation, incarnation, counter); the reason counters alone cannot identify a delegationgrantableAfterMonooverride that is the only way past core's own restart quarantineperformance.now()is process-wide inside a worker, so a process-anchored horizon lets a replacement coordinating worker grant over delegations the worker it replaced issuedacquire: the amortized path, the send-anchored request, and the generation re-read after the await — zero messages under a live delegation; a dead reply is discarded; a grant minted under a generation that has since advanced is handed back rather than installed#installDelegation: renew in place, never move a delegation backwards — a renewal keeps the admissions the node is still answerable for; a losing reply cannot displace a newer tokenhandOffToempties the sender's delegation map, so the existence guard below reads empty after a swap unless the hop is made#releaseUnclaimedGrant: any live delegation blocks the handback — the guard is the delegation's existence, not a token match — the home renews in place, so it can hold a newer token than the one this node installedrevokeLeaseon the handle — what the revoker calls, and why an already-fired handle cannot join a roundadmittingCoordinator— this sink runs off the replication stream, not off the unicast transport, so a reconnect must not swallow a clean handoffTable.lock()— the handle is bound to the delegation that admitted itFor 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.
Table.ts:2880— the follower-reports-leader-failure contract has no regression testleaderFailureis read only on theremaining <= 0line, 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 withremainingalready negative77ac2ddc5's failing-writer test passes whether or not the throw is contained#surrenderdeletes the delegation before it calls the writer and the contender's rejection is caught, so both original assertions hold either way; removing thetryfrom#writeControlSafelyand rebuildingdistleft it green. The home can tell: a recall that resolved is confirmed and never re-sent, a rejected one is re-sent pastRECALL_RETRY_MS. 2 !== 1 without the containmentTable.ts:2855-2866— implicitundefinedreturn drops exclusion for a coalesced follower whose handle expiredifblock is followed byreturn retryOnRemainingBudget();atTable.ts:2871. There is no fall-throughrecordLock.ts:226—release()setsexpired, so an early unlock aborts the commitrecordLock.ts:324-326setsreleasedand clears the timer; onlyrevokeLeaseand#onLeaseExpiretouchexpiredTable.ts:2933noopandTable.ts:972loggerare possibly undefinednoopis a hoisted function declaration atTable.ts:8075,loggeris imported atTable.ts:83Table.ts:5851— table/database retirement can orphan live coordinator authorityrecordLockCoordinator.tsdoes not exist onmainandmain'sTable.tscontains nolockCoordinator. What holds instead is reachability —lockCoordinatoris only constructed behind a registered transport (Table.ts:5857-5866) andregisterClusterLockTransporthas no non-test caller in core, so nothing can be orphaned in any current build. The gap is that the drop path never callsclose(), so theretiredCoordinatorscounter floor is never written and a same-name re-create restarts#counterbelow tokens peers still hold. It is testable only once harper-pro registers a transport, which is where the fix belongsThis 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.
DatabaseTransaction.ts:1399filters the holes out ofthis.writeson the line above the loop, inside the sameif (transaction)block, and that filter is pre-existing onmain(8d69f1bd1)homes[]does not list009cde857, by the second of the two routes offered — the contract now sayshomes[]names every locking node. §9's Adjacent row still read the other way and is corrected heregenerationdenials spin to a 423009cde857—generationandunknown-nodeboth throw 503, and the terminal 423 is gated oncontendedalonef20d98457, 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 tokennstays the thing that completes the handoff71d32bf6d, whose "stop swallowing the resolver error" was a different siteA recall that did not apply is not a confirmation, in three places.
deliverDelegationRecallis core's receiving end ofrecallDelegation, whose contract is "resolves once the delegate has drained and stopped admitting", and#beginRecalllatchesrecallConfirmedon any resolution and never re-sends. It returned normally when the transport-gated resolver yielded nothing — the reconnect windowTable.lockCoordinatordeliberately keeps the coordinator alive for — so the home denied the key to every other node forDELEGATION_LEASE_MS + skew. It rejects now. The same shape was in#beginRecall's own local branch, which assignedgrant.recallingand never cleared it on a rejection; and transport-gating turned out not to be enough, becauseacquirerefuses off the owner thread, so a delegation only ever lives on the coordinating one and a recall routed anywhere else found nothing and resolved —onDelegationRecallis ownership-gated now, likeonDelegationRequest.The takeover quarantine is anchored on
homeIncarnationnow, 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:#ownershipHorizonwas read only from#grant, andonDelegationRequestanswersnot-homebefore 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.#quarantineWaivedon an observed gap never fires on a coordinator that never owned, andTable.lockCoordinatorconstructs 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.homeIncarnationonce 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, andhandOffTocarries it rather than letting a transport reload re-latch it off the new transport.handOffToalso 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 anexportIdMappingread 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 callsinvalidateNodeNamesand drops the cache entry outright.Declined this round, with reasons, so they are not re-filed.
onDelegationRecallinstead 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 throwingkeyIdOf) would test a different thing while reading as if they tested this one.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 aboveadmittingCoordinatorbelonged onlockCoordinator. The rest is the orientation this repo keeps next to the code, declined for the fourth round running.release()setsreleased, neverexpired(recordLock.ts:324-337);noopis hoisted atTable.ts:8075;writeLockControlEntry's synchronous encode cannot escape, because its only caller reaches it through#writeControlSafely's try/catch;stageWriteis not a synchronous function leaking a promise — all three callers collect its result intowritePromises;decodeLockControlPayloadhas its own try/catch around both the unpack anddecodeTuple; andTable.ts:2831's timer callsreject, it does not throw insidesetTimeout.The staged transition §4.3 now specifies — stage, acknowledge-or-fence, drain, activate, with every acknowledgement bound to the acknowledger's
homeIncarnationand 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.
isEncodableKeyhad drifted back to being tighter thanordered-binary. Too strict is not a safe direction here: the predicate gatesonDelegationRequeston a key's home, so a shape it refuses getsnot-home— whichacquireretries 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:#grantLocallynever goes throughonDelegationRequest, so the same table locks fine on its self-homed keys and fails only on the peer's share of the ring.decodeTupleuses the same predicate, so anylockReleasesuch a key does produce is dropped at every receiver.f2ef9375on this branch set the rule — match the encoder — and the50b966d1rewrite lost it, restoringbigintbut not binary, boolean or null, and adding aNumber.isFinitethat 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 scalarboolean,nullandNaNbeforelock()can reach the coordinator at all — but it passes all three inside a composite key, andIditself declares(number | string | null)[]. It also passesInfinity, which the reviewer did not name andNumber.isFiniterefused. 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
distrebuild: the fake-cluster acquire grants aUint8Arraykey 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'skeyIdOfisString(key)and never touchesordered-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()setsexpired = true, solock(); save(); unlock(); commit()inside the lease hits the pre-submit fence and aborts.release()setsreleasedand clears the timer; only#onLeaseExpire()andrevokeLease()setexpired, andisLeaseExpired()readsexpired || #leaseLapsed(). The distinction is the point ofa1a79a7c, anda staged write survives unlock() even when a replay re-saves itinrecordLock.test.jsexecutes 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
Uint8Arrayrecord id breaks the delegation and grantMaps, becausemsgpackrhands the receiver a new object and aMapcompares objects by identity.keyIdOfiswriteKeyId(resources/DatabaseTransaction.ts:513), which returnstoBufferKey(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 assertswriteKeyId(decoded.key) === writeKeyId(sent key)across theUint8Array→Bufferchange 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
729aefd2cleared every prior blocking thread at14c1b551;git show 729aefd2:resources/Table.tsand:resources/recordLockCoordinator.tsshowscopeViolationand#pruneAdmissionsbyte-identical to14c1b551, and neither commit in between touches either function. Both original claims hold on the code:scopeViolationgated its fall-through on transport presence alone, whilelock()'s entry guard fails closed onscopeRequested || isClusterLockRequired. Those twopredicates 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 leavesisClusterLockRequiredtrue — 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.#pruneAdmissionsreturned 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 isO(size)synchronous work on one acquisition, in exchange for amortizedO(1)per admission and a map bounded at twice the live set instead of unbounded. The sweep can only drop an admission whose ownexpiresMonohas passed, which is the §6 property — asserted directly by the second regression.stats.revocablemakes 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:
warnClusterReleaseFailurelatched for the life of the process, whilewarnOncetwo 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 exportwarnOnceand reuse it is declined with a concrete disqualifier:recordLockCoordinator.tscomputesDELEGATION_LEASE_MSfromrecordLock.ts'sMAX_LOCK_LEASE_MSat 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
writeLockControlEntrynever commits its audit write (refuted byrecordLockCluster.test.js, which waits for alockReleaseentry to appear in the real audit store and passes); a claim that the coordinator suite shares one fake clock (its header andFakeCluster.advance(name, ms)do the opposite — one node's clock moves at a time, which is why §12 asked for it); and a claim thatgetNodeNameForId(…, 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: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 futurecontext.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;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 configuredreplication.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.mdpoints 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=Mthat 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 inprose: 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-
versionconflicts by node name, so a replica can hold a losing value at the same timestamp and pass aversion ≥ Vtest. §7.1 replaces it with an inherited(origin → position)dependency set on theLOCK_RELEASEentry.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
mainyet 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); aLockUnavailableErrorfrom 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: nulland the locked key in the payload, because a control entry carrying the key answers_writeUpdate's keyed dedup lookup at exactlyts_Rand silently drops the holder's own first write. And node identity is the node name, never the auditnodeId: 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-existsagainst 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 — arecordLockHomesgeneration 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.
ringVersion, rehoming, a recovery barrier for re-homed keysCold 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+1does not revoke a liveg. 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.AandBholdg={A,B};Apartitions; the operator declaresAremoved and publishesg+1={B};Bdrains and re-homesA'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 becauseAnever 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:performance.now()read as process uptime — butperformance.now()andperformance.timeOriginare process-wide inside a worker thread too. A probe spawning a worker 1.5 s into a process readsnow() = 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 onCOORDINATION_STARTED_MONO, taken at module scope in each thread.contended, whichacquireretries; the quarantine runs a fullDELEGATION_LEASE_MS + skew(365 s) whileMAX_LOCK_TIMEOUT_MSis 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 ownquarantinereason 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:
adoptwaives 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, becauseacquireran 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
handOffTocopies the quarantine to the successor; the adjudicator dropped it as factually wrong by citingsuccessor.#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 withmax, 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
#grantableAfterMononow defaults toDELEGATION_LEASE_MS + skewon 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 isClusterLockTransport.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:
homeIncarnationwas 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 theLockHomeMapcontract 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
mainafter it advanced mid-review, which is why the history moved. One conflict, inTable.ts's import block:main's #2524 (dedicated application worker threads) replaced themanageThreads.jsimport list while this branch had addedisLockControlTypeto theauditStore.tsone. Resolved as the union minusgetWorkerCount, whichmaindropped 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 onownsCoordination()and per-thread module state — so the registration obligation now names the dedicated application worker explicitly.Rebased a second time, onto
mainafter it advanced roughly 30 more commits. One conflict, again inTable.ts's import block, but a different one from above:main's audit-record refactor had addedraiseAuditFloorandboundedAuditPruneEndto theauditStore.tsimport list, while this branch's own commit still only addedisLockControlTypeto 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 --checkandoxlintare clean at the rebased head, the localrecordLock*suite is 181 passing / 1 pending, and all fourUnit TestCI 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.
writeLockControlEntrydeclaredprimaryStore.encoder.structures.length + typedStructs.lengthon an entry whose bytes were packed by the private controlPackr, which shares none of those structures.RocksTransactionLogStoreraises 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 takeHAS_STRUCTURE_UPDATEand 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 defaultmsgpackr, which is not the production reader. The new regression writes releases for 64, 100, 127, 63, 128,[64]and'record-64'throughwriteLockControlEntryand reads each back throughgetValue— 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.writesbut never calleddetachWrite, 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 throughdetachWrite. Separately,applyLockControlEventresolved 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 getscontended, and the recall carries n+1, which the delegate treats as a no-op. It is the availability half of therequestIdresidue 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
returninside an async generator, so the firstLOCK_RELEASEwould terminate a subscriber's whole event stream. It is not a generator — it is the per-event listener callback passed toaddSubscription, wherereturnskips one event, which is what the two neighbouring lines (if (dropDuringReplay) return;and thereloadbranch'sreturn 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.
applyLockControlEventdispatches a received release throughadmittingCoordinator, which is per-thread: on a thread that never acquired, it isundefinedand the release is dropped — and dropped beforeapplyEntry'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 andregisterClusterLockTransport), 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'sString(keyId)destroys binary-key entropy and collapses distinct keys onto one home. It cannot:keyIdOfiswriteKeyId, which istoBufferKey(key).toString('latin1')(DatabaseTransaction.ts:513) —ringKeyForis handed a latin1 string, never a Buffer, andString()on it is identity. The real-table test that grants aBytesprimary key across the wire pins it by execution.Two more are open decisions rather than defects, and both are yours.
lock()'sscopedefaults 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 onregisterClusterLockTransportand 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:
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.Admissionplus two map entries perlock(), 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 ownunlock(), because the write it staged can still commit and the revoker must stay reachable until the handle's lease runs out — so retention islock rate × lease, the floor any correct implementation pays, and not a bound independent of lock rate. What#pruneAdmissionsfixed is a different failure (a longer-lease admission hiding shorter ones behind it), and getting belowrate × leasemeans carrying the revoker on the Phase 0 handle that already exists. That is the redesign harper-pro#824's numbers are for.admittingCoordinator?.registerAdmission(...)is optional-chained, so a standalone claim landing in the microtask gap afteracquire()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.Resolved by the recut, not deferred.ringVersionreaches no comparison, becauseDelegationRequestdoes not carry it.ringVersionexisted to detectmembers[]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 hereTracing
X-Replicate-Toto decide whether it was a usable mitigation turned up #2546 (P1):checkContextPermissions(resources/Table.ts:7258) gates on truthiness, soX-Replicate-To: 0skips the super-user 403 and reachesgetResidencywithcount = 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 onmain, unrelated to this branch, and out of scope here; the fix is!= undefinedon both guards.Changes
docs/record-lock-ownership.mdis 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 torate × lease.DESIGN.mdgains 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 aboutlock(). 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.tsaddsLockUnavailableError(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.tsaddsgetNodeNameForId, 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 wherevernameToIdis 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.jsadds the Phase 0 regression this branch's fence rewrite needed: a write staged under a lock, thenunlock()ed, must survive the commit-time write replay an open read iterator forces. Judging the re-save byisExpired()— 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
distwith the behavior removed, not by reading. The follower regression reportsClientError: Record is locked and was not released in time (423)on the parent once theleaderFailurecapture atTable.ts:2880is deleted. The failing-writer test reports2 !== 1— the home re-sent the recall — once thetry/catchis removed from#writeControlSafely. Both sources were restored anddistrebuilt before the suite runs below.LockCoordinator.acquirethrow on its first loop pass with noawaitbefore it, so every step fromacquireRecordKey's uncontendedtryLockto the rejection is a microtask, and V8 drains those before the timer phase. Run 12 times, 12 passes.dist— not by editing the.tsand re-running, which proves nothing, because the unit tests loaddist/.fails a recall it cannot routedrives the registered transport's ownonDelegationRecallafter the reconnect window closes the getter, and reports "Missing expected rejection" without the fix;leaves a grant recallable when a local recall failsreports "a failed local recall was never retried (1 sent)";re-arms the quarantine on an incarnation change the ownership poll never sampledholdsownsCoordination()attruethroughout, so it fails on any poll-based anchor; andreads the store once per window for an id it will never resolvecounts 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.COORDINATION_STARTED_MONO, so it does not depend on how long the suite itself has been running; a generation that went backwards is rejected onacquireand on the home's inbound request path (reverting only the two call sites in the builtdistflips 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.worker_threads.Worker1.5 s into a process reportstimeOriginidentical to the main thread's andperformance.now() = 1554.6in the worker — which is what makes a process-anchored quarantine the wrong bound for per-thread coordinator state.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, at68bfa46f4.npm run test:unit:resources: 2608 passing, 33 pending, at68bfa46f4, 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:mainwas not re-run at this head — this round changes onlyunitTests/resources/, which that gate does not load; at49ae532bait reported 5519 passing, 2 failing, both pre-existing environment failures unrelated to this branch (gitCredentials.test.jstmpdir leak,configValidator.test.jscwd-length case).npm run test:integration -- integrationTests/resources/record-lock-concurrency.test.ts: 3/3. TherangeReadActivityread-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 onmain.tsc --noEmitandprettier --checkclean. Rebased ontomaintwice 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
BLOCKwith 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: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.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()setsreleased, notexpired, so the commit fence still distinguishes a clean unlock from a revoked lease, andnoopis declared atTable.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
handOffTohad 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()setsreleased, notexpired, andringKeyFor'sString(keyId)is identity becausekeyIdOfiswriteKeyId, which already returns the ordered-binary bytes.Round 8 was a planning round on the recut, and it did not clear —
Framing-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 —writeLockControlEntrydoes commit (DatabaseTransaction.ts:1099→:1242, andrecordLockCluster.test.jsreads the landed entry back out of the real audit store), andapplyEntrydoes 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.tsis a design overview, and this repo keeps design orientation next to the code it explains (DESIGN.mdis 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 thathandOffTore-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 therebuildOnMissbypass. 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
SIGTERMat 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 3600to 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 classifiedpreviously-adjudicatedand left as-is — the table/database-teardown coordinator-orphan minor (already filed as #2591 above), the doubleisLockControlTypeclassification 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.mdfor Ricart–Agrawala on a 3-node loopbackmesh — 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