A live #15678 reproduction left the cockpit controller write-locked after failed call_method attempts from a persistent MCP connection. A second writer path from the same seat then received Write denied ... conflict until the App Worker was reloaded.
The root premise survives current-source inspection: WriteGuard.requestWrite() acquires and holds, admitWrite() returns no acquisition provenance, InstanceService.assertWritable() discards the admitted lock, and callMethod() may await arbitrary application code after admission. WriteGuard already names TTL expiry for silent holders as a follow-up.
The Problem
Held-until-release is intentional: it protects multi-step edits from cross-writer interleaving. The missing lifecycle facts are what make it unsafe for long-lived writers:
an idle or silently dead holder has no expiry path;
a caller cannot distinguish a newly created lock from same-writer re-entry;
an operation owner has no fenced token with which to touch or conditionally release its acquisition;
a naive wall-clock sweep could expire a lock while an async write is still in flight;
a naive “release on error” could release a pre-existing hold or unlock partially mutated state.
Architectural Authority
ADR 0021 is binding:
the writer remains the exact (agentId, sessionId) pair;
two Bridge connections carrying the same AgentIdentity remain two writers;
held-until-release remains the default regime;
the App Worker heap remains the lock authority.
Therefore identity-only coalescing is explicitly rejected in this ticket. Changing it requires a separately evidenced ADR-0021 amendment.
The Fix
Implement one coherent lease + scoped acquisition lifecycle:
Lease metadata: each held lock carries a generation/token, acquiredAt, lastTouchAt, and in-flight state. Public snapshots remain defensive copies.
Acquisition receipt:LockRegistry.acquire() / WriteGuard.requestWrite() report whether the call created a new lock or re-entered an existing one, plus the current fenced token. admitWrite() and the write-path owner retain that receipt instead of discarding it.
Deterministic lazy sweep: an injected clock drives request-time/inspection-time expiry; no owned timer is required. Only idle, expired locks are reclaimed. A request that observes reclamation emits an independently inspectable receipt.
In-flight protection: begin/end (or an equivalent scoped handle) prevents expiry while an admitted synchronous or async operation is executing. Re-entry refreshes lastTouchAt without duplicating the lock.
Fenced touch/release: an old operation or expired holder can never touch or release a successor generation. Disconnect release remains immediate and authoritative.
Phase-aware error semantics: release automatically only when the current operation created the lock and failure is proven pre-mutation or rollback-complete. Re-entrant failures never release the pre-existing hold. Arbitrary call_method failures retain the hold because application code may have partially mutated; they end in-flight protection, emit a failure receipt, and become TTL-reclaimable.
Incident coverage: reproduce persistent MCP writer versus a distinct in-process writer pair. The second path remains a distinct writer per ADR 0021; it succeeds only after disconnect release or observable idle expiry, never through identity coalescing.
Contract Ledger
Target Surface
Source of Authority
Proposed Behavior
Fallback / Failure
Docs
Evidence
LockRegistry.acquire
ADR 0021 writer/subtree model
Preserve conflict math; return created vs reentrant acquisition provenance and a generation
Invalid descriptors deny without mutation
JSDoc
Pure creation/re-entry specs
WriteGuard.requestWrite / held state
Heap authority
Stamp lease metadata, token, and in-flight state; defensive-copy every outward value
Malformed lease input denies; existing valid hold remains
JSDoc
Clock-injected state-transition specs
Sweep owner
ADR 0021 + Base lifecycle
Lazy/request-time sweep with injected clock; reclaim only idle expired generations
In-flight or unexpired locks remain
JSDoc
Boundary-time + async-in-flight specs
Touch/end/release
Acquisition token
Mutate/remove only the matching current generation
Stale token is a no-op and cannot clobber a successor
JSDoc
Expired-A / successor-B fence spec
admitWrite / InstanceService.assertWritable
Admission seam
Return/retain the scoped acquisition receipt for the operation owner
Auto-release only newly-created + pre-mutation/rollback-complete work; otherwise retain and emit failure receipt
TTL/disconnect reclaims safely
JSDoc
New-vs-reentrant and partial-mutation specs
Writer identity
ADR 0021 §2
Preserve (agentId, sessionId) exactly; no AgentIdentity join
Different sessions conflict even if one seat/identity
Ticket + JSDoc
Spoof/coalescing rejection spec
Observability
Verify-before-assert
Bounded receipts name holder, subtree, reason, token/generation, and timestamps
Receipt failure must not change lock decision
JSDoc
Sweep/error receipt specs
Acceptance Criteria
AC1: idle expired holders are reclaimed by an injected-clock lazy sweep; reclaim is observable.
AC2: an active in-flight async operation is never swept, even beyond TTL.
AC3: same-writer re-entry refreshes/touches without duplicating and reports reentrant; a new lock reports created.
AC4: disconnect release remains immediate; expiry is only the silent-holder fallback.
AC5: stale holder/token A cannot touch or release successor generation B.
AC6: error cleanup is acquisition- and mutation-disposition-aware; it never releases a pre-existing re-entrant hold or an unknown partially mutated hold.
AC7: (agentId, sessionId) remains the writer key. Same-identity coalescing is rejected under ADR 0021, including the ?id= spoofing falsifier.
AC8: the #15678 persistent-writer reproduction is covered: the second distinct writer is admitted after disconnect or idle expiry, with no App Worker reload.
Context
A live
#15678reproduction left the cockpit controller write-locked after failedcall_methodattempts from a persistent MCP connection. A second writer path from the same seat then receivedWrite denied ... conflictuntil the App Worker was reloaded.The root premise survives current-source inspection:
WriteGuard.requestWrite()acquires and holds,admitWrite()returns no acquisition provenance,InstanceService.assertWritable()discards the admitted lock, andcallMethod()may await arbitrary application code after admission.WriteGuardalready names TTL expiry for silent holders as a follow-up.The Problem
Held-until-release is intentional: it protects multi-step edits from cross-writer interleaving. The missing lifecycle facts are what make it unsafe for long-lived writers:
Architectural Authority
ADR 0021 is binding:
(agentId, sessionId)pair;Therefore identity-only coalescing is explicitly rejected in this ticket. Changing it requires a separately evidenced ADR-0021 amendment.
The Fix
Implement one coherent lease + scoped acquisition lifecycle:
acquiredAt,lastTouchAt, and in-flight state. Public snapshots remain defensive copies.LockRegistry.acquire()/WriteGuard.requestWrite()report whether the call created a new lock or re-entered an existing one, plus the current fenced token.admitWrite()and the write-path owner retain that receipt instead of discarding it.lastTouchAtwithout duplicating the lock.call_methodfailures retain the hold because application code may have partially mutated; they end in-flight protection, emit a failure receipt, and become TTL-reclaimable.Contract Ledger
LockRegistry.acquirecreatedvsreentrantacquisition provenance and a generationWriteGuard.requestWrite/ held stateBaselifecycleadmitWrite/InstanceService.assertWritable(agentId, sessionId)exactly; no AgentIdentity joinAcceptance Criteria
reentrant; a new lock reportscreated.(agentId, sessionId)remains the writer key. Same-identity coalescing is rejected under ADR 0021, including the?id=spoofing falsifier.#15678persistent-writer reproduction is covered: the second distinct writer is admitted after disconnect or idle expiry, with no App Worker reload.Out of Scope
#15678argument marshalling.Related
Origin Session ID:
eb9be68e-9401-4ecd-9762-ef519b4091edRetrieval Hint:
WriteGuard TTL lease scoped acquisition receipt token fence in-flight async persistent writer