Problem
Two facts, both checkable in this repository at f177520, with no capture required.
One — the field is parsed and thrown away. ThreadStatus's active arm carries a field named
activeFlags:
ThreadActiveFlag = { "enum": ["waitingOnApproval", "waitingOnUserInput"] }
{ "type": "active", "activeFlags": ["waitingOnApproval"] }
All three call sites read the type pointer and nothing else — src/codex_app_server.rs:702, :724,
:732 — all feeding observe_thread_status(status: &str) at :770, whose entire input is one
string. The rest of the status object is discarded at the parse site, and CodexControlState
(:189-201) has no field that could hold it.
Two — the delivery path never consults it. maybe_request selects a delivery method from
state.observed alone (src/codex_app_server.rs:415-419): Idle → Start, Active { turn_id } → Steer { turn_id }, everything else None. No flag, and no other property of the thread status,
reaches that decision.
Those two are the defect. What follows from them is stated separately below, because it is an
inference rather than an observation.
Fleet evidence
Across one catalog of 627 seats (#261, measured 2026-08-16), "working" and "stopped, waiting for
me to approve a command" are the same reading. Both are busy. An agent blocked on approval stays
blocked until somebody opens its pane, and at that seat count nobody opens them all.
What is measured, and what is not. The seat count is measured. The cost of not distinguishing
these two — how many seats sit blocked, and for how long — is not, and cannot be until the
distinction exists. That is the ordinary shape of an observability gap: the thing that would measure
it is the thing being asked for. It is stated that way rather than with a number we do not have.
Cause
observe_thread_status takes &str, so no caller can pass the rest of the status object without
changing the signature. CodexControlState (src/codex_app_server.rs:189-201) is additionally
#[serde(deny_unknown_fields)] (:190), so persisting the flags is a schema change to
st2.codex-control-state.v1 (:41, validated on load at :2243), not an additive field — an older
binary reading a newer record hard-errors rather than ignoring the new key. Whether that matters
depends on whether two binary versions ever read one host's state directory; it should be checked
before the field is added, not after.
What is proved, and what is not
Version scope first, because it bounds everything below. The activeFlags schema and the wire
capture both come from codex-cli 0.147.0.
SUPPORTED_CODEX_CLI_VERSIONS = ["codex-cli 0.145.0", "codex-cli 0.146.0"]
(src/codex_app_server.rs:38, enforced at :2101), so the field is unverified on the versions st2
actually supports. No older build was available to check against. No claim is made here that
activeFlags exists on 0.145 or 0.146.
Proved — on 0.147.0 the field is on the wire, not merely in the schema:
{"method":"thread/status/changed",
"params":{"threadId":"…","status":{"type":"active","activeFlags":[]}}}
The array is present and empty because nothing was pending.
In-repo, and weaker than it looks. st2's own test fixtures carry the field at
src/codex_app_server.rs:3328, :3618 and :3715, all
"status": {"type": "active", "activeFlags": []}, on a tree whose supported versions are 0.145 and
0.146. That is evidence the field was known to st2's authors, and it is a reason to expect it on a
supported version. It is hand-authored test data, not a capture, and it does not establish what
0.145 or 0.146 emit.
Not proved — a populated activeFlags was never captured. The account hit its ChatGPT usage
limit, so no model turn ran and no approval was ever reachable. That ["waitingOnApproval"] appears
when an approval is pending follows from the enum's naming and the field's placement inside the
active arm; it is schema evidence, not wire evidence.
The consequence, stated as an inference
This does not follow from an observation. It chains three steps and the middle one is unobserved.
- A session sitting on an approval prompt reports
status.type == "active". — Schema-supported,
never observed. activeFlags exists only on the active arm, which is why it is the natural
reading; nothing in any capture confirms it.
observe_thread_status therefore yields Active { turn_id }, the state whose doc comment
(src/codex_app_server.rs:160-163) says it is "the only state that permits turn/steer". —
Follows from step 1 and the match arms at :773-790.
maybe_request routes that to CodexDeliveryMethod::Steer (:415-419), so st2 injects a DING
into a session whose next keystroke answers a modal. — Follows from step 2.
If step 1 is wrong and Codex reports approval-pending under some other status word, then
observe_thread_status's trailing _ arm makes it Held{SystemError} and maybe_request returns
None (:420-421). No steer — and a different defect, the fabricated system error described in
Issue 1.
The one run that decides it. One codex app-server session under --approval-policy untrusted,
driven until a real approval is pending, capturing the thread/status/changed frame at that moment.
That needs no new code. The capture account's usage limit resets 2026-08-20, per the error
payload quoted in Issue 1. Until that run exists, this section is an inference and is written as one.
Steps 1-3 decide how urgent this is. They do not decide whether it is a defect: a protocol field is
discarded at the parse site regardless, and maybe_request consults nothing but the state word
regardless. Both are fixable, and testable, without the capture.
The steerability rule is not an invariant today
Worth stating because it changes what "do not break it" means here. The rule at
src/codex_app_server.rs:160-163 is a code comment. No row in INVARIANTS.md covers it and no
named test proves it — all rows were checked. CLAUDE.md says to add a row "only when a genuinely
load-bearing invariant appears, and only once a real test proves it". This rule gates native
delivery, and this issue is the change that forces the question, so it is the natural moment.
Also on the floor: the server→client approval requests
Ten server→client JSON-RPC requests carry a method and therefore reach observe()'s match and fall
through _ => return Ok(false) (src/codex_app_server.rs:765):
item/commandExecution/requestApproval item/fileChange/requestApproval
item/permissions/requestApproval item/tool/requestUserInput
mcpServer/elicitation/request applyPatchApproval / execCommandApproval (v1 legacy)
The exit edge is the serverRequest/resolved notification. activeFlags says that an agent is
blocked; these say what on, and the pairing is what would make blocked dwell time measurable.
This is not a deadlock. st2 attaches as an observer and the interactive TUI is the answering
client (module doc, src/codex_app_server.rs:1-10). An unanswered request does not stall the turn.
There is already a test injecting item/commandExecution/requestApproval
(src/codex_app_server.rs:3214) asserting only that it is not misparsed as a client response —
JSON-RPC request IDs are per-direction. That behavior must be preserved; extracting state here must
not start consuming these as responses.
Not proved. No live requestApproval frame was captured, for the same usage-limit reason.
One benign transit, noted so it is not rediscovered
From Idle, observe_thread_status("active") matches none of its explicit arms and falls to
Held{ActiveWithoutTurn} (:782-786), because thread/status/changed{active} always precedes
turn/started. Measured in both captures at 0.1 ms before turn/started corrects it. Harmless
as a live state; it matters only to a consumer that samples or records observed state.
Required behavior
activeFlags is parsed from thread/started and thread/status/changed and persisted.
- Unknown future flag values degrade to plain
active rather than erroring.
- Steerability while a flag is set is decided explicitly and pinned by a test.
Acceptance
Version note, repeated because it bounds the whole issue. Every wire observation here is
codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS (:38) rejects. activeFlags is
unverified on 0.145.0 and 0.146.0 — no older build was available to check. The two
implementation facts in ## Problem are independent of all of this: they are properties of st2's own
code, not of any Codex version.
Problem
Two facts, both checkable in this repository at
f177520, with no capture required.One — the field is parsed and thrown away.
ThreadStatus'sactivearm carries a field namedactiveFlags:All three call sites read the type pointer and nothing else —
src/codex_app_server.rs:702,:724,:732— all feedingobserve_thread_status(status: &str)at:770, whose entire input is onestring. The rest of the status object is discarded at the parse site, and
CodexControlState(
:189-201) has no field that could hold it.Two — the delivery path never consults it.
maybe_requestselects a delivery method fromstate.observedalone (src/codex_app_server.rs:415-419):Idle → Start,Active { turn_id } → Steer { turn_id }, everything elseNone. No flag, and no other property of the thread status,reaches that decision.
Those two are the defect. What follows from them is stated separately below, because it is an
inference rather than an observation.
Fleet evidence
Across one catalog of 627 seats (#261, measured 2026-08-16), "working" and "stopped, waiting for
me to approve a command" are the same reading. Both are busy. An agent blocked on approval stays
blocked until somebody opens its pane, and at that seat count nobody opens them all.
What is measured, and what is not. The seat count is measured. The cost of not distinguishing
these two — how many seats sit blocked, and for how long — is not, and cannot be until the
distinction exists. That is the ordinary shape of an observability gap: the thing that would measure
it is the thing being asked for. It is stated that way rather than with a number we do not have.
Cause
observe_thread_statustakes&str, so no caller can pass the rest of the status object withoutchanging the signature.
CodexControlState(src/codex_app_server.rs:189-201) is additionally#[serde(deny_unknown_fields)](:190), so persisting the flags is a schema change tost2.codex-control-state.v1(:41, validated on load at:2243), not an additive field — an olderbinary reading a newer record hard-errors rather than ignoring the new key. Whether that matters
depends on whether two binary versions ever read one host's state directory; it should be checked
before the field is added, not after.
What is proved, and what is not
Version scope first, because it bounds everything below. The
activeFlagsschema and the wirecapture both come from codex-cli 0.147.0.
SUPPORTED_CODEX_CLI_VERSIONS = ["codex-cli 0.145.0", "codex-cli 0.146.0"](
src/codex_app_server.rs:38, enforced at:2101), so the field is unverified on the versions st2actually supports. No older build was available to check against. No claim is made here that
activeFlagsexists on 0.145 or 0.146.Proved — on 0.147.0 the field is on the wire, not merely in the schema:
{"method":"thread/status/changed", "params":{"threadId":"…","status":{"type":"active","activeFlags":[]}}}The array is present and empty because nothing was pending.
In-repo, and weaker than it looks. st2's own test fixtures carry the field at
src/codex_app_server.rs:3328,:3618and:3715, all"status": {"type": "active", "activeFlags": []}, on a tree whose supported versions are 0.145 and0.146. That is evidence the field was known to st2's authors, and it is a reason to expect it on a
supported version. It is hand-authored test data, not a capture, and it does not establish what
0.145 or 0.146 emit.
Not proved — a populated
activeFlagswas never captured. The account hit its ChatGPT usagelimit, so no model turn ran and no approval was ever reachable. That
["waitingOnApproval"]appearswhen an approval is pending follows from the enum's naming and the field's placement inside the
activearm; it is schema evidence, not wire evidence.The consequence, stated as an inference
This does not follow from an observation. It chains three steps and the middle one is unobserved.
status.type == "active". — Schema-supported,never observed.
activeFlagsexists only on theactivearm, which is why it is the naturalreading; nothing in any capture confirms it.
observe_thread_statustherefore yieldsActive { turn_id }, the state whose doc comment(
src/codex_app_server.rs:160-163) says it is "the only state that permitsturn/steer". —Follows from step 1 and the match arms at
:773-790.maybe_requestroutes that toCodexDeliveryMethod::Steer(:415-419), so st2 injects a DINGinto a session whose next keystroke answers a modal. — Follows from step 2.
If step 1 is wrong and Codex reports approval-pending under some other status word, then
observe_thread_status's trailing_arm makes itHeld{SystemError}andmaybe_requestreturnsNone(:420-421). No steer — and a different defect, the fabricated system error described inIssue 1.
The one run that decides it. One
codex app-serversession under--approval-policy untrusted,driven until a real approval is pending, capturing the
thread/status/changedframe at that moment.That needs no new code. The capture account's usage limit resets 2026-08-20, per the error
payload quoted in Issue 1. Until that run exists, this section is an inference and is written as one.
Steps 1-3 decide how urgent this is. They do not decide whether it is a defect: a protocol field is
discarded at the parse site regardless, and
maybe_requestconsults nothing but the state wordregardless. Both are fixable, and testable, without the capture.
The steerability rule is not an invariant today
Worth stating because it changes what "do not break it" means here. The rule at
src/codex_app_server.rs:160-163is a code comment. No row inINVARIANTS.mdcovers it and nonamed test proves it — all rows were checked.
CLAUDE.mdsays to add a row "only when a genuinelyload-bearing invariant appears, and only once a real test proves it". This rule gates native
delivery, and this issue is the change that forces the question, so it is the natural moment.
Also on the floor: the server→client approval requests
Ten server→client JSON-RPC requests carry a
methodand therefore reachobserve()'s match and fallthrough
_ => return Ok(false)(src/codex_app_server.rs:765):The exit edge is the
serverRequest/resolvednotification.activeFlagssays that an agent isblocked; these say what on, and the pairing is what would make blocked dwell time measurable.
This is not a deadlock. st2 attaches as an observer and the interactive TUI is the answering
client (module doc,
src/codex_app_server.rs:1-10). An unanswered request does not stall the turn.There is already a test injecting
item/commandExecution/requestApproval(
src/codex_app_server.rs:3214) asserting only that it is not misparsed as a client response —JSON-RPC request IDs are per-direction. That behavior must be preserved; extracting state here must
not start consuming these as responses.
Not proved. No live
requestApprovalframe was captured, for the same usage-limit reason.One benign transit, noted so it is not rediscovered
From
Idle,observe_thread_status("active")matches none of its explicit arms and falls toHeld{ActiveWithoutTurn}(:782-786), becausethread/status/changed{active}always precedesturn/started. Measured in both captures at 0.1 ms beforeturn/startedcorrects it. Harmlessas a live state; it matters only to a consumer that samples or records observed state.
Required behavior
activeFlagsis parsed fromthread/startedandthread/status/changedand persisted.activerather than erroring.Acceptance
activeFlagsis confirmed present on a supported version — 0.145.0 or 0.146.0 — or 0.147.0is admitted under the existing policy at
src/codex_app_server.rs:35-37. Until one of thoseholds, this is a field observed only on a version st2 refuses to launch. See Issue 5.
activeFlagsis captured on the wire before implementation, resolving step 1 ofthe inference above.
:702,:724,:732) and persisted inCodexControlState.deny_unknown_fields/ schema-version consequence for a staggered binary upgrade is stated.[],["waitingOnApproval"],["waitingOnUserInput"], unknown value.INVARIANTS.mdrow with a named test, or is documented asdeliberately unpinned.
Version note, repeated because it bounds the whole issue. Every wire observation here is
codex-cli 0.147.0, which
SUPPORTED_CODEX_CLI_VERSIONS(:38) rejects.activeFlagsisunverified on 0.145.0 and 0.146.0 — no older build was available to check. The two
implementation facts in
## Problemare independent of all of this: they are properties of st2's owncode, not of any Codex version.