Context
Sub-leaf of #13167 (Leaf B/C), the write-path half. The decision core resolveWriteLock (#13207) + the parse transport parseAgentEnvelope+sessionId (#13204/#13205) are now merged on dev, and Client.handleRequest already threads the request context as the 2nd service arg (#13174). The primitives exist but nothing consults them on the mutation path — InstanceService.setInstanceProperties / callMethod mutate unconditionally, ignoring context. This is the slice that makes the enforcement actually enforce (#13167 Contract Ledger rows 4–6).
The Problem
Two agents on one App-Worker heap can issue overlapping write-class NL ops against the same subtree. The merged WriteGuard / LockRegistry / resolveWriteLock are unconsumed: InstanceService calls instance.set(...) / scope[method](...) with no enforcement. Also unresolved: WriteGuard is deliberately not a Neo singleton (its spec constructs per-test for isolation), so there is no per-heap instance yet — the wiring must establish ownership.
The Fix
src/ai/admitWrite.mjs (new, pure): admitWrite({context, componentId, parentOf, writeGuard}) → {admitted, reason, conflict} — composes resolveWriteLock (the decision) with writeGuard.requestWrite (the held-lock acquire). Fail-closed: context absent → {admitted:true} (legacy/unguarded); incomplete identity / unresolvable target → deny with reason; missing writeGuard while enforcement is needed → deny (no-write-guard); cross-writer conflict → deny with conflict. writeGuard + parentOf injected → unit-provable in isolation.
src/ai/Client.mjs: the Client (per-heap connect-on-init singleton) owns the per-heap WriteGuard instance (Neo.create(WriteGuard) at init) — the shared lock truth for the heap. WriteGuard stays non-singleton (test isolation preserved).
src/ai/client/InstanceService.mjs: setInstanceProperties / callMethod take context as the 2nd param and call admitWrite({context, componentId: id, parentOf: cid => Neo.getComponent(cid)?.parentId, writeGuard: this.client?.writeGuard}) before mutating; on !admitted they throw a deny (no mutation). A bare/legacy frame (context absent) is unguarded — backward-compatible.
Acceptance Criteria
Out of Scope
- The
agent_disconnected → WriteGuard.releaseAgent sweep (needs a Bridge disconnect frame not yet on dev) — its own follow-up.
- The live two-agent cross-write-denial whitebox-e2e integration proof (#13167 umbrella-closure evidence) — a follow-up; this slice ships the unit-proven wiring.
#12984 session-id canonicalization.
Related
- Parent #13167 (Leaf B/C contract-of-record); consumes #13207 (
resolveWriteLock), #13134 (WriteGuard), #13138 (deriveSubtreePath). Architecture doc: learn/agentos/MultiWriterEnforcement.md (#13217).
Live latest-open sweep: grep -rl admitWrite src/ ai/ test/ → none; the InstanceService-enforcement wiring has no existing leaf (only the #13167 umbrella).
Release classification: post-release (Agent Harness; nothing v13.x-blocking).
Authored by @neo-opus-ada (Ada, Claude Opus 4.8 [1M context]) — origin session 4c598c8f-d8a7-4288-9420-e825a45d310e.
Context
Sub-leaf of #13167 (Leaf B/C), the write-path half. The decision core
resolveWriteLock(#13207) + the parse transportparseAgentEnvelope+sessionId(#13204/#13205) are now merged ondev, andClient.handleRequestalready threads the requestcontextas the 2nd service arg (#13174). The primitives exist but nothing consults them on the mutation path —InstanceService.setInstanceProperties/callMethodmutate unconditionally, ignoringcontext. This is the slice that makes the enforcement actually enforce (#13167 Contract Ledger rows 4–6).The Problem
Two agents on one App-Worker heap can issue overlapping write-class NL ops against the same subtree. The merged
WriteGuard/LockRegistry/resolveWriteLockare unconsumed:InstanceServicecallsinstance.set(...)/scope[method](...)with no enforcement. Also unresolved:WriteGuardis deliberately not a Neo singleton (its spec constructs per-test for isolation), so there is no per-heap instance yet — the wiring must establish ownership.The Fix
src/ai/admitWrite.mjs(new, pure):admitWrite({context, componentId, parentOf, writeGuard}) → {admitted, reason, conflict}— composesresolveWriteLock(the decision) withwriteGuard.requestWrite(the held-lock acquire). Fail-closed:contextabsent →{admitted:true}(legacy/unguarded); incomplete identity / unresolvable target → deny withreason; missingwriteGuardwhile enforcement is needed → deny (no-write-guard); cross-writer conflict → deny withconflict.writeGuard+parentOfinjected → unit-provable in isolation.src/ai/Client.mjs: the Client (per-heap connect-on-init singleton) owns the per-heapWriteGuardinstance (Neo.create(WriteGuard)at init) — the shared lock truth for the heap.WriteGuardstays non-singleton (test isolation preserved).src/ai/client/InstanceService.mjs:setInstanceProperties/callMethodtakecontextas the 2nd param and calladmitWrite({context, componentId: id, parentOf: cid => Neo.getComponent(cid)?.parentId, writeGuard: this.client?.writeGuard})before mutating; on!admittedthey throw a deny (no mutation). A bare/legacy frame (contextabsent) is unguarded — backward-compatible.Acceptance Criteria
admitWritereturns the documented fail-closed shapes;writeGuard+parentOfinjected; exhaustive unit spec (legacy-skip, incomplete-identity deny, unresolvable-target deny, no-write-guard deny, cross-writer conflict deny, grant→admit + lock held).WriteGuardinstance;WriteGuardremains non-singleton.InstanceService.setInstanceProperties/callMethodenforce viaadmitWritebefore mutating; a denied write does not mutate; a legacy (no-context) write is unaffected.Out of Scope
agent_disconnected→WriteGuard.releaseAgentsweep (needs a Bridge disconnect frame not yet ondev) — its own follow-up.#12984session-id canonicalization.Related
resolveWriteLock), #13134 (WriteGuard), #13138 (deriveSubtreePath). Architecture doc:learn/agentos/MultiWriterEnforcement.md(#13217).Live latest-open sweep:
grep -rl admitWrite src/ ai/ test/→ none; the InstanceService-enforcement wiring has no existing leaf (only the #13167 umbrella).Release classification: post-release (Agent Harness; nothing v13.x-blocking).
Authored by @neo-opus-ada (Ada, Claude Opus 4.8 [1M context]) — origin session 4c598c8f-d8a7-4288-9420-e825a45d310e.