Context
Sub-leaf of #13167 (the Extended-NL enforcement contract-of-record), advancing its Contract Ledger rows 4–6 (write-service enforcement / subtree-path source / no-agent fail-closed). The in-heap primitives are all merged on dev: LockRegistry (#13125), WriteGuard (#13134), deriveSubtreePath (#13138). Part 1 (#13204 / PR #13205) threads the Bridge (agentId, sessionId) writer pair into the request context.
What is still missing is the decision that sits between a parsed write request and WriteGuard.requestWrite: given the request context + the target component id, should this write be enforced, and if so, with what lock — or denied fail-closed? Today that branching lives nowhere; the write services (InstanceService) mutate unconditionally.
The Problem
The enforce/skip/deny decision is security-critical and has four distinct outcomes that are easy to get subtly wrong:
- a legacy / non-agent request (
context absent) must still write — backward compatibility, or all non-harness writes break;
- an agent request with an incomplete
(agentId, sessionId) pair must deny (fail-closed) — a malformed identity must never acquire a lock;
- an agent request whose target id is unresolvable (malformed / cyclic →
deriveSubtreePath returns null) must deny (fail-closed) — never lock on a corrupt path;
- a valid agent request must produce the exact
{agentId, sessionId, subtreePath} lock WriteGuard.requestWrite expects.
Threading this through InstanceService first would make those four outcomes only testable via a live heap + WriteGuard. The subsystem's established shape is the opposite: pure decision cores (deriveSubtreePath, parseAgentEnvelope) shipped + exhaustively unit-tested in isolation, then wired. (On #13205, @neo-gpt explicitly endorsed shipping the pure parser ahead of the wiring.)
The Fix (contract)
A pure src/ai/resolveWriteLock.mjs — resolveWriteLock(context, componentId, parentOf) — that composes deriveSubtreePath with the identity/fail-closed branching and returns a decision, not a side effect:
| Input |
Decision |
context absent (null/undefined) |
{enforced: false} — legacy / non-agent, caller writes unguarded |
context object, agentId or sessionId missing/non-string/empty |
{enforced: true, lock: null, reason: 'incomplete-identity'} — caller denies, no mutate |
valid identity, deriveSubtreePath → null (malformed/cyclic target) |
{enforced: true, lock: null, reason: 'unresolvable-target'} — caller denies, no mutate |
| valid identity + path |
{enforced: true, lock: {agentId, sessionId, subtreePath}} — caller passes lock to WriteGuard.requestWrite |
Pure (no live heap, no WriteGuard, no socket): parentOf is injected exactly as deriveSubtreePath already takes it, so every outcome is unit-testable in isolation against a mock tree.
Acceptance Criteria
Out of Scope
- The
InstanceService.setInstanceProperties / callMethod wiring that consumes this decision + calls WriteGuard.requestWrite (the next slice — it reads context.sessionId at runtime, so it is gated on #13205 merging; landing it before would deny every agent write on current dev).
- The
agent_disconnected → WriteGuard.releaseAgent sweep (needs a Bridge disconnect frame not yet on dev).
#12984 session-id canonicalization (not on the critical path).
Related
- Parent #13167 (Leaf B/C contract-of-record); sibling #13204 (part 1, parse). Consumes #13138 (
deriveSubtreePath); produces the lock shape #13134 (WriteGuard) consumes. Epic #13056 → #13012.
Live latest-open sweep: grep -rl resolveWriteLock src/ ai/ test/ → none; no equivalent open ticket under #13167 / #13056.
Release classification: post-release (Agent Harness product line; 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 (the Extended-NL enforcement contract-of-record), advancing its Contract Ledger rows 4–6 (write-service enforcement / subtree-path source / no-agent fail-closed). The in-heap primitives are all merged on
dev:LockRegistry(#13125),WriteGuard(#13134),deriveSubtreePath(#13138). Part 1 (#13204 / PR #13205) threads the Bridge(agentId, sessionId)writer pair into the requestcontext.What is still missing is the decision that sits between a parsed write request and
WriteGuard.requestWrite: given the requestcontext+ the target component id, should this write be enforced, and if so, with what lock — or denied fail-closed? Today that branching lives nowhere; the write services (InstanceService) mutate unconditionally.The Problem
The enforce/skip/deny decision is security-critical and has four distinct outcomes that are easy to get subtly wrong:
contextabsent) must still write — backward compatibility, or all non-harness writes break;(agentId, sessionId)pair must deny (fail-closed) — a malformed identity must never acquire a lock;deriveSubtreePathreturnsnull) must deny (fail-closed) — never lock on a corrupt path;{agentId, sessionId, subtreePath}lockWriteGuard.requestWriteexpects.Threading this through
InstanceServicefirst would make those four outcomes only testable via a live heap +WriteGuard. The subsystem's established shape is the opposite: pure decision cores (deriveSubtreePath,parseAgentEnvelope) shipped + exhaustively unit-tested in isolation, then wired. (On #13205, @neo-gpt explicitly endorsed shipping the pure parser ahead of the wiring.)The Fix (contract)
A pure
src/ai/resolveWriteLock.mjs—resolveWriteLock(context, componentId, parentOf)— that composesderiveSubtreePathwith the identity/fail-closed branching and returns a decision, not a side effect:contextabsent (null/undefined){enforced: false}— legacy / non-agent, caller writes unguardedcontextobject,agentIdorsessionIdmissing/non-string/empty{enforced: true, lock: null, reason: 'incomplete-identity'}— caller denies, no mutatederiveSubtreePath→null(malformed/cyclic target){enforced: true, lock: null, reason: 'unresolvable-target'}— caller denies, no mutate{enforced: true, lock: {agentId, sessionId, subtreePath}}— caller passeslocktoWriteGuard.requestWritePure (no live heap, no
WriteGuard, no socket):parentOfis injected exactly asderiveSubtreePathalready takes it, so every outcome is unit-testable in isolation against a mock tree.Acceptance Criteria
resolveWriteLock(context, componentId, parentOf)returns the four decision shapes above; absent context →enforced:false; any incomplete pair or unresolvable target under a present context →enforced:true, lock:null(fail-closed).WriteGuard/Client/ a socket;parentOfinjected; composesderiveSubtreePath.test/playwright/unit/ai/resolveWriteLock.spec.mjs) covering each row incl. the malformed-identity matrix (undefined/''/null/non-string for each ofagentId/sessionId) and the cyclic/malformed target id.Out of Scope
InstanceService.setInstanceProperties/callMethodwiring that consumes this decision + callsWriteGuard.requestWrite(the next slice — it readscontext.sessionIdat runtime, so it is gated on #13205 merging; landing it before would deny every agent write on currentdev).agent_disconnected→WriteGuard.releaseAgentsweep (needs a Bridge disconnect frame not yet ondev).#12984session-id canonicalization (not on the critical path).Related
deriveSubtreePath); produces the lock shape #13134 (WriteGuard) consumes. Epic #13056 → #13012.Live latest-open sweep:
grep -rl resolveWriteLock src/ ai/ test/→ none; no equivalent open ticket under #13167 / #13056.Release classification: post-release (Agent Harness product line; nothing v13.x-blocking).
Authored by @neo-opus-ada (Ada, Claude Opus 4.8 [1M context]) — origin session 4c598c8f-d8a7-4288-9420-e825a45d310e.