Skip to content

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.

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 writes meta.json there). 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, not session_end. The parent’s mid-session interaction loop blocks on stop events between turns (SKILL.md “Mid-session interaction” — the S62E anti-pattern: session_end only 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 / AskUserQuestion stall / stop pause / fleet-quiet) — as the canonical orchestrator monitoring primitive, superseding bare wait-for-fleet.sh stop-polling. The parent’s OQ enumeration scan rides this watcher loop rather than a standalone stop-poll. ID-27 also hardens events-base resolution to be CWD-independent (--git-common-dir-derived resolve_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_use gating writes allow/deny to <session-id>/tool-decision (SKILL.md “Events emitted”), and --brief drops 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 disturbing events.jsonl, meta.json, or tool-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 the pre_tool_use gate.
  • .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.

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:

  • OQ-INV-3 — atomicity / no partial record ever visible. POSIX rename(2) within the same directory (same filesystem) is atomic: a reader enumerating oq/questions/ either sees <oq_id>.json fully present (the rename committed) or not at all. The writer writes the complete JSON to a dotfile in the same target directoryoq/questions/.<oq_id>.json.tmp.<pid>fsyncs it, then renames 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 *.json and 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_id is derived deterministically (Task ID + phase + content hash — see Record schemas), so a re-emission after a crash targets the same path oq/questions/<oq_id>.json. rename is idempotent against an identical payload; the parent iterating oq/questions/*.json sees each oq_id exactly 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 exactly decisions/<its-oq_id>.json and never another OQ’s decision (OQ-INV-15). The filesystem enforces “at most one decision per OQ” for free — a second rename onto 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. fsync before rename makes 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 the oq-state.json marker) across every worker session dir. The parent reconstructs “open OQs awaiting decision” as the set difference {oq/questions/*.json} − {oq/decisions/*.json}, filtered to blocking:true with no status: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 seq integer (and emitted_at). Readers sort by seq to 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).
  • Shared append-only JSONL (oq.jsonl, mirroring events.jsonl). The closest substrate-native option. Rejected because:
    • Atomicity (OQ-INV-3) is fragile. A single O_APPEND write of a multi-KB JSON line is only atomic up to PIPE_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-text question + context_ref and 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 status per oq_id by scanning — more read-time state than a decisions/ 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.
  • 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-debuggable cat/jq/ls workflow the SKILL relies on.
    • Cross-process locking under crash. WAL/rollback-journal files and flock semantics across a worker and a parent on the same DB add a failure surface (stale locks after a hard crash) that pure rename does 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.
  • 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_id decisions (OQ-INV-15). A pipe models a live stream, not a durable mailbox; this channel is fundamentally a durable mailbox.
  • cmux send-prompt as the emit transport (worker → parent). Rejected for emission: a worker cannot reliably send-prompt to 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 no oq_id addressing. However send-prompt is retained as the parent’s optional decision-notification nudge (see Read/poll protocol): the authoritative decision is always the decisions/<oq_id>.json file (so correctness never depends on the prompt), but the parent MAY additionally send-prompt the 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.

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 received ack distinct from the decision, 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.md Non-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_at over the set difference {oq/questions/*.json} − {oq/decisions/*.json}. emitted_at (OQ-INV-1) plus the decision-absence test gives the dwell time directly — a received ack 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>.json alongside decisions/. 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-decision duration before auto-abort, or is that strictly a workflow-orchestration policy 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-orchestration policy) writes a decision with outcome: 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.md OQ-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_nonblocking policy 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.

OQDecisionClassification
OQ-Q1 (received ack)No separate ack; stay two-stateSettleable from spec+evidence: YES
OQ-Q2 (awaiting-decision timeout)No channel timeout; orchestrator policy via abort_taskSettleable from spec+evidence: YES
OQ-Q3 (non-blocking cap)No cap; out of scope, revisit on first pathologySettleable 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.

.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.

FieldTypeNotes / invariant
oq_idstringStable id, derived (see below). Unique for the OQ’s lifetime. OQ-INV-1, OQ-INV-12.
worker_idstringEmitter session id. OQ-INV-1, OQ-INV-31.
seqintegerMonotonic per-worker counter (0,1,2…). Read-time FIFO sort key. OQ-INV-4.
emitted_atstringUTC ISO-8601. Temporal provenance + dwell-time source. OQ-INV-1, OQ-INV-31.
questionstringHuman-readable text the parent must answer. OQ-INV-1.
urgencyenumlow | normal | high. Unknown value ⇒ integrity error. OQ-INV-1, OQ-INV-27.
blockingbooleantrue ⇒ worker enters awaiting-decision. OQ-INV-1, OQ-INV-8/9/20/22.
context_refobjectEnough for the parent to act without re-deriving: e.g. { file, commit, subtask_id, phase }. OQ-INV-1.
statusenumopen | cancelled. A cancellation is a new record with same oq_id and status:cancelled (overwrites via rename). OQ-INV-6, OQ-INV-13.
supersedesstring | nulloq_id this record refines/cancels, or null. OQ-INV-2, OQ-INV-6, OQ-INV-13.
schema_versioninteger1. Lets readers fail closed on unknown shapes. OQ-INV-27.
checksumstringSHA-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”
FieldTypeNotes / invariant
oq_idstringThe OQ this resolves (= the filename stem). Addressing. OQ-INV-15.
decided_atstringUTC ISO-8601. Temporal provenance. OQ-INV-10, OQ-INV-31.
decider_idstringParent session id. Provenance. OQ-INV-31.
outcomeenumanswered | deferred | cancelled | abort_task. OQ-INV-10.
answerstringFree-text decision body. OQ-INV-10.
directiveobject | nullOptional 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_versioninteger1. OQ-INV-27.
checksumstringSHA-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 = "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_id is excluded so a relaunched worker with the same identity (OQ-INV-29) re-derives the same id; emitted_at and seq are 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>.json path → the rename is 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 → different oq_id, linked via supersedes (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:

FieldTypeNotes / invariant
worker_idstringOwning session.
lifecycle_stateenumworking | awaiting-decision. (stopped/crashed are derived from cmux stop/session_end + liveness, not owned here — see below.) OQ-INV-20/22.
blocked_onstring | nullThe 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_refobject | nullOpaque-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_atstringUTC ISO-8601.
  • Written with the same atomic-rename discipline. Set to awaiting-decision + blocked_on=<oq_id> after the blocking OQ’s questions/<oq_id>.json is durably renamed (so the parent never sees awaiting-decision pointing at an OQ it cannot yet read). Reset to working
    • blocked_on=null after 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’s questions/ and sorts by seq.
  • stopped / crashed remain cmux-owned signals (the stop / session_end events + process liveness), distinct from awaiting-decision; OQ-INV-20 lists all four as distinct states but only the OQ-channel-owned two live in oq-state.json. This preserves OQ-INV-24: a worker that stops with only non-blocking OQs is working in oq-state.json while cmux-stopped at the turn level — the two axes are orthogonal.

A single primitive underlies every write — atomic-publish(dir, name, payload):

1. tmp = "<dir>/.<name>.tmp.<pid>.<rand>" # dotfile IN the target dir
2. write canonical-JSON(payload) to tmp # includes checksum field
3. fsync(tmp); fsync(dirfd(<dir>)) # durability — OQ-INV-5
4. rename(tmp, "<dir>/<name>") # atomic same-dir publish — OQ-INV-3
5. fsync(dirfd(<dir>)) # durable directory entry

rename 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.

  1. Compute oq_id (derivation above) and the next seq (read current max seq across questions/*.json, add 1 — crash-safe because it is derived from disk, not an in-memory counter; OQ-INV-29).
  2. Idempotency short-circuit (OQ-INV-12): if questions/<oq_id>.json already exists and decisions/<oq_id>.json does 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.
  3. Otherwise atomic-publish(questions/, <oq_id>.json, record).
  4. 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.
  5. If blocking:false: leave lifecycle_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.

  1. Read the worker’s open OQs: {questions/*.json} − {decisions/*.json}, drop any with status:cancelled, sort by seq (FIFO, OQ-INV-4/23).
  2. Decide-once guard (OQ-INV-33): if decisions/<oq_id>.json already exists, do not write again (the parent never re-decides an oq_id). For a cancelled OQ, the parent SHOULD NOT write a decision at all (OQ-INV-13).
  3. atomic-publish(decisions/, <oq_id>.json, decision).
  4. (Optional latency nudge — not correctness-bearing.) send-prompt the 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.

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})
break
  • POLL_INTERVAL default 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 per urgency later (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>.json twice (e.g. crash after apply but before the oq-state.json reset). 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-prompt nudge (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:false OQ’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-23

Reading 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).

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 UNRESOLVED
state = 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/, and oq-state.jsonno parent involvement (OQ-INV-29).
  • Because oq_id is identity-stable (derivation excludes worker_id / emitted_at / seq), a worker that crashed mid-emit and re-forms the same question lands the same oq_id; the idempotency short-circuit (emit step 2) prevents a duplicate (OQ-INV-12, OQ-INV-32 “I check before re-emitting”).
  • checkpoint_ref in oq-state.json is what lets the worker resume the blocked operation rather than re-execute it (OQ-INV-21). The channel treats it as opaque; workflow-orchestration defines its contents.

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?” == “does decisions/<oq_id>.json exist?”. 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 an oq_id I 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.

Every OQ-INV-N from PRODUCT.md maps to one concrete mechanism below. The Checker verifies completeness — none is unmapped.

InvariantMechanism
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.jsonawaiting-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.

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):

  1. 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’s workflow-orchestration flow.
    • Parent-side (oq_scan_fleet, oq_list_open, oq_decide) — called from the parent’s stop-monitor loop. Language: shell is consistent with the existing scripts/ (launch-worker.sh et al.) and keeps the dependency surface to jq + coreutils + sha256sum, already assumed by session-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.
  2. Worker-facing brief fragment. A short “OQ-escalation” section the parent appends to a sub-orchestrator brief (mirroring the --brief/final_report conventions) telling the worker: load this channel, emit OQs here, poll for decisions, and the two-state contract. This is the analogue of the final_report.yaml brief convention already in session-driver-cmux.
  3. session-driver-cmux SKILL.md cross-reference. The skill’s “Escalation” section already points at docs/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.json two states it owns (working/awaiting-decision).
  • workflow-orchestration owns: when an OQ is valid, whether to block, OQ classification (good/premature/local), timeout policy (OQ-Q2), and the contents of checkpoint_ref / directive semantics.
  • cmux/session-driver owns: worker lifecycle (stop/session_end), worktree, the send-prompt nudge 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).

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).

  • 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:open write to an existing oq_id is rejected by the emit short-circuit; assert the only legal overwrite is status:cancelled.
  • oq_id determinism (OQ-INV-12): same (task_id, phase, question, context_ref) ⇒ identical oq_id across calls and across simulated processes; a changed question ⇒ different oq_id; worker_id/emitted_at/seq changes ⇒ same oq_id.
  • Integrity fail-closed (OQ-INV-27): corrupt a record’s checksum, null a required field, set urgency:"urgent", malform emitted_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 -9 after 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.
  • 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/*.json iteration yields the oq_id exactly 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.
  • Blocking gate (OQ-INV-8, OQ-INV-20, OQ-INV-21): emit blocking:true; assert oq-state.json flips to awaiting-decision with blocked_on set after the OQ file commits; assert a restart-while-blocked re-enters awaiting-decision and resumes from checkpoint_ref without re-emitting.
  • Non-blocking continuation (OQ-INV-9, OQ-INV-22): emit blocking:false; assert oq-state.json stays working.
  • stop orthogonality (OQ-INV-24): assert a worker may stop (turn end) with non-blocking OQs open; assert it does not /exit while 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.
  • 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 directive is parsed as data only; there is no code path that evals/executes it.
  • Two-state, no received (OQ-INV-11): assert no received/acks artefact is written or read on the happy path.
  • 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-prompt nudge shortens (never lengthens) the observed latency, and that dropping the nudge still meets the budget via polling alone.
  • 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.

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.

  • 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.
  • fsync on directory entry is platform-sensitive (macOS dev vs Linux CI). macOS fsync does not flush to stable media the way F_FULLFSYNC does; the durability guarantee (OQ-INV-5) is “survives process/OS crash”, which ordinary fsync covers 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 add F_FULLFSYNC on Darwin if a stronger bar is wanted — flag for Liam only if power-loss durability becomes a requirement.
  • seq derivation reads all questions/*.json to 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 in oq-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 uses seq). Low risk on the single-host substrate; would matter only if the channel ever spanned hosts (out of scope — multi-parent is a PRODUCT.md Non-goal).
  • send-prompt nudge could be mistaken for the source of truth. If an implementer wires the worker to act on the prompt text rather than the decisions/<oq_id>.json file, 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 + idempotent oq_id. Mitigation: OQ-INV-17 first-wins + conflict-as-new-OQ is the backstop; no locking needed.
  • Implementation language split (shell vs small Python helper) is not fixed here — PLAN decides; both keep the dependency surface to existing tooling (jq/coreutils/sha256sum or stdlib Python). No new package either way.
  • checkpoint_ref contents are deliberately opaque to the channel — their schema is a workflow-orchestration concern (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-repo session-driver-cmux scripts (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.