From 6ffc655c8572eb3fcf560a26eba60f6eb6e9ef1d Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 7 Oct 2026 15:34:03 -0700 Subject: [PATCH 001/136] docs(PTYCAP): add independent verification reports for phases 1 and 2 Goal-backward verification of Phase 1 (PTY token accounting, #395) and Phase 2 (PTY pre-flight gate, #493) against ROADMAP success criteria, with every proving test run per-file. - Phase 1: 3/3 SC implemented; gaps_found (G-1 MAJOR: emitter dedupes on record uuid, not message.id, so the PTY ledger over-counts; G-2 MINOR: live-Postgres proofs not observed locally). - Phase 2: passed 3/3; three MINOR gaps (CRLF-fragile guard test, stale docs mechanism claim, Telegram permission keystrokes un-preflighted). Co-Authored-By: Claude Opus 5.5 --- .../01-VERIFICATION.md | 77 +++++++++++++++++++ .../02-VERIFICATION.md | 74 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 .planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md create mode 100644 .planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md diff --git a/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md b/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md new file mode 100644 index 00000000..722fc30f --- /dev/null +++ b/.planning/phases/PTYCAP-01-pty-token-accounting/01-VERIFICATION.md @@ -0,0 +1,77 @@ +--- +phase: PTYCAP-01-pty-token-accounting +verified: 2026-10-07 +status: gaps_found +score: 3/3 success criteria implemented; 1 MAJOR accuracy gap (G-1), 1 MINOR evidence gap (G-2); 0 BLOCKERs +verifier: independent goal-backward verification (SUMMARY.md self-reports not trusted) +merged_as: "22cb187 feat(PTYCAP-01) (#395)" +worktree: C:\Users\artic\GitHub\remo-code-gsd (branch phase-ptycap-03-governed-automation-guard) +--- + +# Phase PTYCAP-01: PTY Token Accounting - Verification Report + +**Goal:** The hub knows what a PTY turn is spending while it spends it, not after. + +Method: read implementing code directly, ran each proving test file in isolation (one bun process per file), and +checked one claim against a real on-disk Claude transcript. No source, test, or config file was modified. + +## Success criteria + +| SC | Criterion | Verdict | Evidence | +|----|-----------|---------|----------| +| SC-1 | A live PTY turn's token usage is observable mid-turn, not only on completion | PASS | Tailer: `supervisor/src/usage/pty-usage-emitter.ts` `pinAndTail` L321-386 calls `tailJsonl(..., {fromStart:true})` and emits one `usage_event` per assistant record (L350-362), no exit/kill gating. Lifecycle owner `supervisor/src/runners/session-bridge.ts` L539 (pre-spawn snapshot), L570-579 (`new PtyUsageEmitter().start`, `emit: sendToHub`), stop at L267/L348/L557. Hub ingest `hub/src/ws/agent.ts` L790-840 `recordTokenUsage({runnerType: msg.runner_type ?? 'stream-json'})`. Proof test `supervisor/test/pty-usage-tail.test.ts` 28 pass / 0 fail (incl. "live-appended assistant usage record emits exactly one tagged frame while the file is still open"). Wired into CI: `.woodpecker/qc.yaml` L52. | +| SC-2 | Interactive and programmatic usage remain in separate buckets | PASS | `hub/src/db/schema.sql` L1351 `ADD COLUMN IF NOT EXISTS runner_type ... DEFAULT 'stream-json'`, L1353 guarded CHECK, L1354 `idx_token_usage_user_runner_created` (additive/idempotent; later 'cloud' widening at L1584-1597 is guarded and from another milestone). Zod `hub/src/ws/agent-protocol.ts` L201 `runner_type` (exact key name verified; absent => 'stream-json'). DAL `hub/src/db/token-usage-dal.ts` L27/L38-49 threads runnerType; `getTodayTokenTotalByRunner` L231-252 splits the buckets for observability. `token_usage_daily` PK deliberately unchanged (documented). Tests: `token-usage-runner-type` 7/0, `usage-event-handler` 5/0 (untagged frame => stream-json). Live-Postgres CHECK-rejection case in `hub/test/e2e/schema-double-apply.e2e.test.ts` is DB-gated: 0 pass / 10 skip locally (see G-2). | +| SC-3 | A long-running TUI turn that crosses the ceiling mid-flight is detectable | PASS (caveat G-2) | `getTodayTokenTotal()` (`token-usage-dal.ts` L209-222) sums all four buckets (input+output+cache_creation+cache_read) from `token_usage`, live, no close event required; PTY rows land per assistant record (SC-1), so the total climbs mid-turn. Cap stays token-denominated. Mid-flight proof `hub/test/pty-usage-midflight-visibility.test.ts` is `REMO_E2E_DB_URL`-gated: 0 pass / 6 skip / 0 fail locally; only executes in Woodpecker postgres:16. I could not observe it pass. Phase deliberately does NOT gate (RECORD only; gating is Phase 2). `token-cap-coverage` 3/0, `token-cap-gate-fires` 8/0 still green. | + +## Plan must_haves (existence + wiring, read from code) + +| Plan | Artifact / link | Status | +|------|-----------------|--------| +| 01-01 | `supervisor/src/usage/pty-transcript-tail.ts` (144 lines), `pty-usage-emitter.ts` (405 lines) exist, substantive | VERIFIED | +| 01-01 | session-bridge `ensurePtyRunner` -> `PtyUsageEmitter.start` -> `sendToHub` -> zod -> `recordTokenUsage` chain | WIRED (all hops read) | +| 01-01 | Malformed line never fatal (try/catch L363, `onParseError`); uuid dedupe bounded (cap 5000); capture-once locator fails closed on ambiguity/unreliable snapshot; claude-only (codex no-op L240) | VERIFIED in code + tests | +| 01-01 | No hub module reads homedir transcript path | `hub/test/no-hub-side-transcript-fs.test.ts` 4/0 | +| 01-02 | `hub/test/token-usage-runner-type.test.ts`, usage-event-handler cases, e2e CHECK case | exist; e2e case skipped locally | +| 01-03 | midflight test, hub-fs canary, `supervisor/test/pty-usage-path-containment.test.ts` | exist; containment 6/0, canary 4/0, midflight skipped locally | +| 01-04 | `.woodpecker/qc.yaml` L52 runs both supervisor tests; `tools/regression-baseline.json` has `_skip_note_ptycap` with stated reasons; `docs/usage-cost.md` L241-345 PTYCAP Phase 1 section (flow, 4 decisions, tests); `CLAUDE.md` L392 usage-ledger row names the PTY source | VERIFIED; docs match code (index name `idx_token_usage_user_runner_created`, key `runner_type`) | + +## Hard invariants + +| Invariant | Verdict | Evidence | +|-----------|---------|----------| +| No provider API key on PTY path / argv allowlist-of-one | PASS | Emitter is read-only transcript tail; touches no env/argv. Guards: `no-api-key-no-streamjson-pty` 5/0, `no-apikey-fallback-guard` 7/0, `default-backend-selector` 22/0. | +| Caps denominated in tokens | PASS | `getTodayTokenTotal` 4-bucket token sum; cost_usd from frames is 0/estimated, hub estimates. | +| Additive idempotent DDL only | PASS | `ADD COLUMN IF NOT EXISTS`, pg_constraint-guarded CHECK, `CREATE INDEX IF NOT EXISTS`; no backfill. | +| RECORD only, never gates/writes PTY | PASS | No gate imports; emit only. | + +## Test runs (each file isolated, own bun process) + +| File | Pass | Fail | Skip | +|------|------|------|------| +| supervisor/test/pty-usage-tail.test.ts | 28 | 0 | 0 | +| supervisor/test/pty-usage-path-containment.test.ts | 6 | 0 | 0 | +| supervisor/test/no-api-key-no-streamjson-pty.test.ts | 5 | 0 | 0 | +| supervisor/test/no-apikey-fallback-guard.test.ts | 7 | 0 | 0 | +| supervisor/test/default-backend-selector.test.ts | 22 | 0 | 0 | +| hub/test/token-usage-runner-type.test.ts | 7 | 0 | 0 | +| hub/test/usage-event-handler.test.ts | 5 | 0 | 0 | +| hub/test/no-hub-side-transcript-fs.test.ts | 4 | 0 | 0 | +| hub/test/token-cap-coverage.test.ts | 3 | 0 | 0 | +| hub/test/token-cap-gate-fires.test.ts | 8 | 0 | 0 | +| hub/test/pty-usage-midflight-visibility.test.ts | 0 | 0 | 6 (REMO_E2E_DB_URL unset) | +| hub/test/e2e/schema-double-apply.e2e.test.ts | 0 | 0 | 10 (REMO_E2E_DB_URL unset) | + +## Gaps + +### G-1 (MAJOR) - Per-record uuid dedupe overcounts: one API response is split across several transcript records +- Evidence: `pty-usage-emitter.ts` L340-349 dedupes only on the record `uuid`, and emits every assistant record's `message.usage`. Checked against a real local Claude transcript (read-only analysis, largest-3rd session in `~/.claude/projects/C--Users-artic-GitHub-remo-code`): 165 assistant records with usage, 165 unique uuids, but only 89 unique `message.id`. Summing every record = 39,436,414 tokens vs 21,419,447 when counting each `message.id` once, a 1.84x overcount (dominated by repeated cache_read). Neither the emitter, `extractUsage`, the tests, nor RESEARCH/REVIEW docs mention `message.id` / `requestId`. +- Impact: the ledger and `getTodayTokenTotal()` run high for PTY sessions (fail-safe direction for a cap, but it falsifies "what a PTY turn is spending", trips the token cap early in Phase 2, and skews the interactive-vs-programmatic early-warning comparison). SC-1..3 observability is unaffected; accuracy is. +- Proposed minimal fix: key the dedupe on `record.message.id` (fallback `uuid` when absent), and when the same id reappears keep only the max/last usage per id (emit a delta rather than a repeat). Add a test fixture with three records sharing one `message.id`. + +### G-2 (MINOR) - Live-Postgres proofs not observed in this run +- Evidence: `pty-usage-midflight-visibility.test.ts` (6) and the `token_usage_runner_type_check` case in `schema-double-apply.e2e.test.ts` skip without `REMO_E2E_DB_URL`; no disposable Postgres in this environment. SC-3's mid-flight climb and SC-2's DB-level CHECK rejection are therefore verified by code reading (SQL and DDL inspected) plus the Woodpecker wiring claim, not by an observed green run. +- Proposed minimal fix: confirm the PR #395 / latest main `qc` pipeline ran these un-skipped against postgres:16 (or run them once with `REMO_E2E_DB_URL`). Not a code change. + +## Summary + +Goal is achieved in code: PTY transcript spend is tailed supervisor-side, tagged `pty-interactive`, recorded per assistant record while the session is live, stored in a CHECK-constrained `runner_type` column, and summed live by the existing 4-bucket token total. All invariants hold and all runnable proofs pass (0 fail). Status is `gaps_found` solely because of G-1 (token accuracy: roughly 1.8x overcount on a real transcript); G-2 is an evidence caveat. No BLOCKERs. diff --git a/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md b/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md new file mode 100644 index 00000000..c35608d5 --- /dev/null +++ b/.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md @@ -0,0 +1,74 @@ +--- +phase: PTYCAP-02-pty-preflight-gate +verified: 2026-10-07 +status: passed +score: 3/3 success criteria verified (0 BLOCKER, 0 MAJOR, 3 MINOR gaps) +verifier: independent goal-backward verification (not derived from 02-01-SUMMARY.md) +merged_as: PR #493 (commit 10dcc44) +--- + +# PTYCAP Phase 2 - PTY Pre-Flight Gate - Verification + +**Goal:** "No programmatic turn reaches the PTY without passing the full gate chain." + +Worktree: C:\Users\artic\GitHub\remo-code-gsd (branch phase-ptycap-03-governed-automation-guard). +`hub/src/dispatch/pty-preflight.ts` and `hub/src/ws/client.ts` are byte-identical (index) to the +merge commit 10dcc44 (`git diff 10dcc44 HEAD` empty for both). + +## Success criteria + +| SC | Criterion | Verdict | Evidence | +|----|-----------|---------|----------| +| SC-1 | A programmatic write to the PTY passes [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate] or does not happen | PASS | `hub/src/dispatch/pty-preflight.ts:35-37` `ptyPreflightDispatchConfig.gates = [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]` (exact ROADMAP order); `PTY_AUTOMATION_TURN_GATES` is the same array object (line ~40). `runChain` (line ~155) iterates it first-block-wins; fail-closed on throw (`pty_preflight_error`) and 5s timeout (`pty_preflight_timeout`) in `checkPtyTurnPreflight`. Test `pty-preflight.test.ts` "SC-1: automation chain is EXACTLY the ROADMAP order", "non-human over inject-rate IS blocked", "first block wins", fail-closed tests: all pass. Structural half ("or it does not happen"): no hub path emits a PTY write for a non-human actor today (see audit below), so the chain is a built+proven seam, enforced vacuously-true until Phase 3 opens a door. | +| SC-2 | token-cap-coverage.test.ts extended to cover the PTY path | PASS | `hub/test/token-cap-coverage.test.ts:87-90` adds `'dispatch/pty-preflight.ts'` to the known-dispatchers list; the scan (bracket-balanced `gates: [` finder) parses the literal in pty-preflight.ts and the "EVERY gates[] list carries dailyTokenCapGate AND dailyCostCapGate" test passes. `pty-preflight.test.ts` "SC-2: scanned literal IS the array that runs" proves it is not decorative. | +| SC-3 | Human turns unaffected: never gated by an inject-rate ceiling | PASS | `PTY_HUMAN_TURN_GATES = automation.filter(g => g !== sessionInjectRateGate)` (pty-preflight.ts ~43); `runChain` selects it when `actor==='human'` and sets `humanInteractive: true`, read only at `gates.ts:139-140` (programmatic-credit halt exemption; cost/token caps still apply). `client.ts:279-295` calls `checkPtyTurnPreflight({actor:'human'})` (hard-coded, server-inferred from authenticated cookie connection) only when `classifyPtyInput(...).submit`. Tests: "SC-3 (the absolute): human NEVER blocked by inject-rate ceiling", "human over cost/token/threshold IS blocked", "halt exemption never exempts from daily cost cap" pass. Trust boundary pinned by `human-interactive-flag-guard.test.ts` (see MINOR-1). | + +## Implementation claims (checked in code, not SUMMARY) + +- Wiring in `hub/src/ws/client.ts` `term.input` relay: lines 262-295. Submit classification (`classifyPtyInput`, pty-preflight.ts ~95-150): CR/LF, U+0085/U+2028/U+2029, C1 CSI/SS3, any ESC sequence not on `SAFE_ESCAPES`, OSC/DCS/SOS/PM/APC introducers, `ESC E`, invalid base64/UTF-8 -> submit (fail closed). Plain typing, Ctrl-C, Esc, arrows, Alt+printable -> not gated. Split escapes carried per session in `ptyEscTail` (client.ts:235). VERIFIED. +- Refusal path sends `send_refused` on the term channel (client.ts:288-293); write-chain ordering and single-writer re-check after the await (client.ts:296-327). VERIFIED. +- `docs/usage-cost.md` section "PTYCAP Phase 2" present (line 346) with tests list (line 470); matches code (see MINOR-2 for one over-claim). +- Hard invariants: no provider API key on PTY path - supervisor guard tests pass (below); caps are in tokens (`dailyTokenCapGate`, 4 buckets, unchanged); PR #493 touches no `schema.sql` / migration file (`git show --name-only` has no sql/schema/migrat paths) - additive DDL invariant trivially held. + +## Tests run (one file at a time, this worktree) + +| File | Result | +|------|--------| +| hub/test/pty-preflight.test.ts | 33 pass / 0 fail | +| hub/test/ws-client-pty-preflight.test.ts | 22 pass / 0 fail | +| hub/test/token-cap-coverage.test.ts | 3 pass / 0 fail | +| hub/test/human-interactive-flag-guard.test.ts | 1 pass / 1 FAIL (CRLF checkout artifact, MINOR-1) | +| web/test/term-refusal.test.ts | 4 pass / 0 fail | +| supervisor/test/no-api-key-no-streamjson-pty.test.ts | 5 pass / 0 fail | +| supervisor/test/no-apikey-fallback-guard.test.ts | 7 pass / 0 fail | +| supervisor/test/default-backend-selector.test.ts | 22 pass / 0 fail | + +Not run: the full check-baseline suite (out of scope for a single-question verification). + +## Audit: can any NON-human actor reach a PTY without pty-preflight? + +Method: grep of every hub emitter of `term.*` frames and every supervisor consumer. + +- Supervisor: the only inputs that write the PTY are `term.input` and `term.attach_file` + (`supervisor/src/runners/session-bridge.ts:398-441`, flag `REMO_PTY_INTERACTIVE=1`). `user_message` + (what orchestrator inject, scheduler senders, error-capture, feedback, revanote, ext ask/work, and + Telegram free-text all emit via the dispatch pipeline) goes to `ensureRunner()` = the stream-json + `ClaudeRunner` (session-bridge.ts:444-465), never the PTY. +- Hub emitters of `term.input`: exactly two. (1) `ws/client.ts` relay - authenticated `/ws/client` + cookie connection, actor hard-coded human, preflighted on every submit. (2) + `telegram/transcript/pty-inject.ts:34` `injectPtyKeystroke`, called only from + `api/telegram-webhook.ts:556` for a human's Telegram permission-button tap; NOT preflighted (see + MINOR-3). `term.attach_file` is relayed by client.ts only, types a path with no Enter, and is + deliberately unchecked (tested). `/ws/agent` rejects inbound `term.input` (ws/agent.ts ~221). +- Conclusion: the claim "no admission path yet exists for a non-human actor to reach a PTY" is TRUE in + effect, but for a different reason than the doc states (MINOR-2). + +## Gaps + +| ID | Severity | Gap | Evidence | Proposed minimal fix | +|----|----------|-----|----------|----------------------| +| MINOR-1 | MINOR | `human-interactive-flag-guard.test.ts` second test fails on a Windows checkout with `core.autocrlf=true` (worktree CRLF; index is LF). The test splits source on `'\n'`, so the matched line carries a trailing `\r`. Passes on Linux CI, so it is not a CI/prod defect; local `bun test` on Windows reports a false red. | `git ls-files --eol`: `i/lf w/crlf`; assertion diff shows only the trailing CR. | Split on `/\r?\n/` in that test (one-line change). | +| MINOR-2 | MINOR | Doc/ROADMAP over-claim of mechanism: `docs/usage-cost.md` (~line 370) says `humanOnlyPtyGate`/`humanOnlyRejectsActor` "reject every automation source before a write ever reaches a pty-interactive session". Those gates key on `sessions.runner_type`, which defaults to `stream-json` and which client.ts:268-270 itself calls dead in prod. The real reason automation cannot reach the PTY is structural (supervisor routes `user_message` to the stream-json runner; PTY takes only `term.*`). Phase 3 must not rely on the runner_type gate as the door. | pty-preflight consumers: client.ts:216-217; session-bridge.ts:444. | Reword the doc sentence to name the structural reason and flag the runner_type caveat for Phase 3. | +| MINOR-3 | MINOR | Telegram permission-answer keystrokes (`injectPtyKeystroke`) write `term.input` to the PTY - often digit/y/n plus Enter - with no pty-preflight. It is human-initiated and documented as exempt ("answering a prompt is not a new turn"), and lies on a path the Phase 2 scope did not name, but it is a hub-side PTY write that bypasses the new chokepoint. Phase 3's governed-automation guard should account for it. | pty-inject.ts:34-45; telegram-webhook.ts:556. | No change required for Phase 2; record as an explicit exemption in the Phase 3 plan (or route through `checkPtyTurnPreflight(actor:'human')` if answering a permission prompt after the cap should be refused). | + +No BLOCKER or MAJOR gaps. Phase goal is achieved: the full gate chain exists in the exact ROADMAP order, is single-sourced with the coverage scan, fails closed, is wired on the only human PTY write path that can submit a prompt, and no programmatic path to the PTY exists to bypass it. From 5e907ee2acd0809c87498786314028317c24dd28 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 7 Oct 2026 15:34:14 -0700 Subject: [PATCH 002/136] docs(PTYCAP-03): add codebase research for phases 3 and 4 Read-only recon of the gate chain, actor inference, PTY write paths, counters, owner notification, token-cap defaults and kill-switch precedents, with file:line evidence. Input to discuss/plan for the governed-automation guard and the lifetime counter + kill switch. Co-Authored-By: Claude Opus 5.5 --- .../03-RESEARCH.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 .planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md new file mode 100644 index 00000000..c81b5493 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md @@ -0,0 +1,295 @@ + + +# PTYCAP Phases 3+4 -- landscape (read-only recon) + +Worktree: C:\Users\artic\GitHub\remo-code-gsd | branch `phase-ptycap-03-governed-automation-guard` | HEAD 008c276 | date 2026-10-07. +Facts only. `path:line` are relative to the worktree root. Quotes are ASCII-normalized (source has unicode arrows/dashes). +No repo file was modified. Test commands in section 9 were actually run (results recorded). + +--------------------------------------------------------------------------------------------------- +## 1. humanOnlyPtyGate -- definition, callers, tests, actor derivation, spoof surface, PTY write paths +--------------------------------------------------------------------------------------------------- + +### 1.1 Definition and exports (hub/src/dispatch/gates.ts) +- `gates.ts:458-464` `export const AUTOMATION_ACTORS: ReadonlySet = new Set(['scheduler','orchestrator-background','auto-dev','error-capture','agent'])`. Referenced ONLY by tests; the decision below does not consult it. +- `gates.ts:470-473` `export function humanOnlyRejectsActor(actor: string, runnerType: string): boolean { if (runnerType !== 'pty-interactive') return false; return actor !== 'human' // any non-human actor (incl. all AUTOMATION_ACTORS) is rejected }` -- default-deny, so 'external-ask'/'external-work' are rejected without being listed. +- `gates.ts:487-500` `export function humanOnlyPtyGate(resolveActorAndRunnerType: (req: DispatchRequest) => Promise<{ actor: string; runnerType: string }>): DispatchGate`; `name: 'human_only_pty'`; blocks with `reason: automation_blocked_on_pty:${actor}`. +- `DispatchRequest` has NO actor/source field (`hub/src/dispatch/pipeline.ts:36-48`: userId, sessionId, token, prompt, images?, attachments?, humanInteractive?). The actor reaches the gate only through the resolver closure the caller builds. +- `pipeline.ts:44-47` `/** Set ONLY by the PTY preflight for a server-inferred human keystroke turn. ... Never populated from client input. */ humanInteractive?: true`. + +### 1.2 Every caller +| Caller | file:line | actor passed | runnerType source | +|---|---|---|---| +| ext ask dispatch | hub/src/ask/dispatch.ts:135-141 (list L131-143) | `EXT_ACTOR` ('external-ask'), L140 | `SELECT runner_type FROM sessions WHERE id = ${req.sessionId}` L136-138 | +| ext work dispatch | hub/src/work/dispatch.ts:297-303 (list L293-306) | `EXT_WORK_ACTOR` ('external-work'), L302 | same SQL L298-300 | +| telegram inbound dispatch | hub/src/telegram/dispatch.ts:168-171 (`guard`, list L176) | `source = input.source ?? "human"` L161 | `getSessionRunnerType(input.sessionId, input.userId)` L170 | +| term.input relay | hub/src/ws/client.ts:215-223 uses `humanOnlyRejectsActor('human', runnerType)` (L217) | literal `'human'` -- tautology, can never reject ("Unreachable for a human actor by construction", L219) | `getSessionRunnerType` L216 | + +NOT carrying `humanOnlyPtyGate` (no actor/runner check at all): orchestrator/inject.ts (L262, L390), scheduler/senders/agent.ts:367, scheduler/senders/triage.ts:142, error-capture/dispatcher.ts:167, feedback/dispatcher.ts:130, revanote/dispatcher.ts:317, cloud/send.ts:31, dispatch/pty-preflight.ts:34. + +### 1.3 Tests of humanOnlyPtyGate / humanOnlyRejectsActor (all PASS locally, section 9) +- hub/test/human-only-guard.test.ts (8 tests): every AUTOMATION_ACTORS member rejected on 'pty-interactive' (L29-33); 'human' allowed (L35-37); L39-44 "a client-asserted 'human' string from an agent connection still fails -- actor is server-inferred"; stream-json unaffected (L46-51); gate name `human_only_pty` (L72-76). +- hub/test/term-relay-human-guard.test.ts (3 tests): L101-108 genuine human-connection term.input forwarded; **L110-118 spoof negative test**: `handleAgentMessage(agentSocket, {type:'term.input', ..., source:'human', ...})` => `expect(fwdAgent.length).toBe(0)`; L120-126 agent CAN emit term.data. Mocks pty-preflight to always pass (L37-41). +- hub/test/telegram-human-only-guard.test.ts (4): `automation_blocked` + reason contains `automation_blocked_on_pty` (L79-80). +- hub/test/automation-routing-guard.test.ts (15): data-driven over UNATTENDED_SOURCES (L24) + static check that supervisor runner sources scrub ANTHROPIC_API_KEY (L55-79). +- hub/test/ext-ask-gates.test.ts (11): gate-name list includes 'humanOnlyPtyGate' (L29), `humanOnlyRejectsActor(EXT_ACTOR,'pty-interactive')===true` (L60). +- hub/test/ext-work-containment.test.ts:240-245 asserts dispatchWork composes cost+token+humanOnlyPty+rate+allowlist. +- hub/test/programmatic-hard-halt.test.ts:108-109 (`humanOnlyRejectsActor` human false / scheduler true). +- hub/test/pty-preflight.test.ts:164 comment only. + +### 1.4 How `actor` is determined server-side today +| Principal | Evidence | Resulting actor | +|---|---|---| +| /ws/client with opaque session cookie | client.ts:372-382 (`data.authMethod = 'session_cookie'`) | relay hard-codes `'human'` (client.ts:217, 286) | +| /ws/client with legacy JWT (`config.allowLegacyLogin`) | client.ts:384-397 (`data.authMethod = 'legacy_jwt'`) | ALSO 'human' -- authMethod is never consulted by the relay | +| /ws/client with api_key | not accepted: auth branch only handles cookie or `verifyJwt` | n/a | +| /ws/agent api_key (`agent` scope) | ws/agent.ts:308 `verifyApiKeyWithScope(keyHash,'agent')`; L235-236 `if (!isAgentToHubTermType(frame.type)) return` | cannot emit term.input at all (term-protocol.ts:96-98 AGENT_TO_HUB = {'term.data'}) | +| /api/ext api_key | auth/ext-api-key-middleware.ts:79 `c.set('actor', c.req.path.includes('/work') ? EXT_WORK_ACTOR : EXT_ACTOR)` (EXT_ACTOR L25, EXT_WORK_ACTOR L32) | route-inferred 'external-ask'/'external-work' (substring path match) | +| Telegram webhook | api/telegram-webhook.ts:390-397 calls `dispatchToSession({userId,sessionId,chatId,updateId,text,images})` with NO `source` | defaults to 'human' (dispatch.ts:161) | +| orchestrator / scheduler / error-capture / feedback / revanote / triage / cloud | no actor evaluated anywhere in their paths | none | +| REST (cookie vs bearer) | auth/middleware.ts:16-36 sets authMethod 'session_cookie' | 'legacy_jwt' | not used for any PTY decision | + +### 1.5 Every place a client-supplied `source` / actor-like field is read (hub/src/ws, api, dispatch, orchestrator) +- hub/src/dispatch/*: NONE (DispatchRequest has no such field; no `.source`/`.actor` reads). +- hub/src/orchestrator/*: NONE. +- hub/src/ws/client.ts: `actor: 'human'` literal (L286); `source: 'web'` (L572) is a server-set audit tag in a log record, not read from the frame. `ClientInbound` (ws/protocol.ts) has no source/actor key. `TermFrame` schemas (ws/term-protocol.ts:24-79) are plain `z.object` (no `.strict()/.passthrough()`), so unknown keys like `source` are dropped by `safeParse` and the forwarded frame is `JSON.stringify(tf.data)` (client.ts:232). (Not executed; inferred from zod default strip semantics.) +- hub/src/ws/agent.ts:1315 `budgetSource: msg.source` -- agent-supplied (api_key authed) budget-origin enum in `host_resources`; not an actor. +- hub/src/ws/supervisor-protocol.ts:80 `source: z.string().max(120)` (commands_sync metadata) and :98 `source: z.enum(['cgroup_v2','cgroup_v1','host_fallback'])`; not actors. +- hub/src/api/ext.ts:364-369 `WorkBody.source = z.object({kind:z.literal('email'), from, subject?, message_id?})` -- client-supplied email provenance; used for a TRUST decision (`isKnownSender(site, body.source.from)` ext.ts:441 -- sender allowlist) but never for actor. +- hub/src/revanote/payload-schema.ts:34 `source: z.literal('revanote').optional().nullable()` -- payload tag, not an actor. +- hub/src/telegram/dispatch.ts:67,79,161 `TelegramDispatchSource` / `DispatchInput.source` -- in-process param; the only production call site (telegram-webhook.ts:390) omits it. A future caller that passes it would feed `humanOnlyPtyGate` directly. +- No header-based actor (`x-actor`/`x-source`) anywhere in hub/src (grep empty). + +### 1.6 Every path by which a programmatic (non-human) caller can cause prompts/bytes to reach a PTY or PTY-backed session +KEY STRUCTURAL FACT: on a supervisor with `REMO_PTY_INTERACTIVE=1`, hub `user_message` frames go to the STREAM-JSON runner, not the PTY; the PTY takes only `term.*` frames. +- supervisor/src/runners/session-bridge.ts:31-33 `ptyInteractiveEnabled(){ return process.env.REMO_PTY_INTERACTIVE === '1' }`; :382-383 `if (ptyInteractiveEnabled()) this.ensurePtyRunner() else this.ensureRunner()`. +- :398-402 `term.input` => `pty?.write(Buffer.from(String(anyMsg.bytes ?? ''), 'base64').toString('binary'))`. +- :411-441 `term.attach_file` => writes file under repoPath then `pty?.write(abs + ' ')`. +- :444-465 `user_message` => `const runner = this.ensureRunner()` (ClaudeRunner, stream-json) `runner.sendMessage(prompt, msg.images)`. +- :518 `resolveHumanBackend({ isHuman: true }, ...)` hard-coded; supervisor/src/runners/backend-selector.ts:120 `if (ctx?.isHuman !== true) throw`. The supervisor never learns the hub-side actor; term.* frames carry only {type, session_id, bytes|filename,data_b64}. + +| # | Path (producer) | Frame sent | Gated by pty-preflight? | Gated by humanOnlyPtyGate? | Other gating | +|---|---|---|---|---|---| +| P1 | Web xterm `term.input` -> ws/client.ts relay (L174-358) | term.input | YES, on submit only (L262-295, `actor:'human'` L286; `classifyPtyInput`) | tautology only (L217) | ownership (subscribed + canWriteTerminal L200-204), license (L206), single-writer + turn lock (L253-261, L296-326), per-writer chain bound 256 (L336-343) | +| P2 | Web `term.attach_file` (same relay) | term.attach_file | NO by design (hub/test/ws-client-pty-preflight.test.ts:135 "term.attach_file is never checked (it types a path, it does not submit)") | tautology only | same relay gates; `isWriteTurn` includes it (client.ts:183) | +| P3 | Telegram permission/question callback => `injectPtyKeystroke` (telegram/transcript/pty-inject.ts:34-48; call telegram-webhook.ts:556) | term.input (bytes from keystroke-map.ts, e.g. `ENTER='\r'` L27) | NO | NO | `takePendingPrompt(requestId, user.id)` (webhook L525) + linked Telegram user; documented exempt in docs/usage-cost.md:455-461; flagged MINOR-3 in .planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md | +| P4 | `dispatch()` adapters -- all send `user_message`: orchestrator inject.ts:179-187; scheduler agent.ts:398; triage.ts:156; error-capture dispatcher.ts:188; feedback dispatcher.ts:147; revanote dispatcher.ts:360; ext ask ask/dispatch.ts:178; ext work work/dispatch.ts:340; telegram dispatch.ts:150 | user_message (=> stream-json runner, NOT PTY bytes) | NO (preflight only runs in client.ts) | only ask/work/telegram | their own gate lists (section 3) | +| P5 | Web `send_message` (ws/client.ts:777-786) | user_message | NO | NO | ONLY `checkUserThreshold` (L624-640); no cost/token cap (documented pre-existing gap, docs/usage-cost.md:448-454) | +| P6 | permission_response / question_response (client.ts:558-600, telegram-webhook.ts:590,649) | permission_response/question_response (=> stream-json runner `respondToPermission`) | NO | NO | MUTATING_WS_TYPES license gate (client.ts:462-463) | +| P7 | /ws/agent -> hub term.* | term.data only | n/a | n/a | direction allowlist agent.ts:235-236 | +| P8 | Spawn-without-prompt: orchestrator/orphan-resume.ts:226 (`session.start`), launchSessionForUser (telegram/launch.ts), scheduler senders/supervisor.ts:65, teab.ts | session.start / run_command | n/a (no prompt) | n/a | reserveSessionSlot concurrency, restart cap 10, 24h age cap (orphan-resume header) | +=> Today NO automation producer emits `term.input`/`term.attach_file`. `docs`/Phase 2 SUMMARY agree: ".planning/phases/PTYCAP-02-pty-preflight-gate/02-01-SUMMARY.md:67-73" "no automation write to a PTY was possible at all, by construction". The ONLY raw hub->agent term.input writers are P1 (cookie human) and P3 (Telegram answer keystrokes). + +### 1.7 The runner_type column cannot be the door (must be known by Phase 3) +- `hub/src/ws/client.ts:268-270` "runner_type defaults to 'stream-json' and nothing in the web client sets it, while the supervisor writes every term.input to the PTY on its own env flag -- keying on it left the gate dead in prod." +- hub/src/db/schema.sql:646 `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS runner_type TEXT NOT NULL DEFAULT 'stream-json'`; hub/src/db/dal.ts:100-110 `getSessionRunnerType` defaults to 'stream-json'. +- hub/src/api/ext.ts:257-259 "a default prod install is PTY-interactive; the precondition is a stream-json session"; ask/dispatch.ts:65 `AND runner_type = 'stream-json'` (ask target filter). +- 02-VERIFICATION.md MINOR-2: "Phase 3 must not rely on the runner_type gate as the door." +- ROADMAP invariant L19-20: "The actor is server-inferred, never client-asserted (cookie => human, api_key => agent). A spoofed `source` field must never be able to impersonate a human turn." + +--------------------------------------------------------------------------------------------------- +## 2. hub/src/dispatch/pty-preflight.ts +--------------------------------------------------------------------------------------------------- +- Exports: `ptyPreflightDispatchConfig` (L33-35, `gates: [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]`); `PTY_AUTOMATION_TURN_GATES` (L38, same array object); `PTY_HUMAN_TURN_GATES` (L41-43 `PTY_AUTOMATION_TURN_GATES.filter((g) => g !== sessionInjectRateGate)`); types `PtyPreflightActor = 'human' | 'automation'` (L45), `PtyPreflightResult` (L47), `PtyInputClass` (L95-101); `_setPtyPreflightTimeoutForTests`/`_reset...` (L51-52); `classifyPtyInput(bytesB64, pendingTail='')` (L117); `isPtySubmit` (L166); `checkPtyTurnPreflight({userId,sessionId,actor})` (L191-214). +- Chains: automation = threshold -> daily token cap -> daily cost cap -> per-session inject-rate (L34). Human = same MINUS inject-rate (L41-43) and `runChain` sets `humanInteractive:true` (L177) which exempts the programmatic-credit halt in `dailyCostCapGate` (gates.ts:140) -- never the cost/token caps. +- Header L18-20: "No path admits automation to a PTY yet (`humanOnlyPtyGate`); Phase 3 will call this seam." +- `runChain` L170-184: builds `DispatchRequest{userId,sessionId,token:'pty-preflight:'+sessionId,prompt:''}` and runs gates in order; first block wins. FAIL CLOSED: thrown gate => `{ok:false,reason:'pty_preflight_error'}` (L203-207); 5s timeout => `pty_preflight_timeout` (L49, L198). +- Only production caller: hub/src/ws/client.ts:283-287 `checkPtyTurnPreflight({ userId, sessionId: frame.session_id, actor: 'human' })`, gated by `inputClass?.submit` from `classifyPtyInput(frame.bytes, ptyEscTail.get(sid) ?? '')` (L279-282). On refusal: `ws.send({type:'send_refused', channel:'term', session_id, reason})` (L288-294). `actor:'automation'` has NO production caller (tests only). +- What a Phase-3 caller invokes: `checkPtyTurnPreflight({userId, sessionId, actor:'automation'})` (L191-195). Note the automation chain's sessionInjectRateGate counts `routine_run_log` rows (section 4) -- an admitted automation turn is not counted unless something writes such a row. +- Tests covering it: hub/test/pty-preflight.test.ts (33 tests; chain shape L96-126, human L128-160, automation L162-193, halt L195-219, fail-closed L221-234, classifier L236-343); hub/test/ws-client-pty-preflight.test.ts (22 tests; what is checked L120-167, refusal L169-192, Enter encodings L194-247, ordering L249-388, backpressure L390+; mocks db/dal.ts, ws/registry.ts, pty-preflight.ts; DATABASE_URL fake L15); hub/test/term-relay-human-guard.test.ts (mocks it to pass); hub/test/token-cap-coverage.test.ts (scans its `gates:[...]` literal, known-dispatcher list includes 'dispatch/pty-preflight.ts'); hub/test/human-interactive-flag-guard.test.ts (pins who may mention `humanInteractive`, see below). +- hub/test/human-interactive-flag-guard.test.ts: ALLOWED = {dispatch/pipeline.ts (type), dispatch/gates.ts (reader), dispatch/pty-preflight.ts (writer)} (L13-17); second test pins the EXACT line `" ...(human ? { humanInteractive: true as const } : {}),"` (L38). **FAILS LOCALLY on Windows checkout** (core.autocrlf=true => trailing `\r` after `split('\n')`): 1 pass / 1 fail; passes on Linux CI. Already recorded as MINOR-1 in 02-VERIFICATION.md (proposed fix: split on `/\r?\n/`). Any Phase-3 edit near that line or any new file mentioning `humanInteractive` must update this test. (No AAF ticket filed: documented, in-scope for the Phase 3 branch to fold in.) +- The pty-preflight.ts file contains literal U+2028/U+2029 characters inside `LINE_TERMINATOR_CODEPOINTS` (L93) -- a harness "invisible-unicode" scan flagged it; benign by design. + +--------------------------------------------------------------------------------------------------- +## 3. hub/src/dispatch/gates.ts -- exports, gate lists, coverage test +--------------------------------------------------------------------------------------------------- +Common types (hub/src/dispatch/pipeline.ts): `interface DispatchGate { name: string; check(req: DispatchRequest): Promise<{ok:true} | {ok:false; reason:string}> }` (L51-54). `DispatchRequest` L36-48. `runGates` L210-222 (array order, first block wins), called at L225 `runGates(req, deps.gates)`; promotion re-runs the list (header IR-2). + +| Export | gates.ts line | Signature / ctx | Reads | +|---|---|---|---| +| getCostCapStatus | 53 | (userId, tz) => {over,spent,cap} | `users.daily_cost_cap_usd` (SQL L57-59, NOT NULL DEFAULT 10) + `getTodayTokenCostUsd` | +| isOverCostCap | 67 | boolean wrapper | -- | +| getProgrammaticHaltStatus | 91 | (userId) => {halt,bound,used_usd} | `users.programmatic_halt_usd` (NULL=OFF) + in-memory `getUsage(userId)?.usage.programmatic_credit` | +| thresholdGate | 107 | DispatchGate 'threshold' | `checkUserThreshold(req.userId)` (usage/threshold.ts); reason `quota_threshold_reached:...` | +| dailyCostCapGate | 122 | DispatchGate 'daily_cost_cap' | `users.timezone` + cost cap; reason `over_daily_cost_cap:$x>=$y`; then programmatic halt unless `req.humanInteractive` (L140); reason `programmatic_credit_halt:...` | +| assertTokenCapConfig | 204 | () => void (throws) | env `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP(_DISABLED)`; called from hub/src/index.ts:569-574 (exit 1) | +| getTokenCapStatus | 230 | (userId, tz) => {over,tokens,cap} | env cap (call-time) + `getTodayTokenTotal` (4 buckets) | +| isOverTokenCap | 241 | boolean wrapper | -- | +| dailyTokenCapGate | 257 | DispatchGate 'daily_token_cap' | tz SQL + token status; reason `over_daily_token_cap:${tokens}>=${cap}` | +| maxInjectsPerHour | 284 | () => number | env `REMO_ORCHESTRATOR_MAX_INJECTS_PER_HOUR`, default 4 (L282) | +| sessionInjectRateGate | 301 | DispatchGate 'session_inject_rate' | `countSessionInjectsSince(req.sessionId, 60)`; reason `over_session_inject_rate:${n}>=${cap}`; cap<=0/non-finite => disabled (fail-open) L305 | +| maxAsksPerHour / askRateGate | 326 / 338 | (apiKeyId|null) => gate 'ask_rate' | env `REMO_ASK_MAX_PER_HOUR` (10); dynamic import `db/ask-dal.countAsksForKeySince`; reason `over_ask_rate:` | +| maxWorkPerHour / workRateGate | 365 / 373 | (userId) => gate 'work_rate' | env `REMO_WORK_MAX_PER_HOUR` (4); `db/work-dal.countWorkRunsForUserSince`; `over_work_rate:` | +| workRepoAllowlistGate | 396 | (userId, repoIdent) => gate 'work_repo_allowlist' | `isRepoWorkAllowed`; FAILS CLOSED on DB error (`work_allowlist_check_failed:`) | +| autospawnDailyLaunchCap / isOverAutospawnDailyLaunchCap | 421 / 433 | pure predicate | env `REMO_ORCHESTRATOR_AUTOSPAWN_DAILY_LAUNCHES` (20) | +| AUTOMATION_ACTORS / humanOnlyRejectsActor / humanOnlyPtyGate | 458 / 470 / 487 | see 1.1 | -- | +| concurrencyGate | 510 | (supervisorId) => gate 'concurrency' | `reserveSessionSlot` (side effect: opens reservation) | +Gate factories bound to an identity are an existing pattern: `askRateGate(apiKeyId)`, `workRateGate(userId)`, `workRepoAllowlistGate(userId, repoIdent)`, `concurrencyGate(supervisorId)`, `revanoteBudgetGate(userId, tz)` (revanote/dispatcher.ts:113). + +### 3.1 Every `gates: [...]` list in hub/src (token-cap-coverage scan target) +| file:line | list | +|---|---| +| hub/src/orchestrator/inject.ts:262 and :390 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/scheduler/senders/agent.ts:367 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate]` (NO inject-rate) | +| hub/src/scheduler/senders/triage.ts:142 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate]` (NO inject-rate) | +| hub/src/error-capture/dispatcher.ts:167 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/feedback/dispatcher.ts:130 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate]` | +| hub/src/revanote/dispatcher.ts:317 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate, revanoteBudgetGate(userId, tz)]` | +| hub/src/telegram/dispatch.ts:176 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, guard]` (guard = humanOnlyPtyGate) | +| hub/src/ask/dispatch.ts:131-143 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, humanOnlyPtyGate(...), askRateGate(apiKeyId)]` | +| hub/src/work/dispatch.ts:293-306 | `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, humanOnlyPtyGate(...), workRateGate(userId), workRepoAllowlistGate(userId, repoIdent)]` | +| hub/src/cloud/send.ts:31 | `[dailyCostCapGate, dailyTokenCapGate]` (no threshold) | +| hub/src/dispatch/pty-preflight.ts:34 | `[thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]` | +Orchestrator inject comment (inject.ts:259-262): "Order: threshold -> cost-cap -> token-cap -> rate." pty-preflight order differs on purpose (token before cost, ROADMAP SC-1). + +### 3.2 hub/test/token-cap-coverage.test.ts (102 lines) -- structure +- `walk(hub/src)` all `.ts` (L24-32); `gateLists()` (L44-70): for each `/gates:\s*\[/g` match, BRACKET-BALANCED scan (counts `[ ( {` / `] ) }`); unbalanced => pushed to `unparsed`. +- Test 1 (L73-75): `unparsed` must equal [] (an unparseable list hard-fails). +- Test 2 (L77-94): the set of files with a gates list must contain the known dispatchers: 'orchestrator/inject.ts','scheduler/senders/agent.ts','scheduler/senders/triage.ts','error-capture/dispatcher.ts','feedback/dispatcher.ts','revanote/dispatcher.ts','telegram/dispatch.ts','dispatch/pty-preflight.ts' (ask/dispatch.ts, work/dispatch.ts, cloud/send.ts are scanned generically but NOT in the known list). +- Test 3 (L96-101): EVERY list's source text must `includes('dailyTokenCapGate')` AND `includes('dailyCostCapGate')` (plain substring, so a comment inside the brackets would satisfy it). +- Registering a new dispatcher/gate: a literal `gates: [` containing both identifiers; new chain entries inside pty-preflight.ts:34 keep both caps; a gate list built in a variable and passed as `gates,` (no `gates: [` literal) is INVISIBLE to the scan; optionally add the file to the known list at L79-91. +- Related cap tests: token-cap-gate-fires.test.ts (8), orchestrator-token-cap.test.ts (9), orchestrator-inject-rate-gate.test.ts (8), orchestrator-cap-alert.test.ts (7), cost-cap-real-tokens.test.ts, token-cap-counts-cache.test.ts, ext-ask-gates.test.ts, ext-work-gates.test.ts, orchestrator-autospawn-gate.test.ts. + +--------------------------------------------------------------------------------------------------- +## 4. Counters / state +--------------------------------------------------------------------------------------------------- +### 4.1 sessionInjectRateGate data source +- hub/src/db/orchestrator-rows-dal.ts:539-544 `INJECT_OUTCOMES = ['dispatched','queued','autospawn_launched','autospawn_parked']`; :546-559 `countSessionInjectsSince(sessionId, windowMinutes=60)`: `SELECT COUNT(*)::text FROM routine_run_log WHERE session_id = ${sessionId} AND outcome = ANY(...) AND created_at >= now() - make_interval(mins => ${minutes})`. +- `routine_run_log` (schema.sql:380-392): id, session_id (FK sessions ON DELETE CASCADE), repo_key, command, decision_rationale, outcome, gap_dimension, pr_url, reviewer_verdict, deploy_verify_result, created_at; index (session_id, created_at DESC) L393-394. NO task_id column. No pruning code found (grep DELETE FROM routine_run_log empty). +- WRITERS are orchestrator-only: orchestrator/macro-cycle.ts:267-273 (`command: macro:`, `outcome: outcome.kind`, written AFTER inject returns), controller.ts:358, wave-runner.ts:246, merge-command.ts:124-236, verify-tail.ts:279-356, stale-lock-reaper.ts:125, inject.ts:367 (outcome 'launched', not counted). Consequence: for error-capture/feedback/revanote/pty-preflight, `sessionInjectRateGate` only ever sees ORCHESTRATOR rows for that session. + +### 4.2 Existing per-task / per-session flags and counters (schema.sql) +- scheduled_tasks (L156-169): `id TEXT PK`, user_id, `session_id TEXT NOT NULL REFERENCES sessions ON DELETE CASCADE`, `enabled BOOLEAN NOT NULL DEFAULT true` (L163), last_run_at, next_run_at; later ALTERs: task_type (L180; wide CHECK L207), target_kind/target_id (L230-232), payload, timezone, catchup_policy, max_concurrent (L238), last_fire_at/next_fire_at (L239-240), post_run_actions, email_summary (L272), lifecycle_stage (L299), macro_task_type (L312, 'dev'|'maintenance'|'security'|'brainstorming'), schedule_kind/run_at (L338-343). No lifetime/run-count column exists (grep run_count/fire_count/max_runs: only a comment at L348 "'Once' => max_runs=1 (enforced by the controller, not here)"). +- orchestrator_rows (L351-362): task_id FK scheduled_tasks, command, `enabled`, schedule_rule JSONB, frequency_label, `last_fired_at` (L373 cadence stamp). +- scheduled_task_runs (L446-468): id, `task_id` FK scheduled_tasks ON DELETE CASCADE, user_id, session_id, status CHECK(running|success|failed|skipped|pending|in_flight|cancelled), started_at, ...; index (task_id, started_at DESC) L456. COUNT(*) per task_id is derivable for scheduler runs, but ORCHESTRATOR injects do not create these rows (inject.ts:244-247 "this store does NOT insert a scheduled_task_runs row"). +- routine_queue (L400-419): session_id, status; unique running per session. +- users: `orchestrator_enabled` (L1027, DEFAULT flipped true L1037), `orchestrator_disabled_explicitly` (L1038), `daily_cost_cap_usd` (L489), `programmatic_halt_usd` (L497), `web_push_enabled` (L490), `auto_nudge_idle_sessions`/`notifications JSONB` (L1233-1234), `notify_channels JSONB` (L1401), `coolify_auto_triage_enabled` (L924), `timezone` (L518). +- sessions: status CHECK online|offline|thinking (L20), runner_type (L646), is_orchestrator (L1047), auto_nudge (L1381), dangerously_skip_permissions (L1392), superseded_by (L986), deleted_at (L70). NO paused/kill/disabled column on sessions. +- paused_repos (L505-513): (user_id, supervisor_id, repo_path) PK + paused_at + paused_reason; "supervisor MUST NOT auto-spawn or restart-on-crash an agent for any repo in this table" (L499-504); DAL supervisor-dal.ts:536-563 (`isRepoPaused`). Consulted by supervisor spawn logic, not by dispatch gates. +- error_projects: `daily_dispatch_cap INTEGER DEFAULT 50` (L674), `enabled` (L675). orchestrator_autospawn_allowlist (L616-621, default EMPTY). +- token_usage (L1331-1343; runner_type L1351), token_usage_daily (L1358-1368) -- no task_id; session_id nullable ON DELETE SET NULL. + +### 4.3 What "task" means and how injects are attributed today +- Orchestrator "task" = ONE `scheduled_tasks` row with `task_type='orchestrator'` per session (controller.ts:687 `WHERE task_type = 'orchestrator' AND enabled = true AND session_id IS NOT NULL`; orchestrator-rows-dal.ts:159). `MacroCycleInput.taskId` exists (macro-cycle.ts:43), but `InjectInput` (inject.ts:81-90: userId, sessionId, token, prompt, autospawn?) and `DispatchRequest` carry NO taskId; token = `orch:::`. +- Scheduler task = `scheduled_tasks.id`; its dispatches create `scheduled_task_runs` rows (id = runId used as dispatch token, agent.ts:332 `open(){ return ctx.runId }`). +- Attribution today: orchestrator injects => (session_id, command) in routine_run_log; scheduler => scheduled_task_runs.task_id; other dispatchers => no durable record at all. +- FK cascade hazard for a lifetime counter: routine_run_log.session_id, scheduled_tasks.session_id and scheduled_task_runs.task_id are all ON DELETE CASCADE -- deleting/recreating a session or task resets any count derived from them; `sessions.superseded_by` (L986) shows session ids get replaced. +- Orchestrator tick respects `scheduled_tasks.enabled` (controller.ts:687) and scheduler `fire()` guards `if (!task || !task.enabled) return` (scheduler/dispatcher.ts:99); runNow guard L173 `if (!task.enabled && !opts.isManual)` (test: hub/test/dispatcher-disabled-guard.test.ts). + +--------------------------------------------------------------------------------------------------- +## 5. Owner notification today +--------------------------------------------------------------------------------------------------- +- E4A sender: hub/src/lib/email.ts:13-38 `sendEmail({to,subject,html,text}): Promise`; env E4A_API_KEY / E4A_BASE_URL (default https://api.emails4agents.com) / E4A_INBOX_ID; POST `/v1/messages/send` with `from_inbox_id: inboxId` (L25); returns false (never throws) when env missing (L17-20) or non-2xx; 10s timeout. Two more copies of the same call: scheduler/post-run/email.ts:44, error-capture/notify.ts:239 (`JSON.stringify({ from_inbox_id: inboxId, to, subject, text })`). +- Scheduled-task summary email: scheduler/post-run/dispatcher.ts:46-96 default-on `notify_email` action (`email_summary` column, scheduled-tasks-dal.ts:92; opt-out `email_summary === false` L87); sent via post-run/email.ts. Recent change: d2f3033 "stop emailing owner on every __internal_triage success (#496)". +- Orchestrator fan-out: hub/src/orchestrator/notify.ts -- `NotifyChannel = 'telegram'|'inapp'|'email'|'push'` (L36); `applyChannelPrefs` honours `users.notify_channels` (L49-55; only explicit false mutes); `fanOutNotify(input, deps?)` L160-238 never throws; email branch L217-230 (`to: user.email`, subject `[remo-code orchestrator] `); `NotifyDeps` injectable (L133-138). Owner address = `users.email` via `getUserById`. +- Existing percent-of-cap alarm: hub/src/observability/cap-alert.ts -- `evaluateCapAlert` (L61-112): threshold default **80** via `REMO_ORCHESTRATOR_CAP_ALERT_PCT` (L28-33); fires `fan({event:'info', level:'warning', channels:['inapp','telegram']})` (L100-107) -- NO email; dedupe = in-memory `alertedToday` Set keyed `${userId}:token|cost:${utcDate}` (L16, L76, L89; resets on restart); evaluated ONLY inside orchestrator macro cycle (macro-cycle.ts:255-263, `tz` hard-coded 'UTC' L252/257) so it never runs for human PTY use or other dispatchers. Tests: hub/test/orchestrator-cap-alert.test.ts (7), uses literal 50_000_000 params. +- Cooldown/dedupe precedents: (a) orchestrator/stale-lock-reaper.ts:33-36 `REAP_NOTIFY_COOLDOWN_MS` from `REMO_ORCHESTRATOR_REAP_NOTIFY_COOLDOWN_MS` (default 3_600_000 per CLAUDE.md), in-memory `lastNotifyAt` Map (L41), check L137, channels `['telegram','inapp','email','push']` (L146); (b) DB-backed: table `notifications_sent(id, kind CHECK IN ('dedupe_hit','rate_limit','daily_cap','dispatch_failed','session_offline','stack_not_detected','propose_roadmap'), dedupe_key, sent_at)` schema.sql:731-742 (CHECK is dropped/re-added every boot L740-742, so a new kind needs that list extended) + helper pattern error-capture/notify.ts:46-93 (select-within-TTL, record-BEFORE-send, then email) and orchestrator/propose.ts:104-127, scheduler/post-run/propose-notify.ts:70-91. `kind 'daily_cap'` already exists in the CHECK (used by error-capture projects, not the token ceiling). +- Other email call sites: revanote/notify-pr.ts:82, revanote/quiet-watch.ts:194 (`['telegram','inapp','email']`, disabled by `REMO_REVANOTE_QUIET_ALERT_DISABLED`), api/auth.ts:200 (magic link). +- No code anywhere emails at 60% of the token ceiling. + +--------------------------------------------------------------------------------------------------- +## 6. Token cap defaults -- every definition / doc / assertion of 50M +--------------------------------------------------------------------------------------------------- +Source of truth: hub/src/dispatch/gates.ts:163 `const DEFAULT_DAILY_TOKEN_CAP = 50_000_000`; parse L180-185 (unset/blank => default; invalid => `{ok:false, cap: default}`); `configuredDailyTokenCap` L194-197 (explicit-disable => 0, else parsed cap); `assertTokenCapConfig` L204-222 (explicit-disable => warn; invalid => throw; called hub/src/index.ts:569-574 => `process.exit(1)`); `getTokenCapStatus` L230-238 (cap<=0 only when explicitly disabled); comment block L151-162 ("Owner sets the real prod number via REMO_ORCHESTRATOR_DAILY_TOKEN_CAP"). `getTodayTokenTotal` = hub/src/db/token-usage-dal.ts:209-221 (SUM of input+output+cache_creation+cache_read since local-tz midnight; no runner_type filter; sibling `getTodayTokenTotalByRunner` L231). +Places that state or assert the 50M default (all must move together if the default changes): +- Code comments: hub/src/dispatch/gates.ts:156-163,190; hub/src/observability/orchestrator-metrics.ts:71 ("default 50M"). +- Tests asserting it: hub/test/token-cap-gate-fires.test.ts:35 (`DEFAULT_CAP = 50_000_000`), :48 ("ships a 50M default ceiling"), :104; hub/test/orchestrator-token-cap.test.ts:56,63-66 (reason string `over_daily_token_cap:50000000>=50000000`),87; hub/test/orchestrator-inject-rate-gate.test.ts:54,60 (`over_daily_token_cap:2830214000>=50000000`); hub/test/e2e/orchestrator-tokencap.e2e.test.ts:5,40 (`const CAP = 50_000_000 // the shipped default, asserted below`),115,149. Tests using 50_000_000 only as an injected param (independent of the default): orchestrator-cap-alert.test.ts:54-158, orchestrator-metrics.test.ts:251-273, orchestrator-autospawn-inject.test.ts:41; e2e orchestrator-autospawn.e2e.test.ts:196,280 set their own env. +- Docs: CLAUDE.md:265-266 ("default 50_000_000 = 50M tokens/day; non-positive/non-finite => disabled/fail-open" -- STALE vs gates.ts), CLAUDE.md:424 ("default 50M tokens/day"), :433 ("falls back to the 50M default"), :435 (`..._DISABLED=1`); docs/auto-dev-orchestrator.md:212 (table row, also says fail-open -- STALE); docs/orchestrator-autospawn-runbook.md:67 ("token cap 50M tokens/day"), :71 (`# default 50_000_000`); .planning/phases/PTYCAP-07-self-heal-routing/07-SPEC.md:185 ("50M (Phase 4 lowers to ~20M)"); .planning/ROADMAP.md:115. +- NOT found in: .woodpecker/, tools/, Dockerfile, web/src, supervisor/src, mcp/ (grep empty). Prod value unknown from the repo (may be overridden in Coolify env; not checked -- out of scope/read-only). +- web: no UI shows the token cap (grep getTokenCapStatus/token_cap in hub/src/api + web/src: only metrics gauge `remo_orchestrator_daily_token_cap` and web/src/lib/termRefusal.ts:10 refusal text). + +--------------------------------------------------------------------------------------------------- +## 7. Kill-switch precedents (what exists) +--------------------------------------------------------------------------------------------------- +### 7.1 Env knobs (restart/redeploy to flip; read at call time, no cache) +- Orchestrator: `REMO_ORCHESTRATOR_ENABLED` -- `isOrchestratorEnabled()` controller.ts:67-70 (default '0'; 1|true|yes|on); gates `registerCycleRunnerIfEnabled()` L761-775; other real call sites: orchestrator/inject.ts:315 (`if (!deps.isOrchestratorEnabled() || !deps.isAutospawnEnabled()) return { kind: 'no_session' }`, the BSA autospawn pre-launch chain) and scheduler/dispatcher.ts:626 (reason `orchestrator_disabled`); inject.ts:209, merge-command.ts:31, propose.ts:32 are comment references only; `REMO_ORCHESTRATOR_AUTOSPAWN` -- `isAutospawnEnabled()` L56-59. +- `REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED` (gates.ts:173-177, boot warns). +- Sweep/reaper off-switches (no-op escape hatches): REMO_GHOST_REAPER_DISABLED (ws/ghost-reaper.ts:200-205), REMO_DEAD_SESSION_REAPER_DISABLED, REMO_RUN_REAPER_DISABLED (scheduler/run-reaper.ts:155-159), REMO_SESSION_RUN_REAPER_DISABLED, REMO_ASK_REAPER_DISABLED, REMO_WORK_REAPER_DISABLED, REMO_ONCE_SWEEP_DISABLED, REMO_REVANOTE_QUIET_ALERT_DISABLED; boot wiring in hub/src/index.ts:729-776. +- Typed config object: hub/src/config.ts (`parseBool` L13; `ptyInteractive = process.env.REMO_PTY_INTERACTIVE === "1"` L132; exported L236); GET /api/client-config reads it (api/client-config.ts:21-22). Test: hub/test/config-env.test.ts. +- Supervisor-side: `REMO_PTY_INTERACTIVE` (session-bridge.ts:31-33), per-host. +### 7.2 DB-backed runtime flags (no hub-wide settings/flags table exists) +- Per-task: `scheduled_tasks.enabled`; per-row `orchestrator_rows.enabled`; per-user `users.orchestrator_enabled` + sentinel `orchestrator_disabled_explicitly` (db/orchestrator-dal.ts:7-45; "the machine-triggered auto-launch checks this so it never re-enables a user who opted out"); `users.coolify_auto_triage_enabled`; `error_projects.enabled`; paused_repos (4.2). Nothing is hub-wide-in-DB, nothing blocks `dispatch()` generically, and no gate reads any of these. +- Orchestrator stop: `POST /api/orchestrator/stop` (api/orchestrator.ts:131-149) sends `session.stop` to the user's online supervisors; `PUT /api/orchestrator` sets enabled (+ disabled_explicitly). +### 7.3 Auth patterns for owner-facing routes +- Cookie auth catch-all: auth/middleware.ts:16-36 (cookie wins, else legacy bearer). CLAUDE.md: `/api/api-keys` is cookie-only. +- Step-up: `requireRecentAuth(maxAgeSeconds = 900)` auth/reauth.ts:37 (401 `re_auth_required`); mounted index.ts:412-440 (prompts, profile, supervisor roots, orchestrator PUT); admin: `requireAdmin()` auth/require-admin.ts:9 (checks `c.get('userRole') === 'admin'`) + recent-auth on mutating methods (index.ts:445-450) -- only existing admin route: POST /api/admin/users/:id/revoke-sessions (api/admin.ts). Note comment index.ts:428-436: orchestrator /start and /stop are deliberately NOT step-up (a long-lived cookie must be able to launch/stop). +- Mutation rate limit: `userMutationLimit` (index.ts:405-449). +- mount-order is test-pinned: hub/test/mount-order.test.ts boots the real app. +### 7.4 Web settings surface (exists only) +web/src/pages/settings/{ConnectionsTab,CredentialsTab,UsageTab,ProfileTab}.tsx. UsageTab calls /api/usage/summary, /api/account/claude-thresholds, /api/profile; ProfileTab /api/users/me/profile (+ /api/profile); orchestrator enable/start/stop UI lives in web/src/components/SupervisorPage.tsx:1212-1239 (PUT /api/orchestrator, POST /api/orchestrator/start|stop). No kill-switch UI. 09-SPEC.md:254 says the kill-switch UI is "Milestone GOV". +### 7.5 Hot-path read patterns +- Gates read per call with fresh SQL each time (`userTimezone`, cap rows; gates.ts:72-77, 57-59) -- no cache; pty-preflight adds that latency to every Enter (5s fail-closed timeout). +- Caches that exist: per-connection license TTL (ws/client.ts:116-139, `config.titanium.licenseCacheTtlSeconds`); `/api/usage/summary` per-user Map TTL 60s (api/usage.ts:30-31); in-memory usage snapshot (usage/store.ts); env flags (call-time, free). In-memory locks assume single replica (dispatch/spawn-on-error.ts:40-43). + +--------------------------------------------------------------------------------------------------- +## 8. What Phase 7/8/9 SPECs assume from Phases 3/4 (quotes) + contradictions +--------------------------------------------------------------------------------------------------- +- 07-SPEC.md:7-8 "PTYCAP Phases 1-4 are green. Phase 4 (lifetime inject counter + kill switch) is a hard precondition: this phase ARMS failure-triggered unattended spawning." +- 07-SPEC.md:41 gate chain `[thresholdGate, dailyCostCapGate, dailyTokenCapGate, sessionInjectRateGate, ]` -- "non-bypassable". (Order differs from pty-preflight's token-before-cost.) +- 07-SPEC.md:121-124 reuse the BSA pre-launch AND-chain "orchestrator-enabled AND autospawn-enabled AND allowlisted AND supervisor-online AND not over token cap AND not over the per-day launch cap AND (Phase 4) kill switch not thrown AND lifetime counter not exhausted" => Phase 4 must expose these as PREDICATES callable from inject.ts `maybeAutospawnOffline` (not only as DispatchGates). +- 07-SPEC.md:185 "`REMO_ORCHESTRATOR_DAILY_TOKEN_CAP` | 50M (Phase 4 lowers to ~20M) | non-positive => token ceiling disabled (fail-open)". +- 07-SPEC.md:251-258 "Phases 1-4 own the caps; Phase 7 consumes them." / "A heal turn must never write to a `pty-interactive` session while `governedAutomationPtyGate` is OFF." +- 08-SPEC.md:4-6 "Build starts only after PTYCAP Phase 4 (lifetime inject counter + kill switch) is green ... the Phase-4 counter + kill switch are the preconditions."; 08-SPEC.md:135-141 QC run goes through the shared pipeline with the standard chain (found by token-cap-coverage) PLUS `REMO_TASK_QC_TOKEN_CAP` (500K). +- 09-SPEC.md:4-7 "Build starts only after PTYCAP Phase 4 (lifetime inject counter + kill switch) and Phase 8". +- 09-SPEC.md:32-33 "A QC run triggered by a cap-hit is still subject to the Phase-4 kill switch and the lifetime inject counter -- a thrown kill switch means zero QC spawns, no exception." +- 09-SPEC.md:48-49 "Phase 4 adds the lifetime inject counter + kill switch + a 60%-of-ceiling alarm email. Phase 9 is what happens at 100%." +- 09-SPEC.md:57-72 every blocking gate in gates.ts "(cost cap, token cap, inject-rate, lifetime counter, programmatic halt) emits exactly one `CapHitEvent`" with `cap: 'daily_token' | 'daily_cost' | 'session_inject_rate' | 'lifetime_inject' | 'programmatic_halt'`, `cause.task_id`, `origin` (server-inferred); emit "best-effort and non-blocking: an emit failure NEVER un-blocks the gate" (L82-83). +- 09-SPEC.md:119-121 tests: token cap already exceeded => zero QC runs; "with the Phase-4 kill switch thrown, a cap-hit produces zero QC runs." +- 09-SPEC.md:140-147 escalation email via "the existing E4A sender (send field `from_inbox_id`, NOT `inbox_id`)", throttled to one email per (user, cap) per hour. +- 09-SPEC.md:164-173 env table; "the spend caps fail OPEN when misconfigured (a broken cap must not brick the app); the spawn ceilings here fail CLOSED". +- 09-SPEC.md:254 "The receipts/kill-switch UI is Milestone GOV." +Contradictions / drift to flag: +1. ROADMAP Phase 3 SC-1 (L101) requires a non-human actor to carry "token cap + inject-rate ceiling + lifetime counter + kill switch", but the lifetime counter and kill switch are BUILT in Phase 4, which "Depends on: Phase 3" (L109). Phase 3 can only define the slots/interfaces (fail-closed until Phase 4 fills them); full SC-1 is satisfiable only after Phase 4. +2. 07-SPEC:185 and 09-SPEC:172 say spend caps/ceiling "fail open" when misconfigured; code is FAIL CLOSED (gates.ts:194-197, :204-222, index.ts:569-574). CLAUDE.md:265-266 and docs/auto-dev-orchestrator.md:212 repeat the stale fail-open text while CLAUDE.md:433-435 states fail-closed. +3. Name drift: ROADMAP/07-SPEC use `governedAutomationPtyGate`; .planning/architecture/SESSION-ASK-API-SPEC.md:232,295 and docs/session-ask.md:155 say `governedAutomationGate`. +4. ROADMAP.md:31 shows Phase 1 unchecked "[ ]" but STATE.md:93 says "Phase 1, pty-token-accounting, shipped complete via #395" and docs/usage-cost.md:241 has the Phase 1 section. +5. 09-SPEC needs gate-level event emission and a `task_id` attribution; Phase 4's ROADMAP criteria mention neither (DispatchRequest has no task id). +6. 07-SPEC:41 chain order vs pty-preflight order (token before cost) -- cosmetic but should be one source. + +--------------------------------------------------------------------------------------------------- +## 9. Test infrastructure and exact commands +--------------------------------------------------------------------------------------------------- +- Runner: Bun 1.3.14 (verified `bun --version`). hub/bunfig.toml `[test] preload = ["./test/_setup.ts"]` (sets JWT_SECRET/SESSION_SECRET defaults L22-23, `afterAll(mock.restore())` per file). Bun `mock.module` is process-global, so CI/`check-baseline` run each hub test file in its own process (tools/regression-baseline.json `_comment`). +- DB in unit tests: NO real database. Gate/dispatch tests mock leaf modules: pty-preflight.test.ts:35-68 mocks usage/threshold.ts, usage/store.ts, db/postgres.ts (`sql: async () => [{cap,tz,bound}]`), db/token-usage-dal.ts, db/orchestrator-rows-dal.ts, then imports the real gates through `?t=${Date.now()}` cache-busting URLs; ws-client tests mock db/dal.ts, ws/registry.ts and pty-preflight.ts with a fake `DATABASE_URL` (ws-client-pty-preflight.test.ts:15,28-68); human-only guard tests need no DB. +- Real Postgres only in e2e: hub/test/e2e/*.e2e.test.ts via harness hub/test/e2e/orchestrator-harness.ts (gate `REMO_E2E_DB_URL` => `maybeDescribe`, else `describe.skip`; applies schema.sql UNMODIFIED; synthetic user + cascade-delete teardown). CI (.woodpecker/qc.yaml:14-35) provides postgres:16 and sets DATABASE_URL + REMO_E2E_DB_URL + REMO_E2E_ALLOW_NONLOCAL=1. ROADMAP Phase 6 SC-1 is where PTY gate + governed guard + kill switch real-Postgres e2e is scheduled. +- Baseline: tools/regression-baseline.json pass=2273 skip=259 fail=0 total=2532, tolerance pass_min 1850, skip_max 260, fail_max 0 (check-baseline.ts exits 1 on drift). New skipped tests count against skip_max (e2e adds skips locally). +- CI gate sequence (.woodpecker/qc.yaml:36-56): `bunx tsc --noEmit -p hub/tsconfig.json || echo informational`, `bun run schema-lint` (tools/schema-sql-lint.ts hard-fails any un-pragma'd INSERT/UPDATE/DELETE/DROP/TRUNCATE in schema.sql; verified OK, 5 pragma-allowed), `bun run check-baseline`, `bun test supervisor/test/pty-usage-tail.test.ts supervisor/test/pty-usage-path-containment.test.ts`, `bun run migration-verify`, a named list of orchestrator tests, `bun run orchestrator:e2e`. The supervisor argv/no-API-key guard tests are NOT in qc.yaml (grep of .woodpecker/.github: only the two pty-usage tests) -- run them manually. docs-drift.yaml runs `bun run docs:sync` and fails on diff. No scripts/preflight.ps1 exists in this worktree. +- Supervisor guards (argv allowlist-of-one + no API key): + - supervisor/test/no-api-key-no-streamjson-pty.test.ts (5): static source canary over claude-pty-runner.ts, codex-pty-runner.ts, pty-host.mjs, claude-pty-bridge.ts, tauri/src-tauri/src/pty_host.rs; `FORBIDDEN_FLAGS = ['--input-format','--output-format','--print','stream-json']` (L47) after comment-stripping (L49-55); API key tokens only adjacent to a delete (L72); runners must pin `sanitizeSpawnEnv`/`delete env.` (L102). + - supervisor/test/pty-spawn-interception.test.ts (3, PRIMARY behavioral): intercepts the host spawn; Rust-bridge spawn frame `args.length === 0` (L83) and none of `-p --print --input-format --output-format stream-json` (L85-86); Node fallback argv has 1 entry matching `pty-host.mjs$` (L67-68). + - supervisor/test/no-apikey-fallback-guard.test.ts (7): `sanitizeSpawnEnv`, PROVIDER_KEY_DENYLIST, credential patterns, per-backend spawn env clean. + - supervisor/test/default-backend-selector.test.ts (22): `isHuman:false => throws` (L94-100), flag threading (L196-310). + - supervisor/test/no-legacy-agent-spawn.test.ts (1). +- Commands actually run (worktree root = C:\Users\artic\GitHub\remo-code-gsd): + - hub, one file per process: `cd hub; bun test test/.test.ts`. Results this session: human-only-guard 8/0, term-relay-human-guard 3/0, token-cap-coverage 3/0, pty-preflight 33/0, ws-client-pty-preflight 22/0, ext-ask-gates 11/0, telegram-human-only-guard 4/0, automation-routing-guard 15/0, orchestrator-token-cap 9/0, token-cap-gate-fires 8/0, orchestrator-inject-rate-gate 8/0, orchestrator-cap-alert 7/0 (pass/fail); **human-interactive-flag-guard 1 pass / 1 FAIL (Windows CRLF, see section 2)**. + - supervisor, from repo root: `bun test supervisor/test/.test.ts`. Results: no-api-key-no-streamjson-pty 5/0, no-apikey-fallback-guard 7/0, default-backend-selector 22/0, no-legacy-agent-spawn 1/0, pty-spawn-interception 3/0. + - `bun run schema-lint` => "OK -- no un-pragma'd mutating statements (5 allowed)". + - Full gates to run before a push: `bun run check-baseline`, `bun run schema-lint`, `bun run migration-verify`, `bun run docs:sync` (if routes/OpenAPI change), plus the supervisor guard files above. + +--------------------------------------------------------------------------------------------------- +## 10. Docs that must change with behavior +--------------------------------------------------------------------------------------------------- +- docs/usage-cost.md: "## PTYCAP Phase 2" (L346-480): L373-377 ("No admission path exists yet ... `humanOnlyPtyGate` / `humanOnlyRejectsActor` reject every automation source ... Phase 3's `governedAutomationPtyGate` is what will open that door") -- MINOR-2 says the stated mechanism is wrong (runner_type dead); L435-468 "What this does NOT do" ("Phase 3 admission ... is NOT built here"); L470+ Tests list; L113-123 Phase 18 "Routing invariant (R-PTY-19)" mentions `humanOnlyRejectsActor`. Add new "PTYCAP Phase 3" and "Phase 4" sections here (this file is the PTYCAP home; CLAUDE.md has no PTYCAP text at all). +- CLAUDE.md (worktree): L265-266 (50M + stale "fail-open"), L272-275 (inject-rate knob), L280-288 (`REMO_PTY_INTERACTIVE`), L418-440 (Cost cap non-bypassable / token cap FAILS CLOSED / 50M at L424,L433), L461+ ("No provider API key on the human PTY path -- EVER"), Docs-map rows (Usage cost ledger ~L392, PTY terminal surface ~L393, Session-Ask API L395) -- add new env flags/knobs + new default. +- docs/auto-dev-orchestrator.md:212 (cap row), :205 (autospawn refusals); docs/orchestrator-autospawn-runbook.md:67,71; docs/scheduled-tasks.md:402 (gate list text mentions `humanOnlyPtyGate`); docs/telegram-bridge.md:114 (human-only PTY guard paragraph); docs/session-ask.md:27,32,155; docs/remo-work.md:41,120,248. +- OpenAPI: any new REST route (kill-switch) must be added to hub/src/api/_openapi.ts and `bun run docs:sync` regenerates docs/openapi.json + docs/api.md; .woodpecker/docs-drift.yaml fails the PR on drift (paths hub/src/**, docs/openapi.json, docs/api.md). New refusal reason strings also appear in openapi descriptions (api/_openapi.ts:847,1057 list `over_daily_cost_cap, over_daily_token_cap, over_ask_rate, automation_blocked_on_pty:...`). +- Web: web/src/lib/termRefusal.ts maps `send_refused` reasons (over_daily_cost_cap, over_daily_token_cap, quota_threshold_reached, pty_preflight_timeout|error, term_backpressure, not_current_writer); fallback text `Prompt not sent (${reason})`. +- .planning: ROADMAP.md checkboxes (L31-34) and STATE.md; mount-order/ docs-supersession tests read docs/usage-cost.md and .planning (hub/test/docs-supersession.test.ts) -- keep the R-PTY-24 supersession text intact when editing usage-cost.md. + +--------------------------------------------------------------------------------------------------- +## DESIGN SEAMS (where a change would naturally attach; not decisions) +--------------------------------------------------------------------------------------------------- +1. Chokepoint reality: the only hub->PTY byte writers are ws/client.ts relay (human) and telegram/transcript/pty-inject.ts. `governedAutomationPtyGate` has no producer to govern until a new automation `term.input` sender exists; all current dispatch adapters send `user_message` (stream-json). Phase 3's natural attach points: (a) swap the three `humanOnlyPtyGate(...)` call sites (ask/dispatch.ts:135, work/dispatch.ts:297, telegram/dispatch.ts:168) and keep `humanOnlyRejectsActor` semantics when the flag is OFF; (b) a new automation PTY inject adapter that calls `checkPtyTurnPreflight({actor:'automation'})` (pty-preflight.ts:191) before sending term.input; (c) client.ts:215-223 tautology can stay as the human relay chokepoint. +2. Do NOT key admission on `sessions.runner_type` (dead in prod, client.ts:268-270); key on frame type/producer. +3. Actor must stay closure-supplied from server context (cookie/api_key/route); `DispatchRequest` deliberately has no actor field. Existing negative tests to mirror: term-relay-human-guard.test.ts:110-118, human-only-guard.test.ts:39-44; add one for any new entry point; extend `human-interactive-flag-guard.test.ts` ALLOWED list if a new file mentions `humanInteractive` (and fix its CRLF split). +4. Flag default OFF as true no-op: follow call-time `process.env` reads (controller.ts:56-70) or `config.ts` parseBool (config.ts:13,132); OFF must leave gates.ts behavior byte-identical (existing tests assert it). +5. New gates register by appearing in a literal `gates: [` that still contains both caps (token-cap-coverage L96-101); if added to pty-preflight.ts:34 the human chain must keep excluding them (filter at L41-43; tests pty-preflight.test.ts:106,123 pin "human = automation minus inject-rate"). +6. Lifetime counter needs a durable, non-cascading store and an identity: `DispatchRequest`/`InjectInput` carry no task id; `routine_run_log` is session-keyed, orchestrator-only, FK-cascade; `scheduled_task_runs` is task-keyed but not written by orchestrator injects. Gate-factory-by-identity precedent: askRateGate/workRateGate. Additive DDL only (schema-lint forbids seeding INSERT/UPDATE in schema.sql; backfills go to hub/scripts/). +7. Admitted automation turns must be RECORDED for sessionInjectRateGate/lifetime gates to work (sessionInjectRateGate reads only routine_run_log rows written after orchestrator injects). +8. Kill switch: no hub-wide DB flag exists. Precedents: env flags (restart), `scheduled_tasks.enabled`/`users.orchestrator_enabled` (per task/user), orchestrator `/stop`. Owner-facing routes follow cookie auth + `requireRecentAuth` (index.ts:412-450); per-session granularity has no column on `sessions`. A cheap hot-path read would follow the TTL-cache precedents (client.ts:116-139, usage.ts:30-31) with the single-replica assumption (spawn-on-error.ts:40-43). 07-SPEC wants it callable as a predicate from inject.ts (not only a DispatchGate); 09-SPEC wants it honored by QC spawns. +9. 60% alarm: existing cap-alert.ts is orchestrator-cycle-only, 80% default, inapp+telegram, in-memory dedupe. Event-driven attach point for "any dispatch/any PTY usage" is `recordTokenUsage` (db/token-usage-dal.ts:35, every usage_event) or `dailyTokenCapGate.check` (every dispatch + every PTY submit). Email via `fanOutNotify` channels incl. 'email' (notify.ts:217-230) honoring `users.notify_channels`, or `lib/email.ts sendEmail`; durable dedupe via `notifications_sent` (extend the kind CHECK at schema.sql:733,741). +10. Default 50M -> ~20M: single constant gates.ts:163 plus the test/doc list in section 6 (tests assert literal 50_000_000 and reason strings); prod env override unknown. +11. 09-SPEC CapHitEvent: each blocking gate (incl. new lifetime/kill gates) will need a non-blocking emit hook; reason-string conventions today are `over_daily_cost_cap`, `over_daily_token_cap`, `over_session_inject_rate`, `programmatic_credit_halt`, `automation_blocked_on_pty:` -- new ones (e.g. lifetime/kill) will surface in termRefusal.ts, _openapi.ts descriptions, and 09-SPEC `cap` discriminants (`lifetime_inject`). +12. Fail-direction: 09-SPEC:172-173 sets the rule "spawn ceilings fail CLOSED"; the lifetime counter/kill switch are spawn ceilings (unlike sessionInjectRateGate, which fails open at gates.ts:305). +13. Supervisor side needs no change for admission (it trusts hub term.* frames; isHuman hard-coded at session-bridge.ts:518); no-API-key/argv tests are independent of hub gate code and must stay untouched (ROADMAP Phase 3 SC-4). +14. P3 (Telegram permission keystrokes) and P2 (term.attach_file) are un-preflighted exemptions; Phase 3 plan must either record them as explicit exemptions or route them (02-VERIFICATION MINOR-3). From 083e1743b63048af9e8ec0d803eb3ca147607be3 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 7 Oct 2026 15:52:57 -0700 Subject: [PATCH 003/136] docs(PTYCAP-03): discuss phase - 3-seat design-fork panel verdict + CONTEXT Unattended discuss for Phase 3 (governed-automation guard): the eight design forks were answered by a claude+codex+agy design-fork panel (record in GATES/design-fork-1.md, VERDICT PASS with amendments). CONTEXT captures the binding decisions D-01..D-17: governance primitives only (no PTY byte-producer), fail-closed lifetime/kill-switch slots, enabled-ceiling rule, call-time flag default OFF, hard rename with eligible-actor rule, automation-only governed chain (human chain untouched), door pins. Co-Authored-By: Claude Opus 5.5 --- .../03-CONTEXT.md | 168 ++++++++++ .../GATES/design-fork-1.brief.md | 106 +++++++ .../GATES/design-fork-1.md | 290 ++++++++++++++++++ 3 files changed, 564 insertions(+) create mode 100644 .planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md create mode 100644 .planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md create mode 100644 .planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.md diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md b/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md new file mode 100644 index 00000000..35be9509 --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/03-CONTEXT.md @@ -0,0 +1,168 @@ +# Phase 3: Governed-Automation Guard - Context + +**Gathered:** 2026-10-07 +**Status:** Ready for planning +**Decision record:** `GATES/design-fork-1.md` (3-seat panel: claude + codex + agy, VERDICT PASS, amendments adopted below). Grey areas were +answered by that panel, not by a human (unattended batch run). + + +## Phase Boundary + +Phase 3 delivers the **governance primitives** that decide whether a NON-HUMAN actor may drive an interactive PTY, behind a flag that is +**default OFF and a true no-op when off**. It replaces `humanOnlyPtyGate` with `governedAutomationPtyGate`, defines the four required +governors (kill switch, token cap, inject-rate ceiling, lifetime counter), extends the automation PTY preflight chain, and pins the real +"door" (the hub `term.*` emitters and the supervisor frame router) with structural tests. + +**In scope:** flag + reader; `governedAutomationPtyGate` (hard rename at the 3 call sites); eligible-actor rule; governors module with +two fail-closed placeholder slots; automation-only extension of the PTY preflight chain; door pins (emitter allowlist, supervisor canary, +caller pins); spoof/negative tests; explicit exemptions; Phase-2 MINOR fold-ins (CRLF test fix, docs corrections); docs/OpenAPI. + +**Out of scope (explicitly):** +- Any automation `term.input` / `term.attach_file` byte-producer (typing a prompt into a TUI) -> Phase 5 (first unattended PTY proof). +- The real lifetime counter, its durable ledger, and the real kill switch -> Phase 4 (this phase ships fail-closed slots for them). +- Kill-switch UI -> Milestone GOV. Any prod env change / flag flip -> owner-only live cutover (not done by this run). +- Re-classifying legacy-JWT `/ws/client` connections (they stay `human`: legacy login is the prod login path). + +**Honest scope statement (carry into VERIFICATION):** the three call-site gates are NOT the door. Every automation dispatch adapter sends +`user_message`, which the supervisor routes to the stream-json runner, never the PTY (supervisor/src/runners/session-bridge.ts ~398-465); the +only hub-to-PTY writers are the web relay and Telegram permission keystrokes. SC-1 is therefore met STRUCTURALLY: the admit path is proven +under test dependency-injection, production slots fail closed, and the door is pinned by tests. ROADMAP SC-1 text is NOT edited. + + + + +## Implementation Decisions + +### Door scope and structural pins +- **D-01 (F1 A+):** Governance primitives only; build NO byte-producer. Add three additive pins, all DB-free hub unit tests (they run in `check-baseline`): + (a) an allowlist of hub modules that may emit hub->agent `term.*` frames (today exactly `ws/client.ts` relay and `telegram/transcript/pty-inject.ts`) with a + scanner self-check that the scan FINDS both (cannot pass vacuously, pattern: token-cap-coverage.test.ts:77-94); + (b) a supervisor canary (static read of `supervisor/src/runners/session-bridge.ts`) that the `user_message` and `permission_response` branches never reference + the PTY runner or `pty.write`; + (c) module-allowlist caller pins for `actor: 'automation'` (only the future orchestrator module), `actor: 'human'` preflight calls (only `ws/client.ts`), + `injectPtyKeystroke(` (only `api/telegram-webhook.ts`) and `dispatchToSession(` (webhook + its own replay). + Precedent: `hub/test/human-interactive-flag-guard.test.ts` ALLOWED-set pattern. **Reversibility:** reversible - tests only. +- **D-16:** `injectPtyKeystroke` is pinned (single production caller), not made private. + +### Governors (SC-1) +- **D-02 (F2 A):** New module `hub/src/dispatch/governed-pty.ts`. Closed union `GovernorName = 'kill_switch' | 'token_cap' | 'inject_rate' | 'lifetime_counter'`, fixed order + kill_switch, token_cap, inject_rate, lifetime_counter (the flag check precedes all; first block wins, cheapest/most authoritative first). `kill_switch` and + `lifetime_counter` are real exported `DispatchGate`s whose Phase-3 bodies **FAIL CLOSED**: block reason `governor_not_implemented:`. Phase 4 replaces the bodies + (add a "replaces the fail-closed slots" line under Phase 4 in ROADMAP; leave SC-1 text alone). Dependency injection ONLY through a test-only setter + (`_setGovernorsForTests` / reset, precedent `pty-preflight.ts` `_setPtyPreflightTimeoutForTests`); NO optional public `governors` parameter (a public param is a bypass handle). + With the flag ON in Phase 3 production nothing is admitted - by design. **Reversibility:** costly - Phases 7/8/9 import these gate names. +- **D-03 (F3 A):** A governor is "carried" only when ENABLED. The governed chain blocks `governor_unconfigured:` when the inject-rate ceiling is <= 0 / non-finite + (reuse exported `maxInjectsPerHour()`) or the token cap is explicitly disabled (add tiny export `isDailyTokenCapEnforced()` in gates.ts; `isTokenCapExplicitlyDisabled` + is private). The cost cap is NOT part of the enabled rule (dollars are advisory). Evaluate at call time. The "over the limit" evaluation stays with the existing gates. +- **D-14 (Codex MF-3/MF-6):** Governors in this phase are NON-CONSUMING checks. A thrown/timed-out governor yields a typed fail-closed denial (reuse runChain's existing + `pty_preflight_error` / `pty_preflight_timeout` behaviour). Atomic reserve-before-send consumption is Phase 4's design (note in 04 context). +- **D-12 (M4):** `sessionInjectRateGate` counts only orchestrator `routine_run_log` rows, so an admitted non-orchestrator turn would be uncounted. Phase 3 pins the + orchestrator as the ONLY permitted `actor: 'automation'` caller (D-01c) and records "an admitted automation PTY turn must be counted before this chain is trusted" as a + Phase 4/5 entry criterion (the durable ledger is Phase 4's). + +### Flag, naming, eligibility +- **D-04 (F4 A):** Flag `REMO_GOVERNED_AUTOMATION_PTY`, env var, read at CALL time, truthy only for `1|true|yes|on` (case-insensitive), any other/garbage value = OFF. ONE reader + function `governedAutomationEnabled()` in `governed-pty.ts`; a guard test pins the env-var name to that module. Checked FIRST in the automation branch, before any DB gate. + OFF => no automation turn admitted to a PTY and the 3 call sites behave byte-identically to today: a **differential test** keeps a frozen copy of the legacy decision as an + oracle and proves new==legacy over the full (actor x runnerType) matrix including the reason strings. **Reversibility:** costly - published env contract once documented. +- **D-05 (F5 A):** Hard rename `humanOnlyPtyGate` -> `governedAutomationPtyGate` (gate `name` = `'governed_automation_pty'`); NO alias. Update the 3 call sites (`ask/dispatch.ts`, + `work/dispatch.ts`, `telegram/dispatch.ts`), their tests, and docs (incl. the drifted name `governedAutomationGate` in `.planning/architecture/SESSION-ASK-API-SPEC.md` and + `docs/session-ask.md`). `humanOnlyRejectsActor` and `AUTOMATION_ACTORS` stay unchanged (the web relay still uses the predicate). +- **D-06 (M2, critical):** Eligible-actor rule: only actors in `AUTOMATION_ACTORS` (`scheduler`, `orchestrator-background`, `auto-dev`, `error-capture`, `agent`) can ever be admitted. + `external-ask`, `external-work` and unknown strings are denied EVEN with the flag ON and every governor passing (test). Without this the rename would WEAKEN today's + unconditional deny for untrusted-email-driven actors once Phase 4 fills the slots. +- **D-17:** For a non-PTY `runner_type` the gate returns ok immediately (unchanged semantics, agy MF-3). + +### Chain composition (critical trap) +- **D-07 (M1, critical):** `pty-preflight.ts` derives the human chain as "automation minus inject-rate" (L41-43). Do NOT append governors to the scanned literal at L34 + (it would put fail-closed stubs into the HUMAN chain and refuse every web Enter). Keep the L34 literal and all Phase-2 pins untouched; define an automation-only governed list + and run `[...PTY_AUTOMATION_TURN_GATES, ...governed]` only in `runChain`'s automation branch. Add an explicit test that the human chain contains no governor gate. Never write a + governors list as a `gates: [` literal lacking both caps (token-cap-coverage.test.ts); a list built in a variable is invisible to that scan. +- **D-08 (M3):** Reason contract: keep the prefix `automation_blocked_on_pty:`; a governed block appends `:`; the OFF path is byte-identical. The Telegram + mapper matches by prefix (telegram/dispatch.ts ~218-227). Update the `_openapi.ts` reason lists, run `bun run docs:sync`, and check `web/src/lib/termRefusal.ts` mapping. + +### Actor inference and spoofing (SC-2) +- **D-09 (F6 A+):** Actor stays closure-resolved from server context; `DispatchRequest` stays actor-free. Tests: (i) static pin that the ask/work resolvers return the compile-time + constants and that nothing under `hub/src/dispatch/` (or the gates) reads `req.source` / `req.actor`; (ii) a `/ws/client` test that a `term.input` frame carrying extra + `{source:'scheduler', actor:'orchestrator-background'}` is still treated as `human` and the keys are stripped (zod strip is inferred, so PROVE it); (iii) keep the existing + agent-socket spoof test (`term-relay-human-guard.test.ts:110-118`); (iv) make `source` a REQUIRED compile-time parameter of Telegram `dispatchToSession` and pass the literal + `'human'` at the authenticated webhook boundary (removes the fail-open `input.source ?? "human"` default at telegram/dispatch.ts ~161). +- **D-15:** Codex MF-4 (cookie-only human) is REJECTED: legacy-JWT `/ws/client` is the prod login path and stays `human`. Residual (document): cookie/JWT => human is credential identity, + not typing; any future bearer-automation lane must use `/ws/agent` or an ext key, never `/ws/client`. + +### Exemptions and fold-ins +- **D-10 (F7 A):** Telegram permission-answer keystrokes (`injectPtyKeystroke`, one-shot, approver-authorized via `takePendingPrompt`) and `term.attach_file` (types a path, never + Enter) are explicit, test-pinned human-initiated exemptions with a one-line justification each - NOT routed through preflight (a quota check must not wedge a mid-turn permission prompt). + Document the (non-hole) inconsistency that the web relay would refuse a permission-answer Enter over cap while Telegram would not. +- **D-11 (F8 A):** Fold into this PR: make `hub/test/human-interactive-flag-guard.test.ts` CRLF-safe (`split(/\r?\n/)`); correct `docs/usage-cost.md` (L373-377 wrong mechanism - the real + reason is that `user_message` goes to the stream-json runner; L462-465 Telegram text goes via `user_message`, not the PTY). Keep the R-PTY-24 supersession strings that + `hub/test/docs-supersession.test.ts` reads. +- **D-13 (M6):** No new skipped tests: baseline is skip=259 vs `skip_max=260`. Use DB-free unit tests (mock leaf modules like `pty-preflight.test.ts`); no new e2e that skips locally. + +### Claude's Discretion +Test file names; exact helper signatures; task ordering; whether the governed list lives next to or inside `runChain`; wording of block reasons beyond the contract in D-08. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase text and invariants +- `.planning/ROADMAP.md` lines 14-28 (hard invariants), 95-115 (Phase 3 and 4) +- `.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.md` - the binding panel record (read the DECISION section) +- `.planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md` - file:line facts (sections 1, 2, 3, 4, 8, 9, 10, DESIGN SEAMS) +- `.planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md` - MINOR-1/2/3 inputs; `02-01-SUMMARY.md` +- `.planning/phases/PTYCAP-07-self-heal-routing/07-SPEC.md`, `PTYCAP-08-task-qc/08-SPEC.md`, `PTYCAP-09-cap-hit-qc-trigger/09-SPEC.md` - later consumers of the governor names/semantics + +### Code +- `hub/src/dispatch/gates.ts` (L458-500 actors + gate; L163-310 caps and inject-rate), `hub/src/dispatch/pty-preflight.ts`, `hub/src/dispatch/pipeline.ts` (L36-54) +- `hub/src/ws/client.ts` (term.input relay ~L174-358), `hub/src/telegram/transcript/pty-inject.ts`, `hub/src/api/telegram-webhook.ts` (~L386-399, 515-565) +- `hub/src/{ask,work,telegram}/dispatch.ts`, `supervisor/src/runners/session-bridge.ts` (L380-470) + +### Tests and docs +- `hub/test/{human-only-guard,term-relay-human-guard,telegram-human-only-guard,ext-ask-gates,ext-work-containment,pty-preflight,ws-client-pty-preflight,token-cap-coverage,human-interactive-flag-guard,automation-routing-guard}.test.ts` +- `supervisor/test/{no-api-key-no-streamjson-pty,no-apikey-fallback-guard,default-backend-selector,pty-spawn-interception,no-legacy-agent-spawn}.test.ts` (must stay green UNCHANGED, SC-4) +- `docs/usage-cost.md` (PTYCAP Phase 2 section), `CLAUDE.md` (env section + docs map), `hub/src/api/_openapi.ts` + `bun run docs:sync` + + + + +## Existing Code Insights + +### Reusable Assets +- `checkPtyTurnPreflight({userId, sessionId, actor})` and `runChain` (fail-closed, 5s timeout) - the seam the future producer will call. +- `humanOnlyRejectsActor`, `AUTOMATION_ACTORS`, gate factories bound to an identity (`askRateGate`, `workRateGate`), `maxInjectsPerHour()`, `getTokenCapStatus()`. +- Test pattern: leaf-module mocks + `?t=${Date.now()}` cache-busting imports (`pty-preflight.test.ts:35-68`). + +### Established Patterns +- Call-time `process.env` flags with `1|true|yes|on` (controller.ts `isOrchestratorEnabled`); `_set...ForTests` setters for DI; ALLOWED-set structural guard tests. +- Spawn ceilings fail CLOSED (07/09 specs); spend caps fail closed too in current code (gates.ts L194-222) although two specs still say fail-open (stale - fix in docs touched). + +### Integration Points +- 3 call sites swap the gate; `pty-preflight.ts` `runChain` gains the automation-only governed list; `gates.ts` gains `isDailyTokenCapEnforced()`; Telegram `dispatchToSession` gains a required `source`. + + + + +## Specific Ideas + +No UI. Block reasons must stay greppable: `automation_blocked_on_pty:[:]`, `governor_not_implemented:`, `governor_unconfigured:`. + + + + +## Deferred Ideas + +- Automation `term.input` producer + orchestrator wiring -> Phase 5. Real lifetime counter / kill switch / reserve-before-send atomic consumption / 60% alarm / cap 50M->20M -> Phase 4. +- Kill-switch UI -> Milestone GOV. Repairing the dead `runner_type` key (runtime PTY capability state) -> candidate for Milestone DEBT. +- Cookie/JWT-as-human residual (credential identity) - documented, not fixed. +- P1 verification gap G-1 (PTY usage double count by `message.id`) -> fixed in the Phase 4 PR (token accuracy matters before lowering the ceiling). + + + +--- + +*Phase: 3-governed-automation-guard* +*Context gathered: 2026-10-07* diff --git a/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md b/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md new file mode 100644 index 00000000..e4407f1e --- /dev/null +++ b/.planning/phases/PTYCAP-03-governed-automation-guard/GATES/design-fork-1.brief.md @@ -0,0 +1,106 @@ +# design-fork gate 1 brief -- PTYCAP Phase 3 (Governed-Automation Guard) + +You are a READ-ONLY evaluator seat on a design-fork gate panel (refute-first). Do NOT edit, write, +commit, push, install or change any state. Read, judge, return a verdict. ASCII punctuation only. +All paths below are relative to the repo root you were given (the remo-code checkout). + +## 0. Ground first (mandatory; say in your answer which of these you actually read) +- ~/.claude/architecture-preferences.md and ~/.claude/design-preferences.md (skim; UI is N/A here) +- ~/.claude/CLAUDE.md rules 7a (no workarounds), 7c (no fallbacks), 11 (smallest diff), 12, 22c (no LLM API keys) +- .planning/ROADMAP.md lines 14-28 (hard invariants) and 95-115 (Phase 3 + Phase 4) +- .planning/phases/PTYCAP-03-governed-automation-guard/03-RESEARCH.md sections 1, 2, 3, 4, 8 and DESIGN SEAMS +- .planning/phases/PTYCAP-02-pty-preflight-gate/02-VERIFICATION.md (MINOR-1/2/3) +- Code you must read yourself before claiming anything: hub/src/dispatch/gates.ts (humanOnlyRejectsActor, + humanOnlyPtyGate, sessionInjectRateGate, dailyTokenCapGate), hub/src/dispatch/pty-preflight.ts, + hub/src/dispatch/pipeline.ts (DispatchRequest, DispatchGate), hub/src/ws/client.ts (term.input relay), + hub/src/telegram/transcript/pty-inject.ts, hub/src/{ask,work,telegram}/dispatch.ts, + supervisor/src/runners/session-bridge.ts lines 380-470. +A seat that only restates this brief without reading the code is LOW-CONFIDENCE and will not outvote an evidenced seat. + +## 1. The decision +Phase 3 goal: "Automation may drive the PTY, but only under governance." Success criteria (ROADMAP): + SC-1 humanOnlyPtyGate becomes governedAutomationPtyGate: a non-human actor is admitted ONLY when carrying + token cap + inject-rate ceiling + lifetime counter + kill switch. + SC-2 Actor remains server-inferred; a spoofed `source` cannot impersonate a human (negative test). + SC-3 Flag-gated, default OFF; a TRUE no-op when off. + SC-4 The no-API-key and argv-allowlist guard tests still pass unchanged. +Hard invariants: no provider API key on the PTY path; actor server-inferred never client-asserted; every +dispatch path inside the gate chain (hub/test/token-cap-coverage.test.ts); caps in tokens; additive idempotent +DDL only; new flags DEFAULT OFF and a true no-op when off. Phases are strictly serial: the lifetime inject +counter and the kill switch are BUILT IN PHASE 4, which depends on Phase 3. + +## 2. Verified facts (already checked by two independent agents; re-verify what you rely on) +1. Today NO automation producer emits term.input / term.attach_file to a PTY. The supervisor routes hub + `user_message` frames to the stream-json runner and only `term.*` frames to the PTY + (supervisor/src/runners/session-bridge.ts ~398-465). The only hub->agent term.input writers are the web + relay (cookie human, preflighted on prompt submit) and Telegram permission-answer keystrokes + (injectPtyKeystroke, no preflight, human-initiated, documented exempt). +2. humanOnlyPtyGate (gates.ts ~487) sits in 3 gate lists (ask/dispatch.ts, work/dispatch.ts, telegram/dispatch.ts) + and is keyed on sessions.runner_type == 'pty-interactive', which is DEAD as a key in prod (client.ts ~268-270; + every prod session is runner_type 'stream-json'; PTY is a supervisor runtime fact, not a DB fact). +3. Phase 2 built checkPtyTurnPreflight({userId, sessionId, actor:'automation'|'human'}) with chain + [thresholdGate, dailyTokenCapGate, dailyCostCapGate, sessionInjectRateGate]; human = same minus inject-rate. + actor 'automation' has NO production caller (tests only). +4. DispatchRequest has no actor field; the actor reaches a gate only through a resolver closure the caller builds + from server context (cookie => human; /api/ext route => external-ask/external-work). +5. sessionInjectRateGate counts routine_run_log rows (orchestrator-only writers) and FAILS OPEN when its cap env + is <= 0; the token cap can be explicitly disabled (REMO_ORCHESTRATOR_DAILY_TOKEN_CAP_DISABLED=1). +6. Specs for Phases 7/8/9 treat spawn ceilings as FAIL CLOSED and consume Phase 4's lifetime counter and kill + switch as PREDICATES callable from the autospawn pre-launch chain and as gates. +7. telegram/dispatch.ts ~161 defaults `source = input.source ?? "human"`; its only production caller omits source. + +## 3. Forks. For each: give VERDICT (pick an option or propose a strictly better one), 2-4 lines of reasoning, +## and EVIDENCE (file:line you personally read). My recommendation is marked (R); try to REFUTE it first. + +F1 Door scope. + A (R) Governance primitives only: the flag, governedAutomationPtyGate replacing humanOnlyPtyGate at the 3 + call sites, the PTY automation chain in pty-preflight extended with the flag precondition and the two + governor slots, plus a static "door guard" test pinning the ONLY modules allowed to emit hub->agent + term.* frames (web relay, telegram pty-inject) so a future producer cannot bypass governance. + NO new byte-producer (typing a prompt into a TUI is Phase 5's first-unattended-run proof). + B A plus build the automation term.input producer (bracketed paste + submit) and wire the orchestrator now. + C Rename the gate only; no seam, no guard test. +F2 SC-1 vs the Phase 4 dependency (lifetime counter + kill switch do not exist yet). + A (R) Four named governors in a fixed ordered chain (token cap, inject-rate, lifetime counter, kill switch) in a + new module. The lifetime and kill-switch slots are real exported DispatchGates whose Phase-3 bodies FAIL + CLOSED (`governor_not_implemented:`); Phase 4 replaces the bodies. Tests use dependency injection to + pass governors and prove the admit path, and prove the production slots block. + B Implement a minimal functional lifetime counter + env kill switch inside Phase 3. + C Amend ROADMAP SC-1 to defer the two governors (weakens a locked success criterion). +F3 Ceilings must be ENABLED to count as "carried". + A (R) The governed chain treats a disabled ceiling (inject-rate cap <= 0, token cap explicitly disabled) as NOT + carried and blocks `governor_unconfigured:` even though the underlying gates fail open. + B Reuse the existing gates as-is (they fail open when disabled). +F4 Flag form. + A (R) env var REMO_GOVERNED_AUTOMATION_PTY (truthy 1|true|yes|on), read at CALL time, default OFF. OFF => no + automation turn is admitted to a PTY and the 3 existing call sites behave byte-identically (same reason + string `automation_blocked_on_pty:`). Flipping it in prod is an owner-only cutover; this phase does + not touch prod env. + B typed config.ts value read once at boot. C DB per-user setting. +F5 Naming / compatibility. + A (R) Hard rename to governedAutomationPtyGate (gate name 'governed_automation_pty'); update the 3 call sites and + tests; NO alias; keep the pure predicates humanOnlyRejectsActor / AUTOMATION_ACTORS unchanged. + B Keep humanOnlyPtyGate as a deprecated alias. +F6 Actor inference + spoof tests. + A (R) Actor stays closure-resolved from server context and DispatchRequest stays actor-free. New negative + tests: (i) an ext ask/work request carrying `source:'human'`/actor-like fields still gates as the external + actor; (ii) the governed gate ignores any `source` property on the request object; (iii) the existing + term.input spoof test stays green. List any attack surface I missed (esp. telegram/dispatch.ts ~161). +F7 Explicit exemptions. + A (R) Record Telegram permission-answer keystrokes and term.attach_file as explicit, test-pinned + human-initiated exemptions (door-guard allowlist with a justification each) instead of routing them + through preflight (routing a permission answer through a quota check could wedge a session). + B Route the Telegram keystrokes through the human preflight chain. +F8 Fold-ins from the Phase 2 verification MINORs. + A (R) In this phase's PR: make hub/test/human-interactive-flag-guard.test.ts CRLF-safe (split on /\r?\n/) and + correct docs/usage-cost.md (it credits humanOnlyPtyGate for the wrong mechanism). + B Separate PR. + +## 4. Required output (<= 120 lines) +For each fork F1..F8: `Fn VERDICT: