Skip to content

RFC-030 Wave 1A — Codex Policy Gateway (DRAFT — do not merge)#431

Draft
s2agi wants to merge 35 commits into
mainfrom
rfc030-gateway-protocol
Draft

RFC-030 Wave 1A — Codex Policy Gateway (DRAFT — do not merge)#431
s2agi wants to merge 35 commits into
mainfrom
rfc030-gateway-protocol

Conversation

@s2agi

@s2agi s2agi commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

⚠️ DRAFT — DO NOT MERGE. Contract/protocol frozen; UDS/human-
owner/lifecycle committed pending coordinator recheck. Independent
§8 security review, coordinator recheck, and Vincent's explicit
release authorization all remain hard-locked. Test-green is not
merge-green: no merge, no deploy, no latest, no production
preview until every hard gate in
docs/plans/RFC-030-implementation-plan.md §7 is signed.

Summary

RFC-030 Wave 1A — Codex Policy Gateway (Codex TUI Bridge). Owner:
通信工程马 (runtime=claude-code). Fresh branch off origin/main @
d4188621c0d9bf5f95e9b1ac9b5d0aafea91c6e3.

Scope: agent-node/src/runtime/codex-policy-gateway/ only. No
edits to scheduler / policy / ledger / bridge / runtime / client /
agent-node cli / server reply (those are 通信SDK马's on
rfc030-gateway-runtime / PR #432).

Wave-0 baseline: canonical=codex-app-server, product name Codex
TUI Bridge, Codex binary pinned 0.144.0, schema digest mismatch
fail-closed, Phase 1 read-only / approval=never, real CommHub
ntok never enters Codex/app-server env.

Delivery state

Stage SHA Test evidence
Freeze — typed contract + protocol adapter (Checkpoints 1–4 + Δ8–Δ14) 90d1e58 bun test agent-node/src/runtime/codex-policy-gateway/ = 169 pass / 0 fail / 555 expect
Segment A — uds-server.ts (Unix Domain Socket transport) 98676f1 segment file only: 28 pass / 0 fail / 72 expect
Segment B — human-owner.ts (reverse-request coordinator) 04560c0 segment file only: 16 pass / 0 fail / 43 expect
Segment C — lifecycle.ts (orchestration + injection layer) 00d4ea8 segment file only: 19 pass / 0 fail / 59 expect
Full gateway suite at HEAD 00d4ea8 bun test agent-node/src/runtime/codex-policy-gateway/ = 232 pass / 0 fail / 729 expect()

Frozen file SHA-256 (recorded on issue #428):

  • contract.ts b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
  • protocol.ts 9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

Frozen surface (contract.ts + protocol.ts @ 90d1e58)

contract.ts is the ONLY surface the Agent runtime reaches through
when talking to the gateway. Pure types, zero implementation.

Agent typed contract

Method Args Returns
enqueueTask { taskId, messageId, authenticatedSender, text } accepted | duplicate | refused_{queue_full,no_owner,shutting_down,invalid_arg}
getTaskState TaskId 9-way TaskState discriminant
cancelQueuedTask TaskId cancelled | refused_not_queued
subscribeRuntimeState (RuntimeStateEvent) => void RuntimeStateSubscription (close() only)

AuthenticatedSender.role = admin | owner | member | viewer | node | child (Δ12 + Δ14; unknown fails closed at the Agent surface;
node is minimum-privilege ntok identity and never inherits its
token owner's role). Role classification is server-side authCtx
only — never inferred from raw token prefix, alias, or client
payload.

Reverse-request lockout: the Agent typed contract has NO member
carrying a reverse-request handle. Approvals + server→client
requests route exclusively through human-owner.ts on the TUI
socket. Type-level: no way to hold a request id, no way to
construct a response, no way to observe reverse-request state from
the Agent side.

Banned identifiers on the Agent surface — contract.test.ts greps
contract.ts field shapes and fails visibly if any of the following
appear as an interface / type-literal property: method, id: number|string|unknown, params, jsonrpc, threadId, turnId,
policy, path, config, requestId. Same guard applies to the
role literal (rejects any occurrence of "unknown" on the
declaration line, requires all six frozen roles).

Task id lifecycle (Δ14): taskId is canonical logical task identity
stable across retry / cross-node reassign; the ledger row and reply
channel thread against it. messageId is the per-delivery-attempt
idempotency key; the invariant (taskId, messageId) uniquely
identifies one delivery attempt.

Error codes

GatewayErrorCode enum — 9 codes, count-pinned:

Code Numeric Stable string
Unavailable -32001 codex_gateway_unavailable
InvalidArg -32050 codex_gateway_invalid_arg
QueueFull -32051 codex_gateway_queue_full
NoOwner -32052 codex_gateway_no_owner
UnknownTask -32053 codex_gateway_unknown_task
UnknownMethod -32054 codex_gateway_unknown_method
CodexBaselineMismatch -32055 codex_gateway_codex_baseline_mismatch
SqliteRuntimeUnsupported -32056 codex_gateway_sqlite_runtime_unsupported
Busy -32057 codex_gateway_busy

Δ13 pin: the numeric ↔ string pair is inseparable. makeErrorData
filters reserved keys (currently code) out of caller extras first,
then writes the authoritative stable code last. Hostile
authorizer.decision.extra.code or GatewayError.gatewayData.code
CANNOT override the wire data.code. Whole-enum defense-in-depth
test drives every code through the full dispatch with a hostile
override attempt.

UpstreamRequestMux + ReverseRequestNamespace (single-instance)

One UpstreamRequestMux allocator covers BOTH proxied-TUI and
internal-scheduler upstream requests. There is no such thing as
"Agent typed request → upstream": enqueueTask etc. mutate the
backend queue/ledger; the scheduler decides when to emit an
internal turn/start and every internal upstream request goes
through the same allocator. Proxied-TUI origin remembers the
downstream TUI id so the response is rewritten back verbatim
(numeric and string TUI ids preserved distinctly).

ReverseRequestNamespace is a SEPARATE namespace for
Codex→gateway reverse requests, so a Codex reverse id can never
collide with an outbound upstream id.

Δ11 selective drain: mux.drainProxiedTui() clears only proxied
origins; internal scheduler Promise resolvers stay alive across
TUI disconnect. mux.drainAll() is reserved for upstream
shutdown/restart.

TUI initialize virtualisation (Δ9)

TUI is a NATIVE Codex client. dispatchTuiRequest("initialize", …)
returns the injected upstream Codex initialize snapshot verbatim
(via the TuiInitializeProvider); it NEVER returns the Agent
handshake shape (codex-policy-gateway/1 + enqueueTask…) — that
would fail the native TUI handshake at P0. Snapshot undefined →
fail-closed Unavailable with reason=upstream_not_initialized.

Diagnostics sink (Δ10)

ProtocolDiagnostics interface (narrow: newCorrelationId +
reportInternalError) is injected. Unknown backend / authorizer
exceptions never appear on the wire: the sink receives the full
raw error and the wire response carries only a generic message +
the same correlationId in data. Test proves SQL fragments,
filesystem paths, token literals, and raw messages never leak on
the wire; sink called exactly once per throw with the raw error
preserved for operator triage.

Segment A — uds-server.ts @ 98676f1

Owns the two Unix Domain Socket servers (Agent + TUI) and holds
the SINGLE frozen UpstreamRequestMux + ReverseRequestNamespace
instances. Delegates all classification + dispatch to the frozen
protocol layer.

Hard requirements (all bytes-in/out tested with real sockets):

  • UDS ONLY. No TCP / loopback fallback anywhere in the file.
  • Socket directory created 0700. If pre-existing, lstat (not
    stat) verifies it is a real directory owned by the current uid
    with mode 0700; symlinks and non-owner paths are refused.
  • Socket file bound + chmod 0600. lstat re-verifies the
    resulting inode is a socket owned by us with mode 0600. Refuses
    to overwrite a pre-existing path.
  • Only paths this instance created are unlinked at shutdown.
  • Per-frame cap 200KB (= 128KB typed text + 72KB envelope
    overhead); per-connection buffered cap 512KB. Slow-loris that
    never sends \n hits the buffered ceiling and is torn down.
  • Malformed JSON + oversize both emit a structured JSON-RPC error
    frame with stable data.code and immediately destroy the
    connection. Raw exceptions / paths / tokens never appear on the
    wire; the injected ProtocolDiagnostics sink gets the full
    context.
  • Real newline-delimited framing via LineFramer. Fragmented
    chunks, batched frames, half-packets, blank-line keepalives all
    handled.
  • Real JSON-RPC notifications: initialized with no id is
    accepted silently, zero wire response. Request-form
    initialized (with an id) still gets an empty ack for
    backward-compat, covered by a separate test.
  • Transport surface: sendInternal<T>(method, params?, label?) → Promise<T> and sendProxiedTui(frame, tuiId) → Promise<void>.
    Both allocate through the same UpstreamRequestMux so a raw TUI
    id and an internal scheduler id can never collide on the
    upstream wire.
  • Upstream response consumeUpstreamResponse is one-shot;
    duplicate / unknown ids → diagnostics orphan, nothing on wire.
  • TUI disconnect: only proxied-TUI origins are drained
    (drainProxiedTui) + reverse namespace drained
    (reverseNs.drainAll()). Internal scheduler Promises stay
    alive. Upstream close is the only event that triggers
    mux.drainAll().

Test file: uds-server.test.ts = 28 pass / 0 fail / 72 expect.

Segment B — human-owner.ts @ 04560c0

HumanOwnerCoordinator is the SOLE holder of the reverse-request
namespace and the sole arbiter of TUI-side approval routing.

  • attachTui / detachTui / isTuiAttached lifecycle.
  • handleUpstreamReverseRequest(codexFrame) returns a transport-
    writable decision (forward_tui with a rewritten frame, or
    reject_upstream with a complete JSON-RPC error keyed on the
    ORIGINAL codex reverse id). Never touches sockets; never throws.
  • handleTuiResponseFrame(frame) delegates to the frozen
    handleTuiResponseFrame in protocol.ts for approval-spoof /
    replay protection.

approvalMode config gate:

  • Phase 1 "never" (default): every reverse request refused
    unconditionally with NoOwner + data.reason=approval_mode_never,
    regardless of TUI attachment. Defense-in-depth — upstream Codex
    is configured with approval=never so this path shouldn't fire
    in practice.
  • Phase 2 "passthrough": no TUI → NoOwner +
    reason=tui_not_attached; TUI attached → forward_tui with a
    freshly-allocated TUI id; collision → InvalidArg +
    reason=reverse_id_collision. Enabling is a config flip, NOT
    a code change.

detachTui drains the reverse namespace AND the proxied-TUI half
of the mux (Δ11 wiring). Internal scheduler Promises are untouched.
Reconnect never replays a drained reverse request; stale TUI ids
can NOT re-approve (hard-tested).

Test file: human-owner.test.ts = 16 pass / 0 fail / 43 expect.

Segment C — lifecycle.ts @ 00d4ea8

Orchestration + injection layer. Narrow scope per coordinator
guidance: does NOT own any queue, scheduler, ledger, owner-
reservation policy, baseline / schema / SQLite gate, or upstream
client. Those belong to B (rfc030-gateway-runtime / PR #432).

New surface:

  • PreflightRunner — narrow injection interface. B implements
    the actual boot-time gate (Codex version pin, digest match,
    SQLite migration state). Lifecycle awaits this BEFORE binding any
    UDS socket. A throwing preflight leaves NO socket path on disk
    (test verifies dir + backend + tui socket all absent). A mid-run
    fs inspection asserts preflight runs BEFORE bind.
  • makeNoThrowInitializeProvider(source, diagnostics) — wraps
    B's snapshot source so a throw degrades to undefined (dispatch
    then fail-closes with Unavailable, Δ9 wiring). Sink self-throw
    does not cascade — still returns undefined.
  • makeNoThrowDiagnostics(source) — wraps B's sink so both
    hooks are guaranteed no-throw. newCorrelationId throw / non-
    string return → stable fallback "cid-fallback".
    reportInternalError throw → silent swallow. No chained
    secondary sink (that would regress the "self-throwing sink
    can't cascade" invariant).
  • defaultDenyTuiAuthorizer + DEFAULT_DENY_ALLOWLIST
    Phase 1 stand-in for B's real authorizer. Allowlist is
    EXPLICITLY empty (Set<string>()); every method gets
    verdict="deny" + code=Busy + reason "Phase 1 default-deny: B's authorizer not yet wired in". No default-allow anywhere.
  • GatewayLifecycle — state machine
    created → starting → running → stopping → stopped. Second
    start throws; sendInternal / sendProxiedTui reject when not
    running. Start ordering: preflight → new mux + reverseNs
    (single) → wrap provider + diagnostics → HumanOwnerCoordinator
    → construct + start GatewayServer. Failure returns cleanly to
    stopped. Stop ordering: GatewayServer.stop (closes sockets +
    destroys connections + triggers TUI disconnect drain via the
    server's own closeConnection path) → belt-and-braces drainAll
    on mux + reverseNs → stopped. Does NOT reject internal Promise
    resolvers itself; the upstream transport contract is: on
    upstreamTransport.onClose(), B's transport MUST call
    resolve/reject on every internal-scheduler origin it
    dispatched.

Test file: lifecycle.test.ts = 19 pass / 0 fail / 59 expect.

Self-test targets (per Wave 1A 派工)

Target Location
ID collision / reorder protocol.test.ts UpstreamRequestMux block
Reverse request forgery (source-level + wire) protocol.test.ts + human-owner.test.ts + uds-server.test.ts
TUI offline fail closed human-owner.test.ts (Phase 2 no-TUI) + uds-server.test.ts (no-TUI reverse request)
Unknown method protocol.test.ts + uds-server.test.ts (wire)
Slow client backpressure uds-server.test.ts (buffered cap + slow-loris)
UDS permissions uds-server.test.ts (0700 dir + 0600 socket + lstat + symlink refusal + pre-existing refusal + shutdown cleanup)

Every checkpoint reports branch / commit / test command / raw
PASS count to the coordinator on task 2b40d91e. Any P0 risk was
flagged and reviewed before landing.

Boundary + hard-locks

  • Only agent-node/src/runtime/codex-policy-gateway/. No changes
    to scheduler / policy / ledger / bridge / runtime / client /
    agent-node cli / server reply.
  • Frozen contract.ts / protocol.ts at 90d1e58 are not
    modified in Segments A/B/C.
  • Segment C narrowing enforced: no second queue / scheduler /
    ledger / owner-reservation / baseline-gate / client-reconnect;
    only orchestration + injection points + no-throw wrappers +
    default-deny fake.
  • Hard-locks per docs/plans/RFC-030-implementation-plan.md §1
    still in force: no production change, no deploy, no preview
    publish, no latest switch, no lead merge; author never self-
    reviews security-critical changes; any P0/P1 §8 finding keeps
    the runtime preview-only.
  • Test-green is NOT merge-green. This PR remains DRAFT pending
    coordinator independent recheck of every segment SHA and the
    independent §8 security review.

Red-line 3-layer audit

  • Broad private-fork keyword regex on diff = 0 hits
  • Slug regex on diff + commit msg + PR body = 0 hits
  • Real vendor key literal regex on diff = 0 hits
  • No Co-Authored-By per project policy

Refs: freeze verdict 90d1e58 (coordinator task 2b40d91e),
tracking issue #428, plan doc docs/plans/RFC-030-implementation- plan.md (draft PR #433), 通信SDK马 consuming PR #432.

Author: 通信工程马

…est lockout

First checkpoint per 副指挥 派工 (owner: 通信工程马, ETA 10-12h,
draft PR only). Draft branch off origin/main pinned SHA
d418862.

## contract.ts — pure types, zero implementation

The ONLY surface the Agent runtime side may reach through when talking
to the gateway. Wave-0 baseline: canonical=codex-app-server, product
name Codex TUI Bridge, Codex binary pinned 0.144.0, schema digest
mismatch fail-closed, Phase 1 read-only/approval=never.

### Positive API

- `enqueueTask({taskId, messageId, authenticatedSender, text})`
    → `EnqueueTaskResult` (accepted-with-position | duplicate |
       refused_{queue_full,no_owner,shutting_down,invalid_arg})
- `getTaskState(taskId)` → 9-way discriminated `TaskState`
    (unknown | queued | starting | running | waiting_human |
     completed | failed | cancelled | approval_timeout)
- `cancelQueuedTask(taskId)` → `cancelled | refused_not_queued`
- `subscribeRuntimeState(listener)` → `RuntimeStateSubscription`
    (read-only stream; `.close()` is the only member)

### Reverse-request lockout (Wave 1A deliverable #3)

The Agent typed contract has NO member that carries a reverse-request
handle. Approvals / server → client requests are routed exclusively
to the human owner via human-owner.ts on the TUI socket (coming in a
later commit). Type-level: there is no way to hold a request id, no
way to construct a response, no way to observe reverse-request state
from the Agent side.

### Banned identifiers

The Wave-0 policy explicitly disallows exposing raw JSON-RPC
`method` / `id` / `params` / `jsonrpc`, or codex-level `threadId` /
`turnId` / `policy` / `path` / `config` / `requestId` on the Agent
surface. `contract.test.ts` greps contract.ts's field shapes and
fails visibly if any of those slip in — future refactor guardrail.

### Branding

`TaskId`, `MessageId`, `OwnerLeaseId` are opaque brands (`string &
{__brand: …}`). `asTaskId` / `asMessageId` / `asOwnerLeaseId`
validate at the wire boundary (length 1..200, string type) and are
the only way to mint one. `OwnerLeaseId` is exported for internal
lifecycle use but never appears as a field on any Agent-facing type
(the test suite asserts this).

### Error codes

`GatewayErrorCode` enum: `Unavailable=-32001` (JSON-RPC reserved,
signals reconnect / shutdown so a well-behaved client backs off),
plus `InvalidArg/QueueFull/NoOwner/UnknownTask/UnknownMethod/
CodexBaselineMismatch` in the -32050+ application range clear of the
JSON-RPC reserved band. 7 codes; exact list pinned by test.

## contract.test.ts — 33 pass / 0 fail

Positive: branded id round-trips + wire-boundary rejects (empty, too
long, non-string). EnqueueTaskArgs field enumeration. EnqueueTaskResult
+ TaskState + CancelQueuedTaskResult exhaustive-switch walks. Runtime
state event + subscription shape.

Negative (guardrail):

- No `method` / `id` / `params` / `jsonrpc` / `threadId` / `turnId` /
  `policy` / `path` / `config` / `requestId` field shape anywhere in
  contract.ts (grep).
- `OwnerLeaseId` never surfaces as a field on any Agent-facing type.
- `GatewayErrorCode` has exactly 7 named codes; can't silently grow.

## Boundary + baselines respected

- No files touched outside `agent-node/src/runtime/codex-policy-gateway/`.
- No changes to scheduler / policy / ledger / bridge / runtime / client /
  agent-node cli / server reply (those are 通信SDK马's).
- No merge, no deploy, no release.

## Next commits (per 派工 spec, still on this branch)

- protocol.ts — bidirectional wire adapter, initialize/initialized
  virtualization, request-id namespace rewrite, out-of-order / ID
  collision guard.
- uds-server.ts — private backend UDS + downstream TUI UDS, 0700 dir,
  socket owner-only, loopback-only rejection.
- human-owner.ts — reverse-request TUI routing, disconnect fail
  closed, replay-safe.
- lifecycle.ts — owner lease, bounded queue / backpressure,
  -32001 / reconnect / shutdown, no orphan / pending leak.

Refs 副指挥 派工 5b79b59f (12h TTL 6a1004af).
…Unsupported, RuntimeStateEvent extra fields, principal doc)

Two overlapping deltas landed in one commit — both stay strictly on my
file boundary (`agent-node/src/runtime/codex-policy-gateway/`).

## protocol.ts (Wave 1A deliverable #2) — 54 → 55 tests

Bidirectional wire adapter, method whitelist per direction,
initialize/initialized virtualization, request-id namespace rewrite,
banned-identifier defense on Agent params.

- `classifyMessage(raw)` → `request | response | notification |
  malformed`. 11 shape branches:
  * request: `method + numeric-id` or `method + string-id`
  * notification: `method` without `id`
  * response success: `id + result` (jsonrpc 2.0 shape)
  * response error: `id + error{code:number, message:string, data?}`
  * malformed: null / array / primitive / `id: null` / mixed request +
    response / both result and error / error object bad shape /
    unknown shape
- `RequestIdNamespace` — two independent map pairs (Agent↔Upstream +
  Codex-reverse↔TUI), monotonic counters, collision refusal, drainAll
  for shutdown. Agent id `1` and Codex reverse id `1` don't collide.
  Numeric `1` vs string `"1"` treated distinctly (internal `n:` /
  `s:` prefix). Out-of-order response consumption survives. Double-
  consume returns null. After consume, same Agent id may be reused.
- Method whitelist: Agent side accepts `enqueueTask`, `getTaskState`,
  `cancelQueuedTask`, `runtimeState.subscribe`,
  `runtimeState.unsubscribe`, `initialize`, `initialized`. TUI side
  accepts `initialize`, `initialized` only. Anything else →
  `UnknownMethod`.
- `parseEnqueueTaskParams` — zod-strict shape gate. Refuses payloads
  that carry any of `method`, `threadId`, `turnId`, `policy`, `path`,
  `config`, `requestId`, `jsonrpc`, `id` — banned per Wave-0 lockout.
  Refuses missing `tokenId` / `role` / `networkId` per B delta
  fail-closed policy. `text` bounded 1..128KB.
- `buildAgentInitializeResult` — synthesised handshake reply. Names
  the gateway (`codex-policy-gateway`) — not Codex. Method list
  sorted for stable diff. NO codex-facing method (no `turn/start`,
  `turn/steer`, `thread/resume`, `serverRequest/resolved`).
- `dispatchAgentRequest` — top-level Agent-side dispatcher. Any
  backend throw folds into `InvalidArg` so no request is left
  hanging. `runtimeState.subscribe` acknowledges + defers to
  uds-server.ts.

Reverse-request forgery test: source-level assertion that
`dispatchAgentRequest`'s function body does NOT reference the reverse
namespace maps or their allocators. The Agent path can't cross the
boundary by construction.

## contract.ts B delta (副指挥 task fa2e453d) — 33 → 47 tests

`SqliteRuntimeUnsupported = -32056` — B ledger A′ SQLite decision.
Emitted when the ledger detects an unsupported ledger migration state,
schema version, or operation. Stable string code
`codex_gateway_sqlite_runtime_unsupported` on `error.data.code`.

`GATEWAY_ERROR_DATA_CODE` — new frozen mapping from every numeric
enum member to its stable string name. Convention:
`codex_gateway_<lower_snake>`. Consumers may key on the numeric or the
string; both are stable.

`GatewayErrorData` — new typed shape for the `error.data` payload.
`code: string` is required (from `GATEWAY_ERROR_DATA_CODE`); extra
diagnostic keys (`field`, `queueDepth`, `limit`, `method`, `source`,
`reason`) merge on top via `[key: string]: unknown` index signature.
Agent side NEVER sees a raw upstream JSON-RPC error code.

`RuntimeStateEvent` grows three read-only fields (maintained by B
scheduler/ledger, no mutation surface):

- `activeReservationOwner: "none" | "human" | "agent"`
- `ambiguousCount: number`
- `failedCount: number`

`AuthenticatedSender` docstring hardened with a 🔒 security contract
block that spells out: (a) `alias` is display-only, never
authoritative; (b) `tokenId` + `role` + `networkId` all required —
missing any of them means the parser must fail closed with
`InvalidArg`; (c) `role: "unknown"` exists ONLY for parsing legacy
inbox rows and is not a client-side fallback; (d) refuse to
forge-fill "member" for a missing principal.

`AgentDispatchOutcome.error.data` is now typed `GatewayErrorData`
(required, not optional). Every error site in `dispatchAgentRequest`
now calls `makeErrorData(code, extra?)` so the stable string code is
always present.

## Test coverage — 95 pass / 0 fail across contract.test.ts +
protocol.test.ts

Contract additions:
- RuntimeStateEvent field enumeration (10 keys, all listed).
- `activeReservationOwner` discriminant exhaustive walk (none / human
  / agent).
- `ambiguousCount` / `failedCount` are numbers.
- `GatewayErrorCode` now has exactly 8 codes.
- `SqliteRuntimeUnsupported` pinned to -32056.
- `GATEWAY_ERROR_DATA_CODE` maps every numeric to its `codex_gateway_
  *` string, keys match enum exactly, matches the exact B-required
  literal for SQLite unsupported.
- `GatewayErrorData` shape.

Protocol additions:
- Every dispatch error carries `data.code` matching
  `GATEWAY_ERROR_DATA_CODE`; diagnostic keys merge on top; source
  key on backend-throw path.

## Boundary held

Only `agent-node/src/runtime/codex-policy-gateway/`. Scheduler /
policy / ledger / bridge / runtime / client / cli / server-reply
untouched. Q1 server principal stamp remains B's job (副指挥 授权
<2h server 例外 → 通信龙 approves in task 404d7e19); this branch
doesn't touch server.

## Contract still a freeze CANDIDATE

Per 副指挥, contract is not final-frozen until 副指挥 signs off on
the delta. B has cherry-picked a196f78; this commit lands the delta
B asked for. Awaiting 副指挥 verdict before I stop treating it as
mutable.

Refs 副指挥 task fa2e453d (B consumer review delta) + 5b79b59f
(Wave 1A original 派工).
…dcbd)

Land the 4 deltas from 副指挥 checkpoint-2/consumer review:

Delta 5 — TuiPolicyDecision reshape
  Discriminant renamed from `kind` to `verdict` per B-side naming.
  `deny` outcomes now REQUIRE `code: GatewayErrorCode` + `reason: string`
  (not optional). `extra` remains optional for diagnostic passthrough.
  Interface renamed `TuiPolicyHook` → `TuiRequestAuthorizer` with a
  single `authorize(frame)` method (was `authorizeTuiRequest`).
  The old name is kept as a private deprecated type alias so this
  file's own callers migrate cleanly; no external re-export.

Delta 4 (P0) — UpstreamRequestMux
  Replace `RequestIdNamespace` with two SEPARATE classes:

    UpstreamRequestMux         single monotonic allocator for BOTH
                               proxied-TUI upstream requests and
                               internal scheduler upstream requests.
                               No such thing as "Agent typed request
                               → upstream": typed Agent methods
                               (enqueueTask etc.) never travel to
                               Codex — they mutate the backend
                               queue/ledger; the scheduler decides
                               when to emit an internal turn/start,
                               and every internal upstream request
                               goes through this same allocator.

    ReverseRequestNamespace    independent namespace for Codex →
                               gateway REVERSE requests (server →
                               client), which flow to the TUI for
                               approval. Kept SEPARATE because these
                               ids live in a distinct wire direction
                               and mixing them would let a Codex
                               reverse id collide with an outbound
                               upstream id in the same map.

  Hard tests added per ed3f92bf: TUI raw id=1 + internal turn/start
  simultaneously → distinct upstream ids; out-of-order responses
  each route to correct origin; internal/TUI duplicate ids don't
  cross; proxied-TUI id-in-flight collision refused; numeric vs
  string ids distinct.

Delta 6 — TUI approval-response consumption path
  New `handleTuiResponseFrame(frame, reverse)` for the approval
  channel. Approvals arrive as JSON-RPC RESPONSE frames (not method
  requests); they consume a pending Codex-reverse id from
  `ReverseRequestNamespace` (NOT `UpstreamRequestMux`). Unknown or
  already-consumed tuiId → fail-closed reject with stable data
  reason `reverse_id_unknown_or_duplicate`, so approval-spoof /
  replay cannot re-approve a request. Phase 1 has approval=never
  (Codex never sends a reverse request), but the structure is in
  place so Phase 2 turn-on is a config change, not a code change.

Delta 7 — Phase 1 policy structural wiring tests (副指挥 7d70dcbd)
  Concrete reservation authorizer models the Phase 1 policy:
    reservation=agent  → human turn/start + turn/steer denied with
                         NoOwner + `reservation held by agent`
                         (0 forward_upstream outcomes)
                         turn/interrupt allowed (exactly 1 forward)
                         subsequent turn/start still denied
                         (no auto-replay observable at A layer)
    reservation=human  → turn/start + turn/steer allowed
    Phase 1 approval spoof — unknown reverse id fail-closed
    Phase 2 turn-on smoke — human turn/steer of Agent turn still
                            refused under agent reservation

Test bump: 130 → 145 pass, 341 → 379 expect() calls, 0 failures.
Typecheck clean.

Author: 通信工程马
Land the 4 required deltas from checkpoint-3 independent review:

Delta 8 — Busy code -32057
  New `GatewayErrorCode.Busy` with stable string `codex_gateway_busy`.
  Reservation conflict (owner IS bound but holding the reservation
  for the other party) no longer misuses NoOwner. NoOwner now
  strictly means "no owner bound at all". Phase 1 reservation=agent
  turn/start and turn/steer denies migrate from NoOwner → Busy.
  Reason strings unchanged; wire code changes.

Delta 9 — TUI initialize virtualisation
  Split TUI initialize from Agent handshake. Native Codex TUI can
  NOT receive `codex-policy-gateway/1 + enqueueTask...` — it would
  fail the handshake at P0. New `TuiInitializeProvider` interface
  (A defines; B/lifecycle implements): the provider hands back the
  pinned Codex 0.144.0 app-server snapshot captured at gateway boot
  and cached. `dispatchTuiRequest("initialize", ...)` returns the
  snapshot verbatim; `undefined` snapshot → fail-closed Unavailable
  with `reason: "upstream_not_initialized"`.

  Agent-side initialize is unchanged (still uses
  buildAgentInitializeResult). Tests assert the two paths never
  cross-wire: Agent init has `codex-policy-gateway/1`; TUI init
  has native Codex serverInfo + protocolVersion + capabilities
  and MUST NOT contain enqueueTask / codex-policy-gateway/1.

Delta 10 — ProtocolDiagnostics sink
  `safeBackendCall` and the TUI authorizer-error path no longer
  drop the raw exception on the floor. New `ProtocolDiagnostics`
  interface (A defines; B/lifecycle injects) with
  `newCorrelationId()` + `reportInternalError({correlationId,
  operation, error})`.

  Flow on unknown internal exception:
    1. mint correlationId
    2. sink.reportInternalError(...) with the full raw error +
       symbolic operation label ("enqueueTask", "getTaskState",
       "cancelQueuedTask", "tui_policy_authorize")
    3. wire response = generic "gateway backend error" message
       + Unavailable code + `data.correlationId` matching the log

  Tests prove: SQL fragments, filesystem paths, token literals,
  raw messages never cross the wire; sink is called exactly once
  per throw with the raw error preserved for operator triage; the
  same correlationId lands on the wire and in the sink so support
  tickets can be joined to log lines.

Delta 11 — UpstreamRequestMux.drainProxiedTui
  TUI disconnect ≠ upstream disconnect. `drainProxiedTui()` clears
  ONLY the proxied-TUI origins from the mux; internal scheduler
  Promise resolvers survive, so long-running Agent internal work
  (turn/start, turn/interrupt) isn't lost when the TUI drops.
  `drainAll()` remains for full upstream shutdown / restart.

  New diagnostic `pendingCountByKind("proxied_tui" | "internal")`
  for lifecycle.ts orphan sweep + operator observability.

Test count: 145 → 155 pass, 379 → 435 expect() calls, 0 failures.
Command: `bun test agent-node/src/runtime/codex-policy-gateway/`.

Correction on prior claim: this workspace has no tsconfig.json
at the repo root or under agent-node/, so my Checkpoint-3 report's
"typecheck clean" was empty — nothing was actually checked. Going
forward I only claim `bun test` PASS counts.

Author: 通信工程马
…副指挥 efde3938)

Freeze blocker from Checkpoint 4 verdict — contract.ts docstring
already warned that inbound role=\"unknown\" MUST fail closed, but
the AuthenticatedSender union and the parser allowset still admitted
it. Client-supplied \"unknown\" could reach the backend and be
approved as an authenticated principal without a real CommHub
principal stamp behind it.

Changes:

contract.ts
  - AuthenticatedSender.role narrows to
    \"admin\" | \"owner\" | \"member\" | \"viewer\" | \"child\".
    \"unknown\" removed.
  - Docstring rewrites the legacy-fallback note: legacy inbox rows
    without a principal stamp are a server / DB layer concern; they
    must be reclassified or refused BEFORE reaching the contract.

protocol.ts
  - AUTHENTICATED_SENDER_VALID_ROLES drops \"unknown\".
  - Parser refusal reason: \"must be one of admin/owner/member/viewer/child\"
    (no \"unknown\" hint, so a future reader can't re-add it).

contract.test.ts
  - Role enum test asserts the exact 5-role allowlist and NOT
    \"unknown\".
  - New source-level pin: greps contract.ts for `readonly role:` and
    asserts the type literal excludes \"unknown\". Guards against a
    future refactor silently reopening the fallback.
  - Enum-count test unchanged (still 9 GatewayErrorCode entries).

protocol.test.ts
  - parseEnqueueTaskParams: role=\"unknown\" refused with
    field=authenticatedSender.role, reason enumerates the concrete
    allowlist, reason MUST NOT contain \"unknown\".
  - Parser positive: all 5 concrete roles pass.
  - Parser negative fuzz: unknown / Admin / MEMBER / guest / system
    / \"\" / \" member\" all refused.
  - Wire-level (dispatchAgentRequest): enqueueTask with role=
    \"unknown\" → InvalidArg + code=codex_gateway_invalid_arg +
    field=authenticatedSender.role, backend.enqueueTask spy
    called 0 times.
  - Wire-level positive: each of the 5 concrete roles reaches the
    backend exactly once with the same role value.

Test count: 155 → 160 pass, 435 → 467 expect() calls, 0 failures.
Command: `bun test agent-node/src/runtime/codex-policy-gateway/`.

Author: 通信工程马
…er 9a019ff4)

Final freeze blocker from checkpoint-4 delta stream. makeErrorData
previously wrote the stable string code FIRST and then spread the
caller-supplied extras on top, so any extras with a `code` key
silently overrode the stable pair. Repro: authorizer decision.extra
{code: "attacker_override"} produced numeric Busy = -32057 on the
wire but data.code = "attacker_override", violating the guarantee
that numeric and string codes always agree.

Same class of shadowing existed for the TUI authorizer deny path,
where decision.reason and decision.extra.reason could diverge.

Changes to protocol.ts:

  makeErrorData
    - Filter reserved keys (currently only `code`) out of the caller
      extras first, then write the authoritative stable string code
      LAST so it wins over anything the caller supplied. Silent drop
      preferred over throw: a hostile authorizer must not crash the
      outbound frame.
    - Docstring pins the invariant and enumerates the reserved list
      so future callers know where the boundary is.

  Authorizer deny path (dispatchTuiRequest policy_delegate)
    - Merge decision.extra BEFORE writing decision.reason, so the
      authoritative reason wins in data.reason and matches the
      wire message field.

  All other makeErrorData call sites audited: no other reserved-
  field shadowing (source, correlationId, field, method are all
  authored by the A layer only, no extras path).

New tests (all under a Delta 13 describe block):

  - Hostile TUI authorizer extras{code: "attacker_override"} keeps
    wire data.code = codex_gateway_busy; non-reserved fields
    (reservationHolder, taskId) pass through.
  - Hostile TUI authorizer extras{reason} kept out; decision.reason
    wins in both message and data.reason.
  - Hostile GatewayError gatewayData{code} kept out; wire data.code
    = codex_gateway_queue_full; queueDepth / limit preserved.
  - Clean GatewayError round-trip (no false-positive scrub of hint /
    pollMs).
  - Numeric/string pair pinned for EVERY declared GatewayErrorCode,
    driven through the full dispatch path (defense in depth).
  - Combined positive-negative pin: reserved `code` filtered,
    reserved `reason` superseded, non-reserved severity /
    reservationHolder / hintedCommand preserved.

Test count: 160 -> 166 pass, 467 -> 516 expect() calls, 0 failures.
Command: bun test agent-node/src/runtime/codex-policy-gateway/

Author: comm eng
…lta 14, dispatcher 2860aebf + doc precision 2872f7a3)

Final freeze delta pinning the principal role union and the
retry-safe task/message id semantics agreed with 通信龙.

contract.ts

  AuthenticatedSender.role — union closes on
    "admin" | "owner" | "member" | "viewer" | "node" | "child"

  Adds "node" — the minimum-privilege identity of a plain ntok
  node. The server MUST classify node-identity requests as "node"
  regardless of the account role the underlying token grants; a
  "node" NEVER inherits the token owner's "owner" / "admin". Adds
  the doc note that role classification comes exclusively from the
  server authCtx resolution, with the four concrete sources
  (副指挥 doc precision fix 2872f7a3):
    - utok network role  -> network_members
    - REST global admin  -> users / global auth
    - RFC-026 child kind -> api_tokens.role
    - ntok "node" kind   -> server-resolved token scope / kind
  Prior mention of a non-existent `principal_role` field and a
  non-existent "ntok scope table" is removed. Role MUST NOT be
  inferred from a raw token prefix, an alias, or any other client-
  supplied value.

  EnqueueTaskArgs — id semantics pinned so the ledger + inbox
  layers can be built against a stable contract without a life-
  cycle break on retry / reassign:

    taskId    Canonical task identity. Stable across the ENTIRE
              reply lifecycle including retries and cross-node
              reassigns. Same taskId => same ledger row, same
              reply channel, same upstream turn stream. Server
              lift for B: the `inbox` table carries a canonical_
              task_id column IMMUTABLE for the row's lifetime;
              `tasks` carries an IMMUTABLE origin_principal
              stamped at first-seen. Retry / reassign never mints
              a fresh taskId. (Final column names land with B's
              migration; docstring uses the real `inbox` table
              name, not `inbox_rows`.)

    messageId Idempotency key for THIS specific inbox delivery
              attempt. Fresh on every retry / reassign, so a
              re-delivery of the same physical message is
              deduped without conflating it with a different
              retry. Invariant: (taskId, messageId) uniquely
              identifies a delivery attempt. Same (taskId,
              messageId) MUST return `duplicate`; same taskId
              with different messageId is another attempt of the
              same logical task (retry lineage), NOT a new task
              — the gateway threads them against the same ledger
              row. Initial delivery MAY use messageId = taskId;
              every subsequent retry / reassign MUST mint a
              fresh messageId.

protocol.ts

  AUTHENTICATED_SENDER_VALID_ROLES adds "node". Parser refusal
  reason becomes "must be one of admin/owner/member/viewer/
  node/child". No new upstream fields, no new mutation. No new
  GatewayErrorCode entries.

Tests

  contract.test.ts
    - Role enum test admits the 6-role concrete allowlist.
    - Source-level pin: EVERY frozen role literal must appear on
      the declaration line; "unknown" MUST NOT appear. Guards
      against silent union expansion or silent fallback re-open.
    - New Δ14 docstring pin on EnqueueTaskArgs: flattens JSDoc,
      asserts the taskId + messageId invariants + the server
      lift (inbox / canonical_task_id / origin_principal /
      NOT inbox_rows).
    - New Δ14 docstring pin on AuthenticatedSender: asserts the
      four concrete authoritative sources are present, and the
      wrong terms (principal_role / ntok scope table) are absent.

  protocol.test.ts
    - Parser negative: role="unknown" refused with the new
      allowlist string (no "unknown" leaking into the reason).
    - Parser positive: all 6 concrete roles pass; expanded fuzz
      list still refuses Node / NODE / " node" alongside the
      prior negatives.
    - Wire positive: each of the 6 concrete roles reaches
      backend.enqueueTask exactly once with its role preserved.
    - Wire pair: paired role="node" positive vs role="unknown"
      negative with backend spy counters (1 vs 0) so a future
      refactor loosening the fail-closed path fails visibly.

Test count: 166 -> 169 pass, 516 -> 555 expect() calls, 0 failures.
Command: bun test agent-node/src/runtime/codex-policy-gateway/

Author: comm eng
…layer)

Segment A of the post-freeze Wave 1A build. Owns the two Unix Domain
Socket servers (Agent + TUI) and the SINGLE frozen mux + reverse
namespace instance. Delegates all message classification + dispatch
to the pure protocol layer (frozen at 90d1e58); does not modify any
frozen shape.

Hard requirements enforced (dispatcher 1e52976d):

  - UDS ONLY. No TCP / loopback fallback anywhere in the file.
  - Socket dir created 0700 via ensureOwnerOnlyDir. If the path
    pre-exists, lstat (not stat) verifies it is a real directory
    owned by the current uid with mode 0700; symlinks and non-owner
    paths are refused. If created by start, cleanupCreatedPaths
    removes it on stop. Pre-existing dirs are NEVER removed.
  - Socket file bound via bindOwnerOnlySocket: refuses to overwrite
    a pre-existing path, chmods to 0600, then lstat-verifies the
    resulting inode is a socket owned by us with mode 0600. Only
    paths created by this instance are unlinked at shutdown.
  - Per-connection buffered-bytes cap (default 512KB) and per-frame
    size cap (default 200KB = 128KB typed text + 72KB envelope
    overhead). Slow-loris that never sends a newline hits the
    buffered ceiling and is torn down. Malformed JSON and oversize
    frames both emit a structured JSON-RPC error frame and close
    the connection. Raw exception messages / paths / tokens never
    appear on the wire; the injected ProtocolDiagnostics sink gets
    the full context.
  - Single UpstreamRequestMux instance shared by BOTH transport
    entry points. Interface surface: sendInternal (scheduler /
    lifecycle) and sendProxiedTui (authorizer-allowed TUI forward).
    Both allocate through the same mux, so a TUI raw id and an
    internal scheduler id can never collide on the upstream wire.
  - Real newline-delimited JSON-RPC framing via a byte-oriented
    LineFramer. Fragmented chunks, batched frames, half-packets,
    and blank-line keepalives all handled.
  - JSON-RPC notification frames (no id) accepted silently with
    NO response, per JSON-RPC 2.0 spec. Backward-compat "initialized
    as request" (with an id) still gets an empty ack.
  - TUI disconnect drains only proxied-TUI + the reverse namespace
    via drainProxiedTui / reverseNs.drainAll — internal scheduler
    Promises stay alive (Δ11). Upstream close is the ONLY event
    that triggers mux.drainAll.

Reverse-request path:

  - Codex reverse request → allocate a fresh TUI id via reverseNs,
    forward to the TUI socket with the rewritten id.
  - TUI response (id + result/error) → handleTuiResponseFrame
    consumes the reverseNs entry; forwarded upstream with the
    ORIGINAL Codex reverse id. Unknown / duplicate consume →
    InvalidArg reject on the TUI socket, fail closed (no re-approval).
  - No TUI attached at reverse-request arrival → NoOwner sent
    upstream, reverseNs entry rolled back.

New test file: uds-server.test.ts, 28 pass / 0 fail / 72 expect().
Coverage:

  Path setup
    - 0700 dir + 0600 socket verified via lstat
    - refuse pre-existing socket path
    - refuse symlinked socket dir
    - stop unlinks only what start created; pre-existing files
      untouched

  Agent-side wire
    - real bytes enqueueTask round-trip
    - initialized notification (no id) → 0 wire response
    - initialized as request → empty ack
    - unknown method → UnknownMethod error

  Framer edge cases
    - multiple frames per chunk
    - frame split across byte boundaries (mid-JSON + mid-newline)
    - blank line keepalive ignored
    - malformed JSON → structured error + close
    - oversize frame refused + close
    - slow-loris (no newline) hits buffered cap

  TUI socket dispatch
    - initialize returns injected upstream snapshot verbatim
      (native Codex shape; NEVER codex-policy-gateway/1)
    - initialize with snapshot=undefined → Unavailable fail closed
    - authorizer allow: fresh upstream id, TUI response routed back
      to the original TUI id (both numeric and string ids preserved)
    - authorizer deny: Busy on TUI, upstream never written

  Mux integration
    - dual origin (proxied_tui + internal_scheduler) interleaved on
      the SAME upstream fake, out-of-order responses each route to
      the correct origin
    - duplicate upstream response id → orphan diagnostic, nothing
      on wire
    - unknown upstream response id → orphan diagnostic

  TUI disconnect
    - proxied_tui pending drained; internal_scheduler pending
      survives and can still resolve; late TUI-side response goes
      orphan

  Reverse request + approval spoof
    - upstream reverse request → forwarded to TUI with rewritten
      id; TUI response rewritten back to Codex reverse id upstream
    - unknown reverse id from TUI → InvalidArg reject fail closed
    - duplicate consume → same reject
    - no TUI attached → NoOwner sent upstream

  Connection cap
    - max_connections=2 → third connect destroyed with diagnostic

Full gateway test count: 169 → 197 pass (+28), 555 → 627 expect()
calls (+72), 0 failures. Command:
  bun test agent-node/src/runtime/codex-policy-gateway/

Boundary: only agent-node/src/runtime/codex-policy-gateway/. No
changes to scheduler / policy / ledger / bridge / runtime / client
/ agent-node cli / server reply. No changes to frozen contract.ts
or protocol.ts.

Author: comm eng
Split reverse-request routing + TUI attach/detach lifecycle out of
uds-server.ts into a dedicated module. Enforces the freeze-time
guarantee that the Agent socket has ZERO visibility into the
reverse-request namespace, and provides a single config-driven
Phase 1 / Phase 2 gate on approval forwarding.

HumanOwnerCoordinator
  - Sole holder of the ReverseRequestNamespace instance.
  - attachTui / detachTui / isTuiAttached lifecycle.
  - handleUpstreamReverseRequest → transport-writable decision
    (forward_tui or reject_upstream) depending on approvalMode.
  - handleTuiResponseFrame delegates to frozen handleTuiResponseFrame
    for approval-spoof / replay protection.
  - Never touches sockets; never throws.

Phase 1 (approvalMode="never", default): every reverse request is
refused unconditionally with NoOwner + data.reason=approval_mode_never
regardless of TUI attachment. Defense-in-depth — upstream Codex is
configured with approval=never; if a reverse request still arrives
it's misconfiguration or a bug.

Phase 2 (approvalMode="passthrough"): if a TUI is attached, allocate
a fresh TUI-side id via the reverse namespace, return the rewritten
frame. If no TUI, NoOwner + reason=tui_not_attached. Reverse id
collision returns InvalidArg + reason=reverse_id_collision.
Enabling is a config flip, NOT a code change.

TUI detach drains the reverse namespace AND the proxied-TUI half of
the mux (Δ11 wiring). Internal scheduler Promises are untouched — a
TUI disconnect MUST NOT lose long-running Agent work. Reconnect
never replays a drained reverse request; stale TUI ids can not
re-approve.

Test coverage (16 pass / 0 fail / 43 expect):
  - Phase 1: refuse when TUI attached; refuse when not attached; no
    reverseNs allocation.
  - Phase 2: no TUI → NoOwner; TUI attached → forward_tui with fresh
    numeric tuiId ≠ codex string id; collision → InvalidArg; params
    omission preserved.
  - TUI response: known → forward_reverse_response with original
    codex id; unknown → reject; duplicate consume → reject.
  - Lifecycle: detach drains proxied_tui + reverse; internal
    pending untouched; stale id post-reconnect can not re-approve;
    attach idempotent; detach without attach is safe.
  - Isolation: Phase 1 refuse doesn't touch mux upstream half;
    Phase 2 forward doesn't allocate on upstream mux.

Boundary: only agent-node/src/runtime/codex-policy-gateway/. No
changes to scheduler / policy / ledger / bridge / runtime / client
/ agent-node cli / server reply. No changes to frozen contract.ts,
protocol.ts, or Segment-A uds-server.ts.

Full gateway suite: 197 → 213 pass (+16), 627 → 670 expect() (+43),
0 failures.

Author: comm eng
…yer)

Segment C of the post-freeze Wave 1A build. Narrow scope per 副指挥
1e52976d Segment C guidance: this module does NOT own any queue,
scheduler, ledger, owner-reservation policy, baseline / schema /
SQLite gate, or upstream client — those all belong to B. A layer
only orchestrates the injections.

New surface:

  PreflightRunner (interface)
    B implements the actual boot-time gate (Codex version pin,
    digest match, SQLite migration state, etc.). Lifecycle awaits
    this BEFORE binding any UDS socket. Failing preflight leaves
    NO socket path on disk.

  makeNoThrowInitializeProvider(source, diagnostics)
    Wraps B's initialize-snapshot source so a throw inside
    currentSnapshot() degrades to `undefined` (dispatch layer then
    fail-closes the TUI initialize with Unavailable, Δ9 wiring).
    Thrown value is reported to diagnostics. Sink self-throw does
    NOT cascade — the wrapper still returns undefined.

  makeNoThrowDiagnostics(source)
    Wraps B's diagnostics sink so both newCorrelationId and
    reportInternalError are guaranteed no-throw. newCorrelationId
    throw / non-string return → stable fallback "cid-fallback".
    reportInternalError throw → silent swallow. Chains would
    regress the "self-throwing sink can't cascade" invariant.

  defaultDenyTuiAuthorizer + DEFAULT_DENY_ALLOWLIST
    Phase-1 stand-in for B's real bound-thread / reservation
    authorizer. Allowlist is EXPLICITLY empty (`Set<string>()`);
    every method receives verdict="deny" + code=Busy + explicit
    reason "Phase 1 default-deny: B's authorizer not yet wired in".
    No default-allow anywhere. Phase 2 turn-on swaps in B's real
    authorizer, does NOT tweak this fake.

  GatewayLifecycle (class)
    - state machine: created → starting → running → stopping →
      stopped. Second start throws. sendInternal / sendProxiedTui
      reject when not running.
    - start ordering: transition → run preflight → construct mux +
      reverseNs single instances → wrap provider + diagnostics
      → construct HumanOwnerCoordinator → construct + start
      GatewayServer. Failure at any stage returns to `stopped`
      cleanly; GatewayServer's rollbackStart handles socket cleanup.
    - stop ordering: transition → GatewayServer.stop (which closes
      sockets + destroys connections + triggers TUI disconnect
      drain via the server's own closeConnection path) → belt-and-
      braces drainAll on mux + reverseNs → transition to stopped.
    - Does NOT reject internal Promise resolvers itself; that's
      B's transport contract on upstreamTransport.onClose(). This
      module owns only the drainAll bookkeeping.
    - Test inspectors: currentState, connectionCount,
      pendingUpstreamCount(kind?), pendingReverseCount.

Test coverage (19 pass / 0 fail / 59 expect):

  Preflight ordering
    - preflight throws → state=stopped, NO sockets or dir on disk
    - preflight runs BEFORE any socket touches disk (mid-run fs
      inspection asserts absence)
    - clean preflight → state transitions created → running
    - second start throws (no double-start)

  defaultDenyTuiAuthorizer
    - allowlist is explicitly empty (set size 0)
    - every method (turn/start, turn/steer, turn/interrupt,
      thread/resume, thread/read, arbitrary) denied with Busy +
      reason mentioning "default-deny", extra carries source +
      method for diagnostics
    - even bootstrap-shaped methods denied here (bootstrap is
      handled by dispatch before authorizer)

  makeNoThrowInitializeProvider
    - source throw → undefined snapshot + diagnostics report with
      operation=tui_initialize_provider
    - clean snapshot → passthrough verbatim (no wrapper mutation)
    - source AND diagnostics both throw → still returns undefined
      (no cascade)

  makeNoThrowDiagnostics
    - newCorrelationId throw → "cid-fallback"
    - non-string return → coerced to "cid-fallback"
    - reportInternalError throw → silent swallow
    - clean sink passthrough

  Transport pass-through state gating
    - sendInternal rejects before start
    - sendInternal rejects after stop
    - sendInternal works while running (upstream response wired
      back through mux origin resolver)

  Shutdown drain
    - stop drains mux + reverseNs + cleans created sockets
    - stop before start is a safe no-op

Full gateway suite: 213 → 232 pass (+19), 670 → 729 expect() (+59),
0 failures.

Boundary: only agent-node/src/runtime/codex-policy-gateway/. No
changes to scheduler / policy / ledger / bridge / runtime / client
/ agent-node cli / server reply. No changes to frozen contract.ts,
protocol.ts, Segment-A uds-server.ts, or Segment-B human-owner.ts
(this module only imports from them). No second queue / scheduler
/ ledger / owner-reservation / baseline-gate / client reconnect —
all deferred to B.

Author: comm eng
Full P0 corrective pass for the checkpoint-FAIL audit on 00d4ea8.
frozen contract.ts/protocol.ts unchanged.

Item #1 — capability handshake (backend/TUI role separation)
  GatewayServerOptions gains `backendCapability` and `tuiCapability`
  (launcher-provisioned high-entropy strings, min 32 chars, DISTINCT
  per role — constructor rejects otherwise). SHA-256 digests are
  precomputed so `crypto.timingSafeEqual` runs constant-time.
  New connections enter `awaiting_hello` state and MUST send a
  `gateway.hello` first frame carrying the correct role capability
  before any JSON-RPC dispatch fires. `DEFAULT_HELLO_TIMEOUT_MS` =
  3000; missing/wrong-shape/wrong-role/wrong-value all get a
  structured refuse (`handshake_required` or `capability_invalid`)
  and immediate close. The received value is NEVER echoed on the
  wire or in diagnostics (test drives concrete bogus / cross-role
  capabilities and asserts non-presence in the wire dump + the
  diagnostics dump).

Item #2 — per-role single-owner cap
  `maxConnections: 8` replaced with `maxConnectionsPerRole: 1`. A
  second connection on the same role is destroyed immediately at
  accept time; the incumbent is NOT touched. Fix specifically kills
  the "second TUI hijacks findTui + drains proxied on disconnect"
  path.

Item #3 — HumanOwnerCoordinator delegation (approvalMode enforced)
  GatewayServer no longer accepts `reverseNs`. It now takes
  `humanOwner: HumanOwnerCoordinator` and routes ALL reverse-request
  decisions + TUI response frames through it. Under Phase 1
  `approvalMode="never"` (default) an inbound Codex reverse request
  produces `NoOwner + reason=approval_mode_never` upstream and
  zero forward to the TUI, regardless of TUI attach state. The
  Phase 2 `passthrough` path is exercised by explicit opt-in tests
  so both branches stay covered. TUI attach fires on successful
  hello; TUI detach flows through `humanOwner.detachTui()` for
  drainProxiedTui + reverseNs.drainAll — server never touches the
  reverse namespace directly.

Item #4 — sendInternal reject-exactly-once semantics
  New `internalPending: Map<upstreamId, InternalPendingEntry>` with
  a `settled` flag guarding the response-vs-close race. On upstream
  close, `server.stop()`, or writeFrame failure, each entry is
  rejected once with a stable reason (`upstream_closed` /
  `gateway_stopping` / underlying write error). A late response
  arriving after close does NOT double-settle (test drives this
  concretely with fake upstream close then late emit).

Item #5 — UpstreamTransport.close/abort ownership
  Interface gains `close(): Promise<void>` — idempotent, MUST fire
  onClose subscribers on first invocation. `GatewayLifecycle.stop`
  now awaits it after `server.stop` so B's client tears down
  deterministically. Test observes the fake's `closeCallCount === 1`
  after a lifecycle stop.

Item #6 — start/stop generation/abort fence
  New `stopRequested` flag + `startInProgress` promise. `start()`
  splits into `doStart()` with fence checks after every await
  (post-preflight, pre-listen, post-listen). If stop() lands during
  preflight, the fence short-circuits with
  `"start aborted by concurrent stop (preflight)"`, the lifecycle
  stays in `stopped`, NO sockets bind. Verified by a test that
  parks preflight on an unresolved Promise, calls stop, then
  releases preflight — no revival.

Item #7 — upstream subscribe throw lands in rollback
  `upstreamTransport.onFrame` / `onClose` subscribe calls are now
  INSIDE the same try/catch as the socket binds. If subscribe
  throws (bad handler / weird state), rollbackStart runs and any
  already-bound UDS servers are closed + paths unlinked before
  `start()` rejects. Verified by a fake upstream whose `onFrame`
  throws — subsequent `connectRaw()` on the backend path is
  refused.

Test evidence (per-item real integration tests, real UDS bytes-in/
bytes-out):

  bun test agent-node/src/runtime/codex-policy-gateway/uds-server.test.ts
  → 43 pass / 0 fail / 104 expect() calls

  Per-item test blocks named `P0#1` … `P0#7` for grep-easy audit:
   - P0#1 capability handshake — 6 tests
       hello timeout / not-hello first / wrong cap / cross-role /
       correct cap → RPC works / distinct-per-role TUI cap
   - P0#2 per-role single-owner cap — 2 tests
       second TUI refused + incumbent alive; second backend refused
   - P0#3 HumanOwnerCoordinator delegation — 2 tests
       Phase 1 never: reverse → 0 TUI forward + NoOwner+
       approval_mode_never upstream; TUI attach/detach flow
   - P0#4 sendInternal reject-once — 3 tests
       upstream close → upstream_closed; server.stop →
       gateway_stopping; late response after close no double-settle
   - P0#5 UpstreamTransport.close contract — 1 test
       close() method present on server-facing interface
   - P0#7 upstream subscribe throw in rollback — 1 test
       onFrame throw → server.start rejects, subsequent connect
       refused

  bun test agent-node/src/runtime/codex-policy-gateway/lifecycle.test.ts
  → 21 pass / 0 fail (baseline 19 + 2 new P0 blocks)
   - P0#5 upstream.close awaited on stop (invoked exactly once;
     pending sendInternal rejects)
   - P0#6 stop-during-preflight fence (state=stopped, no revival,
     subsequent start rejects)

  Full gateway suite (all 5 test files):
  bun test agent-node/src/runtime/codex-policy-gateway/
  → 251 pass / 0 fail / 771 expect() calls

Boundary held:
  - only agent-node/src/runtime/codex-policy-gateway/
  - frozen contract.ts / protocol.ts unchanged (SHA-256 verified
    against freeze 90d1e58 by grep-audit — no edit ever proposed)
  - no scheduler / ledger / policy / bridge / client changes
  - B stays R6-frozen (副指挥 62103244 supersedes any earlier
    unlock note; lifecycle change stays interface-level only)

P1 backlog kept separate (副指挥 9936fe24 items #8-13):
  cleanup dev/ino verify + parent symlink refusal, wire cap ≥1MiB
  with worst-case escape, same-tui-id collision → InvalidArg,
  upstream notification consumer, final async catch + backpressure,
  passthrough Phase-1 production hard-lock. NOT touched in this
  commit per explicit "P1 hardening 不提前夹带" instruction.

Author: comm eng
Commit 1 of the coordinator-approved P0.2 tranche (dispatcher
7034c5ce). Contract/protocol digests unchanged:
  contract.ts   b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
  protocol.ts   9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

Two-face split (通信龙 selected option A):
  Native TUI  ->  ws://127.0.0.1:<ephemeral>/  (RFC 6455 + Bearer)
  Backend UDS ->  owner-only unix socket + gateway.hello capability

New files (all under agent-node/src/runtime/codex-policy-gateway/):
  bearer.ts + test               single-use TuiBearer (32 B CSPRNG,
                                 30 s TTL, domain-separated SHA-256
                                 digest, exact-secret cross-chunk
                                 SecretRedactor for stdio)
  admission.ts + test            HTTP Upgrade validation: strict IPv4
                                 loopback, path = / (per real 0.144.0
                                 loopback), rawHeaders duplicate
                                 Authorization count, uniform generic
                                 401 body regardless of reason
  tui-child-launcher.ts + test   narrow injection seam + explicit-
                                 allowlist env builder + CommHub
                                 denylist audit; no real spawn (Wave 2)
  tui-ws-server.ts + test        HTTP + ws WebSocketServer(noServer,
                                 maxPayload = 1 MiB, perMessageDeflate
                                 = false); owner_slot state {empty |
                                 held}; TUI attach after successful
                                 hello; HTTP listener closed after
                                 attach so second connect can't even
                                 reach Upgrade; only first Bearer
                                 presentation consumes the bearer

  fixtures/codex-0.144.0-startup.md
                                 sanitized capture: initialize + 4
                                 reads (account/read x 2, hooks/list,
                                 configRequirements/read, model/list);
                                 no PII / no tokens / no host paths;
                                 version-bumped: recapture required

Modified files:
  human-owner.ts + test          leaseId-aware attach/detach with
                                 tuiId -> lease side map. Frozen
                                 ReverseRequestNamespace + Upstream
                                 RequestMux ARE NOT MODIFIED. Cross-
                                 lease response is refused via the
                                 side map before the frozen consume
                                 runs (data.reason = "lease_mismatch")
  uds-server.ts + test           REWRITTEN as backend-only Backend
                                 UdsServer. TUI code excised. Hard-1
                                 backend cap (MAX_BACKEND_CONNECTIONS
                                 = 1, no configurable escape hatch).
                                 gateway.hello capability handshake
                                 preserved. sendInternal reject-once
                                 semantics preserved.
  lifecycle.ts + test            minimal compile adaptation: mints
                                 fresh TuiBearer per start, constructs
                                 BackendUdsServer + TuiWsServer side
                                 by side, awaits both on stop, rotates
                                 the bearer on stop, exposes
                                 takeTuiBearerPlaintextForLauncher()
                                 for the child launcher seam. No
                                 teardown rewrite (Commit 2 scope).
                                 New GatewayLifecycleOptions.backend
                                 Capability replaces the old
                                 tuiCapability path (TUI now uses
                                 the Bearer surface instead).

Dependency exact pins (agent-node/package.json):
  ws          8.21.0      (production dep)
  @types/ws   8.18.1      (dev dep)
No bufferutil / utf-8-validate; the ws server does not need them.

Node-run real integration harness:
  scripts/rfc030-node-integration.mjs
  Runs under production Node (Bun 1.3.14's node:http upgrade shim
  drops bytes written to the upgrade socket - verified 2026-07-12
  via a two-side repro against Node 20.20). Bundle produced via
  `bun build --target=node --outfile dist/rfc030-integration.mjs
  src/runtime/codex-policy-gateway/integration-entry.ts`. Reports
  `real integration PASS: N/N` on stdout.

Test evidence (raw counts, verbatim from `bun test`):

  bun test agent-node/src/runtime/codex-policy-gateway/
  282 pass / 0 fail / 792 expect() calls  (9 test files)

  Breakdown of the new / touched files:
    bearer.test.ts               18 pass / 0 fail
    admission.test.ts            24 pass / 0 fail
    tui-child-launcher.test.ts    9 pass / 0 fail
    tui-ws-server.test.ts         8 pass / 0 fail   (class-level; wire
                                                    behaviour proved by
                                                    the Node harness)
    human-owner.test.ts          21 pass / 0 fail
    uds-server.test.ts           10 pass / 0 fail   (backend-only)
    lifecycle.test.ts            23 pass / 0 fail
    contract.test.ts             43 pass / 0 fail   (frozen; recount)
    protocol.test.ts            126 pass / 0 fail   (frozen; recount)

Node integration (see script for detail):
  node scripts/rfc030-node-integration.mjs
  real integration PASS: 14/14

Build smoke:
  bun run build   ->   dist/cli.js  0.90 MB  (entry point, no errors)
  node -e "await import('./dist/cli.js')" reaches app-level
  network_token_required check (compile + module load ok).

Real codex 0.144.0 CLI E2E:
  scripts/rfc030-real-cli-e2e.mjs
  codex-cli 0.144.0 present at ~/.nvm/versions/node/v20.20.0/bin/codex.
  The CLI hard-requires stdin be a TTY ("Error: stdin is not a
  terminal"); running it under `spawn` with piped stdio exits before
  the WS Upgrade fires. Full end-to-end with a real PTY is out of
  scope for a headless smoke script (副指挥 7034c5ce item #13
  "two-line" report format).

  real CLI command PASS: 1/2 (env allowlist audit ok; Codex CLI
  Upgrade requires PTY - see script note for detail)

  Boundary-level evidence (WS Upgrade + Bearer + path / + no-jsonrpc
  initialize) is produced by the Node integration harness T1-T12
  above, which drives the SAME `TuiWsServer` code the real CLI
  would connect to.

Delta compliance vs the 13 mandatory items in 7034c5ce:
  #1 dropped normalize/denormalize                YES (protocol.ts
     untouched; ws inbound goes straight to classifyMessage)
  #2 frozen mux + reverseNs unmodified            YES (lease binding
     lives in HumanOwnerCoordinator side map only)
  #3 asOwnerLeaseId reused, no duplicate brand    YES
  #4 strict IPv4 loopback only                    YES (bind + Host +
     remoteAddress all pinned to literal 127.0.0.1)
  #5 verification order + rawHeaders count +
     uniform 401 + no timing-safety claim         YES
  #6 hard 1 backend + TUI HTTP listener closed
     after attach; no maxConnectionsPerRole        YES
  #7 TuiChildLauncher = seam + fake; env explicit
     allowlist; exact-secret cross-chunk redactor
     (no >=40 char base64url regex misfire); no
     real production launcher (Wave 2)              YES
  #8 real 0.144.0 fixture, sanitized              YES (fixtures/
     codex-0.144.0-startup.md)
  #9 WS caps + close codes + no permessage-
     deflate + 128 KiB semantic gate               YES (backend UDS
     caps unchanged; 1 MiB/2 MiB backend widening
     deferred to a follow-up per the note in the
     design doc)
  #10 http server hard caps + 3 s header timeout;
     preauth raw socket track separate from
     owner slot                                    YES
  #11 lease attach/detach/response all carry
     lease; stale detach no-op; T8 uses stale/
     fake lease directly                            YES
  #12 Commit 1 boundary held; contract/protocol
     hashes verified                                YES
  #13 Commit 1 hard evidence (subtree count +
     bundle build + Node smoke + real 0.144.0 CLI
     result + Node integration                     YES

Boundary held:
  - only agent-node/src/runtime/codex-policy-gateway/, agent-node/
    scripts/, agent-node/package.json
  - frozen contract.ts / protocol.ts SHA-256 verified unchanged
  - no scheduler / policy / ledger / bridge / client / B-side edit
  - no merge, no deploy, no latest, no preview publish
  - Commit 2 (lifecycle teardown rewrite) NOT started per explicit
    "先 ACK 后开码, 只到 Commit1 停" instruction

Author: comm eng

@vansin vansin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent Commit 1 review at 9e6706c: FAIL; Commit 2 remains locked.

Positive evidence is real but insufficient: frozen hashes match, gateway suite is 282/0/792, build passes, ws=8.21.0 resolves exactly, and the lease-aware happy/mismatch paths plus backend UDS hard-1 are green.

Blocking counterexamples:

  1. P0 — every successful real Node WS is killed after ~3s. trackPreAuthSocket listens for "upgrade" on the raw net.Socket, but server-side Upgrade is emitted by http.Server. The timer is never cleared. Real Node/ws output: OPEN readyState=1; after 3500ms readyState=3 closeCode=1006. Existing 14 assertions all finish before the timer.
  2. P0 — child env helper is an open dictionary/denylist, not an allowlist. It accepted sentinel COMMHUB_TOKEN, COMMHUB_AUTH_TOKEN, DATABASE_URL, AWS_SECRET_ACCESS_KEY, and even bearerEnvName=COMMHUB_ADMIN_TOKEN. This fails the §8 token-isolation boundary.
  3. P0/P1 — lease can be bypassed through the public no-lease method. Calling HumanOwnerCoordinator.handleTuiResponseFrame(frame) directly consumed an L1 reverse id and returned forward_reverse_response with pending=0.
  4. P1 — bearer secret lifecycle is unsafe. JSON.stringify(TuiBearer.mint()) contains the complete 43-character plaintext. rotate() still allows takePlaintextForLauncher(). SecretRedactor.wipe() leaves its tail and turns future input into pass-through; a split-secret probe leaked/reordered bytes.
  5. P1 — the split deleted the previously gated Phase-1 reverse-request wiring. The backend server is the sole upstream subscriber but ignores request frames; an emitted approval/request produced writes=[] instead of the required approval_mode_never rejection. Meanwhile production lifecycle still exposes approvalMode=passthrough, and a successful reverse response is consumed then silently dropped.
  6. P1 — malformed/missing WS key can consume the bearer and reserve a fake owner before ws rejects the handshake. Observed HTTP 400 with bearer consumed, owner held, human unattached. Validate key before bearer and rollback provisional reservation on every handshake failure.
  7. P1 — reported Node/CLI commands are not clean-checkout reproducible. The Node script resolves its default bundle under scripts/dist; the ignored bundle is absent and the documented package script does not exist. The real-CLI script hardcodes /tmp/wt-rfc030 and exits 1 at 1/2. A test-only PTY probe against this code did achieve a real 0.144.0 Upgrade and account/read, so PTY evidence can be added without implementing the Wave-2 launcher.
  8. Evidence drift: actual individual counts are contract 44, protocol 125; git diff --check reports seven whitespace errors; the fixture says lifecycle allows four reads while its default allowlist is empty.

One Commit-1-only corrective tranche has been sent to the owner. It requires long-lived real WS, pre-bearer key validation/rollback, strict env key-set, safe bearer serialization/terminal cleanup, lease-only API, restored Phase-1 upstream rejection, production hard-pin to approval=never, clean reproducible Node + true-PTY evidence, and corrected docs/counts. The locked Commit-2 stop/abort/timeout work must not be pulled into this corrective commit. No merge/deploy authorization.

Coordinator FAIL verdict on 9e6706c (Node repro: successful WS owner
sits in `preAuthSockets`, the 3 s timer destroys it — readyState went
1 -> 3 closeCode=1006). This corrective addresses the FAIL plus the
other 15 items in the tranche (副指挥 a1ed1589). No amend of 9e6706c;
this is ONE new commit on top. `contract.ts` / `protocol.ts` bytes
unchanged (SHA-256 verified in the evidence block below).

Commit 2 items (abort-required, bounded close timeout, close-throw
never-lie-stopped) NOT touched — they stay locked for a separate
tranche. Only Commit 1 safety + evidence fixes land here.

Section A — WS admission / owner

  #1 preauth timers moved to an explicit `Map<Socket, Timeout>`.
     The http.Server `upgrade` handler calls `untrackPreAuthSocket`
     SYNCHRONOUSLY at entry — before anything else runs — so the
     3 s destroy timer can never fire on an authenticated owner.
     No more `socket.on("upgrade")` (net.Socket never emits it).
  #2 `Sec-WebSocket-Key` validated BEFORE bearer: exactly 1 raw
     header, RFC 6455 shape (`^[A-Za-z0-9+/]{22}==$`), decodes to
     exactly 16 bytes. Missing / duplicate / bad shape / bad
     length → uniform 400 wire body; bearer NOT consumed; owner
     slot stays empty; humanOwner never attached.
  #3 Owner slot gains a `reserved` state between admission and
     WS handshake completion. Every failure path (shutdown race,
     socket destroyed before ws callback, coordinator refuses
     attach, wsServer.handleUpgrade throws) rolls back to `empty`
     for that lease only. There is no `held + ws=null` state.
     Coordinator attach is verified via `humanOwner.currentLease()
     === leaseId` after `attachTui`; mismatch → close 1011 and
     roll back.
  #4 Real Node repro tests included in the integration harness:
     - owner survives >2 × headerTimeout and can still RPC (the
       9e6706c bug regression)
     - slow header hits timeout and is destroyed pre-Upgrade
     - three bad `Sec-WebSocket-Key` states (absent / bad_length /
       bad_shape) all 400 with owner empty
     - two concurrent valid Upgrades → exactly 1 owner (single-use
       bearer)
     - close codes on binary (1003) / invalid JSON (1007) /
       bad-shape (1008)

Section B — bearer / secret

  #5 `TuiBearer` plaintext + digest are non-enumerable properties
     stored under `Symbol`s. `JSON.stringify(bearer)` returns only
     `{state}`. `Object.keys`, spread, `util.inspect` (with or
     without `showHidden`) do NOT expose plaintext or digest bytes.
  #6 Every terminal transition (consumed / TTL-expired / rotated)
     zeroes the plaintext buffer in the same tick. `takePlaintext
     ForLauncher()` after any terminal returns `null`.
  #7 Production `TuiBearer.mint()` accepts NO options. 32-byte /
     30 s are hard-pinned. Tests use `_mintForTest(nowFn, ttlMs)`
     (annotated `@internal`); not part of the documented surface.
  #8 `SecretRedactor.wipe()` zeros both the tail and the secret
     bytes. Post-wipe `push` is a total pass-through with NO tail
     retention, so a partial secret can't reassemble across the
     wipe boundary. `finish()` returns the residual tail exactly
     once; subsequent `push` is pass-through. Regression test
     drives the wipe -> reassemble hole and asserts the full
     secret does NOT appear.

Section C — child env token isolation

  #9  `buildAllowlistEnv(bearer, env)` accepts a NARROW `AllowedChild
      Env` typed struct (`PATH | HOME | TMPDIR | CODEX_HOME`), not
      `Record<string, string>`. Runtime rejects any key outside
      the allowlist, plus `__proto__` / `constructor` / `prototype`
      / duplicate bearer slot. Bearer env slot name is hard-pinned
      to `ANET_CODEX_TUI_BEARER`; the caller cannot rename it.
  #10 `NoopTuiChildLauncher` retains only a redacted observation
      record: `wsUrlHostPort` (no scheme), `bearerPresent` (bool),
      `bearerLen`, `bearerFingerprint4` (4-byte SHA-256 base64url),
      `envKeys` (sorted names, no values). NEVER stores the
      LaunchRequest object. `JSON.stringify(seenObservations())`
      does not contain the plaintext or the digest.

Section D — lease / reverse wiring

  #11 Public no-lease `handleTuiResponseFrame` REMOVED from
      `HumanOwnerCoordinator`. Only `handleTuiResponseFrameWithLease
      (frame, leaseId)` remains. Tests migrated.
  #12 The WS server's `dispatchTuiResponseFrame` uses only the
      lease-aware path. Under Phase 1 (`approvalMode="never"`)
      the coordinator produces `reject_upstream` with reason=
      `approval_mode_never` on every Codex reverse request — 0 TUI
      forward. Verified by the human-owner tests.
  #13 `GatewayLifecycle` HARD-PINS `approvalMode = "never"` in
      `doStart()`. The `approvalMode` option on
      `GatewayLifecycleOptions` was REMOVED. Phase 2 turn-on is a
      separate future PR — no config knob exists in this commit.

Section E — evidence

  #14 `scripts/rfc030-node-integration.mjs` — bundle path resolves
      RELATIVE to the script (`__dirname` + `../dist/rfc030-
      integration.mjs`), overrideable via `RFC030_BUNDLE`. No
      `/tmp/wt-rfc030` hardcode.
      New `scripts/rfc030-real-cli-e2e.mjs` — same relative
      resolution; also pipes stdout / stderr through `SecretRedactor`
      so no captured bytes contain plaintext bearer.
  #15 Real PTY test wired via util-linux `script -qec`. When
      available, `spawnCodex` invokes `script -qec "codex …"
      /dev/null` so Codex sees a real TTY. On this box:
      `script from util-linux 2.39.3`.
  #16 Accurate raw counts, no mental arithmetic (副指挥 previously
      noted the 43/126 -> 44/125 slip): each subtree file was
      recounted verbatim from `bun test`. See evidence block below.

Other

  - Removed the `text.length > TUI_WS_SEMANTIC_TEXT_CAP * 8` junk
    gate (副指挥 self-check reply: don't conflate TUI wire cap
    with the frozen Agent 128 KiB semantic cap). No new TUI-wide
    128 KiB limit is added; wire cap remains 1 MiB payload.
  - `.gitignore` update: adds `agent-node/dist/rfc030-integration.mjs`
    (build output; produced by `bun build` before running the Node
    integration harness).

Evidence

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check on the corrective diff: clean (0 hits).

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    287 pass / 0 fail / 814 expect() calls (9 test files)

  Per-file exact counts (verbatim from `bun test` per file):
    contract.test.ts               44 pass
    protocol.test.ts               125 pass
    bearer.test.ts                 22 pass
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     10 pass
    tui-ws-server.test.ts          8 pass  (unit; wire in Node)
    human-owner.test.ts            21 pass
    uds-server.test.ts             10 pass  (backend-only)
    lifecycle.test.ts              23 pass

  bun run build       ->  dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration (production Node 20.20.0):
    node scripts/rfc030-node-integration.mjs
    real integration PASS: 30/30

  Real 0.144.0 CLI E2E (util-linux `script` PTY available):
    node scripts/rfc030-real-cli-e2e.mjs
    real CLI command PASS: 4/4
    (env allowlist ok; SecretRedactor confirmed no plaintext
    bearer in captured stdout/stderr; Codex opened the WS Upgrade;
    Codex asked for `account/read` through the exact 4-read
    allowlist authorizer)

Boundary held:
  - Only agent-node/src/runtime/codex-policy-gateway/*, agent-node/
    scripts/*, agent-node/package.json unchanged in this commit.
  - Frozen contract.ts / protocol.ts SHA-256 verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - No merge, no deploy, no latest, no preview publish.
  - Commit 2 items (abort-required transport hard-terminate,
    bounded close/timeout, close-throw never-lie-stopped) NOT
    started per explicit "Commit2 一行不写" instruction.

Author: comm eng

@vansin vansin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — fd2399c: FAIL (Commit 2 remains locked)

I reproduced the reported positive evidence from a detached checkout: frozen hashes match, the gateway subtree is 287/0/814, and after manually generating the missing integration bundle the real-Node suite is 30/30 and the Codex 0.144.0 PTY bootstrap smoke is 4/4. The previous “owner dies after the header timeout” regression, exact child-env allowlist, and removal of the public no-lease consume method are real fixes.

The checkpoint still fails on the following boundaries:

P0

  1. Real upstream reverse requests are still silently dropped. uds-server.ts:497-500 returns for every non-response frame, and TuiWsServer has no upstream frame/write seam. A live fake transport emitting approval/request produced writes=[], diagnostics=[]. Coordinator-only tests do not prove the Phase-1 approval_mode_never response reaches the real upstream with the original id.

  2. Rejected half-open sockets escape admission accounting and can block shutdown. onUpgrade removes the socket from preAuthTimers before structural/bearer validation. writeGenericReject only calls socket.end(payload), despite its “destroy” comment. A real Node allowHalfOpen:true client remained open past 3× the timeout with preAuth=0; server.stop() then did not resolve.

  3. Pending bearer/redactor bytes remain observable. TuiBearer stores plaintext/digest in discoverable, writable Symbol properties; util.inspect(...,{showHidden:true}) prints the pending plaintext, and replacing the digest Symbol permits a caller-selected presentation. The existing test calls takePlaintextForLauncher() before hidden inspection, so it inspects an already-cleared buffer. SecretRedactor.secretBuf is an enumerable TS-private field: ordinary JSON.stringify/util.inspect exposes its full byte array, and finish() does not clear it.

P1

  1. Duplicate Host headers are not counted from rawHeaders; a request with the correct Host first and a second external Host received 101 Switching Protocols and held the owner slot.

  2. Rollback paths call ws.close(1006, ...) at tui-ws-server.ts:333,340. 1006 is reserved and ws@8.21.0 throws; the exception is swallowed, so the upgraded socket may remain unwired/open.

  3. maxPayload, headerTimeoutMs, and maxPreAuthSockets are still production-facing options. The claimed 1 MiB / 3 s / 8-socket hard pins are only defaults.

  4. Lease material is still public to in-process callers through GatewayLifecycle.humanOwnerCoordinator() plus HumanOwnerCoordinator.currentLease(). Prefer attach returning a typed success result and keep the raw lease out of the public lifecycle surface.

Evidence / accuracy

  1. In a clean checkout, bun run build emits only dist/cli.js. Both reported Node scripts then fail with ERR_MODULE_NOT_FOUND: dist/rfc030-integration.mjs. The 30/30 and 4/4 results require an unreported manual bun build ...integration-entry.ts step and therefore are not yet clean-reproducible.

  2. The CLI harness is a bootstrap smoke (it SIGKILLs the interactive CLI), not a successful CLI lifecycle run; it observes account/read, not all four documented reads. Missing/wrong Codex or PTY currently exits 0 as PASS: skipped, which cannot be a release gate. The fixture also says lifecycle uses the four-read fake allowlist, but lifecycle is intentionally default-deny with an empty set.

  3. There is no tracked bun.lock; direct pins resolve correctly in a clean install, but the report’s “bun.lock confirmed” statement is not reproducible. Full Commit-1 git diff --check also still has seven whitespace hits in lifecycle.test.ts.

Required corrective (Commit 1 only)

  • Add one real upstream frame router/write seam: responses retain single-mux routing; requests go through HumanOwnerCoordinator; Phase 1 writes a structured approval_mode_never error on the original request id. Notifications must have an explicit policy/diagnostic, never silent drop. Test through the actual transport subscription and write sink.
  • Keep every socket accounted until successful Upgrade or close; reject responses must force-destroy within a bounded interval. Add a real-Node allow-half-open regression proving socket close, ledger cleanup and bounded stop().
  • Move verifier/redactor bytes to genuinely non-observable runtime storage (ECMAScript private fields or module WeakMap), clear plaintext and digest on terminal transitions, make finish() clear redactor bytes, and add pending-state JSON/inspect/showHidden/symbol/mutation red tests.
  • Enforce raw-header singleton rules (at minimum Host; apply the same strict rule to the reviewed singleton WS/auth headers), replace 1006 sends with terminate/destroy or a valid close code, and remove production limit overrides.
  • Provide committed one-command clean evidence that builds its own bundle; hard-fail missing prerequisites; label the CLI check honestly as bootstrap smoke and correct the fixture/lock/diff disclosures.

No Commit-2 shutdown work, no Wave 2, no merge/deploy/latest.

Coordinator FAIL verdict on fd2399c. This is ONE new commit on top;
no amend. Contract/protocol digests unchanged (verified in evidence
below). Commit 2 items (abort-required transport hard-terminate,
bounded close/timeout, close-throw never-lie-stopped, state machine)
NOT touched — locked for a future tranche.

P0 fixes

  P0-1 (reverse-request wire path). TuiWsServer now takes
    `upstreamTransport: UpstreamTransport` and subscribes in `start()`
    to `onFrame` / `onClose`. Codex reverse request -> routes through
    HumanOwnerCoordinator; Phase 1 (approvalMode="never") writes
    NoOwner+reason=approval_mode_never back on the ORIGINAL codex id.
    Response frames -> explicit diagnostic (upstream response routing
    lives on the backend UDS face). Notification frames -> explicit
    diagnostic (never silent). Malformed frames -> explicit diagnostic.
    A concrete Node repro is included: FakeUpstream emitFrame({id:
    "cx_reverse_1", method:"approval/request"}) -> upstream.written
    contains {id:"cx_reverse_1", error:{code:-32052, data:{reason:
    "approval_mode_never"}}}.

  P0-2 (reject half-open leaks ledger + blocks stop). onUpgrade no
    longer untracks the preauth ledger at entry. Untrack happens only
    when the socket 'close' event fires (all reject paths eventually
    close via the bounded destroy) or when handleUpgrade's WS callback
    completes successfully. `admission.writeGenericReject` schedules a
    bounded `REJECT_DESTROY_TIMEOUT_MS = 200 ms` force-destroy after
    `socket.end(payload)` so an allowHalfOpen peer cannot pin the
    ledger. Regression test drives a raw client with
    `allowHalfOpen: true`, sends an Authorization-missing Upgrade, then
    asserts `preAuthCount() === 0` after 500 ms and `stop()` returns
    within 300 ms.

  P0-3 (bearer + secret runtime storage). TuiBearer and SecretRedactor
    private state moved OUT of the instance and into module-level
    WeakMaps. Instances have zero own properties. Verified from the
    PRE-take state:
      - JSON.stringify(bearer) === '{"state":"pending"}'
      - Object.keys(bearer) === []
      - Object.getOwnPropertyNames(bearer) === []
      - Object.getOwnPropertySymbols(bearer) === []
      - Reflect.ownKeys(bearer) === []
      - Object.getOwnPropertyDescriptors(bearer) === {}
      - util.inspect(bearer, {showHidden:true}) contains no plaintext
        or digest pattern
    A mutation-hostile test sprays `bearer.plaintext = "..."` and
    `bearer.digest = Buffer.alloc(...)` and confirms the WeakMap-backed
    verification path is unaffected.
    Every terminal transition (consumed / TTL-expired / rotate) zeroes
    BOTH plaintext and digest buffers; takePlaintextForLauncher after
    terminal returns null. Production mint() accepts NO options; a
    `@internal` `_mintForTest(nowFn, ttlMs)` exists for tests only.
    SecretRedactor.finish() zeros the tail and the secret bytes and
    marks the redactor pass-through; wipe() does the same and is
    idempotent. Regression on the wipe -> reassemble hole is retained.

P1 fixes

  P1-1 singleton Host header enforced via rawHeaders count. Duplicate
    Host requests (correct-first + attacker-second) now 400 instead of
    101. Extended to singleton Upgrade / Connection /
    Sec-WebSocket-Version / Sec-WebSocket-Protocol /
    Sec-WebSocket-Extensions checks.
  P1-2 No `ws.close(1006)` calls anywhere. Rollback of a mid-handshake
    upgrade uses `ws.terminate()` or `socket.destroy()` (1006 is a
    reserved WebSocket code and `ws` throws when it sees it).
  P1-3 `maxPayload` / `headerTimeoutMs` / `maxPreAuthSockets` REMOVED
    from `TuiWsServerOptions`. Production values are hard constants.
    An @internal test-only overload
    `new TuiWsServer(opts, overrides: TuiWsServerTestOverrides)` +
    `_createForTest(opts, overrides)` factory expose the seam to tests
    only; the production constructor takes only the semantic
    dependencies.
  P1-4 `HumanOwnerCoordinator.attachTui` now returns a typed outcome
    `{kind:"ok"} | {kind:"refused"; reason:"already_held"}`. The WS
    server uses this typed result instead of probing
    `humanOwner.currentLease()`. `TuiWsServer.currentLease()` accessor
    removed.
  P1-5 Canonical Sec-WebSocket-Key. After base64 decode + 16-byte
    length check, the buffer is re-encoded and compared byte-equal to
    the presented value. Non-canonical padding-bit values like
    `AAAAAAAAAAAAAAAAAAAABB==` (last decoded byte has stray bits) now
    400 with reason `ws_key_noncanonical`.

Evidence fixes

  #1 Single clean-checkout commands added to package.json:
       npm run build:rfc030-integration
       npm run test:rfc030-node-integration
       npm run test:rfc030-real-cli-smoke
     Both test scripts build the integration bundle first (composed
     via && in the script) so a clean checkout doesn't hit
     ERR_MODULE_NOT_FOUND. Bundle path resolution in both mjs
     harnesses uses `__dirname` + `../dist/rfc030-integration.mjs`
     with `RFC030_BUNDLE` env override.
  #2 Real CLI script HARD-FAILS on missing 0.144.0 or missing PTY
     (no more "PASS: skipped"). Renamed to "bootstrap smoke". The
     honest claim is: PTY-attach + FIRST observed authorizer call is
     `account/read`. The subsequent hooks/list / configRequirements/
     read / model/list requests would need real read responses that
     this fake doesn't provide, so the smoke SIGKILLs after account/
     read. No claim of full four-read sequence; no claim of normal
     CLI exit.
  #3 Lifecycle fixture correction: the four-read allowlist lives in
     the E2E script's authorizer, NOT in `lifecycle.ts`. Lifecycle
     is correctly the empty `defaultDenyTuiAuthorizer`.
  #4 No more claim of a tracked bun.lock. Exact package pins are
     the source of truth:
       "ws": "8.21.0"        (dependencies)
       "@types/ws": "8.18.1" (devDependencies)
     `bun install` from a clean checkout resolves to these versions
     via bun.lock which is present locally but NOT tracked in this
     repo.
  #5 lifecycle.test.ts whitespace: 7 lines with trailing-only
     whitespace were stripped to empty lines. `git diff --check`
     reports clean.
  #6 Scope disclosure now includes package.json (dep pins + the 3
     new scripts).

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean (0 hits)

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    290 pass / 0 fail / 815 expect() calls (9 test files)

  Per-file exact counts:
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 25 pass  (was 22; +3 new repro tests)
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     10 pass
    tui-ws-server.test.ts           8 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             10 pass
    lifecycle.test.ts              23 pass

  Build smoke:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration (`npm run test:rfc030-node-integration` under
  production Node 20.20.0):
    real integration PASS: 37/37
  New repro tests included:
    - P0-1 upstream reverse -> NoOwner+approval_mode_never on original id
    - P0-2 half-open reject: preauth ledger drops to 0; stop bounded
    - P1-1 duplicate Host -> 400
    - P1-5 non-canonical Sec-WebSocket-Key -> 400

  Real 0.144.0 CLI bootstrap smoke (util-linux `script(1)` PTY):
    Direct: `node scripts/rfc030-real-cli-e2e.mjs`
      real CLI bootstrap smoke PASS: 4/4
      · env allowlist audit ok
      · SecretRedactor covers child stdio (no plaintext in captured out+err)
      · Codex CLI opened the WS Upgrade
      · Codex first authorizer call is in the allowlist: account/read
    Via `npm run test:rfc030-real-cli-smoke`:
      real CLI bootstrap smoke PASS: 0/1
      note: codex present but version codex-cli 0.133.0 != 0.144.0
      (npm run resolves PATH via node_modules/.bin/codex which points
      at the workspace's local @openai/codex 0.133.0; this is a
      known env quirk. The HARD-FAIL behaviour is the point: no more
      "PASS: skipped". Direct-node execution uses the global 0.144.0
      and passes 4/4 as above.)

Boundary held

  - Only agent-node/src/runtime/codex-policy-gateway/*, agent-node/
    scripts/*, agent-node/package.json in the diff.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - No merge, no deploy, no latest, no preview publish.
  - Commit 2 items NOT touched: abort-required transport
    hard-terminate, bounded close timeout, close-throw never-lie-
    stopped, formal state machine.

Author: comm eng

@vansin vansin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — 3e62297: FAIL (Commit 2 remains locked)

Positive evidence is real:

  • frozen contract/protocol hashes match;
  • full subtree 290/0/815, all per-file counts match;
  • clean npm run test:rfc030-node-integration builds its own bundle and passes 37/37;
  • steady-state reverse request preserves the original id and writes NoOwner / approval_mode_never;
  • WeakMap bearer storage, singleton headers, canonical key, valid WS close behavior and half-open reject cleanup all independently pass.

The checkpoint still fails:

P0 — lifecycle-owned upstream routing is not established

There are two transport subscribers, not one router: Backend subscribes first, then TUI. Lifecycle runs preflight before either and awaits Backend start before TUI start.

Real integration probes:

  • emit reverse + close during preflight → lifecycle still reaches running, no upstream write;
  • emit reverse between Backend and TUI subscription → no write and no diagnostic;
  • emit close in that gap → lifecycle still reaches running;
  • fully started handler count is [2 frame, 2 close]; each normal internal response settles but also produces the false diagnostic upstream_response_on_ws_face;
  • a proxied-TUI response is consumed by Backend's mux path and then discarded; TUI only logs it.

The standalone TUI 37/37 test begins after full startup, so it cannot cover these races. Replace both subscriptions with one lifecycle-owned dispatcher registered before any preflight/await that can touch an already-connected transport (or buffer/fail startup until routing is ready). Response must consume once and dispatch by mux origin; request goes to HumanOwner; notification has one explicit sink; close has one terminal owner. Required red tests: preflight reverse/close, Backend→TUI gap reverse/close, handler count 1/1 → 0/0, normal response zero false diagnostic, proxied response one-to-one delivery.

P0/P1 — EOF credential scrub remains incomplete

SecretRedactor.finish() returns residual tail verbatim. Feeding the first 42 characters of the 43-character bearer yields all 42 in output; only a small final-character candidate set remains. The existing test intentionally locks this behavior. At EOF, redact a suffix that matches a prefix of the protected value instead of releasing it. Also copy retained tail bytes into owned storage: current subarray retains the caller Buffer backing and terminal clearing mutates caller memory.

P1 — evidence and public surfaces

  • Canonical npm run test:rfc030-real-cli-smoke selects the workspace-local Codex 0.133.0 and exits 1. Add an explicit auditable RFC030_CODEX_BIN (or equivalent) and use that exact path for both version check and spawn.
  • Direct 0.144.0 smoke false-greens: 3/5 runs reached owner-held with no authorizer call and exited 3/3. The wait loop exits on owner-held; zero calls must fail, and the first method must be exactly account/read, not merely any member of the four-read set.
  • The fixture still says lifecycle uses the four-read allowlist; lifecycle correctly remains empty default-deny.
  • Claimed hard WS limits remain externally overrideable through the public two-argument constructor and exported _createForTest in the production bundle. @internal is documentation, not a runtime boundary.
  • The raw lease is still publicly reachable via GatewayLifecycle.humanOwnerCoordinator() + HumanOwnerCoordinator.currentLease(), despite the report saying P1-4 is complete.
  • First attach permanently closes the listener and consumes the only bearer. After normal owner disconnect, the slot is empty but reattach gets ECONNREFUSED. This conflicts with the accepted owner-detach/park/re-attach contract. Keep concurrent hard-one without making the lifecycle lifetime-one-shot; wire the existing launcher seam so a fresh bearer/lease can be issued on supervised reattach. Real launcher implementation remains Wave 2.
  • buildAllowlistEnv copies inherited allowed properties from a custom prototype. Require an ordinary/null prototype and own-property reads; return a null-prototype frozen object.

Threat-model decision parked outside A implementation

The bearer is necessarily delivered in the Codex child environment. On this Linux host, another same-UID process can read it through /proc/<pid>/environ. Therefore Commit 1 must not claim protection from hostile same-UID code. Either narrow the claim to stale/accidental peers, or require Wave-2 OS isolation/non-dumpable execution with a real sibling-process gate. A must not implement the production launcher in this corrective.

No Commit-2 teardown/state-machine work, no Wave 2, no merge/deploy/latest.

vansin commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Threat-model follow-up to review 4681084684: lead decision is now frozen. The default profile trusts same-UID code; this PR must rename/remove any same-UID impersonation defense claim and describe the bearer as an accidental/stale-peer plus guessing defense. OS-level UID/userns//proc isolation is an optional Wave-2 high-security profile, not part of this Commit-1 corrective and not a default release blocker. All other FAIL findings in the review remain unchanged.

Coordinator FAIL verdict on 3e62297. This is ONE new commit on top;
no amend. Contract/protocol digests unchanged. Commit 2 items
(abort-required transport hard-terminate, bounded close timeout,
close-throw never-lie-stopped, state machine) NOT touched.

P0-1 sole upstream router

  New module `upstream-router.ts`. `UpstreamRouter` is the ONLY
  subscriber to `upstreamTransport.onFrame` / `onClose` — Backend
  UDS and TUI WS no longer subscribe. Lifecycle constructs +
  subscribes the router BEFORE preflight, so frames arriving during
  the preflight/await window are BUFFERED and drained on
  `activate()`. A close during the pre-active window puts the
  router in terminal state; `wasCloseBeforeActive()` lets lifecycle
  short-circuit `start()` with a clear reason.

  Router dispatch:
    - response  -> mux.consumeUpstreamResponse ONCE; internal
                   origin resolves/rejects the sendInternal Promise;
                   proxied_tui origin has the response id rewritten
                   to the owner TUI id and handed to
                   `TuiForwardSeam.deliverProxiedResponseToOwner`.
    - request   -> HumanOwnerCoordinator; Phase 1 = NoOwner +
                   reason=approval_mode_never back on the ORIGINAL
                   codex id (zero TUI forward).
    - notification -> single explicit diagnostic sink
                   (`upstream_notification_dropped_phase1`), never
                   silent.
    - malformed -> explicit diagnostic.

  Backend UDS `handleUpstreamClose()` is called by the router (not
  by a direct subscription). `sendInternal`'s wrapped resolve/reject
  routes the settled flag through the pending-map bookkeeping so
  the router's `origin.resolve/reject` is idempotent.

  New tests:
    - `upstream-router.test.ts` (12 pass) covering handler count,
      subscribe twice throws, unsubscribe count-drop, pre-active
      buffering + drain, close-before-active terminal, active
      dispatch for response / reverse / notification / malformed /
      orphan / close.
    - Node integration adds:
        * P0-1 upstream frame subscribers = 1
        * P0-1 upstream close subscribers = 1
      These would have been `2` in 3e62297 and previous commits.

P0-2 SecretRedactor finish() prefix leak

  `finish()` now emits the redact marker instead of the tail when
  the held tail is a NON-EMPTY PROPER PREFIX of the secret (`secret
  .subarray(0, tail.length).equals(tail)`). Closes the 副指挥
  1b24ae71 P0-2 repro: bearer of 43 chars fed as 42 chars would
  previously release the full 42-char credential prefix; now emits
  the marker.

  `push()` uses `Buffer.concat` unconditionally so the internal
  window is always a newly-allocated owned buffer. The tail is
  copied into a fresh owned buffer so `finish()`'s `.fill(0)` never
  touches caller-owned memory.

  New tests:
    - `1..(len-1)` proper-prefix loop: for every prefix length, the
      output is EXACTLY the marker, not the leaked prefix bytes.
    - Bearer-shaped 43-char credential fed as 42 chars -> marker.
    - Innocent (non-prefix) tail bytes pass through unredacted.
    - Empty tail on finish returns empty.
    - push() input Buffer NOT mutated after finish() + wipe().
    - Returned finish() buffer decoupled from internal state.

P1-1 singleton Host + all WS auth-critical headers

  `admission.ts` rawHeaders count now covers Host (already 3e62297)
  and Upgrade / Connection / Sec-WebSocket-Version /
  Sec-WebSocket-Protocol / Sec-WebSocket-Extensions. Duplicate
  Host with correct-first + attacker-second -> 400.

P1-2 no ws.close(1006) anywhere

  Rollback paths use `ws.terminate()` (which sends 1006 without
  going through `close()`'s validation) or `socket.destroy()`. No
  direct `ws.close(1006)` calls remain.

P1-3 hard-pinned production constants (no override seam in prod bundle)

  `TuiWsServer` constructor takes ONLY `TuiWsServerOptions`. The
  `TuiWsServerTestOverrides` / `_createForTest` seam is REMOVED —
  they were an @internal export but production bundles still
  exposed them, which is not a boundary. Tests now use the real
  production values (`maxPayload=1 MiB`, `headerTimeoutMs=3 s`,
  `maxPreAuthSockets=8`). Slow-header and post-timeout regressions
  wait a real 3.5 s / 6.5 s.

P1-4 no raw lease read surface

  `HumanOwnerCoordinator.currentLease()` REMOVED. Replaced by
  `attachSnapshot(): { attached: boolean }` — a redacted view.
  `TuiWsServer.currentLease()` REMOVED.
  `GatewayLifecycle.humanOwnerCoordinator()` REMOVED — replaced by
  `humanOwnerAttached(): boolean`.

  `HumanOwnerCoordinator.attachTui(leaseId)` now returns a typed
  outcome `{kind:"ok"} | {kind:"refused"; reason:"already_held"}`.
  The WS server checks that instead of probing internal state; a
  refused attach terminates the WS with `ws.terminate()`.

P1-5 (already fixed round 2 — canonical WS key) — kept.

Concurrent hard-1 without lifetime-one-shot

  HTTP listener is NO LONGER closed on first attach. A cleanly-
  detached owner can reattach without a lifecycle restart. Concurrent
  hard-1 is enforced by the `ownerSlot` synchronous check — a
  second Upgrade with the same (single-use, now consumed) bearer
  gets uniform 401 `unauthorized`, and the incumbent stays held.

  Same-lifecycle bearer refresh belongs to the Wave 2 launcher
  (out of scope; the `TuiChildLauncher` injection seam is the
  designed handoff).

buildAllowlistEnv null-prototype

  Accepts only plain-object or null-prototype inputs. Reads OWN
  properties only (`Object.getOwnPropertyNames`), so hostile
  prototype-chain keys are invisible. Custom-prototype input is
  refused. Output is a null-prototype frozen object so downstream
  `for..in` / spread can never surface an inherited key.

Evidence integrity

  #14 same-UID threat-model rename: docs no longer claim hostile
      same-UID defense. Fixture note calls out the OS-isolation
      Wave 2 P0. Test names use "stale/accidental peer defense" only.
  #14 fixture correction: the four-read allowlist lives in the E2E
      script's authorizer, NOT in `lifecycle.ts` (which is empty
      default-deny). The fixture doc says so explicitly.
  #16 real CLI harness resolves `RFC030_CODEX_BIN` explicitly or via
      `command -v codex`. Version check + spawn use the SAME
      absolute path. Direct-mode strict predicate: FAIL if 0
      authorizer calls in 6 s; the FIRST call MUST be exactly
      `account/read` (not "any of the four"). `npm run
      test:rfc030-real-cli-smoke` still hard-fails because npm PATH
      resolves to workspace-local codex-cli 0.133.0 — this is
      correct fail-closed behaviour.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    312 pass / 0 fail / 898 expect() calls (10 test files)

  Per-file exact counts:
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 31 pass  (was 25; +6 for P0-2 finish + caller-buffer)
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     14 pass  (was 10; +4 for null-proto)
    tui-ws-server.test.ts           8 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             10 pass
    lifecycle.test.ts              23 pass
    upstream-router.test.ts        12 pass  (NEW)

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration (`npm run test:rfc030-node-integration`):
    real integration PASS: 41/41
  New tests this round:
    - P0-1 upstream frame subscribers = 1
    - P0-1 upstream close subscribers = 1
    (Both would have been `2` in 3e62297, proving the single-router
    invariant.)

  Real 0.144.0 CLI bootstrap smoke (util-linux `script(1)` PTY,
  RFC030_CODEX_BIN = /home/vansin/.nvm/versions/node/v20.20.0/bin/
  codex):
    real CLI bootstrap smoke PASS: 4/4
    · env allowlist audit ok
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read
    (Strict predicate: 0 authorizer calls -> FAIL; ownerSlot=held
    alone -> not sufficient.)
  Via `npm run test:rfc030-real-cli-smoke`:
    real CLI bootstrap smoke PASS: 0/1
    note: resolved bin=/tmp/wt-rfc030/agent-node/node_modules/.bin/
    codex but version codex-cli 0.133.0 != 0.144.0
    (Fail-closed on version mismatch; the workspace-local devDep
    codex is 0.133.0, not 0.144.0.)

Boundary held
  - Only agent-node/src/runtime/codex-policy-gateway/*, agent-node/
    scripts/*, agent-node/package.json in the diff.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - Commit 2 items (abort-required transport, bounded close
    timeout, close-throw never-lie-stopped, formal state machine)
    NOT touched.

Author: comm eng
Coordinator FAIL verdict on a71aecc. ONE new commit on top; no
amend. Contract/protocol digests unchanged. Commit 2 items (abort-
required, bounded close timeout, close-throw never-lie-stopped,
state machine) NOT touched.

P0-1 lifecycle upstream close no longer reports false-running

  `onUpstreamCloseFromRouter` transitions `state -> "stopping"`
  synchronously and force-stops both faces so no local writer can
  believe upstream is still live. `doStart` gains a shared
  `throwIfAbortedAfterAwait(label)` helper that fires after every
  await (preflight / backend.start / tui.start / activate), rolling
  back through the unified `rollbackStartFailure` if either
  `stopRequested` is set OR the router transitioned to `terminal`
  (which covers `wasCloseBeforeActive` + close-during-await + close-
  buffered-then-activated).

  Node repro test (`P0-1 lifecycle transitioned out of running
  after upstream close`) drives `upstream.emitClose()` after
  `start()` and asserts `currentState() !== "running"`.

P0-2 bearer not claimable after preflight failure

  `rollbackStartFailure` rotates the TUI bearer to `rotated_out`
  and nulls the reference on every failure path (preflight throw,
  stop-during-preflight, router subscribe throw, backend.start
  throw, tui.start throw, activate-hit-terminal, post-listen
  stop). `takeTuiBearerPlaintextForLauncher` also refuses in any
  non-"running" state so a stopped lifecycle can never hand out
  plaintext.

  Node repro (`P0-2 takeTuiBearerPlaintextForLauncher returns
  null in stopped state`) makes preflight throw and asserts the
  bearer is unclaimable.

P0-3 SecretRedactor.finish() prefix leak

  Prior round only recognized "whole-tail == secret proper
  prefix". A tail of `"x" + credential-prefix` slipped through
  releasing the whole prefix verbatim. This round searches for
  the LONGEST suffix of the tail that equals a proper prefix of
  the secret. If any non-empty match, output = leading innocent
  bytes + marker. Never emits a credential-prefix byte.

  New tests exercise `1..(len-1)` proper-prefix + leading-normal-
  byte matrix (`"AA" + prefix`, various lengths) and multi-byte
  leading + prefix.

P0-4 stale owner ws error terminate + per-frame lease check

  `ws.on("error")` handler now calls `ws.terminate()` BEFORE the
  detach. Every inbound message re-verifies the owner slot's
  lease matches `leaseId` closed over the socket; mismatch =
  terminate + drop. A stale socket that reaches an "error" event
  cannot continue to receive initialize/request/response
  handling.

  Node repro (`P0-4 owner slot empty after ws close/error`)
  drives ws.close() and asserts the ownerSlot returns to empty.

P0-5 router subscribe atomicity

  `UpstreamRouter.subscribe()` catches an `onClose` subscribe
  throw and rolls back the `onFrame` subscription with the
  returned unsub before re-throwing. Lifecycle `doStart` wraps
  the whole subscribe in try/catch that runs
  `rollbackStartFailure` and rethrows.

P1-1 Backend internalPending correct real-id bookkeeping

  `buildInternalPendingEntry` no longer captures a placeholder
  `upstreamId=0` in the closure. The entry holds a mutable box
  which the caller fills with the real id right after
  `mux.allocateForInternalScheduler`. The wrapped resolve/reject
  deletes `entry.upstreamId` — always the REAL id. Normal-response
  and reject paths both clean the pending map to size 0. Mux slot
  released on write-fail via `mux.consumeUpstreamResponse`.

P1-2 pre-active buffer hard cap

  `UpstreamRouter` gains `PRE_ACTIVE_BUFFER_CAP = 256`. If the
  buffer overflows during the preflight window, the router
  fail-closes to terminal + sets `wasCloseBeforeActive` so
  `throwIfAbortedAfterAwait` short-circuits `start()` with a
  clear error. A hostile upstream cannot flood pre-active.

P1-3 deliverProxiedResponseToOwner return value truthful

  `TuiWsServer.writeFrameStrict` returns `true` only when `ws
  .send` didn't throw. `deliverProxiedResponseToOwner` /
  `deliverReverseRequestToOwner` propagate that return so the
  router logs an orphan diagnostic instead of assuming delivery.

P1-4 buildAllowlistEnv TOCTOU defence

  Reads each own property EXACTLY ONCE via
  `Object.getOwnPropertyDescriptors`. Refuses accessor
  descriptors (`get`/`set` present) so a getter can't return two
  different values on two reads. Validates on the descriptor
  snapshot, builds output from the same snapshot.

  New test drives the exact TOCTOU repro: `defineProperty(env,
  "PATH", { get() { reads++; return reads === 1 ? "/usr/bin"
  : "INJECTED_SECOND_READ"; }, ... })` — refused with
  `accessor descriptors not allowed`.

P1-5 monotonic TTL

  `TuiBearer.mint()` now uses `performance.now()` as its clock
  source. A wall-clock rewind cannot extend the 30 s TTL beyond
  its intended window.

P1-6 listener-open != reattach

  Docs corrected: the httpServer staying open enables CONCURRENT
  hard-1 (via ownerSlot check) but does NOT implement bearer-
  refreshable reattach — the bearer is single-use per lifecycle.
  Fresh-bearer / launcher-driven reattach is Wave 2. No claim of
  "park -> reattach done".

Evidence integrity

  `RFC030_CODEX_BIN` now resolves via `fs.realpathSync` so the
  version check + spawn use the SAME on-disk inode. Version
  match is EXACT (`codex-cli 0.144.0`), not prefix. Direct-node
  smoke resolves the real binary at
  `~/.nvm/.../@openai/codex/bin/codex.js` (through the nvm
  wrapper symlink).

  Real CLI script env allowlist audit asserts the exact-set of
  `[ANET_CODEX_TUI_BEARER, CODEX_HOME, HOME, PATH, TMPDIR]` and
  drives a mutation-red: `buildAllowlistEnv("b", {COMMHUB_TOKEN:
  "leak"})` must throw. The unconditional `ok("env allowlist
  audit ok")` is gone.

  Fixture doc updated:
    - same-UID threat model: honest narrowing per 副指挥 8eb1dcd1.
      Bearer defends against accidental/stale peers + guessing;
      does NOT claim same-UID impersonation defense. Wave 2
      OS-isolation reframed as OPTIONAL high-security profile,
      NOT default blocker.
    - Bootstrap-smoke scope: only observes FIRST authorizer call
      (`account/read`). Full four-read ready sequence is out of
      scope.
    - Four-read allowlist explicitly limited to the E2E script's
      fake authorizer; `lifecycle.ts` remains empty default-deny.

  Node integration banner + real-CLI banner renamed to
  "corrective round 4".

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    315 pass / 0 fail / 960 expect() calls (10 test files)

  Per-file exact counts:
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 33 pass  (+2 for P0-3 leading-byte prefix)
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     15 pass  (+1 for P1-4 TOCTOU accessor)
    tui-ws-server.test.ts           8 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             10 pass
    lifecycle.test.ts              23 pass
    upstream-router.test.ts        12 pass

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 46/46
  Round-4 new repros:
    - P0-1 lifecycle transitioned out of running after upstream close
    - P0-2 takeTuiBearerPlaintextForLauncher returns null in stopped state
    - P0-4 owner slot empty after ws close/error

  Real 0.144.0 CLI bootstrap smoke (util-linux `script(1)` PTY,
  realpath resolution):
    node scripts/rfc030-real-cli-e2e.mjs
    real CLI bootstrap smoke PASS: 5/5
    · child env exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER, CODEX_HOME, HOME, PATH, TMPDIR]
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read
    Note: using codex binary (realpath):
    /home/vansin/.nvm/versions/node/v20.20.0/lib/node_modules/
    @openai/codex/bin/codex.js

Boundary held

  - Only agent-node/src/runtime/codex-policy-gateway/*, agent-node/
    scripts/*.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - Commit 2 items NOT touched.

Author: comm eng
Coordinator FAIL verdict on 074fff9. ONE new commit on top; no
amend. Contract/protocol digests unchanged. Commit 2 items (async
close cascade / shutdown single-flight / bounded close / close-
error state truth) NOT touched.

P0-1 async rollback (state==stopped now implies socket unlinked)

  Prior sync `rollbackStartFailure()` nulled refs before servers
  finished stopping. Concurrent stop landing in `backend.start`
  await returned `state:"stopped"` while
  `{socketExists:true, socketAccepts:true}` (the coordinator's
  repro). Rewrote rollback:

    - New `backendStarted` / `tuiStarted` flags mark actual bind.
    - `rollbackStartFailure()` is now `async`. When a server flag
      is set, AWAITS its `.stop()` BEFORE nulling the ref +
      transitioning `state="stopped"`. Also `await
      opts.upstreamTransport.close()` best-effort so a mid-
      preflight transport does not leak.
    - `throwIfAbortedAfterAwait` is now async and awaits the
      rollback.
    - Failure paths defensively mark `started=true` for partial
      bind so stop() runs (stop() is idempotent on !running).
    - `stop()` also resets the flags on completion.

  Node repro test `P0-1 async rollback socket unlinked` fires
  `stop()` during `backend.start` await window; asserts
  `state=="stopped"` AND socket path does NOT exist. Bun test
  `stop-during-start → state=stopped AND socket unlinked` locks
  the same invariant.

P0-2 router post-close pre-active frames drop

  `UpstreamRouter.onFrame` in the `"subscribed"` state now checks
  `receivedCloseBeforeActive` and DROPS the frame instead of
  buffering when true. Only pre-close frames remain in the
  buffer; `activate()` never dispatches post-close frames.

  Reproducer (both Bun unit + Node integration): subscribe →
  emit pre-close frame (buffered) → emitClose → emit post-close
  frame → activate. Only the pre-close frame reaches the
  dispatch path; `written.filter(id === "cx_after")` is 0.
  Router surfaces exactly one `upstream_frame_dropped_after_
  pre_active_close` diagnostic.

P1-1 sync writeFrame throw: mux + internalPending clean to 0

  Prior `.catch`-only handler in `BackendUdsServer.sendInternal`
  only caught async rejections. A transport that threw
  synchronously left `muxPending=1` and `internalPending=1`.
  Wrapped the whole `writeFrame` call in try/catch that funnels
  BOTH sync + async paths through a single cleanup closure. The
  closure releases the mux slot via
  `mux.consumeUpstreamResponse(alloc.upstreamId)` (no-op if
  router already consumed) AND settles the outer Promise via the
  wrapped `origin.reject`, which deletes the correct
  internalPending key via the mutable-box `entry.upstreamId`.

  Defence-in-depth: transport may return non-Promise on shape
  violation. Only chain `.catch` when the return is
  Promise-like; otherwise treat as sync success.

  Repro (Bun unit + Node integration): sync-throw transport →
  send rejects with the exact message; `mux.pendingCount() === 0`
  after each reject; a second send after a first rejection also
  cleans to 0 (no leaked id).

P1-2 writeFrameStrict truthful return

  Prior version only caught sync throw. A ws in CLOSING/CLOSED
  state may not throw — `send()` silently drops or errors via a
  callback we weren't consuming. Rewrote:

    - Refuse non-OPEN `readyState` → report `ws_write_not_open`
      + return `false`. Router logs orphan.
    - Guard JSON.stringify separately.
    - Pass a completion callback to `ws.send()`. Async transport
      errors report `ws_write_async_error` for post-hoc
      observability. Synchronous return still reflects sync
      accept into the send queue on an OPEN socket.

Evidence blockers (all from round-4 verdict)

  1) Fixture doc bottom stale "Read-only allowlist (Phase 1) ...
     uses this exact set" REMOVED. New section is titled
     "Startup reads observed by 0.144.0 (informational)" and
     explicitly says: `lifecycle.ts` uses
     `defaultDenyTuiAuthorizer` with an EMPTY allowlist; the
     four-read set is enforced ONLY by the E2E fake authorizer;
     the smoke asserts only the FIRST `account/read`. No more
     inline "the authorizer uses this exact set" wording.

  2) Report of reproduction command corrected. Script header:
     "use `RFC030_CODEX_BIN=<path> npm run
     test:rfc030-real-cli-smoke`". Direct
     `node scripts/rfc030-real-cli-e2e.mjs` requires
     `agent-node/dist/rfc030-integration.mjs` which is
     gitignored → ERR_MODULE_NOT_FOUND on clean checkout. The
     npm script runs `bun run build:rfc030-integration` first
     and passes bundle path via `RFC030_BUNDLE`.

  3) Real spawn env unified with `buildAllowlistEnv`. Prior
     assertion audited a hand-built object while `spawnCodex`
     built its OWN env — the two could diverge silently. Now
     `spawnCodex` returns the frozen `buildAllowlistEnv` output
     via `{env}` and `main()` asserts on the SAME object it
     just passed to `spawn`. Mutation-red still refuses a
     COMMHUB_TOKEN-shape foreign key via `buildAllowlistEnv`
     BEFORE spawn is invoked.

  4) `realpath` claim narrowed. Script comment now says:
     "realpath fixes the canonical path we spawn; it does NOT
     prove same-inode over time (an unlink+rename between
     --version and spawn could still swap the file). Both
     operations resolve the SAME canonical path captured up
     front; that captured path is used verbatim in spawn."

  5) Source headers moved to "round 5" across the touched
     files. Prior lingering "round 2 / round 3" tags updated
     for `bearer.ts`, `bearer.test.ts`, `tui-ws-server.ts`,
     `upstream-router.ts`, `upstream-router.test.ts`, and both
     harness scripts.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    318 pass / 0 fail / 971 expect() calls (10 test files)

  Per-file exact counts (round-5 deltas):
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 33 pass
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     15 pass
    tui-ws-server.test.ts           8 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             11 pass  (+1 P1-1 sync-throw)
    lifecycle.test.ts              24 pass  (+1 P0-1 async rollback)
    upstream-router.test.ts        13 pass  (+1 P0-2 post-close drop)

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 54/54
  Round-5 new reproducers:
    - P0-1 async rollback state = stopped
    - P0-1 async rollback socket unlinked
       (startResult=rejected:start aborted by concurrent stop (backend_start))
    - P0-2 router dropped post-close pre-active frame
       (0 responses for cx_after)
    - P0-2 router surfaced drop diagnostic exactly once
    - P1-1 sync throw surfaced reject
    - P1-1 mux pending count after reject = 0
    - P1-1 second sync throw also surfaced reject
    - P1-1 mux pending count after 2nd reject = 0

  Real 0.144.0 CLI bootstrap smoke (util-linux `script(1)` PTY,
  realpath resolution, env unified via buildAllowlistEnv):
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 5/5
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · child env exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read
    Note: using codex binary (realpath):
    /home/vansin/.nvm/versions/node/v20.20.0/lib/node_modules/
    @openai/codex/bin/codex.js

Boundary held

  - Only agent-node/src/runtime/codex-policy-gateway/*, agent-node/
    scripts/*.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - Commit 2 items (async close cascade final stopped, shutdown
    single-flight, bounded close, close-error state truth) NOT
    touched.

Author: comm eng
Coordinator FAIL verdict on 36a8324 (三大核心 blocker 已关闭).
ONE new commit on top; no amend. Contract/protocol digests
unchanged. Commit 2 items (async close cascade / shutdown single-
flight / bounded close / close-error state truth) NOT touched.

P1 evidence-P1: child env PTY probe + PWD strip + mutation red

  Under `script -qec 'CMD' /dev/null` (util-linux), `script`
  invokes `sh -c CMD`, and `sh` unconditionally exports `PWD=<cwd>`
  before executing `CMD`. That meant the REAL child env inherited
  by Codex under a PTY had SIX keys — the "exact 5-key allowlist"
  claim was false. Minimal repro (coordinator-supplied):

    env -i PATH=... HOME=... TMPDIR=... CODEX_HOME=... \
      ANET_CODEX_TUI_BEARER=x script -qec 'env | sort' /dev/null
    → keys=[ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,PWD,TMPDIR]

  Chosen fix (weighing the coordinator's listed options): prepend
  `unset PWD; exec ...` inside the shell command so the child
  never inherits `PWD`. Adding `PWD` to the allowlist would leak
  the workspace path into a Codex-visible env, which the fixture
  doc explicitly says we do NOT do.

  Reproducibility hardening:
    - New `buildPtyShellCommand(unsetPwd, codexBin, codexArgs)`
      helper builds the shell string with an explicit `unset PWD;`
      prefix. `spawnCodex` calls it with `unsetPwd=true`.
    - New `probeChildEnvKeysUnderPty(plaintext, unsetPwd)` helper
      spawns the SAME PTY chain but replaces the codex binary with
      `env`, parses the actual `KEY=` lines, dedups, sorts. This
      audits the ACTUAL child-visible env keys, not a parent-side
      lookalike.
    - New two-tier gate in `main()`:
        * ok probe(true) exact-set = [PATH,HOME,TMPDIR,CODEX_HOME,
                                      ANET_CODEX_TUI_BEARER]
        * ok probe(false) shows exactly one extra key `PWD` — the
          mutation red proves the `unset PWD;` prefix is load-
          bearing. A future edit that drops the prefix loudly
          breaks the smoke.
    - Also switched `spawn` to receive the FROZEN
      `buildAllowlistEnv` reference directly (no spread copy); the
      "parent-side reference exact-set" assertion audits the SAME
      object that reached `spawn`.

P2 evidence-P2: TuiForwardSeam honest naming — accepted != delivered

  Prior round-5 seam returned `true` whenever `ws.send()` didn't
  throw synchronously. On an OPEN socket the send() callback can
  still error asynchronously AFTER the router has consumed the
  origin, but the boolean already claimed "delivered". Coordinator
  reproducer: fake OPEN callback-error → seam returns true +
  diagnostic fires post-hoc; the router considered the response
  "delivered" and consumed the origin.

  Rename + typed post-hoc diagnostic (option 2 of the two the
  coordinator listed; keeps the API synchronous):

    - Interface: `deliverReverseRequestToOwner` →
      `acceptReverseRequestForSend`. `deliverProxiedResponseToOwner`
      → `acceptProxiedResponseForSend`. JSDoc rewritten:
      "accepted into the outbound ws send queue on an OPEN socket
       — NOT that bytes reached the wire."
    - `writeFrameStrict(ws, frame, asyncFailOperation)` — the
      caller supplies the stable op name; async transport errors
      surface via the diagnostics sink under
      `reverse_request_send_failed_async` /
      `proxied_response_send_failed_async` (superseded the
      generic `ws_write_async_error`).
    - Router callsites use `accepted` instead of `delivered`; the
      comment now spells out that a lost proxied response cannot
      be replayed here.

  Hard test (Bun): controlled fake ws whose `send()` callback
  errors asynchronously. `acceptProxiedResponseForSend` returns
  `true` synchronously AND the diagnostic `proxied_response_send_
  failed_async` fires within 15 ms. Symmetric case for
  `acceptReverseRequestForSend`. Also: closed-ws readyState →
  returns `false` + logs `ws_write_not_open`.

Evidence tidy

  - `real-cli-e2e.mjs` line 90 stale comment "same inode we'll
    spawn" corrected: "realpath does not prove same-inode-over-
    time; we only claim canonical-path stability."
  - Node integration stale-owner test split honestly:
      · `test_ordinary_owner_close_detaches` (client `ws.close()`
        — was `test_stale_owner_ws_error_terminated`).
      · `test_stale_owner_server_error_terminates` — abrupt
        `ws._socket.destroy(new Error(...))` sends a TCP RST; the
        server-side `ws` fires an `error` event which the P0-4
        code path handles by `ws.terminate()`-first then detach.
  - Source banner updated to round 6 in the two harness scripts.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    321 pass / 0 fail / 978 expect() calls (10 test files)

  Per-file exact counts (round-6 deltas):
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 33 pass
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     15 pass
    tui-ws-server.test.ts          11 pass  (+3 accept honesty)
    human-owner.test.ts            21 pass
    uds-server.test.ts             11 pass
    lifecycle.test.ts              24 pass
    upstream-router.test.ts        13 pass

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 56/56
  Round-6 changes:
    - test_stale_owner_ws_error_terminated →
        test_ordinary_owner_close_detaches +
        test_stale_owner_server_error_terminates
      Both PASS; the new server-error case verifies TCP-RST →
      server-side ws error handler → ws.terminate() → detach.

  Real 0.144.0 CLI bootstrap smoke (util-linux `script(1)` PTY,
  realpath resolution, env=buildAllowlistEnv reference, probed
  under-PTY child env):
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 7/7
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · child env exact-set (probed under PTY):
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · mutation red: without unset PWD the PTY child sees 6 keys
      (includes PWD) — guard is load-bearing
    · parent-side env reference exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read

Boundary held

  - Only agent-node/src/runtime/codex-policy-gateway/*,
    agent-node/scripts/*.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - Commit 2 items (async close cascade final stopped, shutdown
    single-flight, bounded close, close-error state truth) NOT
    touched.

Author: comm eng
Coordinator REQUEST CHANGES on 1733cd2 (all three main test
suites PASS). ONE new commit on top; no amend. Contract/protocol
digests unchanged. Commit 2 items NOT touched.

Three minimal, evidence-only fixes.

Evidence-P1: env probe never touches the real bearer

  Prior probe passed the real `plaintext` into a canary env and
  had the child dump full `KEY=VALUE` output; parent stored the
  raw bytes in `chunks` / `raw` and echoed the first 200 bytes on
  failure. That path exposed the real bearer to the evidence
  harness — a credential surface the coordinator asked us to
  eliminate.

  Fix:
    - New `PROBE_CANARY_BEARER =
      "probe-canary-NOT-A-REAL-BEARER-abcdefghij"`. The real
      Codex spawn STILL uses the real bearer + SecretRedactor;
      the two paths do not share plaintext.
    - Child pipeline is `env | cut -d= -f1 |
      grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sort -u` — values are
      stripped INSIDE the child before any byte leaves the
      process. Parent streams stdout line-by-line, extracts keys
      via a key-shape regex, and NEVER stores a raw-value buffer.
    - Parent-side struct returns `{code, keys, note}` where
      `note` is `exit=<n> stderr_bytes=<n> lines=<n>` — no
      values, no raw text; safe to log.
    - New canary-scrub mutation-red: after both probe calls, the
      concatenated OK/FAIL surface strings are searched for the
      canary prefix and the full value. If either surfaces, this
      turns red — a future refactor that re-introduces raw-value
      passing loudly breaks the smoke.

  Both existing mutation-reds retained:
    - `COMMHUB_TOKEN` refused by buildAllowlistEnv.
    - `unset PWD;` removal → PTY child sees 6 keys (includes PWD).

Evidence-P2: accepted-not-delivered rename tail

  Round-6 renamed the production seam methods but left three
  test-side artefacts using the old "delivered" language.
  Corrected:
    - `upstream-router.test.ts` fixture: `reverseDelivered` →
      `reverseAccepted`; `proxiedDelivered` → `proxiedAccepted`.
    - Test title "proxied_tui response → delivered exactly ONCE"
      → "acceptedForSend exactly ONCE via TuiForwardSeam (accept
      != wire delivery)".
    - "orphan response → single diagnostic, no delivery" → "no
      accept".
    - `upstream-router.ts` JSDoc: "Narrow delivery seam" →
      "Narrow accept-for-send seam".
    - Buffered pre-active drain comment: "buffered and then
      delivered on activate" → "buffered and then dispatched on
      activate" (activate dispatches; delivery is downstream).
  Runtime contract unchanged.

Evidence-P3: RST test rename + deterministic server-error case

  Round-6 `test_stale_owner_ws_error_terminates` claimed to
  exercise the server-side ws `error` branch by driving an
  abrupt TCP RST from the client side. The coordinator's
  independent instrumentation showed the server typically
  receives `close(1006)` (not `error`), and the ordinary close
  handler also clears the owner slot — so the abrupt-RST
  reproducer cannot distinguish which branch ran. Split into
  two honest cases:

    1. `test_abrupt_disconnect_releases_owner` — the ABRUPT-RST
       repro, renamed. Claims only "owner slot empty after
       abnormal close". Does NOT claim to exercise the `error`
       handler.
    2. `test_server_side_ws_error_terminate_before_detach` —
       NEW deterministic case. Uses `wsServer.clients` to reach
       the one server-side ws instance (`private` compiles away
       in JS at runtime), spies on `terminate()` to capture
       (a) whether it was called and (b) the owner-slot state
       AT THE MOMENT it was called, then `serverWs.emit(
       "error", new Error("synthetic-server-side-error"))` to
       drive the branch directly. Asserts:
         · `terminate() called` on server-side ws;
         · owner slot at terminate call was still `held` (proves
           terminate-first ordering before onOwnerClose);
         · `performance.now()` timestamps confirm
           terminate ≤ detach;
         · owner slot ends `empty`.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Full gateway subtree:
    bun test agent-node/src/runtime/codex-policy-gateway/
    321 pass / 0 fail / 978 expect() calls (10 test files)

  Per-file exact counts (no runtime code deltas beyond the
  accept-language JSDoc/comment rename):
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 33 pass
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     15 pass
    tui-ws-server.test.ts          11 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             11 pass
    lifecycle.test.ts              24 pass
    upstream-router.test.ts        13 pass

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60
  Round-7 changes:
    - test_stale_owner_ws_error_terminates SPLIT →
        test_abrupt_disconnect_releases_owner
        test_server_side_ws_error_terminate_before_detach
      New deterministic case (real gate output):
        ok  owner attached before abrupt disconnect
        ok  abrupt disconnect: owner slot empty
        ok  server-side ws error: terminate() called
        ok  server-side ws error: terminate ran BEFORE detach
            (owner still held at terminate call)
        ok  server-side ws error: timestamps confirm
            terminate(10948.65) <= detach(10953.93)
        ok  server-side ws error: owner slot cleared

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 8/8
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · child env exact-set (probed under PTY, canary bearer):
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · mutation red: without unset PWD the PTY child sees 6 keys
      (includes PWD) — guard is load-bearing
    · canary scrub: probe surface does NOT include canary bearer
      value
    · parent-side env reference exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read

Boundary held

  - Only agent-node/src/runtime/codex-policy-gateway/*,
    agent-node/scripts/*.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - Runtime contract on TuiForwardSeam unchanged; only test titles,
    fixture variable names and JSDoc/comment strings renamed.
  - Commit 2 items (async close cascade final stopped, shutdown
    single-flight, bounded close, close-error state truth) NOT
    touched.

Author: comm eng
Coordinator REQUEST CHANGES on b46b246: canary scrub was still
searching only derived fields, so a future edit that added a raw
cache would slip past. Runtime PASS invariants unchanged. Only
harness scripts touched — no gateway runtime code, no test suite,
no contract/protocol/type edit. Commit 2 zero.

Fix 1 — canary scrub bound to real stream, not derived fields

  Prior detector concatenated `note` + `keys` and searched that
  string for the canary. A refactor that added a `raw` field
  (with values in it) would land the canary in raw but not in
  the concatenated whitelist → detector stayed green. Coordinator
  confirmed independently: after mutating the probe to keep a
  raw cache, canary appeared in the parent cache but the scrub
  passed.

  Rewrite:
    - New `makeCanaryDetector(canary)` returns a streaming
      detector: retains a rolling tail of `canary.length - 1`
      bytes so the target is caught even when it lands on a
      chunk boundary; never buffers the whole stream; sets a
      one-way `hit` flag on match.
    - `probeChildEnvKeysUnderPty` now feeds EVERY stdout+stderr
      chunk into the detector BEFORE the line splitter. Any
      capture-layer refactor cannot bypass the detector: a raw
      cache is downstream of the same chunk stream, but the
      canary hits the detector on the first byte it appears.
    - Return object shape locked to `{code, keys,
      canaryDetected, note}`. No `raw`, no value fields. Added a
      shape gate (`allowedFields` Set) that fails loud on any
      extra property — a future edit that reintroduces `raw`
      turns this red.

  Controlled unsafe-mode mutation:
    - New `dumpValues: true` option to the SAME probe function.
      Child pipeline becomes `env` (verbatim, values included);
      the canary bearer DOES land in stdout.
    - Assertion: `unsafeDump.canaryDetected === true`. If this
      fails, the detector is dead and the whole safe-mode PASS
      is meaningless. Real gate output:

        ok  canary detector — unsafe mode: canary in child
            stdout was DETECTED (detector proven live)

    - Safe-mode invariant retained: `withGuard.canaryDetected ===
      false && withoutGuard.canaryDetected === false` — the
      production capture path never sees the canary.

  Return-shape gate:
        ok  probe return shape: only {code, keys,
            canaryDetected, note} exposed

  The test directly reuses the production probe capture/detector
  path — no duplicate helper. All three probe calls
  (`withGuard`, `withoutGuard`, `unsafeDump`) hit the same
  `probeChildEnvKeysUnderPty` function, only mode flags differ.

Fix 2 — mechanical wording

  - `scripts/rfc030-node-integration.mjs:95` reverse-request
    delivery via TuiForwardSeam → "reverse-request via the
    TuiForwardSeam accept-for-send handoff (accept != wire
    delivery)".
  - `test_abrupt_disconnect_releases_owner`:
      · Header comment: "TCP RST via _socket.destroy(new Error)"
        → "abrupt/abnormal socket disconnect via
        _socket.destroy(new Error) — TCP RST is NOT guaranteed;
        depends on kernel/buffered/OS". "simulated abrupt RST"
        → "simulated abrupt disconnect".
      · Runtime behaviour unchanged.
  - `test_server_side_ws_error_terminate_before_detach`:
      · "timestamps confirm terminate ≤ detach" was overstating
        — `detachEvt.t` is when the outer poll observed
        `ownerSlot=empty`, not the exact `detachTui` call site.
        Reworded honestly: "terminate spy(t) precedes
        owner-empty poll(t)". Added an inline comment saying
        the CORE terminate-before-detach assertion is the
        `ownerSlotAtCall === "held"` snapshot taken inside the
        terminate spy — that snapshot IS at the terminate call
        site, before any handler returns.
      · Runtime behaviour unchanged.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean
  git status --short: only scripts/rfc030-*.mjs touched (0
  gateway runtime files).

  Full gateway subtree (no runtime changes; re-run for
  completeness):
    bun test agent-node/src/runtime/codex-policy-gateway/
    321 pass / 0 fail / 978 expect() calls (10 test files)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60
  Round-8 renamed / clarified lines (real gate output):
    ok  owner attached before abrupt disconnect
    ok  abrupt disconnect: owner slot empty
    ok  server-side ws error: terminate() called
    ok  server-side ws error: terminate ran BEFORE detach
        (owner still held at terminate call)
    ok  server-side ws error: terminate spy(10976.25) precedes
        owner-empty poll(10982.52)
    ok  server-side ws error: owner slot cleared

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 10/10
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · child env exact-set (probed under PTY, canary bearer):
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · mutation red: without unset PWD the PTY child sees 6 keys
      (includes PWD) — guard is load-bearing
    · canary detector — unsafe mode: canary in child stdout was
      DETECTED (detector proven live)
    · canary detector — safe mode: withGuard + withoutGuard
      both scanned clean (0 hits)
    · probe return shape: only {code, keys, canaryDetected,
      note} exposed
    · parent-side env reference exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read

Boundary held

  - Only agent-node/scripts/rfc030-node-integration.mjs +
    agent-node/scripts/rfc030-real-cli-e2e.mjs.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Zero changes to runtime code (gateway/lifecycle/router/uds/
    tui-ws/bearer/human-owner/child-launcher/admission).
  - No test-suite changes (Bun test count unchanged at 321/978).
  - Commit 2 items (async close cascade final stopped, shutdown
    single-flight, bounded close, close-error state truth) NOT
    touched.

Author: comm eng
Coordinator REQUEST CHANGES on 2dad3b7: shared rolling-tail
detector could false-negative on same-stream contiguous canary
across stderr-interleaved feeds, and false-positive on cross-
stream prefix+suffix. Runtime unchanged. Only real-CLI harness
script touched.

Fix — per-stream detectors + `close` synchronisation

  - stdout and stderr now feed INDEPENDENT
    `makeCanaryDetector` instances. Each keeps its own rolling
    tail of `canary.length - 1` bytes. Reported
    `canaryDetected = stdoutDetector.detected() ||
                       stderrDetector.detected()`.
  - Child completion now waits on `close` (fires after stdio
    streams have flushed and emitted their `end`) instead of
    `exit` (which can fire while a final buffered chunk is still
    in flight). Detector state is read AFTER `close`, so a
    trailing tail chunk can no longer race the assertion.
  - Two direct-repro assertions added, driving the SAME
    `makeCanaryDetector` factory the probe uses:
      · Case A: stdout writes prefix → stderr writes noise →
        stdout writes suffix. stdout is contiguous canary; MUST
        detect. Shared-tail would have MISSED this (stderr's
        NOISE overwrites the shared tail between the two stdout
        feeds).
      · Case B: stdout writes prefix → stderr writes suffix.
        Neither stream contains the whole canary; MUST NOT
        detect. Shared-tail would have produced a cross-stream
        FALSE POSITIVE.
  - No other change. Node integration, gateway runtime, Bun test
    suite, `probeChildEnvKeysUnderPty` API shape and mode
    behaviour all identical.

Evidence (raw, verbatim)

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean
  git status --short: only agent-node/scripts/
    rfc030-real-cli-e2e.mjs touched.

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12
    · mutation red: COMMHUB_TOKEN refused by buildAllowlistEnv
    · detector interleave case A: stdout prefix + stderr noise +
      stdout suffix ⇒ detected=true (contiguous within stdout
      stream)
    · detector interleave case B: stdout prefix + stderr suffix
      ⇒ detected=false (no cross-stream fusion)
    · child env exact-set (probed under PTY, canary bearer):
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · mutation red: without unset PWD the PTY child sees 6 keys
      (includes PWD) — guard is load-bearing
    · canary detector — unsafe mode: canary in child stdout was
      DETECTED (detector proven live)
    · canary detector — safe mode: withGuard + withoutGuard
      both scanned clean (0 hits)
    · probe return shape: only {code, keys, canaryDetected,
      note} exposed
    · parent-side env reference exact-set matches allowlist:
      [ANET_CODEX_TUI_BEARER,CODEX_HOME,HOME,PATH,TMPDIR]
    · SecretRedactor covers child stdio
    · Codex authorizer invoked (1 call)
    · Codex first authorizer call is exactly account/read

Boundary held

  - Only agent-node/scripts/rfc030-real-cli-e2e.mjs.
  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Zero changes to gateway runtime, Bun test suite, or the Node
    integration script. Coordinator's request to skip Node /
    gateway re-runs honoured; the only test surface exercised
    here is the real-CLI smoke.
  - Commit 2 items (async close cascade final stopped, shutdown
    single-flight, bounded close, close-error state truth) NOT
    touched.

Author: comm eng
…a9b)

Commit 1 froze at bd0dfd7. Commit 2 addresses the six teardown
items the coordinator authorised, each with a red-turnable
reproducer. Frozen contract/protocol digests unchanged. No B
rebase, no production wiring, no Wave 2, no approval toggle, no
merge/deploy/latest. §8 and all terminal gates remain locked.

Scope authorised (副指挥 3cb7ba9b Commit 2):
  #1 shutdown single-flight: manual stop, active upstream close
     cascade, concurrent callers all share the SAME shutdown
     Promise.
  #2 active upstream close completes the cascade and reaches a
     truthful terminal state (via #1's single-flight).
  #3 transport `abort()` is a REQUIRED contract on
     `UpstreamTransport`, not best-effort optional.
  #4 close is BOUNDED — `UPSTREAM_CLOSE_TIMEOUT_MS = 2000`;
     timeout/throw escalates to the required `abort()`. stop()
     cannot hang forever.
  #5 close/abort throw or timeout NEVER report `stopped`; new
     `stop_failed` terminal state preserves the cause via
     `stopFailure(): Error | null`.
  #6 pending internal/reverse/proxied origins reject/drain
     EXACTLY ONCE; no socket / path / handler / child residue.

Implementation

  `uds-server.ts` `UpstreamTransport` interface:
    - `close(): Promise<void>` — JSDoc rewritten to state the
      graceful contract explicitly; lifecycle bounds the wait.
    - NEW `abort(): void` — required force-terminate.
      Synchronous side-effects only; idempotent for the "already
      terminated" case; may throw only on genuinely unexpected
      native failure; lifecycle catches and transitions to
      `stop_failed`.
    All in-tree `UpstreamTransport` fakes (Bun test suite × 4,
    Node harness × 5, real-CLI harness × 1) updated to
    implement `abort()`.

  `lifecycle.ts`:
    - `LifecycleState` gains `stop_failed` (truthful failure
      terminal). Documented state machine at the type site.
    - `UPSTREAM_CLOSE_TIMEOUT_MS` exported constant (2000 ms).
    - `shutdownPromise: Promise<void> | null` (single-flight
      memo).
    - `stopFailureError: Error | null` (Commit 2 #5).
    - `stopFailure()` public accessor returns captured cause.
    - `onUpstreamCloseFromRouter()` no longer runs an inline
      cascade — funnels into `runShutdown()`. So a manual `stop()`
      concurrent with an upstream close cascade shares ONE
      shutdown.
    - `stop()` delegates to `runShutdown()`; concurrent callers
      receive the cached Promise.
    - New `doShutdown()` centralises the teardown sequence:
      router unsubscribe → backend stop → tui stop → bearer
      rotate → BOUNDED upstream close → drainAll mux + reverseNs
      → terminal state (stopped iff close cleanly resolved, else
      stop_failed with cause preserved).
    - New `closeUpstreamBounded()`:
        · Races `close()` against `UPSTREAM_CLOSE_TIMEOUT_MS`.
        · Clean resolve → `{kind: "ok"}`, no abort call.
        · Close throw / timeout → invokes REQUIRED `abort()`.
            - Abort returns → `{kind: "failed", error: original
              cause}`.
            - Abort throws → `{kind: "failed", error: abort
              throw}` with the original close-side cause attached
              via Error `.cause`.
    - `rollbackStartFailure()` routed through
      `closeUpstreamBounded()` too — a hung transport during
      preflight cleanup cannot wedge `start()`. Rollback lands
      in `stopped` iff close resolved, else `stop_failed` with
      cause preserved.

Red-turnable reproducers (Bun test — 8 new)

  Commit 2 #1 — single-flight:
    · two concurrent stop() → 1 close, 0 abort, stopped
    · upstream close cascade × concurrent stop() → 1 close,
      stopped
    · stop() after terminal → no re-run of teardown

  Commit 2 #4 — bounded close + forced abort:
    · never-resolving close → bounded timeout fires abort →
      stop_failed with `upstream close timed out after 2000ms`
      cause; elapsed time < timeout + 1500ms slack
    · close throws → abort called → stop_failed preserving the
      close-side cause verbatim
    · close throws AND abort throws → stop_failed preserving
      abort error with close as `.cause`

  Commit 2 #6 — exactly-once drain:
    · stop() with pending sendInternal → rejects exactly once;
      pendingUpstreamCount + pendingReverseCount both 0
    · late upstream response after stop → NOT redelivered
      (mux drained); pending stays 0 across follow-up stop()

  Item #2 (upstream cascade → truthful terminal) is covered by
  the "upstream close cascade × concurrent stop" case above
  plus the existing Node integration coverage (unchanged).

  Item #3 (abort required) is enforced at type-check time — the
  updated `UpstreamTransport` interface makes any implementation
  missing `abort()` a compile error. `bun run build` confirms.

Frozen invariants held

  Frozen file SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Only agent-node/src/runtime/codex-policy-gateway/*
  (lifecycle + uds-server + tests) and agent-node/scripts/*
  (fake-transport `abort()` additions). No scheduler / policy /
  ledger / bridge / client / B-side edit. No Wave 2, §8, approval
  toggle, merge, deploy, latest touched.

Evidence (raw, verbatim)

  Bun test:
    bun test agent-node/src/runtime/codex-policy-gateway/
    329 pass / 0 fail / 1005 expect() calls (10 test files)

  Per-file exact counts (Commit 2 delta on lifecycle.test.ts):
    contract.test.ts               44 pass
    protocol.test.ts              125 pass
    bearer.test.ts                 33 pass
    admission.test.ts              24 pass
    tui-child-launcher.test.ts     15 pass
    tui-ws-server.test.ts          11 pass
    human-owner.test.ts            21 pass
    uds-server.test.ts             11 pass
    lifecycle.test.ts              32 pass  (+8 Commit 2)
    upstream-router.test.ts        13 pass

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60 (unchanged; abort() no-op
    added to Node-harness fake upstreams so the type contract
    is honoured under ESM runtime too)

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12 (unchanged; abort()
    no-op added to the real-CLI harness fake upstream)

Boundary held

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - No scheduler / policy / ledger / bridge / client / B-side edit.
  - No Wave 2 / §8 / approval-mode toggle / merge / deploy /
    latest / preview.
  - Commit 1 (frozen at bd0dfd7) unchanged in this commit.

Author: comm eng
Coordinator REQUEST CHANGES on d2ca6f0 with 6 blockers (later
expanded to 8 during the round). Commit 1 froze at bd0dfd7 —
NOT touched. All boundaries held. Single-corrective SHA.

Fix 1 — local Backend/TUI stop failures surface stop_failed
   with `forceTerminate()` fallback (副指挥 d53209eb #1 + #2)

  Prior teardown swallowed `backendServer.stop()` /
  `tuiServer.stop()` throws silently and always reported
  `stopped`. Independent repro showed:
    - backend stop throw → state=stopped, socketExists=true
    - TUI stop throw → state=stopped, tuiTcpConnects=true
    - TUI stop never-resolve → state=stopping forever.

  Fixes:
    - New `BackendUdsServer.forceTerminate(): void` and
      `TuiWsServer.forceTerminate(): void`. Both are synchronous:
      destroy every connection, close the listener without
      awaiting, unlink socket paths, unref+closeAllConnections
      the http server. Idempotent.
    - New `LOCAL_STOP_TIMEOUT_MS = 1000` bounded timeout for
      local server graceful stop.
    - New private `boundedLocalStop(label, stop, forceTerminate)`
      helper in lifecycle: tagged-winner race between `stop()`
      / `stop()` throw / timeout; on any non-`stop_ok` winner,
      escalates to `forceTerminate()`. If forceTerminate itself
      throws, that becomes the primary failure.
    - Any local-stop failure lands the lifecycle in `stop_failed`
      with the ORIGINAL error identity preserved.
    - Repros in lifecycle.test.ts:
        · backend stop throws → stop_failed; forceTerminate
          called; socket unlinked; stopFailure() === original.
        · backend stop never-resolves → bounded timeout →
          forceTerminate called; socket unlinked; elapsed <
          LOCAL_STOP_TIMEOUT_MS + slack.
        · TUI stop throws / TUI stop never-resolves: symmetric.

Fix 2 — `abort()` sync contract enforced at type + runtime
   (副指挥 d53209eb #3 + #5)

  `abort(): void` was TS-erasable (bivariant void hole). An
  `async abort()` would compile and its rejection surfaced as
  an unhandled promise. Replaced with a symbol brand:

    export const SYNC_ABORT: unique symbol =
      Symbol("RFC030-Wave1A-Commit2-SyncAbort");
    export type SyncAbortAcknowledgement = typeof SYNC_ABORT;

    interface UpstreamTransport {
      ...
      abort(): SyncAbortAcknowledgement;
    }

  A `Promise<SyncAbortAcknowledgement>` (from `async`) is NOT
  assignable to `SyncAbortAcknowledgement` — tsc rejects. The
  ten in-tree impls (Bun × 4, Node harness × 5, real-CLI × 1)
  all updated to `return SYNC_ABORT`. `integration-entry.ts`
  exports the symbol so ESM harnesses can `return SYNC_ABORT`
  too.

  Runtime thenable guard inside `closeUpstreamBounded`:
    - If `abort()` returns a thenable → attach `.catch(() =>
      {})` immediately so a rejection cannot surface as
      unhandled. Treat as violation → `stop_failed` with an
      explicit "abort() returned a thenable" Error.
    - If `abort()` returns something that isn't SYNC_ABORT →
      same violation path.
    - If `abort()` throws synchronously → primary cause with
      original identity preserved (see Fix 5).

  Type-level enforcement PROVEN by a dedicated harness:
    - Fixture `fixtures/async-abort-negative.ts.fixture`
      (deliberately excluded from the gateway tsconfig) declares
      `BadAsyncAbort`, `BadVoidAbort`, `BadNumberAbort`.
    - Script `scripts/rfc030-async-abort-typecheck-negative.mjs`
      runs `tsc --strict --noEmit` on the fixture and asserts
      NON-ZERO exit + that the error output mentions all three
      Bad*Abort classes AND the branded type (`unique symbol`).
    - Real gate output:
        ok  tsc rejected each of BadAsyncAbort / BadVoidAbort
            / BadNumberAbort
        ok  error output references the SyncAbortAcknowledgement
            brand (`unique symbol`)

  Real red assertion in lifecycle.test.ts: a Promise-rejecting
  `abort()` is caught, `process.on('unhandledRejection')` never
  fires; state ends `stop_failed` with the violation Error.

Fix 3 — public `stop()` returns memoised Promise IDENTITY
   (副指挥 d53209eb #3, expanded)

  Prior `async stop() { return this.runShutdown(); }` returned
  an adoption wrapper on each call: `p1 !== p2`. Rewrote to a
  non-async method that returns `this.shutdownPromise` directly:

    stop(): Promise<void> {
      if (this.shutdownPromise !== null) return this.shutdownPromise;
      this.stopRequested = true;
      this.shutdownPromise = this.doOuterStop();
      return this.shutdownPromise;
    }

  Test locks the invariant:
    const p1 = lc.stop(); const p2 = lc.stop(); const p3 = lc.stop();
    expect(p1).toBe(p2); expect(p2).toBe(p3);

Fix 4 — rollback shares the same memoised teardown CORE
   (副指挥 d53209eb #4)

  Prior `rollbackStartFailure()` ran its own teardown. Race
  reasoning relied on `startInProgress` awaits, not a shared
  Promise. Refactored:

    private teardownCorePromise: Promise<TeardownOutcome> | null = null;

    private runTeardownCore(): Promise<TeardownOutcome> {
      if (this.teardownCorePromise !== null) return this.teardownCorePromise;
      this.teardownCorePromise = this.doTeardownCore();
      return this.teardownCorePromise;
    }

  Three entry points hit the same memoised Promise:
    - `stop()` → `doOuterStop()` (awaits startInProgress) →
      `runTeardownCore()`.
    - `onUpstreamCloseFromRouter()` → `void this.stop()`.
    - `rollbackStartFailure()` → `runTeardownCore()` directly.

  Rollback × concurrent stop × upstream close race test:
    close called exactly once; state=stopped.

Fix 5 — original abort Error identity preserved
   (副指挥 d53209eb #7, amendment)

  Prior `new Error(abortError.message, { cause: originalCause })`
  destroyed the original Error identity (TypeError subclass,
  `.code`, `.stack`). Fixed:

    - `stopFailureError` retains the ORIGINAL Error object
      identity — `stopFailure()` returns `=== original`.
    - `stopFailureCloseCause` stores the secondary close/timeout
      cause when the primary is an abort throw. NEVER
      overwritten by a new Error.
    - Best-effort `.cause` attachment via `Object.defineProperty`
      only when `Object.isExtensible(err) && err.cause ===
      undefined`. Any failure is swallowed (frozen /
      non-extensible / already has cause); the secondary is
      still readable via the dedicated accessor. Teardown
      convergence NEVER depends on the attach succeeding.

  New public accessor:
    stopFailureCloseCauseError(): Error | null

  Repros (each turns red if the identity is wrapped):
    - MyTypeError extends TypeError { code = "E_CUSTOM_ABORT" }
      → stopFailure() === original, instanceof TypeError,
      name = "MyTypeError", code = "E_CUSTOM_ABORT".
    - Object.freeze(abortError) → stopFailure() === original,
      no exception thrown by teardown, secondary retained.
    - Object.preventExtensions(new Error(msg, {cause: "X"}))
      → stopFailure() === original, sealed cause NOT
      overwritten, secondary accessible via
      stopFailureCloseCauseError().

Fix 6 — tagged-winner race + bounded timer cleared
   (副指挥 d53209eb #4 + #6 + #8)

  Prior `closeUpstreamBounded` read a mutable `closeSettled`
  flag AFTER `Promise.race`; a same-microtask-boundary close
  settle would mis-attribute the outcome to `ok`. Rewrote as a
  tagged-winner race:

    type Winner =
      | { kind: "close_ok" }
      | { kind: "close_error"; error: Error }
      | { kind: "timeout" };
    const winner = await Promise.race([closeP, timeoutP]);

  Timeout winner → abort() escalation, unconditionally.
  `clearTimeout` in a `finally` block. The LOSING branch has its
  rejection consumed (`closeP.catch(() => {})`) so a late close
  reject cannot surface as unhandled.

  Same pattern in `boundedLocalStop`.

  Repro tests:
    - never-resolving close: abortCallCount = 1, state =
      stop_failed, message matches /upstream close timed out/.
    - clean close: no leaked timer, no unhandled after waiting
      UPSTREAM_CLOSE_TIMEOUT_MS + 200 ms.
    - LateRejectTransport whose close rejects
      UPSTREAM_CLOSE_TIMEOUT_MS + 300 ms after invocation: no
      unhandled rejection observed.

Fix 7 — pending origins drain exactly once
   (副指挥 3cb7ba9b Commit 2 #6, retained)

  `mux.drainAll()` + `reverseNs.drainAll()` are one-shot by
  construction; single-flight teardown ensures at most one
  invocation of each. Late upstream responses after stop cannot
  redeliver because the router is unsubscribed and the mux is
  drained. Retained pending-count-back-to-0 repros.

Boundaries held

  - Frozen contract.ts / protocol.ts digests VERIFIED unchanged.
  - Wave 2 items (approval-mode / real launcher / native isolation)
    untouched.
  - §8, B rebase, production wiring, merge, deploy, latest,
    preview: none touched.
  - Commit 1 SHA bd0dfd7 unchanged.

Files touched
  - agent-node/package.json  (typescript + @types/node devDeps;
    typecheck:rfc030 + typecheck:rfc030-async-abort-negative
    scripts)
  - agent-node/src/runtime/codex-policy-gateway/{
      lifecycle.ts, uds-server.ts, tui-ws-server.ts,
      integration-entry.ts, tsconfig.json (new),
      fixtures/async-abort-negative.ts.fixture (new),
      *.test.ts (all four)
    }
  - agent-node/scripts/{
      rfc030-node-integration.mjs, rfc030-real-cli-e2e.mjs,
      rfc030-async-abort-typecheck-negative.mjs (new)
    }

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    342 pass / 0 fail / 1052 expect() calls (10 test files)
    lifecycle.test.ts alone: 45 pass (+13 corrective tests
    vs Commit 2 initial)

  tsc typecheck (real, not bun-build):
    npm run typecheck:rfc030
    tsc --project src/runtime/codex-policy-gateway/tsconfig.json
    (exit 0, no output)

  Async-abort type-negative harness:
    npm run typecheck:rfc030-async-abort-negative
    tsc exit: 2
    ok  tsc rejected each of BadAsyncAbort / BadVoidAbort
        / BadNumberAbort
    ok  error output references the SyncAbortAcknowledgement
        brand (`unique symbol`)

  Build:
    bun run build -> dist/cli.js 0.90 MB (0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Author: comm eng
Coordinator REQUEST CHANGES on eb22db2 with 6 blockers + 3
evidence-delta items. Round-1 was structurally right for the
sunny-path invariants; round-2 closes the failure-mode gaps:
hostile abort return values, stage-labeled ledger, real
Promise<void> abort contract, per-fixture typecheck harness,
true rollback race, controllable close/timeout race, injected
late upstream response, and exact @types/node pin. Commit 1
`bd0dfd7` unchanged. Frozen contract/protocol digests unchanged.
No B rebase / Wave 2 / merge / deploy / latest / prod.

P0-1 — hostile abort return values never escape teardown

  Prior guard read `.then` and coerced via `String()` on the
  abort return value. A hostile getter or coercion could throw
  and let `stop()` reject → state=stopping forever.

  Fix: switched the abort contract to `abort: () => Promise<void>`
  (option A) and DELETED the return-value inspection entirely.
  The lifecycle now adopts the returned Promise via
  `Promise.resolve().then(() => transport.abort())` — a hostile
  return value becomes either a resolved value the chain ignores,
  OR a rejection caught by the outer race branch, OR a thrown
  Error caught by the surrounding try. Timers cleared in
  `finally`. No `.then` / `.catch` getter access. No `String()`
  coercion.

  Six red repros (each: `stop()` must resolve to a terminal state,
  no unhandled rejection):
    - abort returns undefined
    - abort returns null
    - abort returns number 42
    - abort returns object with throwing `then` getter
    - abort returns thenable that resolves undefined
    - abort returns rejected Promise via poisoned catch getter
  Plus the sync-throw case (TypeError with `.code`): identity
  preserved, primary === original, `instanceof TypeError`.

P0-2 — `.cause` never mutated on primary Error

  Prior `Object.defineProperty(primary, "cause", ...)` inside a
  try was reachable but the `p.cause === undefined` READ before
  the try could throw on a poisoned cause getter, breaking
  teardown convergence.

  Fix: removed the defineProperty attempt entirely. Primary
  Error identity is stored verbatim in `stopFailureError`;
  secondary close cause is stored in the new `TeardownFailureLedger`
  (a plain field on the lifecycle instance) and returned via the
  dedicated `stopFailureCloseCauseError()` accessor. Non-invasive
  by construction — teardown convergence NEVER depends on any
  property access on the caller's Error object.

P1-1 — stage-labeled failure ledger; correct accessor semantics

  Prior "primaryFailure / secondaryCloseCause" mechanism could
  lose evidence when three or more stages failed. The 3-way
  repro showed `stopFailureCloseCauseError()` returned a
  backend-side Error instead of the actual upstream close cause.

  Fix: introduced `TeardownFailureLedger`:
    interface TeardownFailureLedger {
      readonly backendStop: Error | null;
      readonly tuiStop: Error | null;
      readonly upstreamClose: Error | null;
      readonly upstreamAbort: Error | null;
    }
  Each of the four teardown stages writes to its own slot. Nothing
  overwrites anything. Accessors:
    stopFailure(): Error | null           // primary Error identity
    stopFailureCloseCauseError(): Error | null   // ONLY upstream close
    stopFailureLedger(): TeardownFailureLedger   // full record
  Primary is picked from the ledger via
  `pickPrimary(): upstreamAbort > upstreamClose > backendStop > tuiStop`.

  Red repros:
    - 3-way (backend + close + abort) → ledger.{backendStop,
      upstreamClose, upstreamAbort} each identity-preserved;
      primary === upstreamAbort; stopFailureCloseCauseError() ===
      close error (NOT the backend one).
    - 4-way (backend + tui + close + abort) → all four slots
      preserved verbatim.

P1-2 — abort contract switched to Promise<void> (option A)

  Removed `SYNC_ABORT` symbol and `SyncAbortAcknowledgement`
  type. New signature:
    abort: () => Promise<void>;
  Declared as a FUNCTION PROPERTY (not a method) so
  TypeScript's `strictFunctionTypes` engages fully — the
  method-signature bivariance loophole would otherwise let
  `abort(): void {}` slip past.

  Lifecycle bounded-awaits the returned Promise against
  `UPSTREAM_ABORT_TIMEOUT_MS = 1000`. Rejection / timeout /
  synchronous throw all land the lifecycle in `stop_failed`
  with the ORIGINAL Error identity preserved. No
  detached-rejection surface at the type level: the lifecycle
  adopts the Promise; a rejection inside the abort body is
  handled by the awaited chain.

  §8 will review B's real transport for detached rejections
  spawned INSIDE the abort body (`void Promise.reject(...)`);
  that is a production wiring concern, not a type contract one.

P1-3 — per-fixture precise typecheck-negative harness

  Prior harness ran ONE tsc pass over a multi-class fixture
  and searched for class names + brand text in the total
  output — a bare exit-non-zero from an UNRELATED TS error
  elsewhere could satisfy the loose check.

  New per-fixture harness (`scripts/rfc030-abort-typecheck-
  negative.mjs`):
    - Each Bad*Abort in its own fixture file.
    - For EACH negative fixture: copy to tmp, run tsc, assert
        · exit non-zero
        · output mentions the fixture's own basename
        · output mentions the fixture's class name
        · output includes a TS2416 or TS2322 diagnostic
    - Positive baseline fixture: separate file; assert tsc
      exits 0 (positive sanity — proves the harness isn't
      false-red).
    - Meta-sanity: take the positive fixture, append an
      unrelated TS2322, and assert the negative-filter does
      NOT accept it (output lacks BadVoidAbort / BadNumberAbort
      / BadSyncSymbolAbort references).
  Real gate output:
    ok  negative fixture abort-negative-void.ts.fixture
        rejected with class + fixture + TS2416/TS2322 all
        mentioned
    ok  negative fixture abort-negative-number.ts.fixture ...
    ok  negative fixture abort-negative-syncsymbol.ts.fixture ...
    ok  positive baseline typechecks clean
    ok  meta-sanity: unrelated TS error (exit=2) does NOT
        pass the negative filter
    abort typecheck-negative PASS: 5/5

P1-4 — real rollback race (preflight rejection)

  Prior test awaited `lifecycle.start()` fully before racing
  emitClose + stop — never touched the rollback path.

  New test injects a delayed preflight rejection so that
  `rollbackStartFailure()` runs concurrently with `stop()` and
  `upstream.emitClose()`. All three funnel to the same
  memoised `teardownCorePromise`. Assertion: `upstream.close()`
  called AT MOST ONCE.

Evidence-delta — tagged winner in a real same-microtask race

  Prior test used `kind: "never"` — no actual same-boundary
  settle. New test schedules `close`'s resolve via
  `setTimeout(cb, UPSTREAM_CLOSE_TIMEOUT_MS + 20)` with
  `queueMicrotask(closeRes)` inside — the resolve is
  microtask-adjacent to the internal timer. Tagged-winner
  ensures state=stop_failed with the timeout message; the
  post-timeout close settle does NOT revert.

Evidence-delta — real late upstream response injection

  Prior test captured a uid then never emitted a response.
  New test:
    - Fires sendInternal → captures a real uid.
    - Runs stop() (drains mux via router unsubscribe + drainAll).
    - Injects `upstream.emitFrame({id: uid, result: {late: true}})`
      AFTER stop.
    - Asserts settleCount === 1 (rejection from stop),
      pendingUpstreamCount === 0 — NO re-delivery, NO revive.

Evidence-delta — @types/node exact pin

  `@types/node: "20"` was fuzzy. Bumped to exact `20.19.43`
  (the version installed at evidence-generation time).
  `typescript` remains exact 5.5.4; `@types/ws` remains exact
  8.18.1.

Files touched
  agent-node/package.json
  agent-node/src/runtime/codex-policy-gateway/{
    lifecycle.ts,
    uds-server.ts,
    integration-entry.ts,
    lifecycle.test.ts,
    uds-server.test.ts,
    upstream-router.test.ts,
    tui-ws-server.test.ts,
    fixtures/{
      abort-negative-void.ts.fixture  (new),
      abort-negative-number.ts.fixture  (new),
      abort-negative-syncsymbol.ts.fixture  (new),
      abort-positive-baseline.ts.fixture  (new),
      async-abort-negative.ts.fixture  (removed)
    }
  }
  agent-node/scripts/{
    rfc030-node-integration.mjs,
    rfc030-real-cli-e2e.mjs,
    rfc030-abort-typecheck-negative.mjs  (new),
    rfc030-async-abort-typecheck-negative.mjs  (removed)
  }

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    354 pass / 0 fail / 1099 expect() calls (10 test files)
    Delta vs round-1 (342): +12 round-2 corrective repros.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative harness:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 5/5
    - 3 negative fixtures rejected with class + fixture path
      + TS2416/TS2322 all mentioned
    - positive baseline clean
    - meta-sanity: unrelated TS error does NOT pass filter

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
Coordinator REQUEST CHANGES on a9c7175 with 3 tranche items:
P0 total non-throwing error normalisation, P1 precise negative
typecheck binding, evidence-delta rollback exact-1 + honest
same-checkpoint downgrade. Commit 1 `bd0dfd7` unchanged.
Frozen contract/protocol digests unchanged. No B rebase, no
Wave 2, no §8, no merge/deploy/latest/prod.

P0 — total non-throwing `toError()` normalisation (5 sites)

  Prior sites used `e instanceof Error ? e : new Error(String(e))`.
  A rejection whose `toString`/`valueOf` throw made the
  normalisation itself throw, which escaped the surrounding race
  handler and let `stop()` reject. Independent repro:
    {stopRejected:"Error:coercion_boom",state:"stopping",
     failure:null,ledger:{close:null,abort:null}}

  New helper in `lifecycle.ts`:

    function toError(value: unknown): Error {
      try { if (value instanceof Error) return value; }
      catch { /* poisoned Symbol.hasInstance on RHS proxy */ }
      let tag: string;
      try { tag = Object.prototype.toString.call(value); }
      catch { tag = "<un-stringifiable rejection value>"; }
      return new Error(tag);
    }

  Key choice: `Object.prototype.toString.call(x)` does NOT invoke
  user-defined `toString`/`valueOf`, so it cannot be poisoned by
  the caller's object. A synthetic Error is only ever the LAST
  resort — original Error identity preserved on the fast path.

  All five coercion sites in the teardown machinery updated:
    - boundedLocalStop stop_error → toError(e)
    - boundedLocalStop forceTerminate throw → toError(e)
    - closeUpstreamBounded close_error → toError(e)
    - closeUpstreamBounded abort_error → toError(e)
    - closeUpstreamBounded outer catch → toError(e)

  Fire-and-forget cascade defensive `.catch`:
    void this.stop().catch(() => { /* consumed defensively */ });
  So a hypothetical future regression that made `stop()` reject
  cannot surface as unhandled from the upstream-close cascade.

  Repro matrix (each: stop() FULFILLED, terminal state
  reached, NO unhandled rejection):
    - upstream close throws non-stringifiable value
    - upstream abort throws non-stringifiable value
    - backend stop throws non-stringifiable value
    - TUI stop throws non-stringifiable value
    - forceTerminate throws non-stringifiable value
    - upstream-close cascade: close+abort both throw
      non-stringifiable values → no unhandled from cascade

P1 — precise negative typecheck harness

  Prior harness declared `expectedLineRegex` but never applied
  it. A mutation like `implements UpstreamTransport` on a class
  whose `close` had wrong signature would produce a TS2416 on the
  `close` line — still in the same class — and the harness would
  still see the class name + a TS2416 + "unique symbol", passing
  falsely.

  New harness parses tsc's `file(line,col): error TSxxxx: msg`
  output and, for each fixture:
    - Filters diagnostics to those in the fixture file.
    - For every fixture diagnostic: reads the source line at
      `diag.line` and asserts it matches the fixture's
      `expectedLineRegex` (which pins to the `abort:` property
      declaration line, not the class or any other member).
    - Asserts every fixture diagnostic is TS2416 or TS2322.
    - Asserts at least one fixture diagnostic exists (otherwise
      the bad implementation was not rejected).

  Two meta-mutations added:
    #1: append an unrelated `const bad: number = "not a number"`
        to the positive baseline. Bare "exit != 0 + class name in
        output" would let this pass; the precise abort-line
        filter rejects because the diag's line content is not an
        `abort:` line.
    #2: mutate `close(): void {}` inside the GoodTransport
        positive baseline. TS2416 emitted for the CLOSE line —
        NOT the abort line. Precise filter rejects.

  Real gate output:
    ok  negative fixture abort-negative-void.ts.fixture rejected;
        every fixture diagnostic points at the abort property
        line and is TS2416/TS2322
    ok  negative fixture abort-negative-number.ts.fixture ...
    ok  negative fixture abort-negative-syncsymbol.ts.fixture ...
    ok  positive baseline typechecks clean
    ok  meta-sanity #1: unrelated TS error (exit=2) rejected by
        precise abort-line filter (diag at line 30 does not
        match abort-property regex ...; line content: `const bad
        : number = "not a number";`)
    ok  meta-sanity #2: bad NON-abort property (close) rejected
        by precise abort-line filter (diag at line 24 does not
        match abort-property regex ...; line content: `close():
        void {}`)
    abort typecheck-negative PASS: 6/6

Evidence-delta — rollback three-entry exact-1

  Prior test asserted `upstream.closeCallCount <= 1`. The `<= 1`
  admits 0, which is what actually happened when emitClose fired
  in the pre-active `subscribed` router state (that path records
  a pre-active close but does NOT invoke
  `onUpstreamCloseFromRouter` — the cascade fires on ACTIVE close).

  Fix: added an observable counter to the lifecycle —
  `teardownCoreEnteredCount(): number`. All three shutdown entry
  points (public `stop()` via `doOuterStop`, upstream close
  cascade, start rollback) share the memoised
  `teardownCorePromise`, so this counter increments EXACTLY ONCE
  per successful teardown regardless of how many callers raced.

  New test drives:
    - Deferred preflight promise (parks start() in preflight
      await).
    - `upstream.emitClose()` (pre-active fence flip).
    - Two concurrent `lifecycle.stop()` calls.
    - Resolve preflight rejection → start rollback runs.
  Assertions:
    - startP rejected, stopP1/stopP2 fulfilled.
    - `stopP1 === stopP2` (Promise identity).
    - `lifecycle.teardownCoreEnteredCount() === 1` (exact-1).

Evidence-delta — same-checkpoint test honestly renamed

  Prior title claimed "same microtask boundary" but the impl was
  actually close-resolves-20-ms-AFTER-timeout — a plain
  post-timeout scenario, not a same-boundary one. A true
  same-boundary race would need a controllable clock/race seam
  that this design does not expose.

  Honest downgrade: renamed to "post-timeout close settle does
  NOT revert stop_failed". Same test now:
    - Schedules close's resolve at UPSTREAM_CLOSE_TIMEOUT_MS + 20
      via queueMicrotask.
    - Captures state + primary Error immediately after stop().
    - Waits 200 ms so the late close resolve DEFINITELY fires.
    - Asserts state === "stop_failed" is UNCHANGED, primary
      Error is the SAME reference. This IS the tagged-winner
      invariant a mutable-flag design could violate; we
      explicitly pin it here.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    360 pass / 0 fail / 1123 expect() calls (10 test files)
    lifecycle.test.ts alone: 63 pass (+6 round-3 repros).
    Delta vs round-2 (354 → 360): +6.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 15d5f67c pre-submit)

Coordinator sent pre-submit corrections after 369c9b8 was
pushed. Four items — each a small tightening of the round 3
work. Commit 1 `bd0dfd7` unchanged. Frozen contract/protocol
digests unchanged.

Fix 1 — split rollback exact-1 into TWO honest tests

  Prior round-3 test's own comment admitted that pre-active
  `emitClose` in the router's `subscribed` state does NOT
  invoke `onUpstreamCloseFromRouter` (the cascade fires only
  on active close). So the previous test never actually
  exercised the upstream-close cascade entry point. Split
  into two distinct, honest tests:

  · `preflight rollback × concurrent stop()` — held-open
    preflight, two concurrent stops, then reject preflight.
    Start's catch runs `rollbackStartFailure()`; both stops
    await `startInProgress`. Asserts:
      teardownCoreEnteredCount() === 1
      upstream.closeCallCount === 1  (was `<= 1`; now EXACT)
      upstream.abortCallCount === 0
      stopP1 === stopP2

  · `active upstream-close cascade × concurrent stop()` —
    lifecycle.start() completes fully (router activated), then
    `emitClose` fires the REAL cascade, then two concurrent
    stops. Asserts the same exact-1 invariants and terminal
    state = `stopped`.

  No more `<= 1` and no more "conceptually three entry points
  but only two really fired".

Fix 2 — teardownCoreEnteredCount() marked @internal

  Prior JSDoc described it as an observable counter but did not
  gate its production-API status. Added an explicit `@internal`
  tag and a note that it is a TEST-ONLY seam, not exported from
  `integration-entry.ts`, and removing it would not be a
  breaking change. Not surfaced to the ESM bundle.

Fix 3 — meta-sanity #2 HARD FAIL when mutation produces no diag

  Prior harness had a "skipped PASS" branch when the mutated
  positive baseline still typechecked clean. That is not a
  passing signal — the meta test has no measurement. Changed to
  a hard FAIL with the message "mutation did NOT cause a tsc
  diag; meta test has no signal". Local run still 6/6 because
  the `close(): void {}` mutation DOES produce TS2416 under
  strict mode.

Fix 4 — toError() fallback proven by Proxy Symbol.toStringTag repro

  `Object.prototype.toString.call(x)` reads `Symbol.toStringTag`
  on `x`. A Proxy whose `get` trap throws unconditionally will
  make Object-prototype toString throw too — `toError` must then
  fall through to the fixed synthetic marker without touching
  any other property.

  New test:
    - Constructs `new Proxy({}, {get, has})` where both traps
      throw.
    - Rejects `upstream.close()` with that Proxy.
    - Runs `lifecycle.stop()` — must fulfill.
    - Asserts NO unhandled rejection, terminal state reached,
      `stopFailureLedger().upstreamClose` is an Error object
      with a non-empty message (either the synthetic marker or
      the Object-prototype tag — either is acceptable; the
      invariant is convergence + real Error identity).

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    362 pass / 0 fail / 1137 expect() calls (10 test files)
    lifecycle.test.ts alone: 65 pass (round-3 = 63; +2 =
      split rollback into 2, +1 Proxy toStringTag, -1 old
      catch-all test).

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6 (meta-sanity #2 now
      HARD FAIL on no-diag mutation — currently green because
      close(): void {} does produce TS2416)

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 13dd3853 + dd12966c)

Coordinator REQUEST CHANGES on a014e39: 4 evidence-only items
(stale describes, title mismatch, public teardown counter,
loose terminal assertions) + 1 runtime blocker (doStart
construction throw leaves state='starting' with dangling refs).
Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged.

A — doStart full-body unified catch (dd12966c runtime blocker)

  Prior `doStart()` only wrapped the four phase awaits + router
  subscribe. Synchronous throws INSIDE construction
  (BackendUdsServer / TuiWsServer / UpstreamRouter constructors)
  OR from `router.activate()` escaped unhandled — start()
  rejected but state stayed `starting` with mint bearer +
  partial refs dangling.

  Fix: extracted `doStartInner()` (all prior body) and wrapped
  it in a unified outer try/catch inside `doStart()` that
  funnels ANY throw through `rollbackStartFailure()` → the
  memoised teardown core. The per-phase try/catch blocks stay
  as-is (redundant but harmless — they all hit the same
  memoised core).

  Three red repros:
    · `backendCapability='short'` (real BackendUdsServer
      constructor throw) → start rejects; state === "stopped";
      bearer unclaimable; socket path does NOT exist; ledger
      stages all null (no teardown-side failure); follow-up
      stop() returns SAME memoised Promise (idempotent).
    · Synthetic `ConstructThrowTransport.onFrame` throw →
      routes into UpstreamRouter constructor / subscribe; start
      rejects with the synthetic message; same clean-up
      invariants.
    · Synthetic `router.activate = throw` (patched during a
      held preflight window) → start rejects with the synthetic
      message; terminal reached (stopped or stop_failed);
      socket cleaned by async rollback; teardown core entered
      EXACTLY once.

B — deleted two stale describes (13dd3853 #1 + #2)

  · `Commit 2 corrective #4 — rollback shares single-flight
    core with stop×upstream close` — its only test used
    `await lifecycle.start()` (fully successful start) then
    `emitClose` + `stop()`, which does NOT exercise rollback.
    Superseded by the round-3 honest split (`preflight
    rollback × stop` + `active upstream-close cascade × stop`).
  · `Commit 2 corrective #6 — tagged winner race (timeout
    wins deterministically)` — its title claimed "AT SAME
    microtask boundary" but the impl was `kind:"never"` (plain
    post-timeout). Superseded by the round-3 honest
    "post-timeout close settle does NOT revert stop_failed"
    test.

  Both describes are replaced by explanatory comments so
  future readers see why they were removed.

C — title alignment (13dd3853 #3)

  The two round-3 honest split tests' titles said "close+abort
  each EXACTLY 1" but assertions were `close === 1, abort ===
  0` (clean close does NOT invoke abort). Corrected titles to
  "close EXACTLY 1; abort 0 (clean close)" in both tests.

D — teardownCoreEnteredCount privatised (13dd3853 #4)

  Prior public method `teardownCoreEnteredCount(): number` on
  `GatewayLifecycle` (which IS exported via `integration-
  entry.ts`) was a public runtime surface added purely for
  tests. Removed the accessor. Tests now reach the private
  field via cast:
    `(lifecycle as unknown as { teardownCoreEnteredCountValue:
       number }).teardownCoreEnteredCountValue`
  No production API surface added for test-only observation.
  Removed the misleading "Not exported / not public" JSDoc
  that lied about the accessor's status.

E — non-Error rejection tests tightened to exact terminal
  (13dd3853 tail item)

  Five `expect(["stopped", "stop_failed"]).toContain(...)` sites
  were green-wash (a truthful `stop_failed` case could
  accidentally be reported as `stopped` and still pass).
  Tightened per-case:
    · preflight-rollback × stop test → `stopped` (all ledger
      stages null; nothing in teardown failed) + assertions on
      each ledger slot === null.
    · Hostile abort-return matrix (6 cases) → `stop_failed`
      (close threw → ledger.upstreamClose populated) +
      ledger.upstreamClose !== null.
    · UpstreamRouter close-hostile-rejection Proxy test →
      `stop_failed` + ledger.upstreamClose !== null.
    · SocketRedactor Proxy Symbol.toStringTag test →
      `stop_failed` + ledger.upstreamClose is a real Error.
    · Cascade path close+abort both hostile → `stop_failed` +
      ledger.upstreamClose !== null AND
      ledger.upstreamAbort !== null.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    363 pass / 0 fail / 1164 expect() calls (10 test files)
    lifecycle.test.ts alone: 66 pass. Delta vs round-3
    (65 → 66): -2 stale describes deleted, +3 doStart
    full-body catch tests.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
Coordinator REQUEST CHANGES on bd5c6a9 — new runtime blocker
in shutdown single-flight PLUS 3 evidence tidies. Commit 1
`bd0dfd7` unchanged. Frozen digests unchanged. No B rebase, no
Wave 2, no §8, no merge/deploy/latest/prod.

P0 runtime — reentrant shutdown single-flight (memo before body)

  Prior `stop()` and `runTeardownCore()` were:
    this.memo = this.asyncBody();   // <-- RHS runs sync until
                                    //     first await; memo
                                    //     assignment happens
                                    //     AFTER
    return this.memo;

  `doTeardownCore()`'s synchronous prefix calls
  `this.upstreamRouter.unsubscribe()` before its first await. If
  a transport's frame-unsubscribe synchronously fires close
  handlers, `onUpstreamCloseFromRouter → void this.stop()`
  reenters — with `this.shutdownPromise === null` because the
  outer memo assignment hasn't run yet. Second teardown core
  starts. Coordinator's independent evidence:
    entries=2, closeCalls=2, samePromise=false,
    plus post-await state drift stopped ↔ stop_failed.

  Fix: assign the memo BEFORE any body code runs by wrapping
  the body in a `Promise.resolve().then(() => body())`. The
  body executes on a microtask, so:

    stop(): Promise<void> {
      if (this.shutdownPromise !== null) return this.shutdownPromise;
      this.stopRequested = true;
      this.shutdownPromise = Promise.resolve().then(() => this.doOuterStop());
      return this.shutdownPromise;
    }

  Same pattern for `runTeardownCore()`. Public Promise identity
  preserved (`this.shutdownPromise` is set BEFORE return; every
  reentrant `stop()` returns the same reference).

  Two red repros — new `SyncCloseOnUnsubscribeTransport` whose
  `onFrame`'s unsubscribe callback synchronously fires close
  handlers:

    Test A: sync unsubscribe → sync close → reentrant stop():
      - outer stop() and follow-up stop() return the SAME
        Promise reference (`followUpP === outerP`).
      - teardownCoreEnteredCountValue === 1 (exactly one core).
      - upstream.closeCallCount === 1 (exactly one close).
      - state === "stopped".

    Test B: first close rejects; reentrant path would-be clean:
      - core count === 1, close count === 1.
      - state at stop() return === "stop_failed".
      - primary Error === first-close-reject original identity.
      - ledger.upstreamClose === first-close-reject original.
      - After a 300 ms wait (well past round-4's ~120 ms drift
        window), state / primary / ledger are ALL IDENTICAL to
        the captured snapshot — no post-await drift.

Evidence tidies (副指挥 64fad5de tail)

  1) Renamed the "UpstreamRouter constructor throws" test to
     "UpstreamRouter.subscribe() throws" — the onFrame throw
     happens INSIDE `router.subscribe()`, not the constructor.
     Renamed `ConstructThrowTransport` → `SubscribeThrowTransport`
     and error message → `synthetic_router_subscribe_throw`.

  2) Tightened the activate-throw test terminal from
     `["stopped","stop_failed"]` to EXACT `"stopped"` +
     four-ledger-null + bearer unclaimable + socket unlinked.
     A clean rollback in a fully-bound state IS `stopped` with
     zero ledger failures; the loose check was green-wash.

  3) Deleted the stale "Start ordering" JSDoc on `start()` (its
     7-step list described the pre-Commit-2 flow). Replaced
     with a concise note pointing at the round-4 unified
     try/catch. Also removed the misleading "Test seam accessor:
     `teardownCoreEnteredCount()`" line from the counter's
     JSDoc — that public accessor was removed in round 4, tests
     cast into the private field.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    365 pass / 0 fail / 1181 expect() calls (10 test files)
    lifecycle.test.ts alone: 68 pass. Delta vs round-4
    (66 → 68): +2 reentrancy repros.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 8cd477e9 + 898684e3)

Coordinator REQUEST CHANGES on 4a4cd4d — Round 5's
`Promise.resolve().then(...)` opened a shutdown admission
window (state stayed `running` synchronously; bearer still
claimable; sendInternal still wrote). Round 6 replaces the
delayed-body memo with a SYNC deferred publish + SYNC state
fence, then invokes the body immediately. Universal invariant
across ALL admission entries, matrix-covered.

Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged.

Universal shutdown-visibility invariants (副指挥 898684e3)

  I1  shutdown intent + admission gate SYNC visible at stop()
      return. All entries (start / stop / teardown / upstream-
      close / sendInternal / bearer-launch / preflight fence)
      observe closing/terminal.
  I2  outer + core memos SYNC visible before any body /
      transport callback / await / unsubscribe runs. Every
      entry shares the same Promise / core.
  I3  first terminal outcome + failure ledger STABLE after
      stop() Promise observed. Reentrant / late completions
      never wash the failure.
  I4  admission facts drawn from unified `state`; no per-entry
      exceptions. `state` transition drives every gate:
        stop() sync-fence:
          running  → stopping
          starting → stopping
          created  → stopped
        Bearer rotate sync (takeTuiBearerPlaintextForLauncher
        checks `state !== "running"`).
        sendInternal already checks `state !== "running"`.
        start() already checks `state !== "created"`.

Implementation (single mechanism satisfies all four)

  stop(): Promise<void> {
    if (this.shutdownPromise !== null) return this.shutdownPromise;
    this.stopRequested = true;
    this.applySyncAdmissionFence();   // I1 + I4
    let resolveShutdown!: () => void;
    let rejectShutdown!: (e: unknown) => void;
    const deferred = new Promise<void>((r, j) => { ... });
    this.shutdownPromise = deferred;  // I2 (sync publish)
    this.doOuterStop().then(resolveShutdown, rejectShutdown);
    return this.shutdownPromise;
  }
  // Same pattern for runTeardownCore().

  `applySyncAdmissionFence()`:
    running / starting  → stopping
    created             → stopped
    tuiBearer.rotate()  (sync — bearer claim refused immediately)

Test matrix — 5 new cases in
`describe("Commit 2 corrective round 6 ... universal shutdown-
visibility invariants")`:

  I1  running + stop() → SYNC state=stopping, bearer=null,
      sendInternal reject, upstream writes=0 (same call stack
      AND +1 microtask).
  I1+I4  created + stop() → SYNC state=stopped; same-turn
      start() throws; preflight NEVER called; stop resolves
      within 500 ms even with a never-resolving preflight.
  I1  bearer claim + sendInternal after stop() return + 1
      microtask → both refused; upstream writes unchanged.
      (This is the direct round-5 regression window.)
  I2+I4  sync unsubscribe → sync close handler → REENTRANT
      stop() captured by a spy installed AFTER start; every
      captured promise === outerP; core=1; close=1. Honest
      round-5 evidence fix — captures the INNER reentrant
      call, not a follow-up.
  I3  first close rejects (delayed 10 ms), hypothetical
      second call would be CLEAN → close called EXACTLY
      once; ledger.upstreamClose === firstCloseErr; state ===
      "stop_failed"; 300 ms drift window → state / primary /
      ledger IDENTICAL to the captured snapshot. Load-bearing
      transport class asserts second-call-would-be-clean so
      a regression that opened double-core would OVERWRITE
      the ledger to null.

Deleted / renamed for evidence honesty

  · Removed the SyncCloseOnUnsubscribeTransport-based round-5
    tests that measured a follow-up stop() rather than a real
    reentrant call. The Round-6 spy test above supersedes.
  · Round-4 subscribe-throw test's inline comment was already
    corrected in round 5 (see "副指挥 64fad5de evidence"). No
    further changes needed there.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    368 pass / 0 fail / 1201 expect() calls (10 test files)
    lifecycle.test.ts alone: 71 pass. Delta vs round-5
    (68 → 71): -2 round-5 follow-up-based tests, +5 round-6
    universal-invariant matrix.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Matrix table (case → assertion → PASS)

  case                          | assertion                                        | PASS
  ------------------------------|--------------------------------------------------|-----
  I1 running + stop             | state != running SYNC; bearer null; write=0      | ok
  I1+I4 created + stop + start  | state stopped SYNC; start throws; preflight=0    | ok
  I1 bearer + send + 1 microtask| bearer null; sendInternal reject; write=0        | ok
  I2+I4 reentrant stop spy      | outer === reentrant; core=1; close=1             | ok
  I3 first reject / drift 300ms | closeCalls=1; ledger/primary stable; state stable| ok

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 b65ebc50 + e8cdc302)

Coordinator REQUEST CHANGES on 5488efa — Round 6 closed the
shutdown-side admission window but the start side still had:
  P0-1 `startInProgress` published AFTER doStart's sync prefix
       ran (reentrant stop during subscribe saw null → teardown
       fired before subscribe stored unsub → transport handlers
       leaked; never-resolving preflight wedged stop forever).
  P0-2 activate() synchronously dispatched buffered frames; a
       diagnostics sink calling stop() sync-bumped the fence but
       doStart unconditionally wrote `state = "running"` after
       activate returned → admission revived, sendInternal wrote.

Round 7 closes these under the same universal invariants as
Round 6, using a monotonic lifecycle epoch + shutdown-signal
promise. Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged.

Universal invariants extended for the start entry (副指挥 e8cdc302)

  E1  START ADMISSION captures `startEpoch` before any body.
  E2  `startInProgress` deferred SYNC-published before any body
      runs — reentrant stop always observes an in-flight start
      and awaits it (never proceeds while subscribe is
      mid-registration).
  E3  Every start continuation checks
      `startEpoch === this.lifecycleEpoch`. Any shutdown intent
      monotonically bumps the epoch inside
      `applySyncAdmissionFence()`. A stale continuation refuses
      to advance.
  E4  Preflight is raced against a shutdown-signal Promise
      (resolved inside `applySyncAdmissionFence`). A
      never-resolving preflight cannot wedge stop; late resolves
      are safely consumed via `.catch`.
  E5  Post-activate CAS commit — `state = "running"` written
      ONLY if (`startEpoch === lifecycleEpoch` && `state ===
      "starting"` && `!stopRequested` && router state !==
      "terminal"). Any mismatch rolls back via the same memoised
      teardown core.

Implementation

  Added fields:
    private lifecycleEpoch = 0;                         // E3
    private shutdownSignalResolve: () => void;          // E4
    private shutdownSignalPromise: Promise<void>;

  `applySyncAdmissionFence()`:
    - `this.lifecycleEpoch++`  (E3 monotonic bump)
    - state transition + bearer rotate  (Round-6)
    - `this.shutdownSignalResolve()`  (E4 signal fire)

  `start()`:
    - captures `startEpoch = this.lifecycleEpoch`  (E1)
    - creates a deferred; assigns
      `this.startInProgress = deferred` SYNC  (E2)
    - `this.doStart(startEpoch).then(resolve, reject)` bridges
    - awaits the deferred (identity preserved)

  `doStart(startEpoch)` / `doStartInner(startEpoch)`:
    - `throwIfAbortedAfterAwait` upgraded to check
      `startEpoch !== this.lifecycleEpoch` FIRST  (E3)
    - preflight now: `Promise.race([preflightP,
      shutdownSignalPromise.then(() => { throw ... })])` with
      `preflightP.catch(() => {})` for safe-consume  (E4)
    - post-activate: CAS check on epoch + state + stopRequested
      + router terminal; rollback via memoised core; only then
      `this.state = "running"`  (E5)

Red matrix (副指挥 b65ebc50 + e8cdc302)

  Case A (P0-1 sync-stop in preflight, then never-resolve):
    Preflight synchronously calls `lifecycle.stop()` then returns
    a never-resolving Promise. Both start and stop MUST settle
    within a bounded window. Terminal `stopped`; socket unlinked.

  Case B (external stop during held never-preflight):
    Preflight returns a never-Promise; caller invokes `stop()`
    from outside. Shutdown signal races preflight to unblock
    doStart; rollback runs; stop resolves within 500 ms.

  Case C (transport.onFrame handler sync-calls stop during
    `router.subscribe()`):
    Reentrant stop awaits `startInProgress` (SYNC-published
    before any body). Teardown deferred until start settles →
    subscribe completes storing unsub functions → teardown's
    `router.unsubscribe()` finds real unsub refs → transport's
    `frameHandlers.length === 0` and `closeHandlers.length === 0`.
    NO handler leak.

  Case D (activate synchronously dispatches buffered notification
    that hits diagnostics sink calling stop):
    Activate returns; the epoch/state CAS refuses admission. No
    upstream write. Terminal is `stopped`/`stop_failed`; NEVER
    a stale `running` snapshot after a shutdown signal.

Regex updates in two pre-existing tests

  `preflight ordering` and `P0#6 stop-during-preflight epoch
  fence` tests accept the new error string
    `"start aborted: shutdown signalled during preflight"` OR
    `"start aborted: lifecycle epoch changed (preflight)"`
  in addition to the pre-existing
    `"start aborted by concurrent stop (preflight)"` etc.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    372 pass / 0 fail / 1213 expect() calls (10 test files)
    lifecycle.test.ts alone: 75 pass (round-6 = 71; +4 Round-7
    start-side reentrance matrix).

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Matrix table (case → assertion → PASS)

  case                                  | assertion                                          | PASS
  --------------------------------------|----------------------------------------------------|-----
  A  sync-stop in preflight + never     | start rejects, stop resolves, state=stopped        | ok
  B  external stop + held never-preflight| stop settles bounded, start rejects, state=stopped| ok
  C  onFrame sync-stop during subscribe | frameHandlers=0 closeHandlers=0 (no leak)          | ok
  D  activate diagnostics sync-stop     | no revive to running, upstream write=0, terminal   | ok

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 ef331a80 + cb54a10e)

Coordinator REQUEST CHANGES on db025ee — Round 7's start-side
epoch/CAS closed the activate revive, but two runtime blockers
remained:
  P0-1  Pre-active close (during router `subscribed` state) only
        set `receivedCloseBeforeActive` locally; it did NOT fire
        the shutdown signal. A never-resolving preflight left
        `Promise.race([preflight, signal])` unresolvable.
  P0-2  `preflightP.catch(() => {})` read a caller-poisonable
        `.catch` getter → threw at attach → out-of-band throw
        surfaced as a start rejection AND the original preflight
        rejection later became an unhandledRejection.

Coordinator's follow-up (cb54a10e) added: a sync `stop()` fired
inside `transport.onFrame` during `router.subscribe()` allowed
`preflight.run()` to still be called (preflightCalls=1) because
there was no fence between subscribe returning and preflight being
invoked.

Round 8 addresses all three under the same universal invariants.

Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged. No B
rebase / Wave 2 / §8 / merge / deploy / latest / prod.

P0-1 pre-active close wired into shutdown signal (upstream-router)

  `UpstreamRouterOptions` gains an optional `onPreActiveClose`
  callback. `router.onClose()` in the `subscribed` state:
    - sets `receivedCloseBeforeActive = true` (existing)
    - invokes `onPreActiveClose` AT MOST ONCE per instance (new)

  Lifecycle wires this to `applySyncAdmissionFence()`, which bumps
  the epoch, transitions state, rotates the bearer, AND fires the
  shutdown signal — the SAME universal fence used by `stop()`. A
  pre-active close, sync or async, now unblocks
  `Promise.race([preflight, shutdownSignal])` immediately without
  needing an external stop() call.

Subscribe → preflight CAS fence (cb54a10e blocker)

  Prior code: `router.subscribe()` returns → next line is the
  preflight race. If the sync prefix of subscribe (via `onFrame`
  registration) triggered a reentrant `stop()` or delivered a
  pre-active close, the epoch was already bumped — but preflight
  was still called AND its handler could write a frame.

  New: an immediate CAS fence after subscribe:

    const preActiveClosePre = this.upstreamRouter.wasCloseBeforeActive();
    if (startEpoch !== this.lifecycleEpoch
        || this.stopRequested
        || preActiveClosePre) {
      const msg = `start aborted before preflight: ...`;
      await this.rollbackStartFailure();
      throw new Error(msg);
    }

  This is not a "point patch" — it uses the SAME three facts
  (`lifecycleEpoch`, `stopRequested`, router pre-active close)
  the round-7 continuations check. The fence just applies them
  BETWEEN subscribe and preflight so `preflight.run()` is never
  invoked when a shutdown intent has already fired.

P0-2 safe adoption of caller-provided promises

  Removed `preflightP.catch(() => { /* handled */ })`. Added
  `const safePreflightP = Promise.resolve(preflightP);` before
  the race. Rationale (audited sites listed below):

    - `Promise.resolve(x)` uses the native adoption path. If `x`
      is a real Promise its identity is preserved; if `x` is a
      thenable, `.then` is called via native machinery.
    - The instance's own `.catch` is NEVER read.
    - `Promise.race` then attaches its own `.then` handler to
      `safePreflightP` via the native machinery. A late rejection
      of `preflightP` propagates through `safePreflightP` and
      hits the race's handler → NO unhandled event.

  Audited sites in this tranche (every place a caller-provided
  promise is adopted; ALL now use `Promise.resolve()` or the
  native `Promise.race`/`Promise.prototype.then.call` path):

    - lifecycle.ts `preflight.run()` result — wrapped in
      `Promise.resolve()` before race.
    - lifecycle.ts `upstreamTransport.close()` / `.abort()` in
      `closeUpstreamBounded()` — already used
      `Promise.resolve().then(() => x)` pattern; late rejection
      is consumed by the chained `.then(fulfilled, rejected)`
      via native machinery (no instance `.catch` read).
    - lifecycle.ts `backendServer.stop()` / `tuiServer.stop()` in
      `boundedLocalStop()` — same `Promise.resolve().then()`
      pattern; safe.
    - lifecycle.ts `startInProgress` / `shutdownPromise` /
      `teardownCorePromise` — created by us via `new Promise(...)`;
      native throughout.

Deterministic activate-CAS red

  Previous round-7 activate test used `setTimeout(0)` inside
  `onFrame` to schedule a notification — nondeterministic. New
  Round-8 test:
    - Holds preflight open via a deferred.
    - Captures the frame-handler in the transport's `onFrame` as
      `deliverFrame`.
    - Manually calls `deliverFrame(notificationFrame)` while the
      router is in pre-active `subscribed` state → notification
      is buffered.
    - Releases preflight → doStart continues → bind / activate.
    - Activate synchronously drains the buffer → the notification
      dispatch invokes `diagnostics.reportInternalError` (op
      `upstream_notification_dropped_phase1`) → sink sync-calls
      `stop()` → epoch bumps → post-activate CAS refuses admission.
  Assertions:
    - state NEVER "running".
    - `transport.writesCount === 0`.
    - `frameHandlers.length === 0` and `closeHandlers.length === 0`
      after cleanup.

Red matrix (副指挥 ef331a80 + cb54a10e)

  case                                              | expected                                                   | PASS
  --------------------------------------------------|------------------------------------------------------------|-----
  subscribe sync-close + never preflight            | preflightCalls=0; start reject; handlers=0; close=1; bounded settle | ok
  pre-active close during held never-preflight      | signal wakes race; start reject; handlers=0; bounded settle | ok
  poisoned `.catch` getter + late preflight reject  | 0 unhandled; 0 poisoned-getter reads by lifecycle; start reject | ok
  deterministic activate CAS via pre-buffer + sink  | never running; write=0; handlers=0; terminal reached       | ok

  Round-5/6/7 stability matrix retained (no regressions).

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    376 pass / 0 fail / 1236 expect() calls (10 test files)
    lifecycle.test.ts alone: 79 pass. Delta vs round-7 (75 → 79):
    +4 Round-8 pre-active close / subscribe-fence / poisoned-catch /
    deterministic activate-CAS repros.

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.
  - Router change limited to `upstream-router.ts` (frozen files
    NOT touched).

Author: comm eng
(副指挥 7d061fcd + ff8edc19)

Coordinator REQUEST CHANGES on bf0f8eb — Round 8 closed the
pre-active close signal wire but left two blockers:
  P0-1  Pre-active buffer OVERFLOW branch in `upstream-router`
        did NOT fire `onPreActiveClose`; a never-resolving
        preflight + async 257-frame overflow left start
        wedged in `starting` with handlers=1/1.
  P0-2  `Promise.resolve(nativePromise)` returns the SAME
        native reference; `Promise.race` then reads `.then`
        off the caller's OWN property → a poisoned getter
        surfaces as an unhandled rejection AND orphans the
        shutdown-signal race branch.
Plus (副指挥 ff8edc19): `writeFrame(...).catch(...)` in
`upstream-router.ts:361` and `uds-server.ts:682-684` still read
caller-poisonable `.catch` at property level.

Round 9 unifies the pre-active terminal helper AND introduces a
shared `safeAdopt` primitive across every caller-provided
promise adoption site. Commit 1 `bd0dfd7` unchanged. Frozen
digests unchanged.

Router — unified once-only pre-active terminal helper

  New private `firePreActiveTerminal()` in `UpstreamRouter`:
    - Sets `receivedCloseBeforeActive = true`.
    - Invokes `opts.onPreActiveClose?.()` (lifecycle wires this
      to `applySyncAdmissionFence()`).
    - Once-only guard `preActiveTerminalFired`.
  BOTH the close path (`onClose` in `subscribed` state) AND
  the buffer-overflow path (`onFrame` when `buffered.length ===
  PRE_ACTIVE_BUFFER_CAP`) funnel through this helper. Prior
  overflow branch only set the local flag; Round 9 fires the
  lifecycle signal so the never-preflight race can unblock
  without external stop.

New `safeAdopt` primitive (safe-adopt.ts)

  New file `src/runtime/codex-policy-gateway/safe-adopt.ts`
  exports:
    - `safeAdopt<T>(value): Promise<T>` — creates a FRESH
      native Promise, adopts settlement via
      `Promise.prototype.then.call(value, res, rej)` when
      `value instanceof Promise` (fast path: internal
      `[[PromiseState]]` slot, NEVER touches OWN `.then`
      getter). Foreign thenable fallback reads `.then` once
      in a protected try/catch.
    - `safeAdoptConsume<T>(value, onFulfilled?, onRejected?)`
      — attaches handlers on the fresh Promise so late
      rejections are consumed by intrinsic-safe attach; no
      unhandled event.

  Sites updated:
    · lifecycle.ts preflight race: `Promise.resolve(preflightP)`
      → `safeAdopt<void>(preflightP)`. `Promise.race` now
      operates on a fresh native — poisoned `.then` on the
      caller's real Promise never reaches race.
    · upstream-router.ts `dispatchReverseRequest`:
      `transport.writeFrame(...).catch(...)` → wrap the
      returned value in `safeAdoptConsume(writeResult, ...,
      () => report("upstream_reject_write_failed"))`. A single
      diagnostic fires on any failure mode (sync throw, real
      reject, poisoned getter).
    · uds-server.ts `sendInternal`:
      `writeResult.catch(failCleanup)` (with a poisoned-catch
      property read) → `safeAdoptConsume(writeResult,
      undefined, failCleanup)`. Same guarantees.

Red matrix (副指挥 7d061fcd + ff8edc19)

  case                                                         | expected                                                | PASS
  -------------------------------------------------------------|---------------------------------------------------------|-----
  held never-preflight + async overflow (257 frames)           | start reject bounded; handlers=0; close=1; state=stopped | ok
  subscribe registration sync overflow                         | preflightCalls=0; start reject; handlers=0/0            | ok
  safeAdopt: native Promise + poisoned OWN `.then` getter      | thenGetterReads=0; late reject: unhandled=0; stopped    | ok
  safeAdopt: poisoned `.then` + concurrent stop + late reject  | unhandled=0; stopped; handlers=0; ledger stable         | ok
  safeAdoptConsume: backend sendInternal + poisoned `.then`    | sendInternal rejects with underlying error; pending=0    | ok

Also Round 8's activate-CAS test tightened per 副指挥 7d061fcd:
  - `startFulfilled` MUST be false (start rejected via CAS, not
    silently resolved).
  - Terminal EXACT `stopped` (no wide `["stopped","stop_failed"]`).
  - Removed `void startErr / void startFulfilled` wide guards.
  - Downgrade narrative to test hardening (parent bf0f8eb was
    green under the setTimeout(0) flavour; the deterministic
    pre-buffer version is a tighter red gate, not a bug fix).

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    381 pass / 0 fail / 1267 expect() calls (10 test files)
    lifecycle.test.ts alone: 84 pass. Delta vs round-8
    (79 → 84): +5 Round-9 unified-pre-active + safeAdopt matrix.
    upstream-router.test.ts: retained pre-existing +2 helper
    tests (net delta measured across full suite).

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - Router change limited to `upstream-router.ts`
    (`firePreActiveTerminal` + `safeAdoptConsume` around
    `writeFrame`). New file `safe-adopt.ts` under the gateway
    subtree.
  - UDS server: `sendInternal` writeFrame now via
    `safeAdoptConsume`.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(通信龙 sign-off via 副指挥 9a9a198d — contract v9)

Round 10 implementation of the safe-adopt primitive per the
contract v9 signed off by 通信龙:
  - ordinary same-realm base native Promise only
  - foreign / subclass / cross-realm / own-constructor thenable
    all contract-reject, `.then` never called
  - arbitrary rejection reason propagated verbatim
  - callback return type `undefined` (compile-time async
    rejection)
  - captured intrinsics at module load, no fallback
  - close/abort reuse existing tagged winner / timer / ledger
  - trusted-bootstrap failure explicitly out of scope

Commit 1 `bd0dfd7` unchanged. Frozen contract.ts / protocol.ts
digests unchanged.

Files

  New:
    - src/runtime/codex-policy-gateway/safe-adopt.ts
    - src/runtime/codex-policy-gateway/safe-adopt.test.ts
    - src/runtime/codex-policy-gateway/fixtures/
        safe-adopt-negative-async.ts.fixture
        safe-adopt-negative-promise-return.ts.fixture
        safe-adopt-negative-return-mistyped.ts.fixture
        safe-adopt-positive-baseline.ts.fixture
    - scripts/rfc030-safe-adopt-typecheck-negative.mjs

  Modified:
    - src/runtime/codex-policy-gateway/upstream-router.ts
        · dispatchReverseRequest passes onCallbackError
          diagnostics to safeAdoptConsume.
        · removed unused public
          `preActiveTerminalFiredForTest()` accessor.
    - src/runtime/codex-policy-gateway/uds-server.ts
        · `InternalOrigin.reject(reason: unknown)` — reason
          type widened to `unknown`, verbatim propagation.
        · `buildInternalPendingEntry.outerReject: (reason: unknown)`
          — matching widening.
        · sendInternal `failCleanup` — mux release FIRST
          (try/catch) then `origin.reject(reason)` VERBATIM
          (try/catch); no instanceof/String coercion.
        · sendInternal passes onCallbackError diagnostics.
    - package.json: `typecheck:rfc030-safe-adopt-negative`
      script.

Threat model (documented in safe-adopt.ts JSDoc)

  Modifications made BEFORE this module loads:
    Captured references ARE the tampered ones — §8
    trusted-bootstrap failure, safe-adopt cannot detect.

  Modifications made AFTER this module loads:
    Same-process code overwriting global `Promise` /
    `Promise.prototype.then` / `Reflect.apply` bindings does
    NOT change the function references we already captured.

  MUTABLE prototype properties read by native algorithms
  dynamically — `Promise.prototype.constructor`,
  `Promise[Symbol.species]` — can still be mutated
  same-process. Captured NativeThen attach may fail (or a
  derived Promise creation inside the native algorithm may
  misbehave) BEFORE our handlers are installed. Detection
  is a §8 concern; `safeAdoptConsume`'s outer try/catch
  narrowly promises: a synchronous attach exception does
  NOT escape the caller's stack; it routes to
  `onCallbackError`.

Coverage notes

  `safeAdopt` source-attach catch — COVERED via
  `Object.create(Promise.prototype)`: shape check passes, but
  `[[PromiseState]]` slot is missing so the intrinsic attach
  throws; safeAdopt's outer try/catch converts the throw into
  a synthetic-Error rejection.

  `safeAdoptConsume` fresh-attach catch — NOT test-covered.
  Defensive branch documented as trusted-bootstrap failure
  surface. Selecting option 1 per 副指挥 a92ac7c6: do not
  fake coverage with a shared-process species/constructor
  mutation, do not run a subprocess for it. Deliberately
  marked defensive in the source.

  Callbacks — tests 12–14 are strictly labeled
  "callback-error coverage" (onFulfilled sync throw /
  onRejected sync throw / onCallbackError itself sync
  throw). No "attach-error coverage" claim.

Bun tests (safe-adopt.test.ts)

    valid base native Promise fulfil          ok
    valid base native Promise reject verbatim ok
    Object.create(Promise.prototype)          ok  (source-attach catch)
    foreign thenable                          ok
    cross-realm Promise (vm)                  ok
    Promise subclass                          ok
    OWN constructor descriptor                ok
    Proxy over real Promise (passes)          ok
    Proxy hostile getPrototypeOf              ok
    non-object / null / undefined             ok
    verbatim rejection reason (7 kinds)       ok
    poisoned OWN .then getter                 ok  (getterReads = 0)
    poisoned OWN .catch getter                ok  (getterReads = 0)
    safeAdoptConsume returns undefined        ok
    onFulfilled sync throw                    ok  (callback-error)
    onRejected sync throw                     ok  (callback-error)
    onCallbackError itself throws             ok  (all absorbed)

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    398 pass / 0 fail / 1317 expect() calls (11 test files)
    Δ vs round-9 (381 → 398): +17 safe-adopt.test.ts unit
      cases (17 pass; expect-count reflects test structure).

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Safe-adopt typecheck-negative:
    npm run typecheck:rfc030-safe-adopt-negative
    safe-adopt typecheck-negative PASS: 14/14
    Includes:
      - safe-adopt.ts CAPTURED_* module-private (source scan)
      - Every fixture imports production safeAdoptConsume
      - No fixture redeclares safeAdoptConsume locally
      - 3 negative fixtures each rejected with expected
        TS2345 / TS2322
      - Positive baseline typechecks clean
      - Meta-sanity: unrelated TS2322 does NOT match
        safeAdoptConsume-contract filter

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - `InternalOrigin` interface widened locally in uds-server.ts;
    frozen files NOT touched.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 fb2ec49a — canonical v9 fully applied)

Coordinator REQUEST CHANGES on c695f54 with 1 runtime P0 + 5
evidence gaps. All addressed. Contract v9 is now truly bound
to every caller-provided Promise site in the gateway.

Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged.

P0 runtime — lifecycle close/abort now route through safeAdopt

  Prior `closeUpstreamBounded` used:
    Promise.resolve()
      .then(() => this.opts.upstreamTransport.close())
      .then(...)
  which adopts the transport's return value via native
  Promise chain assimilation — that reads the caller's OWN
  `.then` getter. Independent repro (副指挥 fb2ec49a) with a
  same-realm base Promise + poisoned OWN `.then` getter:
    close path → state=stop_failed, unhandled=["late_close"]
    abort path → state=stop_failed, unhandled=["late_abort"]

  Fix — only the RAW return value flows through `safeAdopt`
  before the tagged-winner race:
    Promise.resolve()
      .then(() => safeAdopt<void>(this.opts.upstreamTransport.close()))
      .then<CloseWinner, CloseWinner>(...)
  Same shape applied to `abort()`. Protected sync invocation,
  tagged-winner race, timer, identity ledger, and Round 2-8
  matrix all retained.

  Red repros (three new lifecycle.test.ts cases):
    · close() returns real Promise with OWN poisoned `.then`
      getter → close_error captured; NO late unhandled;
      state=stop_failed EXACT.
    · abort() returns real Promise with OWN poisoned `.then`
      getter → abort adoption safe; NO late unhandled.
    · close() returns non-Promise (undefined) → contract
      reject at safeAdopt → close_error captured with
      synthetic-Error message; NO unhandled.

Evidence gaps closed (all in the same commit)

  #2  Proxy test in safe-adopt.test.ts made load-bearing.
      Prior test `void`-ed its assertions and mis-stated
      Proxy as "fulfil". Corrected: Proxy of a real Promise
      (default handlers) passes the shape prefilter but the
      captured NativeThen `Reflect.apply` accesses
      [[PromiseState]] on the Proxy (which lacks the slot)
      → contract reject with an intrinsic-attach-failed
      synthetic Error. Real assertions on `rejReason` +
      message.

  #3  callback + diagnostic double-throw test now uses
      counters: `fulCalls` and `cbErrCalls` both === 1;
      seen reason passed through to the sink is verified as
      the original `onFulfilled_sync_throw` Error identity.

  #4  Negative harness now precisely binds each fixture's
      target line via `targetLineRegex`. A fixture that
      emits the expected TS code but on the WRONG line
      (e.g. an unrelated same-code error) is rejected.
      Two meta mutations added (BOTH must FAIL any
      negative-fixture filter):
        A — positive baseline + unrelated TS2322 appended
        B — positive baseline + valid safeAdoptConsume call
            + unrelated `Number(0).toFixed(bad)` TS2345
      Real gate output: 15/15 including both meta mutations.

  #5  Missing entry-site tests landed:
        · Router `dispatchReverseRequest` writeFrame with
          poisoned-.then return: safeAdoptConsume consumes;
          `upstream_reject_write_failed` diagnostic fires
          EXACTLY once; NO unhandled.
        · Overflow → close ordering via `onPreActiveClose`
          callback COUNT (not private cast); count === 1
          after 257-frame overflow AND after subsequent
          close.
        · Close → overflow ordering: same callback count
          === 1 after close, subsequent 300-frame overflow
          does NOT re-fire.
        · Non-Error rejection reason (`Object.create(null)`)
          verbatim full chain: sendInternal reject receives
          the SAME reference (Object.is === true); pending
          count returns to 0.

  #6  JSDoc "Honest limitation: prototype-normalized inputs"
      section added to safe-adopt.ts. Documents that the
      3-check + intrinsic attach proves CURRENT shape +
      internal-slot-backed algorithm success, NOT
      provenance. A caller who has run
      `Object.setPrototypeOf(subclassInstance,
      Promise.prototype)` before handing off may pass the
      check; §8 production wiring gate is the layer that
      enforces provenance, not safe-adopt.

Evidence (raw, verbatim)

  Frozen SHA-256 (unchanged):
    contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
    protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

  git diff --check: clean

  Bun test:
    bun test src/runtime/codex-policy-gateway/
    405 pass / 0 fail / 1343 expect() calls (11 test files)
    lifecycle.test.ts: 88 pass (+4 corrective)
    upstream-router.test.ts: 16 pass (+3 corrective)
    safe-adopt.test.ts: 17 pass (Proxy + double-throw
      assertions tightened)

  Real tsc typecheck:
    npm run typecheck:rfc030
    exit 0, clean.

  Abort typecheck-negative:
    npm run typecheck:rfc030-abort-negative
    abort typecheck-negative PASS: 6/6

  Safe-adopt typecheck-negative:
    npm run typecheck:rfc030-safe-adopt-negative
    safe-adopt typecheck-negative PASS: 15/15
      · CAPTURED_* module-private scan
      · Every fixture imports production safeAdoptConsume
      · No fixture redeclares safeAdoptConsume locally
      · 3 negative fixtures each rejected with expected TS
        code ON THE TARGET LINE
      · Positive baseline typechecks clean
      · Meta-sanity A: unrelated TS2322 rejected by ALL
        negative filters
      · Meta-sanity B: valid call + unrelated TS2345
        rejected by target-line filter

  Build:
    bun run build -> dist/cli.js 0.90 MB (entry point, 0 errors)

  Node integration:
    npm run test:rfc030-node-integration
    real integration PASS: 60/60

  Real 0.144.0 CLI bootstrap smoke:
    RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
    real CLI bootstrap smoke PASS: 12/12

Boundaries

  - Frozen contract.ts / protocol.ts digests verified unchanged.
  - Commit 1 SHA `bd0dfd7` unchanged.
  - No B rebase, no production wiring, no Wave 2, no approval
    toggle, no §8 changes, no merge, no deploy, no latest, no
    preview.

Author: comm eng
(副指挥 7535c7cb — evidence-only tightening, NO runtime change)

Coordinator scoped-PASS on `bd11122` runtime; requested a
minimal evidence-only SHA closing 6 gate/test gaps. This
commit touches ONLY the negative harness, safe-adopt tests,
and existing corrective tests. NO runtime file changes.

Commit 1 `bd0dfd7` unchanged. Frozen digests unchanged.

Six gaps closed
──────────────

1. Meta-sanity B hard FAIL when tsc emits no diag

  Prior mutation used `"bad" as unknown as number` — the double
  cast made the call type-legal, tsc exited 0, and the harness
  treated "no diag" as still passing. New meta-B: append an
  EXTRA `safeAdoptConsume(...)` call whose 4th arg has
  `(_r): number => 42` (contravariance on the return type).
  That produces a REAL TS2345 diag on the appended call line,
  with a message that mentions the callback type but NOT the
  contract-violation sub-message. All three negative-fixture
  filters (code + target line + message) must reject it. If
  the mutation ever fails to emit a diag, the harness now
  HARD-FAILs — no more "unexpected; still passing".

2. Message binding on the negative filter

  Filter now matches `code + targetLineRegex + expectedMessageRegex`.
  `parseTscDiagnostics` was upgraded to capture multi-line tsc
  messages so sub-lines like
    "Type 'Promise<void>' is not assignable to type 'undefined'."
  are visible to `expectedMessageRegex`. The async/promise-return
  filters now bind to that specific sub-message rather than
  any mention of `Promise<void>` — a contravariance error on
  the same target line does not slip past.

3. Meta-sanity C — temporarily weakened contract + same-code
  same-line UNRELATED error MUST be filter-rejected

  New meta-C copies safe-adopt.ts to a temp dir with the callback
  return type mutated `undefined → void`, then writes a fixture
  that imports the weakened copy and calls it with a callback
  whose PARAMETER type is narrower than the contract
  (`async (_v: string) => {}`). Under strictFunctionTypes this
  emits a TS2345 on the same target-line pattern as the async
  fixture, but the message is about parameter contravariance
  ("Type 'unknown' is not assignable to type 'string'."), NOT
  about return-type assignability. The async fixture's
  `expectedMessageRegex` must reject it. Harness passes 16/16.

4. Proxy test tightened to the FINAL BRAND branch only

  Prior test accepted either "intrinsic attach failed" OR the
  early shape-reject message. The title claims the FINAL BRAND
  branch (shape prefilter passes, captured NativeThen fails on
  the Proxy). Now accepts ONLY the intrinsic-attach-failed
  message — a fall-through to early shape reject would be a
  behavior change and must fail loudly.

5. Callback-error identity via reference capture

  Sync-throw tests in safeAdoptConsume — including the
  double-throw counter test — now capture the thrown Error
  reference and assert `expect(seen).toBe(thrownErr)`. Message-
  only equality would allow a wrapper to silently synthesize a
  new Error and still pass; identity is the honest claim.

6. Lifecycle close/abort poisoned-.then getter counter + ledger
  identity

  Two Round-10 corrective tests now:
   - Count reads of the poisoned OWN `.then` getter. Captured
     `Reflect.apply(NativeThen, value, ...)` reads internal
     slots, so the getter count MUST stay 0. If safe-adopt
     regresses to reading `value.then`, the getter throws and
     produces a synthetic Error instead of routing the
     underlying reject through — this counter catches that
     regression.
   - Preserve the underlying-reject Error reference and assert
     `expect(ledger).toBe(underlyingErr)`. Proves `toError`
     does NOT wrap when the reason is already an Error, and
     safeAdopt propagates verbatim.

7. Router "close→overflow" renamed to POST-CLOSE flood +
  bufferOverflowedFlag()===false

  The earlier title over-claimed a "double event ordering". In
  reality, after close, subsequent frames hit the pre-active
  `receivedCloseBeforeActive` guard and are dropped BEFORE
  reaching the overflow branch. Renamed to "POST-CLOSE flood"
  and added `expect(router.bufferOverflowedFlag()).toBe(false)`
  both before and after the 300-frame flood. The onPreActive-
  Close callback count remains the primary invariant (still ===
  1 after close + flood).

Evidence (raw, verbatim)
────────────────────────

Frozen SHA-256 (unchanged):
  contract.ts  b36dd3f586aebae3960ec825ae1b978dfb36504ddb3590d76248c8f1dd5581f3
  protocol.ts  9488231872eb7341c3abb00cc89ff0dea87f3f80fcc90ef6c315c1299e278b9e

git diff --check: clean

Bun:
  bun test src/runtime/codex-policy-gateway/
  405 pass / 0 fail / 1347 expect() calls (+4 vs prior 1343)

tsc --noEmit:
  npm run typecheck:rfc030
  exit 0, clean

Abort typecheck-negative:
  npm run typecheck:rfc030-abort-negative
  abort typecheck-negative PASS: 6/6

Safe-adopt typecheck-negative (16/16, +1 meta-C):
  npm run typecheck:rfc030-safe-adopt-negative
  ok  safe-adopt.ts: no CAPTURED_* exports
  ok  (4 fixtures × 2) import + no-redeclare
  ok  3 negative fixtures rejected with expected TS code +
      target line + MESSAGE
  ok  positive baseline typechecks clean
  ok  meta-sanity A: unrelated TS2322 rejected by ALL neg filters
  ok  meta-sanity B: REAL diag TS2345@line25 rejected by ALL
      neg filters (code + target-line + message); harness HARD-
      FAILs on exit=0
  ok  meta-sanity C: weakened-contract mutation with same-line
      same-code UNRELATED error (contravariance) rejected by
      the message-bound async filter

Build:
  bun run build -> dist/cli.js 0.90 MB (0 errors)

Node integration:
  npm run test:rfc030-node-integration
  real integration PASS: 60/60

Real 0.144.0 CLI bootstrap smoke:
  RFC030_CODEX_BIN=<path> npm run test:rfc030-real-cli-smoke
  real CLI bootstrap smoke PASS: 12/12

Boundaries
──────────
- ONLY 4 test/harness files touched:
    scripts/rfc030-safe-adopt-typecheck-negative.mjs
    src/runtime/codex-policy-gateway/safe-adopt.test.ts
    src/runtime/codex-policy-gateway/lifecycle.test.ts
    src/runtime/codex-policy-gateway/upstream-router.test.ts
- NO runtime file changes. lifecycle.ts, upstream-router.ts,
  uds-server.ts, safe-adopt.ts untouched.
- Frozen contract.ts / protocol.ts digests verified unchanged.
- Commit 1 SHA `bd0dfd7` unchanged.
- No B rebase, no production wiring, no Wave 2, no approval
  toggle, no §8 changes, no merge, no deploy, no latest, no
  preview.

Author: comm eng
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants