TECH.md — Open Question (OQ) Escalation Channel
TECH.md — Open Question (OQ) Escalation Channel
Section titled “TECH.md — Open Question (OQ) Escalation Channel”Companion to PRODUCT.md (33 invariants, OQ-INV-1..OQ-INV-33).
This spec chooses the on-disk transport, ratifies the three open questions, and
maps every invariant to a concrete mechanism. No production code is authored
here; this is the implementation plan the PLAN phase decomposes into Subtasks.
Context
Section titled “Context”The OQ-escalation channel rides on the existing cmux filesystem substrate.
session-driver-cmux already establishes the relevant facts (verified against
.claude/skills/session-driver-cmux/SKILL.md):
- Per-session event directory. Each worker has
.claude/cmux-events/<session-id>/, created at launch (SKILL.md §1, step 5 writesmeta.jsonthere). The directory is git-ignored and visible to both the worker and the parent on the same host — they share one filesystem and one git repo (worktrees + cmux-events are the shared substrate). - Append-only JSONL event stream. The lifecycle hooks emit
<session-id>/events.jsonl(session_start,user_prompt_submit,pre_tool_use,stop,session_end) — SKILL.md “Events emitted”. The skill states explicitly: “Event files are append-only JSONL. Treat each line as a self-contained JSON object.” - Parent monitors
stop, notsession_end. The parent’s mid-session interaction loop blocks onstopevents between turns (SKILL.md “Mid-session interaction” — the S62E anti-pattern:session_endonly fires on/exit). This is the cadence at which the parent will scan for OQs and write decisions. - Footnote (currency, post-ID-27 {27.5} RESEARCH; S281). ID-27 promotes
watch-fleet.sh— a multi-signal smart-watcher (session_end/final_report.*/ OQ-heading growth /AskUserQuestionstall /stoppause / fleet-quiet) — as the canonical orchestrator monitoring primitive, superseding barewait-for-fleet.shstop-polling. The parent’s OQ enumeration scan rides this watcher loop rather than a standalonestop-poll. ID-27 also hardens events-base resolution to be CWD-independent (--git-common-dir-derivedresolve_project_root+KH_CMUX_EVENTS_DIR); the OQ channel’s parent scan consumes that helper. These are cadence/path-resolution changes only — they do not alter any OQ-INV-N semantics or the file-per-record transport chosen here. - Decision-return path =
send-prompt.sh. The parent influences a paused worker by sending a follow-up prompt (SKILL.md §2 / mid-session loop). There is precedent for a file-based control channel too:pre_tool_usegating writesallow/denyto<session-id>/tool-decision(SKILL.md “Events emitted”), and--briefdrops a known on-disk file plus a pointer prompt. - Greenfield OQ schema.
.claude/cmux-events/is absent in this worktree and empty on disk in live sessions; there is no prior OQ artefact. We design the OQ sub-layout from scratch, additively, without disturbingevents.jsonl,meta.json, ortool-decision.
The behavioural contract this transport must satisfy is PRODUCT.md in full
(no behaviour is restated here). The orchestration context — why a
sub-orchestrator cannot resolve an OQ inline — is
.claude/skills/workflow-orchestration/SKILL.md “Open-Question escalation from
sub-orchestrators”: the parent owns the roadmap/backlog and cross-Task scope, so
a worker running workflow-orchestration on its own ID-N Task must surface
cross-Task decisions through this channel rather than deciding locally.
Relevant files:
.claude/cmux-events/<session-id>/— per-worker event directory (substrate)..claude/cmux-events/<session-id>/events.jsonl— existing lifecycle stream (we do not write OQs into this file — see Transport decision)..claude/cmux-events/<session-id>/meta.json— managed-worker marker..claude/cmux-events/<session-id>/tool-decision— existing file-based control precedent for thepre_tool_usegate..claude/cmux-events/<session-id>/final_report.yaml— sub-orchestrator final report convention (SKILL.md “Final-report convention”); the OQ layout sits beside it..claude/skills/session-driver-cmux/scripts/—launch-worker.sh,send-prompt.sh,converse.sh,wait-for-fleet.sh,stop-worker.sh. The OQ helpers are new siblings here (PLAN decides exact split).
This is not a Supabase/Postgres feature: there is no migration, no RLS, no TanStack Query surface, no MCP tool. The KH database/auth/data-fetching gotchas do not apply. The applicable KH conventions are the worktree-isolation rules (shared filesystem, append-only JSONL discipline, relative paths in sub-agent-facing scripts) and UK English throughout.
Transport decision
Section titled “Transport decision”Chosen transport: one immutable file per record, written
write-tmp-then-atomic-rename, under a per-worker OQ sub-directory of the existing
.claude/cmux-events/<session-id>/ substrate. State is the directory listing
plus the records themselves — there is no separate index and no in-memory
state. Concretely: each OQ is a file oq/<oq_id>.json; each decision is a file
decisions/<oq_id>.json; the worker’s lifecycle state is a single small file
oq-state.json. Reading the channel is ls + parse; writing is
write tmp → fsync → rename.
This is a file-per-record store, not a shared append-log. It reuses the cmux
substrate idioms (a known per-session directory, JSON records, file-based
control à la tool-decision) but deliberately diverges from events.jsonl’s
single-stream shape. Rationale, tied to the constraining invariants:
Why file-per-record + atomic rename
Section titled “Why file-per-record + atomic rename”- OQ-INV-3 — atomicity / no partial record ever visible. POSIX
rename(2)within the same directory (same filesystem) is atomic: a reader enumeratingoq/questions/either sees<oq_id>.jsonfully present (the rename committed) or not at all. The writer writes the complete JSON to a dotfile in the same target directory —oq/questions/.<oq_id>.json.tmp.<pid>—fsyncs it, thenrenames it onto the final name in place. A reader never observes a truncated or half-written record, because the partially-written bytes live under the dotfile tmp name that readers skip (enumeration matches*.jsonand excludes dotfiles). This is the concrete technique OQ-INV-3 demands. - OQ-INV-12 / OQ-INV-26 — idempotent emit, no duplication. The filename is
the dedup key.
oq_idis derived deterministically (Task ID + phase + content hash — see Record schemas), so a re-emission after a crash targets the same pathoq/questions/<oq_id>.json.renameis idempotent against an identical payload; the parent iteratingoq/questions/*.jsonsees eachoq_idexactly once regardless of re-emissions. A shared append-log would instead append a second line on re-emit, forcing readers to dedup by scanning — error-prone and stateful. - OQ-INV-15 / OQ-INV-17 — decisions addressed, one per OQ. A decision is
decisions/<oq_id>.json. Addressing is structural: the worker reads exactlydecisions/<its-oq_id>.jsonand never another OQ’s decision (OQ-INV-15). The filesystem enforces “at most one decision per OQ” for free — a secondrenameonto the same decision path overwrites, but the worker consumes the first decision it observes and treats later content as a no-op (OQ-INV-17), surfacing a genuine conflict via a fresh OQ. - OQ-INV-5 / OQ-INV-25 — durability, no loss.
fsyncbeforerenamemakes the record durable before emit returns success; the directory entry survives worker crash, parent crash, and host reboot. Crash-then-read yields the OQ because it is an ordinary file on a synced directory. - OQ-INV-30 — parent re-derives state purely from disk, zero in-memory
state. The complete channel state is the union of three directory listings
(
oq/,decisions/, and theoq-state.jsonmarker) across every worker session dir. The parent reconstructs “open OQs awaiting decision” as the set difference{oq/questions/*.json} − {oq/decisions/*.json}, filtered toblocking:truewith nostatus:cancelled. No daemon, no in-memory queue, no offset bookkeeping. - OQ-INV-4 — per-worker FIFO. Filenames alone do not order; the record
carries a monotonic
seqinteger (andemitted_at). Readers sort byseqto recover the worker’s emission order. FIFO is therefore a read-time sort over an immutable set, not a property the writer must preserve via append position (which a crash mid-append could corrupt).
Why the rejected transports lose
Section titled “Why the rejected transports lose”- Shared append-only JSONL (
oq.jsonl, mirroringevents.jsonl). The closest substrate-native option. Rejected because:- Atomicity (OQ-INV-3) is fragile. A single
O_APPENDwrite of a multi-KB JSON line is only atomic up toPIPE_BUF(4 KB on macOS/Linux); larger records, or a crash mid-write, leave a half-line that every subsequent reader must detect and skip. OQ records carry free-textquestion+context_refand can easily exceed 4 KB. - Idempotent emit (OQ-INV-12) needs an external dedup pass. Re-emit appends
a duplicate line; readers must dedup by
oq_id, holding state. File-per-record makes dedup structural (same path overwrite). - No-loss vs in-place edits collide. Cancellation (OQ-INV-13) and
supersession (OQ-INV-2/6) are new records either way, but in an append-log
the parent must fold the latest
statusperoq_idby scanning — more read-time state than adecisions/set-difference. - It does win on append-position FIFO, but we recover FIFO cheaply via
seq, so that advantage does not offset the atomicity/idempotency costs.
- Atomicity (OQ-INV-3) is fragile. A single
- SQLite (
oq.db). Gives ACID atomicity and FIFO ordering “for free”, but rejected because:- Substrate mismatch / no precedent. cmux workers exchange everything via
flat files (
events.jsonl,tool-decision,meta.json,final_report.yaml); introducing a binary DB file per session breaks the “treat each line/file as inspectable JSON” idiom and the operator-debuggablecat/jq/lsworkflow the SKILL relies on. - Cross-process locking under crash. WAL/rollback-journal files and
flocksemantics across a worker and a parent on the same DB add a failure surface (stale locks after a hard crash) that purerenamedoes not have. - Per-worker isolation (OQ-INV-28) is harder. One DB per worker re-creates the directory-per-worker layout anyway, with more machinery; one shared DB violates isolation and adds contention.
- Substrate mismatch / no precedent. cmux workers exchange everything via
flat files (
- Named pipe / FIFO (
mkfifo). Rejected outright: a pipe is not durable (OQ-INV-5) — unread bytes vanish on reader/writer exit, and there is nothing on disk to re-read after a crash (fails OQ-INV-25/29/30). It also has no random-access addressing for per-oq_iddecisions (OQ-INV-15). A pipe models a live stream, not a durable mailbox; this channel is fundamentally a durable mailbox. - cmux
send-promptas the emit transport (worker → parent). Rejected for emission: a worker cannot reliablysend-promptto its parent (the primitive is parent → worker), and even if it could, prompts are turn-buffer text, not durable atomic records — they fail OQ-INV-3/5/25 and carry nooq_idaddressing. Howeversend-promptis retained as the parent’s optional decision-notification nudge (see Read/poll protocol): the authoritative decision is always thedecisions/<oq_id>.jsonfile (so correctness never depends on the prompt), but the parent MAY additionallysend-promptthe worker “decision ready for <oq_id>” to collapse the polling latency toward zero. This is the justified hybrid: files are the source of truth; send-prompt is a latency optimisation, never a correctness dependency.
File-per-record + atomic-rename is the only option that makes atomicity
(OQ-INV-3), idempotency (OQ-INV-12), addressed decisions (OQ-INV-15), and
zero-in-memory parent re-derivation (OQ-INV-30) all structural — properties of
the filesystem layout rather than of reader/writer bookkeeping — while staying
within the cmux flat-file substrate. FIFO (OQ-INV-4) is the one property we
recover at read time via a seq field, which is a cheap and crash-safe trade.
Open-question ratifications
Section titled “Open-question ratifications”PRODUCT.md surfaces three open questions (OQ-Q1/Q2/Q3). A fresh review pass:
OQ-Q1 — separate received acknowledgement?
Section titled “OQ-Q1 — separate received acknowledgement?”Should the channel carry a
receivedack distinct from thedecision, to support future parent-side UI (“OQ open for 30 min”)?
Decision: NO separate received ack. The protocol stays two-state
(emitted → decided), exactly as OQ-INV-11 specifies.
Rationale. A received record would be a third state with no consumer:
- The only stated motivation is a parent-side UI, which
PRODUCT.mdNon-goals explicitly excludes (“Any UI for the parent to browse, triage, or batch OQs is a future enhancement; this spec covers the wire-level behaviour only”). Building a state to serve an out-of-scope consumer is premature. - The “OQ open for 30 min” derivation needs no new record. With
file-per-record, the parent already computes open-OQ age as
now − emitted_atover the set difference{oq/questions/*.json} − {oq/decisions/*.json}.emitted_at(OQ-INV-1) plus the decision-absence test gives the dwell time directly — areceivedack adds nothing the timestamps don’t already carry. - A third state widens the protocol surface OQ-INV-11 deliberately minimised and introduces a new “seen-but-not-decided” half-state the spec calls out as exactly the spurious mid-state to avoid.
- If a future UI genuinely needs an explicit “parent has eyeballed this” mark, it
is additive and non-breaking under file-per-record: drop an
acks/<oq_id>.jsonalongsidedecisions/. Deferring costs nothing.
Evidence: PRODUCT.md OQ-INV-11 (two-state by design), Non-goals
(“Parent-side UI … future enhancement”), OQ-INV-1 (emitted_at present).
Settleable from spec+evidence: YES. The spec’s own two-state mandate and UI non-goal settle this; deferral is reversible and free.
OQ-Q2 — maximum awaiting-decision duration / auto-abort?
Section titled “OQ-Q2 — maximum awaiting-decision duration / auto-abort?”Should the channel impose a maximum
awaiting-decisionduration before auto-abort, or is that strictly aworkflow-orchestrationpolicy concern?
Decision: the channel imposes NO timeout. Timeout/auto-abort is a
workflow-orchestration policy concern, enacted via an ordinary abort_task
decision.
Rationale.
- OQ-INV-24 already states the position in the inline open-question note: “This
spec defers the timeout policy to
workflow-orchestration; the channel itself imposes no timeout.” This ratification confirms that lean. - The channel is a durable mailbox, not a scheduler. It has no daemon and no in-memory state (OQ-INV-30) — there is deliberately nothing running that could fire a timer. Adding one would contradict the “parent re-derives state purely from disk, zero in-memory state” invariant: a timeout enforcer is in-memory, always-on state by definition.
- The mechanism to act on a timeout already exists in-band: the parent (driven
by its
workflow-orchestrationpolicy) writes a decision withoutcome: abort_task/directive: {kind: 'abort_task'}(OQ-INV-10, OQ-INV-19). No new channel primitive is required to abort a stale OQ — only a policy decision about when to write that record. - Placing the timeout in the channel would also force a single global policy on
all workers; leaving it to the orchestrator lets per-Task urgency
(
urgency ∈ {low,normal,high}, OQ-INV-1) inform the wait the parent tolerates.
Evidence: PRODUCT.md OQ-INV-24 (explicit deferral), OQ-INV-30 (zero
in-memory state — incompatible with a channel-side timer), OQ-INV-10/19
(abort_task decision is the existing enactment path).
Settleable from spec+evidence: YES. OQ-INV-24 already defers it and OQ-INV-30 makes a channel-side timer architecturally inconsistent; the abort path exists.
OQ-Q3 — per-worker cap on accumulated non-blocking OQs?
Section titled “OQ-Q3 — per-worker cap on accumulated non-blocking OQs?”Should non-blocking OQs accumulate beyond a per-worker cap to prevent runaway emission from a confused worker?
Decision: NO cap in the channel. Out of scope for this iteration; revisit on the first integration that exposes the pathology.
Rationale.
PRODUCT.mdOQ-Q3 itself scopes this out (“Out of scope here; revisit with the first integration that exposes the pathology”). A fresh pass finds no evidence to override that.- A cap is a back-pressure / rate-limit concern, which presupposes a failure mode (a confused worker emitting unboundedly) we have no observed instance of — the substrate is greenfield (no OQs on disk yet). Designing a limit against a hypothetical risks the wrong threshold and a false-positive abort of a legitimately chatty worker.
- File-per-record naturally bounds the blast radius: runaway emission produces
many small files in one worker’s
oq/dir, fully isolated (OQ-INV-28) and trivially observable by the parent’s scan. The parent can already throttle a worker out-of-band (stop-worker, or simply stop reading), so there is no loss-of-control hazard that demands an in-channel cap today. - If the pathology appears, the fix is additive and informed by real data: a
max_open_nonblockingpolicy enforced by the parent’s scan (count{oq/questions/*.json where blocking:false and no decision}), not a wire-format change.
Evidence: PRODUCT.md OQ-Q3 (explicit out-of-scope + revisit trigger),
OQ-INV-28 (per-worker isolation already bounds blast radius), session-driver
stop-worker (out-of-band throttle already exists).
Settleable from spec+evidence: YES. The spec defers it explicitly, no pathology is observed, and isolation + out-of-band throttle remove any urgency. Honest note: this is a deferral, not a solved design — if the first live integration shows runaway emission, it returns to the backlog as a real design question. It does not require Liam now.
Ratification summary
Section titled “Ratification summary”| OQ | Decision | Classification |
|---|---|---|
OQ-Q1 (received ack) | No separate ack; stay two-state | Settleable from spec+evidence: YES |
| OQ-Q2 (awaiting-decision timeout) | No channel timeout; orchestrator policy via abort_task | Settleable from spec+evidence: YES |
| OQ-Q3 (non-blocking cap) | No cap; out of scope, revisit on first pathology | Settleable from spec+evidence: YES |
None require product-owner (Liam) escalation: each either restates a lean the spec already encodes (Q2, Q3) or is settled by the spec’s own non-goals plus a free, reversible deferral (Q1). No fabricated justification was needed to avoid escalation — all three genuinely resolve from the spec text and the substrate constraints.
Record schemas and on-disk layout
Section titled “Record schemas and on-disk layout”Directory layout (per worker)
Section titled “Directory layout (per worker)”.claude/cmux-events/<session-id>/├── events.jsonl # existing — lifecycle stream (untouched)├── meta.json # existing — managed-worker marker (untouched)├── tool-decision # existing — pre_tool_use gate (untouched)├── final_report.yaml # existing — final-report convention (untouched)└── oq/ # NEW — OQ channel root for this worker ├── oq-state.json # worker lifecycle-state marker (OQ-INV-20/22) ├── questions/ # one immutable file per OQ │ ├── <oq_id>.json │ └── ... └── decisions/ # one file per resolved OQ, written by parent ├── <oq_id>.json └── ...The OQ channel is rooted under the existing per-session directory, so it
inherits that directory’s git-ignore and is discoverable from the launch
script’s returned events_file/session-id without any new path convention.
Write-tmp-then-rename uses a same-directory dotfile: the tmp file is written
inside the target directory itself (e.g. questions/.<oq_id>.json.tmp.<pid>),
fsyncd, then renamed in place onto questions/<oq_id>.json. Because tmp and
final share the same directory they are trivially on the same filesystem, so
rename(2) is atomic (OQ-INV-3). Readers/enumeration match final records only —
questions/<oq_id>.json exactly, or *.json excluding dotfiles — so an
in-flight tmp dotfile is never visible.
Per-worker isolation (OQ-INV-28) is the directory boundary: worker A’s OQs
live only under A’s <session-id>/oq/. A reader of A’s stream lses A’s
questions/ only and can never see B’s. Cross-worker discovery (OQ-INV-28
second sentence) is the parent enumerating sibling <session-id>/oq/ dirs.
OQ record — oq/questions/<oq_id>.json
Section titled “OQ record — oq/questions/<oq_id>.json”| Field | Type | Notes / invariant |
|---|---|---|
oq_id | string | Stable id, derived (see below). Unique for the OQ’s lifetime. OQ-INV-1, OQ-INV-12. |
worker_id | string | Emitter session id. OQ-INV-1, OQ-INV-31. |
seq | integer | Monotonic per-worker counter (0,1,2…). Read-time FIFO sort key. OQ-INV-4. |
emitted_at | string | UTC ISO-8601. Temporal provenance + dwell-time source. OQ-INV-1, OQ-INV-31. |
question | string | Human-readable text the parent must answer. OQ-INV-1. |
urgency | enum | low | normal | high. Unknown value ⇒ integrity error. OQ-INV-1, OQ-INV-27. |
blocking | boolean | true ⇒ worker enters awaiting-decision. OQ-INV-1, OQ-INV-8/9/20/22. |
context_ref | object | Enough for the parent to act without re-deriving: e.g. { file, commit, subtask_id, phase }. OQ-INV-1. |
status | enum | open | cancelled. A cancellation is a new record with same oq_id and status:cancelled (overwrites via rename). OQ-INV-6, OQ-INV-13. |
supersedes | string | null | oq_id this record refines/cancels, or null. OQ-INV-2, OQ-INV-6, OQ-INV-13. |
schema_version | integer | 1. Lets readers fail closed on unknown shapes. OQ-INV-27. |
checksum | string | SHA-256 over the canonical-JSON of all other fields. Reader recomputes; mismatch ⇒ integrity error (fail closed). OQ-INV-27. |
The record is immutable once its <oq_id>.json exists with status:open
(OQ-INV-2). The only legitimate overwrite of that path is a status:cancelled
record carrying the same oq_id (OQ-INV-13) — semantically a state transition,
not a content edit; the original question/emitted_at are preserved in the
cancellation record so history is not lost.
Decision record — oq/decisions/<oq_id>.json
Section titled “Decision record — oq/decisions/<oq_id>.json”| Field | Type | Notes / invariant |
|---|---|---|
oq_id | string | The OQ this resolves (= the filename stem). Addressing. OQ-INV-15. |
decided_at | string | UTC ISO-8601. Temporal provenance. OQ-INV-10, OQ-INV-31. |
decider_id | string | Parent session id. Provenance. OQ-INV-31. |
outcome | enum | answered | deferred | cancelled | abort_task. OQ-INV-10. |
answer | string | Free-text decision body. OQ-INV-10. |
directive | object | null | Optional structured instruction, e.g. { kind: 'abort_task' } or { kind: 'rewrite_spec', target: 'PRODUCT.md', section: '3.2' }. Never executable code — advisory only. OQ-INV-10, OQ-INV-19. |
schema_version | integer | 1. OQ-INV-27. |
checksum | string | SHA-256 over the other fields; reader fails closed on mismatch. OQ-INV-14 (inherits 27). |
Decisions inherit OQ records’ write guarantees (atomic rename, fsync, immutable
filename = oq_id) per OQ-INV-14. “One decision per OQ” is the single
decisions/<oq_id>.json slot; conflicting overwrites are handled at read time
(OQ-INV-17 — first observed wins, conflict re-surfaced as a new OQ).
oq_id derivation (OQ-INV-12)
Section titled “oq_id derivation (OQ-INV-12)”oq_id = "oq-" + sha256_hex( task_id "|" phase "|" content_hash )[0:16] where content_hash = sha256_hex( normalised(question) "|" json(context_ref) )- Inputs are exactly the stable ones PRODUCT.md names — Task ID, phase,
content hash (OQ-INV-12).
worker_idis excluded so a relaunched worker with the same identity (OQ-INV-29) re-derives the same id;emitted_atandseqare excluded so they never perturb the id. normalised(question)trims and collapses internal whitespace so trivially different renderings of the same question still collide to one id.- A re-emission after a crash (worker re-runs the same Planner/Executor step,
forms the same question for the same Task+phase) therefore produces the same
oq/questions/<oq_id>.jsonpath → therenameis idempotent and the parent sees one OQ (OQ-INV-12, OQ-INV-26). - A genuine follow-up/refinement is a different question → different
content_hash→ differentoq_id, linked viasupersedes(OQ-INV-2). This cleanly distinguishes “I crashed and re-emitted the same OQ” from “I have a new related OQ”.
awaiting-decision state — oq/oq-state.json
Section titled “awaiting-decision state — oq/oq-state.json”The parent must enumerate workers by lifecycle state without reading individual OQ streams (OQ-INV-20) and list a blocked worker’s open OQs deterministically (OQ-INV-23). A single small marker file per worker carries it:
| Field | Type | Notes / invariant |
|---|---|---|
worker_id | string | Owning session. |
lifecycle_state | enum | working | awaiting-decision. (stopped/crashed are derived from cmux stop/session_end + liveness, not owned here — see below.) OQ-INV-20/22. |
blocked_on | string | null | The single oq_id the worker is currently blocked on, or null when working. Gives the parent the “answer this first” pointer without a stream read. OQ-INV-23. |
checkpoint_ref | object | null | Opaque-to-channel pointer the worker persists so it can resume the blocked operation post-decision (e.g. { phase, subtask_id, note }). OQ-INV-21. |
updated_at | string | UTC ISO-8601. |
- Written with the same atomic-rename discipline. Set to
awaiting-decision+blocked_on=<oq_id>after the blocking OQ’squestions/<oq_id>.jsonis durably renamed (so the parent never seesawaiting-decisionpointing at an OQ it cannot yet read). Reset toworkingblocked_on=nullafter the worker has observed and applied the decision.
- The parent enumerates fleet state by reading only each worker’s
oq-state.json(cheap, one small file per worker) — satisfying OQ-INV-20’s “without reading individual OQ streams”. To then list what to answer (OQ-INV-23) it reads that worker’squestions/and sorts byseq. stopped/crashedremain cmux-owned signals (thestop/session_endevents + process liveness), distinct fromawaiting-decision; OQ-INV-20 lists all four as distinct states but only the OQ-channel-owned two live inoq-state.json. This preserves OQ-INV-24: a worker thatstops with only non-blocking OQs isworkinginoq-state.jsonwhile cmux-stoppedat the turn level — the two axes are orthogonal.
Write protocol (emit + decide)
Section titled “Write protocol (emit + decide)”A single primitive underlies every write — atomic-publish(dir, name, payload):
1. tmp = "<dir>/.<name>.tmp.<pid>.<rand>" # dotfile IN the target dir2. write canonical-JSON(payload) to tmp # includes checksum field3. fsync(tmp); fsync(dirfd(<dir>)) # durability — OQ-INV-54. rename(tmp, "<dir>/<name>") # atomic same-dir publish — OQ-INV-35. fsync(dirfd(<dir>)) # durable directory entryrename is the commit point: before it, the only on-disk trace is a dotfile in
the target directory itself that readers skip; after it, the record is fully
present. Because tmp and final live in the same directory, the rename is an
in-place same-directory commit — the strongest atomicity guarantee. There is no
intermediate observable state (OQ-INV-3). fsync before rename is what lets
emit “return success” with the durability OQ-INV-5 promises.
Worker emit (OQ-INV-1..13)
Section titled “Worker emit (OQ-INV-1..13)”- Compute
oq_id(derivation above) and the nextseq(read current maxseqacrossquestions/*.json, add 1 — crash-safe because it is derived from disk, not an in-memory counter; OQ-INV-29). - Idempotency short-circuit (OQ-INV-12): if
questions/<oq_id>.jsonalready exists anddecisions/<oq_id>.jsondoes not, the OQ is already emitted and unresolved — skip the write, treat as a successful (idempotent) emit. If a decision already exists, the OQ is resolved — apply the decision rather than re-emit. - Otherwise
atomic-publish(questions/, <oq_id>.json, record). - If
blocking:true:atomic-publish(oq/, oq-state.json, {lifecycle_state: 'awaiting-decision', blocked_on:<oq_id>, checkpoint_ref:…})— after step 3 commits (ordering above). The worker then enters the decision-poll loop and makes no OQ-dependent progress (OQ-INV-8); it MAY do independent side-work. - If
blocking:false: leavelifecycle_state:'working'and continue immediately (OQ-INV-9, OQ-INV-22).
Cancellation (OQ-INV-6, OQ-INV-13). A worker cancels by
atomic-publish(questions/, <oq_id>.json, {…, status:'cancelled', supersedes: <oq_id>}) — overwriting the slot with a terminal-state record. The cancellation
is itself atomic + durable (OQ-INV-3/5). The worker then resets oq-state.json
to working if it was blocked on that OQ. A worker never deletes a record
(append-only/no-retract semantics, OQ-INV-6) — cancellation is a state, not a
removal.
Parent decide (OQ-INV-14..17)
Section titled “Parent decide (OQ-INV-14..17)”- Read the worker’s open OQs:
{questions/*.json} − {decisions/*.json}, drop any withstatus:cancelled, sort byseq(FIFO, OQ-INV-4/23). - Decide-once guard (OQ-INV-33): if
decisions/<oq_id>.jsonalready exists, do not write again (the parent never re-decides anoq_id). For a cancelled OQ, the parent SHOULD NOT write a decision at all (OQ-INV-13). atomic-publish(decisions/, <oq_id>.json, decision).- (Optional latency nudge — not correctness-bearing.)
send-promptthe worker “decision ready”. The worker would have observed the file on its next poll regardless; the prompt only shortens the wait (see Read/poll protocol).
Because each decision is a distinct oq_id-named file and the parent guards on
existence, there is exactly one decision per OQ from the protocol’s perspective
(OQ-INV-10). A buggy double-decide overwrites the file, but the worker has
already consumed the first observation (OQ-INV-17), so the second is a no-op at
the worker; if contents conflict, the worker raises a fresh OQ.
Read / poll protocol and latency
Section titled “Read / poll protocol and latency”Worker decision-poll loop (OQ-INV-16, OQ-INV-18)
Section titled “Worker decision-poll loop (OQ-INV-16, OQ-INV-18)”While lifecycle_state == awaiting-decision, the worker polls
decisions/<blocked_on>.json on a fixed cadence:
every POLL_INTERVAL seconds: if exists(decisions/<blocked_on>.json): d = parse+verify(...) # checksum/schema check, OQ-INV-27 apply(d) exactly once # OQ-INV-16/17 idempotent atomic-publish(oq/, oq-state.json, {lifecycle_state:'working', blocked_on:null}) breakPOLL_INTERVALdefault 2 s; latency budget 10 s (OQ-INV-18). A decision written by the parent is observed within at most one poll interval. 2 s gives ≤2 s typical and a comfortable margin under the 10 s wall-clock budget even allowing for fsync/scheduling jitter. The budget (10 s) is the testable behaviour; the cadence (2 s) is the implementation knob and may be tuned perurgencylater (high-urgency OQs could poll faster) without changing the contract.- At-least-once + idempotent application (OQ-INV-16/17): the worker tolerates
observing the same
decisions/<oq_id>.jsontwice (e.g. crash after apply but before theoq-state.jsonreset). On restart it re-reads, re-derives “this OQ is decided”, and applying the same decision again yields the same state — applying twice == applying once. - Optional
send-promptnudge (parent decide step 4) collapses the observed latency toward zero: the prompt wakes the worker’s turn, which checks the decision file immediately rather than waiting for the next tick. Correctness never depends on it — if the prompt is lost, the next poll still observes the file inside the budget. - Non-blocking OQs are not polled in a loop. A
blocking:falseOQ’s decision is checked opportunistically (e.g. at natural phase boundaries) and applied if still relevant or discarded if the worker has already taken a subsuming path (OQ-INV-9). No latency budget applies to non-blocking decisions.
Parent enumeration scan (OQ-INV-20/23/28/30)
Section titled “Parent enumeration scan (OQ-INV-20/23/28/30)”The parent’s natural cadence is the stop-event monitor loop
(session-driver-cmux mid-session pattern) — between worker turns it scans:
for each <session-id>/ under .claude/cmux-events/: state = parse(<session-id>/oq/oq-state.json) # OQ-INV-20 (no stream read) if state.lifecycle_state == 'awaiting-decision': open = sort_by_seq({questions/*.json} − {decisions/*.json}, drop cancelled) # answer open[0] first (FIFO) — OQ-INV-23Reading oq-state.json alone answers “which workers are blocked” (OQ-INV-20)
without opening any OQ file; only when the parent chooses to act does it read
that worker’s questions/. Because everything is on disk, this scan is
identical whether the parent is fresh or long-lived (OQ-INV-30 — see below). The
scan also surfaces non-blocking OQs against the next turn / fleet review
(OQ-INV-24).
Crash / restart re-derivation
Section titled “Crash / restart re-derivation”Both sides reconstruct their entire view of the channel from the directory contents alone. No process holds authoritative state; the disk is the state.
Worker restart (OQ-INV-29, OQ-INV-21, OQ-INV-32)
Section titled “Worker restart (OQ-INV-29, OQ-INV-21, OQ-INV-32)”A worker relaunched with the same worker_id runs this on startup, before any
new work:
for each questions/<oq_id>.json (sorted by seq): if status == 'cancelled': classify RESOLVED (cancelled) elif exists(decisions/<oq_id>.json): classify DECIDED → apply if not yet applied else: classify UNRESOLVEDstate = parse(oq-state.json)if state.lifecycle_state == 'awaiting-decision' and UNRESOLVED contains state.blocked_on: re-enter awaiting-decision; resume polling decisions/<blocked_on>.json (do NOT re-run the work that produced the OQ — use checkpoint_ref) — OQ-INV-21- Classification is a pure function of
questions/,decisions/, andoq-state.json— no parent involvement (OQ-INV-29). - Because
oq_idis identity-stable (derivation excludesworker_id/emitted_at/seq), a worker that crashed mid-emit and re-forms the same question lands the sameoq_id; the idempotency short-circuit (emit step 2) prevents a duplicate (OQ-INV-12, OQ-INV-32 “I check before re-emitting”). checkpoint_refinoq-state.jsonis what lets the worker resume the blocked operation rather than re-execute it (OQ-INV-21). The channel treats it as opaque;workflow-orchestrationdefines its contents.
Parent restart (OQ-INV-30, OQ-INV-33)
Section titled “Parent restart (OQ-INV-30, OQ-INV-33)”A relaunched parent runs the enumeration scan (above) across all
<session-id>/oq/ dirs and reconstructs the full open-OQ set:
open_awaiting = ⋃ over workers { questions/*.json − decisions/*.json − {status:cancelled} } filtered to blocking:true- This is stateless re-derivation — the fresh parent’s view is identical to a never-crashed parent’s, because both compute the same set difference over the same files (OQ-INV-30 “No in-memory state is required for correctness”).
- The decide-once guard (parent decide step 2) is also disk-derived: “have I
decided
oq_id?” == “doesdecisions/<oq_id>.jsonexist?”. So a parent that crashed after writing a decision will, on restart, see the decision file and not re-decide (OQ-INV-33 “I never write a decision for anoq_idI have already decided”) — even across a crash that wiped its memory.
Integrity / fail-closed on read (OQ-INV-27)
Section titled “Integrity / fail-closed on read (OQ-INV-27)”Every read (worker or parent) verifies schema_version and recomputes
checksum. On mismatch, missing required field, malformed timestamp, or unknown
urgency/outcome, the reader fails closed: it reports a channel error and
refuses to advance past the bad record (does not silently skip or drop it),
pending operator resolution. A half-written tmp dotfile is never read (readers
enumerate only the published *.json names, excluding dotfiles), so the only way a bad record surfaces is
genuine corruption (e.g. disk damage), which is exactly the case OQ-INV-27 wants
surfaced rather than swallowed.
Invariant → mechanism map
Section titled “Invariant → mechanism map”Every OQ-INV-N from PRODUCT.md maps to one concrete mechanism below. The
Checker verifies completeness — none is unmapped.
| Invariant | Mechanism |
|---|---|
| OQ-INV-1 (self-contained record fields) | OQ record schema: oq_id, worker_id, emitted_at, question, urgency, blocking, context_ref all present + validated. |
| OQ-INV-2 (immutable; refine via new OQ) | questions/<oq_id>.json is write-once for status:open; refinements are new files with new oq_id + supersedes. |
| OQ-INV-3 (atomic emit) | atomic-publish: write same-directory dotfile (questions/.<oq_id>.json.tmp.<pid>), fsync, rename(2) in place onto questions/<oq_id>.json (same-dir atomic rename); readers skip dotfile tmp. |
| OQ-INV-4 (per-worker FIFO) | seq monotonic integer in each record; readers sort by seq. No global order. |
| OQ-INV-5 (durable) | fsync(file) + fsync(dir) before rename returns; record is an on-disk file surviving crash/reboot. |
| OQ-INV-6 (append-only / no retract) | No delete primitive; retraction is a status:cancelled record overwriting the slot. |
| OQ-INV-7 (emit at any phase) | Channel is phase-agnostic — emit is callable any time; context_ref.phase records where, but no phase gate. |
| OQ-INV-8 (blocking ⇒ no dependent progress) | On blocking:true, oq-state.json → awaiting-decision; worker enters decision-poll loop, does no OQ-dependent work; side-work allowed. |
| OQ-INV-9 (non-blocking ⇒ continue) | On blocking:false, oq-state.json stays working; worker continues; decision applied-if-relevant / discarded later. |
| OQ-INV-10 (≤1 resolving decision; schema) | Single decisions/<oq_id>.json slot; decision schema oq_id/decided_at/outcome/answer/directive; outcome ∈ {answered,deferred,cancelled,abort_task}. |
| OQ-INV-11 (two-state ack = decision) | No received record (OQ-Q1 ratified NO); worker infers “seen” only from presence of decisions/<oq_id>.json. |
| OQ-INV-12 (idempotent emit) | `oq_id = sha256(task_id |
| OQ-INV-13 (cancellation) | Cancel = atomic-publish of {status:cancelled, supersedes:<oq_id>} to same slot; atomic+durable; parent skips deciding it. |
| OQ-INV-14 (decisions durable/atomic/append-only) | Decisions use the same atomic-publish + fsync + immutable-filename discipline as OQs. |
| OQ-INV-15 (decisions addressed) | Decision filename = oq_id; worker reads exactly decisions/<its-oq_id>.json; never another OQ’s. |
| OQ-INV-16 (at-least-once delivery) | Worker polls decisions/<oq_id>.json until observed; tolerates duplicate observations (idempotent apply). |
| OQ-INV-17 (in-order-per-OQ; first wins) | Single slot; worker applies first observed decision once; later overwrites are no-ops; true conflict raised as new OQ. |
| OQ-INV-18 (10 s latency budget) | Decision-poll loop at POLL_INTERVAL=2 s ⇒ observed ≤ budget; optional send-prompt nudge collapses toward zero. |
| OQ-INV-19 (no executable code in decisions) | directive is structured advisory data ({kind:…}); worker decides how to act under its own rules; channel never executes it. |
OQ-INV-20 (parent-observable awaiting-decision) | oq-state.json per worker; parent enumerates state by reading only that file, no OQ-stream read. |
| OQ-INV-21 (persist resume state; checkpoint) | checkpoint_ref in oq-state.json; on restart-while-blocked, worker resumes via it without re-running the OQ-producing work. |
OQ-INV-22 (non-blocking ⇒ stays working) | oq-state.json left working when only blocking:false OQs outstanding; lifecycle unaffected. |
| OQ-INV-23 (deterministic open-OQ listing) | Parent computes {questions/*} − {decisions/*} (drop cancelled), sorts by seq ⇒ FIFO “answer-first” order. |
OQ-INV-24 (stop ⊥ OQ; no stop while blocking-open) | cmux stop/session_end orthogonal to oq-state.json; worker stays awaiting-decision (doesn’t /exit) while a blocking OQ is undecided; non-blocking OQs may outlive a stop. |
| OQ-INV-25 (no loss) | fsync-before-rename durability ⇒ crash-then-read yields the OQ; the record is a synced directory entry. |
| OQ-INV-26 (no duplication per parent) | oq_id-named files; iterate questions/*.json ⇒ each oq_id once; re-emissions dedup to same file. |
| OQ-INV-27 (no silent corruption; fail closed) | Per-record schema_version + checksum; reader recomputes, on mismatch/missing-field/bad-enum reports channel error and refuses to advance. |
| OQ-INV-28 (per-worker isolation) | Directory boundary: OQs live under <session-id>/oq/; a reader of A lses A only; cross-worker = parent enumerating sibling dirs. |
| OQ-INV-29 (worker restart safety) | Startup re-classification over questions/+decisions/+oq-state.json, pure function, no parent involvement. |
| OQ-INV-30 (parent restart safety, zero in-memory state) | Enumeration scan re-derives open-awaiting set as a set-difference over disk; identical for fresh vs long-lived parent. |
| OQ-INV-31 (observable provenance) | worker_id/decider_id + emitted_at/decided_at carried in each record; never relies on filesystem mtime. |
| OQ-INV-32 (worker contract) | Emit → poll-for-decision → apply; restart re-classifies before re-emit; never assume received absent a decision file. (Realised by emit + poll + restart procedures.) |
| OQ-INV-33 (parent contract) | FIFO read; ≤1 decision per OQ via decide-once existence guard; tolerant of re-seeing OQs across restart; never re-decides an oq_id. |
Proposed changes
Section titled “Proposed changes”This feature is shell + small-library, not application code — it lives beside
session-driver-cmux, not in app//lib//supabase/. No migration, no MCP
tool, no React/TanStack surface. New artefacts (exact file split is a PLAN-phase
decision; this is the shape):
- OQ channel library — emit/decide/read primitives. A single module
implementing
atomic-publish,derive_oq_id, the emit short-circuit, the decide-once guard, the read/set-difference enumeration, and integrity verification. Two consumer surfaces:- Worker-side (
oq_emit,oq_poll_decision,oq_cancel,oq_restart_classify) — called from inside a sub-orchestrator’sworkflow-orchestrationflow. - Parent-side (
oq_scan_fleet,oq_list_open,oq_decide) — called from the parent’sstop-monitor loop. Language: shell is consistent with the existingscripts/(launch-worker.shet al.) and keeps the dependency surface tojq+ coreutils +sha256sum, already assumed bysession-driver-cmux. A small Python helper is an acceptable alternative for the checksum/canonical-JSON step if shell proves awkward — PLAN decides. No new npm/pip dependency either way.
- Worker-side (
- Worker-facing brief fragment. A short “OQ-escalation” section the parent
appends to a sub-orchestrator brief (mirroring the
--brief/final_reportconventions) telling the worker: load this channel, emit OQs here, poll for decisions, and the two-state contract. This is the analogue of thefinal_report.yamlbrief convention already insession-driver-cmux. session-driver-cmuxSKILL.md cross-reference. The skill’s “Escalation” section already points atdocs/specs/id-43-oq-escalation/PRODUCT.md; once implemented it gains a pointer to the helper scripts (the carry-forward “known limitations” table is the natural home). No behavioural change to the existing five scripts — the OQ helpers are additive siblings.
Ownership boundaries.
- The channel owns: record format, atomic write, FIFO sort, dedup,
fail-closed integrity, the
oq-state.jsontwo states it owns (working/awaiting-decision). workflow-orchestrationowns: when an OQ is valid, whether to block, OQ classification (good/premature/local), timeout policy (OQ-Q2), and the contents ofcheckpoint_ref/directivesemantics.- cmux/
session-driverowns: worker lifecycle (stop/session_end), worktree, thesend-promptnudge transport.
This separation is exactly the PRODUCT.md Non-goals boundary (transport,
classification, UI, multi-parent routing all explicitly out of the channel’s
scope).
Testing and validation
Section titled “Testing and validation”Validation is filesystem-level and crash-injection-level; no live cmux daemon is
required for the core invariants (the channel is pure files). Each PRODUCT.md
invariant maps to a concrete check — grouped here by technique, with invariant
ids in parentheses (the Invariant → mechanism map is the completeness index).
Schema / record tests (no crash needed)
Section titled “Schema / record tests (no crash needed)”- Round-trip + field presence (OQ-INV-1, OQ-INV-10): emit an OQ / write a decision; assert every required field present and typed; assert reading back yields the identical record.
- Immutability (OQ-INV-2, OQ-INV-6): assert a second
status:openwrite to an existingoq_idis rejected by the emit short-circuit; assert the only legal overwrite isstatus:cancelled. oq_iddeterminism (OQ-INV-12): same(task_id, phase, question, context_ref)⇒ identicaloq_idacross calls and across simulated processes; a changedquestion⇒ differentoq_id;worker_id/emitted_at/seqchanges ⇒ sameoq_id.- Integrity fail-closed (OQ-INV-27): corrupt a record’s
checksum, null a required field, seturgency:"urgent", malformemitted_at— assert the reader raises a channel error and does not advance past it (not a silent skip). - Provenance independence (OQ-INV-31): blank the files’ mtimes / touch them
out of order; assert ordering + provenance still derive from in-record
seq/emitted_at, not filesystem metadata.
Atomicity / durability tests (crash injection)
Section titled “Atomicity / durability tests (crash injection)”- No partial record visible (OQ-INV-3, OQ-INV-14): interpose between the
tmp-dotfile write and the
rename(or write a giant >4 KB record); assert a concurrent reader sees either nothing or the complete record, never a truncated one. Assert the dotfile tmp is never enumerated. - Durability across crash (OQ-INV-5, OQ-INV-25): emit → simulate
kill -9after emit returns → re-read; assert the OQ is present (“no loss”). - At-least-once + idempotent apply (OQ-INV-16, OQ-INV-17): deliver the same decision file twice; assert worker state after two applies == after one.
Ordering / dedup tests
Section titled “Ordering / dedup tests”- Per-worker FIFO (OQ-INV-4, OQ-INV-23): emit A then B; assert any reader’s
seq-sorted listing yields A before B. Interleave a second worker; assert no cross-worker ordering is imposed or required. - No duplication (OQ-INV-26): re-emit the same OQ N times; assert
questions/*.jsoniteration yields theoq_idexactly once. - Per-worker isolation (OQ-INV-28): emit in worker A; assert worker B’s enumeration never sees it; assert the parent’s sibling-dir scan does.
Lifecycle / state tests
Section titled “Lifecycle / state tests”- Blocking gate (OQ-INV-8, OQ-INV-20, OQ-INV-21): emit
blocking:true; assertoq-state.jsonflips toawaiting-decisionwithblocked_onset after the OQ file commits; assert a restart-while-blocked re-entersawaiting-decisionand resumes fromcheckpoint_refwithout re-emitting. - Non-blocking continuation (OQ-INV-9, OQ-INV-22): emit
blocking:false; assertoq-state.jsonstaysworking. stoporthogonality (OQ-INV-24): assert a worker maystop(turn end) with non-blocking OQs open; assert it does not/exitwhile a blocking OQ is undecided.- Cancellation (OQ-INV-13): cancel an open OQ; assert the slot shows
status:cancelled, the parent’s open set excludes it, and a stray decision written afterwards is ignored by the worker.
Decision-path tests
Section titled “Decision-path tests”- Addressing (OQ-INV-15): write decisions for OQ-X and OQ-Y; assert X’s worker reads only X’s decision.
- Decide-once (OQ-INV-10, OQ-INV-33): assert the parent’s existence guard
refuses a second decision for the same
oq_id; assert a forced double-decide is a no-op at the worker (first-observed wins, OQ-INV-17) and a content conflict raises a fresh OQ. - No-executable-code (OQ-INV-19): assert
directiveis parsed as data only; there is no code path thatevals/executes it. - Two-state, no
received(OQ-INV-11): assert noreceived/acksartefact is written or read on the happy path.
Latency test
Section titled “Latency test”- 10 s budget (OQ-INV-18): with the poll loop at the default cadence, write a
decision and assert the worker observes+unblocks within 10 s wall-clock.
Separately assert the optional
send-promptnudge shortens (never lengthens) the observed latency, and that dropping the nudge still meets the budget via polling alone.
Restart re-derivation tests
Section titled “Restart re-derivation tests”- Worker (OQ-INV-29, OQ-INV-32): seed a mix of open/decided/cancelled OQs on
disk; relaunch with same
worker_id; assert classification matches the seeded truth with no parent involvement. - Parent (OQ-INV-30, OQ-INV-33): seed multiple workers’ dirs; “restart” the parent (fresh process, no memory); assert the re-derived open-awaiting set equals the union set-difference, identical to a never-crashed parent.
Integration (live cmux — Phase B)
Section titled “Integration (live cmux — Phase B)”End-to-end against a live cmux daemon (mirrors session-driver-cmux “Phase B
verifies end-to-end”): a real sub-orchestrator emits a blocking OQ, the parent’s
stop-monitor loop scans, decides, and the worker unblocks — exercising the
real <session-id>/oq/ layout, the send-prompt nudge, and the
wait-for-fleet cadence together. This is the only check that needs the daemon;
all invariant-level tests above run as plain filesystem unit tests.
Risks and mitigations
Section titled “Risks and mitigations”rename(2)atomicity assumes same filesystem. The tmp file is a same-directory dotfile — written inside the very directory it renames into (questions/.<oq_id>.json.tmp.<pid>→questions/<oq_id>.json,decisions/likewise) — so tmp and target are trivially on the same filesystem and atomic rename always holds. Mitigation/guard: the helper must always derive the tmp path from the target directory (never a separate scratch dir); PLAN should assert this and a test should confirm tmp and target share a device.fsyncon directory entry is platform-sensitive (macOS dev vs Linux CI). macOSfsyncdoes not flush to stable media the wayF_FULLFSYNCdoes; the durability guarantee (OQ-INV-5) is “survives process/OS crash”, which ordinaryfsynccovers on both, but a hard power loss on macOS is weaker. Mitigation: the channel’s durability target is crash/reboot (per OQ-INV-5 wording), not power-loss; document the limit. PLAN may addF_FULLFSYNCon Darwin if a stronger bar is wanted — flag for Liam only if power-loss durability becomes a requirement.seqderivation reads allquestions/*.jsonto find the max. O(n) per emit. Fine for the expected handful of OQs per worker; would degrade if OQ-Q3’s runaway-emission pathology materialises. Mitigation: OQ-Q3 deferral already covers this; if it bites, cache the max inoq-state.json(still disk-derivable). Not a correctness risk, only cost.- Clock skew on
emitted_at/decided_at. Worker and parent share one host (same wall clock), so ISO-8601 timestamps are mutually consistent; FIFO does not depend on them anyway (it usesseq). Low risk on the single-host substrate; would matter only if the channel ever spanned hosts (out of scope — multi-parent is aPRODUCT.mdNon-goal). send-promptnudge could be mistaken for the source of truth. If an implementer wires the worker to act on the prompt text rather than thedecisions/<oq_id>.jsonfile, correctness silently depends on a lossy transport. Mitigation: the contract is explicit — the file is authoritative, the prompt only wakes the poll. PLAN should encode this as a test (drop the nudge; assert correctness via polling alone) and a code comment.- Two writers to one slot (protocol violation). Two parents deciding one
oq_id, or a worker + a rogue process writing the same OQ. The single-parent rule (session-driver-cmux“One orchestrator per worker”) makes the decision-side case a non-issue in practice; the worker-side is guarded by the emit short-circuit + idempotentoq_id. Mitigation: OQ-INV-17 first-wins + conflict-as-new-OQ is the backstop; no locking needed.
Gaps / open items for PLAN and Checker
Section titled “Gaps / open items for PLAN and Checker”- Implementation language split (shell vs small Python helper) is not fixed
here — PLAN decides; both keep the dependency surface to existing tooling
(
jq/coreutils/sha256sumor stdlib Python). No new package either way. checkpoint_refcontents are deliberately opaque to the channel — their schema is aworkflow-orchestrationconcern (OQ-INV-21). PLAN should not over-specify them in channel Subtasks.- Exact file/dir split of the helper module (one script vs worker-side + parent-side pair) is a PLAN decomposition choice; this spec fixes the behaviour, not the file count.
- No pre-ratification external-API check was required. This feature cites
no external library symbols — it uses POSIX
rename/fsync(OS primitives),jq/sha256sum(already-assumed CLI tools), and the in-reposession-driver-cmuxscripts (internal, indexed by gitnexus/ast-dataflow). Per the Planner’s empirical-verification rule, OS primitives and standard/already-present tooling are out of scope for the import-and-call check. Verification result: not applicable — no third-party/non-pinned symbols cited. - The three OQ ratifications are all “Settleable from spec+evidence: YES” — none gates implementation on a Liam decision. The Checker should confirm the ratifications match the spec leans (they do) and that the Q3 deferral is recorded as a backlog-revisit trigger, not a silent drop.