[v0.1/P1] Final interface simplification audit before public freeze #326

Closed
opened 2026-09-21 22:54:14 +09:00 by natsukium · 9 comments
Owner

Background

The first public release is the last inexpensive point at which to simplify the public IPC and CLI surfaces. The current wire architecture is sound, but several contracts still have duplicated authorities or expose combinations that the implementation does not use.

This issue is a final simplification audit. It does not reopen the already-settled protocol baseline or the correlation-shape decisions in #47, #48, #147, and #313.

Findings to resolve before the public freeze

1. Remove the unused SessionMsg correlation capability

SessionMsg currently carries the field-100 correlation envelope and implements the correlated-message path, but no session arm uses correlation. This exposes an impossible capability to domain and external implementations and permits future accidental use.

Either remove the field and correlated implementation, or document and add a real correlated session conversation. The preferred outcome is removal.

2. Make routing metadata have one authority

The routing contract is now machine-readable, but Rust ArmMeta tables and schema metadata still need to remain synchronized. Keep the protobuf custom options as the cross-language authority established by #303, and generate or mechanically verify the Rust tables instead of maintaining two independent descriptions.

3. Unify the CLI and bridge operation model

The CLI and persistent JSONL bridge expose overlapping operations but use separate parsing, validation, schema, and dispatch paths. Decide whether the bridge is a complete machine API or an explicitly smaller API. Prefer a shared typed operation model from which clap parsing, bridge deserialization, validation, dispatch, and JSON Schema are derived.

A new operation should not require independent updates to the CLI grammar, bridge parameter allow-list, bridge Params type, dispatch match, and schema definitions.

4. Normalize machine output and error transport

Reconfirm the final choice for point/stream machine output and the json/jsonl vocabulary. The current class-specific format names and the difference between CLI stderr errors and bridge stdout errors increase the number of contracts a machine client must implement. If retained, document this as an intentional frozen distinction; otherwise use one machine format vocabulary and one error-envelope policy.

Coordinate this decision with #23, #29, and #145 rather than creating another parallel output contract.

5. Reduce the sessions send grammar cross-product

send currently combines text, raw input, key input, waiting, and timeout behavior. Decide whether this should be split into focused verbs such as send, key, and wait, or represented by one explicitly typed operation. Avoid adding more mutually-dependent flags to the current command.

6. Separate operation-specific error vocabularies

Review shared enums such as AttachFailure when they are used by both attach and spawn. Variants that are meaningful for only one operation should not become part of the other operation's public error vocabulary. Share only genuinely common error categories.

Acceptance criteria

  • The SessionMsg correlation decision is implemented and covered by protocol tests.
  • Routing metadata has one documented authority and drift is rejected mechanically.
  • The CLI and bridge operation model decision is recorded, with schema and validation generated or checked from the same types.
  • Machine output, format names, and error transport are explicitly frozen in the CLI reference.
  • sessions send and operation-specific error types have an explicit final design before the first public tag.
  • Related reference/explanation docs, CHANGELOG.md, and skills/felis are updated if the public surface changes.
  • #12 — release blocker tracker
  • #23, #29, #145 — CLI and machine output contracts
  • #47, #48, #50 — frame routing, correlation, and versioned send authorization
  • #303 — machine-readable routing metadata
  • #313 — first public protocol 1.0 baseline
## Background The first public release is the last inexpensive point at which to simplify the public IPC and CLI surfaces. The current wire architecture is sound, but several contracts still have duplicated authorities or expose combinations that the implementation does not use. This issue is a final simplification audit. It does not reopen the already-settled protocol baseline or the correlation-shape decisions in #47, #48, #147, and #313. ## Findings to resolve before the public freeze ### 1. Remove the unused `SessionMsg` correlation capability `SessionMsg` currently carries the field-100 correlation envelope and implements the correlated-message path, but no session arm uses correlation. This exposes an impossible capability to domain and external implementations and permits future accidental use. Either remove the field and correlated implementation, or document and add a real correlated session conversation. The preferred outcome is removal. ### 2. Make routing metadata have one authority The routing contract is now machine-readable, but Rust `ArmMeta` tables and schema metadata still need to remain synchronized. Keep the protobuf custom options as the cross-language authority established by #303, and generate or mechanically verify the Rust tables instead of maintaining two independent descriptions. ### 3. Unify the CLI and bridge operation model The CLI and persistent JSONL bridge expose overlapping operations but use separate parsing, validation, schema, and dispatch paths. Decide whether the bridge is a complete machine API or an explicitly smaller API. Prefer a shared typed operation model from which clap parsing, bridge deserialization, validation, dispatch, and JSON Schema are derived. A new operation should not require independent updates to the CLI grammar, bridge parameter allow-list, bridge `Params` type, dispatch match, and schema definitions. ### 4. Normalize machine output and error transport Reconfirm the final choice for point/stream machine output and the `json`/`jsonl` vocabulary. The current class-specific format names and the difference between CLI stderr errors and bridge stdout errors increase the number of contracts a machine client must implement. If retained, document this as an intentional frozen distinction; otherwise use one machine format vocabulary and one error-envelope policy. Coordinate this decision with #23, #29, and #145 rather than creating another parallel output contract. ### 5. Reduce the `sessions send` grammar cross-product `send` currently combines text, raw input, key input, waiting, and timeout behavior. Decide whether this should be split into focused verbs such as `send`, `key`, and `wait`, or represented by one explicitly typed operation. Avoid adding more mutually-dependent flags to the current command. ### 6. Separate operation-specific error vocabularies Review shared enums such as `AttachFailure` when they are used by both attach and spawn. Variants that are meaningful for only one operation should not become part of the other operation's public error vocabulary. Share only genuinely common error categories. ## Acceptance criteria - [ ] The `SessionMsg` correlation decision is implemented and covered by protocol tests. - [ ] Routing metadata has one documented authority and drift is rejected mechanically. - [ ] The CLI and bridge operation model decision is recorded, with schema and validation generated or checked from the same types. - [ ] Machine output, format names, and error transport are explicitly frozen in the CLI reference. - [ ] `sessions send` and operation-specific error types have an explicit final design before the first public tag. - [ ] Related reference/explanation docs, `CHANGELOG.md`, and `skills/felis` are updated if the public surface changes. ## Related issues - #12 — release blocker tracker - #23, #29, #145 — CLI and machine output contracts - #47, #48, #50 — frame routing, correlation, and versioned send authorization - #303 — machine-readable routing metadata - #313 — first public protocol 1.0 baseline
Author
Owner

The audit confirms this issue is the right tracking item; no duplicate issue is needed.

Additional concrete observation from the current tree:

  • docs/reference/cli.md still shows protocol.minor: 10 in the daemon status JSON example, while the current first-release schema is protocol 1.0. The example should be corrected or made non-version-specific as part of the documentation cascade.
  • The current domain/wire split should remain: generated protobuf types plus validated domain types are useful here. The simplification should target duplicated routing metadata and CLI/bridge operation definitions, not remove the conversion layer.
  • The existing family split and correlation shape have recorded rationale in #47/#48 and should remain out of scope unless this audit explicitly reopens those decisions.

cargo test -p felis-protocol --lib --quiet passes (183 tests).

The audit confirms this issue is the right tracking item; no duplicate issue is needed. Additional concrete observation from the current tree: - `docs/reference/cli.md` still shows `protocol.minor: 10` in the daemon status JSON example, while the current first-release schema is protocol `1.0`. The example should be corrected or made non-version-specific as part of the documentation cascade. - The current domain/wire split should remain: generated protobuf types plus validated domain types are useful here. The simplification should target duplicated routing metadata and CLI/bridge operation definitions, not remove the conversion layer. - The existing family split and correlation shape have recorded rationale in #47/#48 and should remain out of scope unless this audit explicitly reopens those decisions. `cargo test -p felis-protocol --lib --quiet` passes (183 tests).
Author
Owner

Pre-freeze interface/proto/CLI review completed against the current tree (felis.proto at PROTOCOL 1.0, CLI placement matrix, config path table). Findings that overlap this tracker's six items are not re-filed:

  • reserved fragmentation from pre-baseline breaks → belongs to the baseline workflow in #115 / #313 (densify field numbers before proto-baseline/ lands, then prune BREAKING.md base: lines).
  • CLI/bridge operation model, point/stream vocabulary, sessions send cross-product, shared error enums → items 3–6 above already track them.
  • docs/reference/cli.md daemon-status example still shows protocol.minor: 10 against the 1.0 schema — doc cascade for whichever issue touches cli.md next.

New child issues for what this audit does not cover:

  • #327 — implicit-absence meanings (RetargetTarget.carrier unset, OpsStop.mode unset, SessionAttach id/prefix siblings, theme-vs-palette reset spellings).
  • #328 — wire scalar shapes (Attachment.id u64-vs-string, ResourceReport absent≠0, env/env_base duality, pull_paced bool beside ConnectionMode).
  • #329 — Windows config path decision before the frozen table locks in %APPDATA%\felis\config\config.toml.

The underlying architecture needs no change: frozen preface, effective-minor send-side contract, single protobuf encoding with the bridge for JSON, field-100 correlation with shared ConnEnd/Error terminals, atomic create+attach, and daemon-side prefix resolution are all worth freezing as-is.

Pre-freeze interface/proto/CLI review completed against the current tree (`felis.proto` at PROTOCOL 1.0, CLI placement matrix, config path table). Findings that overlap this tracker's six items are not re-filed: - `reserved` fragmentation from pre-baseline breaks → belongs to the baseline workflow in #115 / #313 (densify field numbers before `proto-baseline/` lands, then prune `BREAKING.md` `base:` lines). - CLI/bridge operation model, point/stream vocabulary, `sessions send` cross-product, shared error enums → items 3–6 above already track them. - `docs/reference/cli.md` daemon-status example still shows `protocol.minor: 10` against the 1.0 schema — doc cascade for whichever issue touches `cli.md` next. New child issues for what this audit does not cover: - #327 — implicit-absence meanings (`RetargetTarget.carrier` unset, `OpsStop.mode` unset, `SessionAttach` id/prefix siblings, theme-vs-palette reset spellings). - #328 — wire scalar shapes (`Attachment.id` u64-vs-string, `ResourceReport` absent≠0, `env`/`env_base` duality, `pull_paced` bool beside `ConnectionMode`). - #329 — Windows config path decision before the frozen table locks in `%APPDATA%\felis\config\config.toml`. The underlying architecture needs no change: frozen preface, effective-minor send-side contract, single protobuf encoding with the bridge for JSON, field-100 correlation with shared `ConnEnd`/`Error` terminals, atomic create+attach, and daemon-side prefix resolution are all worth freezing as-is.
Author
Owner

Two child issues filed from a pre-freeze interface review:

  • #330 — Strip pre-release history and redundancy from the 1.0 base schema (reserved numbers and history comments, ConnWelcome.build_id, 86× since: 0)
  • #331 — Make the unset-means-default unions explicit oneof variants (SessionAttach, RetargetTarget, OpsStop)

One more finding that belongs under this tracker's #3 (unify the CLI and bridge operation model) rather than a separate issue: the Cmd enum is matched exhaustively in four places that all encode per-verb metadata — Cli::machine_output (crates/felis-cli/src/main.rs:295), verb_reading_no_config (:592), verb_refusing_carrier (:637), and dials (:508). A new CLI verb therefore touches four match sites beyond the clap grammar, each restating part of the placement matrix in docs/reference/cli.md. Folding them into one Cmd::surface(&self) -> Surface (format, reads-config, carrier refusal, dials) would make a verb addition a one-line change and keep the matrix and the code in one place. The window-launch path still forwards globals to the frontend, so clap cannot own the whole matrix, but the rest can collapse.

Two child issues filed from a pre-freeze interface review: - #330 — Strip pre-release history and redundancy from the 1.0 base schema (`reserved` numbers and history comments, `ConnWelcome.build_id`, 86× `since: 0`) - #331 — Make the unset-means-default unions explicit oneof variants (`SessionAttach`, `RetargetTarget`, `OpsStop`) One more finding that belongs under this tracker's **#3 (unify the CLI and bridge operation model)** rather than a separate issue: the `Cmd` enum is matched exhaustively in four places that all encode per-verb metadata — `Cli::machine_output` (`crates/felis-cli/src/main.rs:295`), `verb_reading_no_config` (`:592`), `verb_refusing_carrier` (`:637`), and `dials` (`:508`). A new CLI verb therefore touches four match sites beyond the clap grammar, each restating part of the placement matrix in `docs/reference/cli.md`. Folding them into one `Cmd::surface(&self) -> Surface` (format, reads-config, carrier refusal, dials) would make a verb addition a one-line change and keep the matrix and the code in one place. The window-launch path still forwards globals to the frontend, so clap cannot own the whole matrix, but the rest can collapse.
Author
Owner

Correction to the comment above: #331 was a duplicate of #327 (same three unions) and is now closed. The surviving new child from this review is #330 (pre-release reserved numbers, build_id, and since: 0).

Correction to the comment above: #331 was a duplicate of #327 (same three unions) and is now closed. The surviving new child from this review is #330 (pre-release `reserved` numbers, `build_id`, and `since: 0`).
Author
Owner

The final wire/CLI/config freeze review split the newly verified contract findings into focused issues:

  • #332 — version the pre-preface FRLY carrier envelope;
  • #333 — remove pre-release compatibility residue and duplicate handshake/status authority before #115 records the baseline;
  • #334 — replace implicit operation defaults and attach cross-field validation with explicit oneof arms;
  • #335 — resolve pipe file targets relative to config.toml;
  • #336 — give structural session JSON an explicit owner/version boundary.

Existing issues already own two findings:

  • #306 owns the typed input-admission fence. The review recommends making it P1 and shipping it without an old-daemon roster fallback. This gives SessionMsg correlation a real use, resolving this issue's first decision in favor of retention.
  • This issue continues to own generation/mechanical verification of Rust ArmMeta from the protobuf custom-option authority established by #303.

#193 fixes the nested row representation but does not replace #336: the latter decides whether the surrounding Rust-serde shape is a public versioned format at all.

The final wire/CLI/config freeze review split the newly verified contract findings into focused issues: - #332 — version the pre-preface `FRLY` carrier envelope; - #333 — remove pre-release compatibility residue and duplicate handshake/status authority before #115 records the baseline; - #334 — replace implicit operation defaults and attach cross-field validation with explicit oneof arms; - #335 — resolve pipe file targets relative to `config.toml`; - #336 — give structural session JSON an explicit owner/version boundary. Existing issues already own two findings: - #306 owns the typed input-admission fence. The review recommends making it P1 and shipping it without an old-daemon roster fallback. This gives `SessionMsg` correlation a real use, resolving this issue's first decision in favor of retention. - This issue continues to own generation/mechanical verification of Rust `ArmMeta` from the protobuf custom-option authority established by #303. #193 fixes the nested row representation but does not replace #336: the latter decides whether the surrounding Rust-serde shape is a public versioned format at all.
Author
Owner

Correction: #334 duplicated #327 and is closed. #327 is the surviving issue for explicit oneof/default semantics. #333 was narrowed to semantic minor-reset residue not owned by #330: session-sequence fallback, dead CLI error vocabulary, duplicate status identity, and the unknown-mode codec exception.

Correction: #334 duplicated #327 and is closed. #327 is the surviving issue for explicit oneof/default semantics. #333 was narrowed to semantic minor-reset residue not owned by #330: session-sequence fallback, dead CLI error vocabulary, duplicate status identity, and the unknown-mode codec exception.
Author
Owner

Triage decision (reviewed, 6 rounds of independent design review)

This tracker stays open, narrowed to items 3–6. Items 1 and 2 are decided here and need no further work before the freeze.

  • Item 1, SessionMsg field 100: retained. MessageKind::Session.is_correlated() is true and the family is wired as correlation-capable end to end; every base arm is CORRELATION_UNCORRELATED, and the connection driver refuses an envelope on an uncorrelated arm per the arm table (driver tests cover it). An unset field costs nothing on the wire, and removing it would make #306 a wire break later. #306 is not raised to P1 for this. The retention is recorded in the IPC explanation as "the family is correlation-capable; no base arm uses it yet" (lands with the #330 doc cascade).
  • Item 2, one routing authority: already met. the_schema_declares_the_same_arm_table parses felis.proto and asserts the declared arm options equal the Rust ArmMeta tables, so drift fails the build. That is source-level verification, which satisfies "drift is rejected mechanically". Generation instead of verification is a post-v0.1 refinement.
  • Item 3: only the decision is freeze-relevant (is the bridge a complete machine API or an explicitly smaller one?). The shared typed operation model, including the Cmd::surface() fold from the comment above, is internal refactoring that does not change the public contract and may land after v0.1.
  • Items 4, 5, 6: freeze-relevant, owned here. Item 4 records the outcome of the closed #23/#29/#145 rather than reopening them.
  • The protocol.minor: 10 example in cli.md is fixed by whichever PR touches cli.md first (#333).

Child decisions are recorded on #327, #328, #329 (closed, already decided), #330, #332, #333, #335, #336. Landing order: #329, #335 first (independent); then the proto batch strictly sequentially, one PR per issue, each rebased on the previous merge and carrying its own base: line: #330 → #328 (case 2) → #327 → #333; #332 and #336 are independent of the proto batch; items 3–6 here last.

## Triage decision (reviewed, 6 rounds of independent design review) This tracker stays open, narrowed to items 3–6. Items 1 and 2 are decided here and need no further work before the freeze. - **Item 1, `SessionMsg` field 100: retained.** `MessageKind::Session.is_correlated()` is true and the family is wired as correlation-capable end to end; every base arm is `CORRELATION_UNCORRELATED`, and the connection driver refuses an envelope on an uncorrelated arm per the arm table (driver tests cover it). An unset field costs nothing on the wire, and removing it would make #306 a wire break later. #306 is *not* raised to P1 for this. The retention is recorded in the IPC explanation as "the family is correlation-capable; no base arm uses it yet" (lands with the #330 doc cascade). - **Item 2, one routing authority: already met.** `the_schema_declares_the_same_arm_table` parses `felis.proto` and asserts the declared arm options equal the Rust `ArmMeta` tables, so drift fails the build. That is source-level verification, which satisfies "drift is rejected mechanically". Generation instead of verification is a post-v0.1 refinement. - **Item 3: only the decision is freeze-relevant** (is the bridge a complete machine API or an explicitly smaller one?). The shared typed operation model, including the `Cmd::surface()` fold from the comment above, is internal refactoring that does not change the public contract and may land after v0.1. - **Items 4, 5, 6: freeze-relevant**, owned here. Item 4 records the outcome of the closed #23/#29/#145 rather than reopening them. - The `protocol.minor: 10` example in `cli.md` is fixed by whichever PR touches `cli.md` first (#333). Child decisions are recorded on #327, #328, #329 (closed, already decided), #330, #332, #333, #335, #336. Landing order: #329, #335 first (independent); then the proto batch strictly sequentially, one PR per issue, each rebased on the previous merge and carrying its own `base:` line: #330 → #328 (case 2) → #327 → #333; #332 and #336 are independent of the proto batch; items 3–6 here last.
Author
Owner

Decision for items 3–6 (reviewed: 3 luna rounds, 10 sol rounds, final PASS)

Scope: only what the public freeze needs. Internal refactors already deferred by the triage comment (shared typed operation model, Cmd::surface() fold) stay deferred.

Item 3 — the bridge is an explicitly smaller API, recorded as such

Facts: every sessions verb and notifications subscribe exist on the bridge; the bridge alone has cancel; the CLI alone has the operator verbs (daemon, config, doctor, version), window launches (attach, bare felis, frontend, ssh, window retarget), and shell plumbing (completions, __mangen, __complete-sessions). Within shared ops the bridge differs in six places, four of which are policy and two of which are defects: sessions.send has no --wait/--key and requires text; notifications.subscribe has no once/timeout (cancel is the bridge's way to stop a stream); sessions.switch requires from where the CLI defaults it from $FELIS_SESSION_ID, and answers queued == 0 with a successful result carrying queued where the CLI fails with not_queued (op_switch, cli_sessions.rs switch path). The two defects, both in sessions.spawn: the bridge never calls env_base::fill_for_carrier, so a local bridge-created child inherits the daemon's environment instead of the bridge process's, against REQ-912a and ipc.md (the CLI does, cli_sessions.rs:2107); and the bridge parses rows/cols as u16 with a 65535 schema bound while the CLI parses u32 and lets daemon admission refuse, so an out-of-range geometry is malformed_request (exit-2 class) on one surface and invalid_request on the other, and the comment justifying the u16 (Params::u16_opt: a create refusal is terminal for the anchor) is stale since op_spawn moved to the correlated, reusable OpsMsg::Spawn.

Decision: the bridge is the session-automation surface, not a mirror of the CLI. Its op set is exactly {sessions.* (10), notifications.subscribe, cancel}. Operator and host-process verbs are CLI-only as surface policy, not for a technical reason (several of them are correlated Ops requests on the wire, Status, Stop, Switch, exactly like the point verbs the bridge does expose): the bridge is the surface a program drives sessions through, and daemon lifecycle, config diagnosis, GUI launches and shell integration are the operator's, reached through the one-shot process whose exit status and exec they need. A non-Rust client that needs daemon status runs the point verb with --format json. The two intra-op gaps are a surface-policy choice, not an implementation constraint: the op_send comment claiming a chord needs client-side keyboard modes is false (InputMsg::Key is admitted on an attached Ops link and the daemon encodes it against the modes it owns, felis.proto arm key = 10, session_task.rs), and it is corrected in PR A. The bridge freezes the parameters its clients have needed; keys on sessions.send (the CLI's --key, same chord grammar, same InputMsg::Key) and wait are additive optional params under the v:1 rule and are filed as one follow-up issue rather than added blind before the tag. once/timeout on notifications.subscribe stay out: cancel is the bridge's way to end a stream. The two switch differences are retained and frozen with their reasons: the bridge runs inside an editor, not a felis window, so $FELIS_SESSION_ID would name whatever session launched the editor, hence from is required; and queued == 0 is data on the bridge because whether "nothing moved" is a failure is the editor's judgement, while the one-shot CLI has to pick an exit code and picks not_queued. Both are stated in the bridge reference today only as code comments; PR A writes them into cli.md's bridge op table. The two spawn defects are fixed in PR A, not frozen: op_spawn fills env_base for a local carrier exactly as the CLI does (with a test that a local bridge spawn carries a captured base and a relay spawn carries none); geometry is parsed at the wire's u32 width and admission is the daemon's on both surfaces (the felis-bridge-v1 schema bound is regenerated to the wire width, a relaxation that no existing valid request notices), and the stale u16_opt comment goes with it.

Deliverable: docs, the comment fix, and the two bridge spawn fixes (op_spawn fills env_base for a local carrier; rows/cols parsed at u32; felis-bridge-v1.schema.json regenerated; the stale u16_opt comment removed; tests: a local bridge spawn carries a captured base and a relay spawn none, and a geometry above u16::MAX reaches the daemon and comes back invalid_request). docs/reference/cli.md "felis bridge": state the op set is closed to session automation and list the CLI-only verb groups; note text is required on the bridge and that keys/wait are absent by policy, additive if added; record sessions.switch's required from and its queued == 0 success policy with the reasons above. cli_bridge.rs op_send: replace the false why-not with the policy (or drop the comment). docs/explanation/architecture/control-surfaces.md: the rationale above with the rejected alternative (a complete mirror: it would put process-lifecycle verbs behind a correlated stream and force every operator verb to grow a bridge twin, the very cost the tracker's "one operation, one definition" concern names). skills/felis already says "no --wait and no --key"; add the CLI-only groups in one line.

Item 4 — one vocabulary, one error object, two transports: frozen

Facts: --format is class-typed (PointFormat: human|json; StreamFormat: human|jsonl; the other value is a clap error, exit 2). CLI: machine framing starts only once clap has accepted the argv and a machine format is selected; a clap rejection (unknown flag, wrong --format value, missing positional) is human text on stderr, exit 2, before any format exists. After that, a point failure = one {"v":1,"error":{kind,message}} on stderr + exit code from kind; stream terminals on stdout. Bridge: every outcome goes to stdout with the same error object and event terminals; a request whose id could be recovered is answered on that id even when the rest of the line is malformed, and only a line with no usable id, an oversized line, or a startup failure is answered with id: null (parse_request, serve). The bridge process exits 0; 1 for its own pipe failure and also when its initial Ops dial is refused at_capacity (cli_bridge.rs serve: a typed refusal, so the point-verb rule for exit 1 applies); 2 when the daemon is unreachable/lost or the protocol breaks (cli.md exit-code table). ErrorKind is the single vocabulary for both (BridgeError says so). Three of the four wire→kind tables are frozen in tests/golden/error_kinds.txt; from_connect_error is not. Issues #23/#29/#145 are uncited anywhere in the tree.

Decision: retain, and record as an intentional frozen distinction. json = point (one object), jsonl = stream (one object per line, exactly one terminal); a verb advertises only the value its class admits, so the vocabulary is two words and the class is visible in --help. Errors: one object shape and one kind vocabulary everywhere; what differs is only where it goes, per class: a Point result is stdout and a Point failure is one error object on stderr with the exit code from kind; a Stream's items, lag events and its single terminal, clean or failing ({"v":1,"event":"error",…}, including a failure before the first item), are all stdout; on the bridge every reply and every per-request error is stdout correlated by id, and stderr is diagnostics only. That follows from the process model: a one-shot Point verb has an exit status and stderr to answer with; a stream is a stdout protocol whose consumer reads objects until the terminal; a multiplexed bridge has neither an exit status nor a stderr per request, so id on stdout is the only correlated channel. Unifying transports would mean either putting CLI errors on stdout (breaking $(felis …) capture and every shell contract) or giving the bridge per-request exit codes (impossible). Do not reopen #23/#29/#145; cite them once in the explanation so the record is reachable.

Deliverable: docs/explanation/architecture/control-surfaces.md "Machine output" gets a short "Frozen for v:1" paragraph naming the two-transport rule, the rejected unification, and the source issues. docs/reference/cli.md "Machine output": state what is frozen for the epoch: the two format words and their class binding, the error object shape, the existing kind spellings and their exit codes, and the two transports, with the boundaries spelled out: point JSON error framing begins only after successful argument parsing (a clap rejection is human stderr, exit 2), and from there every failure is framed. Today some post-parse failures still escape as anyhow prose after a machine format was accepted: the three pre-dispatch steps (resolve_config_source, dial_target, build_runtime in main.rs, which the source comment at main.rs:343-347 acknowledges), and inside a verb sessions send ID -'s stdin read (collect_send_payload(..)?, cli_sessions.rs:578); and Cli::machine_output() omits Cmd::Version although version --format json is a Point verb. PR A closes every such escape, not a list: it audits each ? whose error reaches main on a machine-formatted verb (grep anyhow/? returning from cli_*::run and the pre-dispatch steps) and routes each through the verb's Reporter with the kind the table already defines (usage for the config path, daemon_unreachable for the target, internal for the runtime, input_failed for a failed stdin read), machine_output() covers Version, and integration tests pin one JSON error object on stderr with no stdout payload and exit 2 for an unresolvable --config and for send ID - --format json with a closed stdin; a bridge shape failure is correlated whenever a valid id was recovered and id: null otherwise; the bridge's exit 1 cases are its own pipe failure and an initial at_capacity refusal (add the latter to the exit-code table row, which today names only the pipe failure). error.kind stays additive within v:1 and fields may be added, exactly as the page already says; the freeze is on spellings, mappings and transport, not on the set. Code: add the [ConnectError -> kind] table to error_kinds.txt and its golden test so every translation table is mechanically frozen (five after item 6: stream reason, attach failure, create failure, connection refusal, ConnectError; from_connect_error today is not in the golden), and make from_connect_error an exhaustive match (it ends in _ => Protocol today, so a new ConnectError variant would silently take that arm; an exhaustive match forces a mapping decision). This lands in PR B because that PR already rewrites the golden.

Item 5 — sessions send stays one typed operation; the landed grammar is confirmed and frozen

Facts: send <ID> [TEXT] [--raw] [--key CHORD]... [--wait [--timeout SECS]]; constraints: --raw requires TEXT, --timeout requires --wait, at least one of TEXT/--key/--wait. No conflicts_with. The skill's headline use is send id "cmd" --key enter --wait --timeout N.

Decision: do not split into send/key/wait; confirm the grammar that has landed. The constraints below are already implemented in clap and control-surfaces.md already records why a separate wait verb races; the freeze adds the missing tests and the reference wording, nothing more. The operation is "deliver this input, then optionally await the next prompt mark"; with no payload it degenerates to "watch the command already running" (--wait alone attaches and waits without writing, which is what the help says and what cmd_send does). The send-and-wait form is the one that is not separable without a race: cmd_send subscribes to the session before injecting the input, so the mark a fast command emits is buffered on that subscription and wait_for_command_end reads it after admission; a separate wait verb issued after send opens its subscription after the mark may already have passed. A separate key verb has no race (a finished send has already enqueued its paste ahead of anything issued later), so the case for keeping --key on send is only that one call names one target, resolves it once, and states the intended input sequence in order. The cross-product is small and every combination is meaningful (payload kind × chords × wait), which is what "one explicitly typed operation" means. Freeze the grammar: no new flag may depend on another; the only dependencies are the two requires above. Anything new (a different wait condition, a different payload kind) is a new verb, not a flag.

Deliverable: docs plus parser tests. docs/explanation/architecture/control-surfaces.md: record the decision, the race that rejects the split, and the "no new mutually-dependent flag" rule. docs/reference/cli.md "send": state the three constraints as the whole grammar (add the "at least one of" rule explicitly). Tests today pin --raw without TEXT (raw_send_needs_a_payload) and the valid wait forms (sessions_send_reports_without_a_wait), but not bare sessions send <id> nor --timeout without --wait; add one parser table in crates/felis-cli/src/tests.rs covering all three constraints both ways: rejected bare send ID, rejected --timeout N without --wait, rejected --raw without TEXT; accepted key-only, wait-only, --wait --timeout N, and TEXT with --raw, so the frozen grammar cannot regress silently.

Item 6 — split AttachFailure into attach and create vocabularies

Facts (verified in serve.rs): AttachFailure has 9 variants. SessionMsg::Attach produces UnknownSession, NoMatch, Ambiguous (take_attach_handle), and SessionEnding / SessionExited (subscribe_to, the latter only under live_only). SessionMsg::Create and OpsMsg::Spawn produce SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining (create_session), and Create additionally SessionEnding when the just-spawned child ends before subscribe_to lands (serve.rs:1160-1172, live_only: false so never SessionExited). OpsMsg::Spawn never subscribes, so it produces only the four create_session variants. Read per half rather than per request: the four create_session variants belong to the spawn half (shared by Create and Spawn), and SessionEnding/SessionExited/UnknownSession/NoMatch/Ambiguous belong to the attach half (Attach, and SessionEnding also on Create, whose attach half runs after its spawn half). No variant crosses halves. The enum rides on SessionAttachFailed.reason (answers both Attach and Create, since create is create+attach) and on SpawnRefused.reason (OpsMsg::Spawn). Consumers: convert/session.rs, convert/ops.rs, minor.rs, client-core/dial.rs (matches the three attach-side variants), cli_output.rs (7 of 9 collapse onto invalid_request/at_capacity), felis-grid/json_v1/session.rs (AttachFailureJson, all nine, published in felis-json-v1.schema.json), felis.el (attach_failed).

Decision: split into two wire enums, one per half, and let the reply name which half refused.

// Why the attach half (SessionAttach, or the attach after a SessionCreate's spawn) failed.
enum AttachFailure {
  ATTACH_FAILURE_UNSPECIFIED = 0;
  ATTACH_FAILURE_UNKNOWN_SESSION = 1;
  ATTACH_FAILURE_SESSION_ENDING = 2;
  ATTACH_FAILURE_SESSION_EXITED = 3;
  ATTACH_FAILURE_NO_MATCH = 4;
  ATTACH_FAILURE_AMBIGUOUS = 5;
}
// Why the daemon refused the spawn half of a SessionCreate or an OpsSpawn.
enum CreateFailure {
  CREATE_FAILURE_UNSPECIFIED = 0;
  CREATE_FAILURE_SPAWN_FAILED = 1;
  CREATE_FAILURE_GEOMETRY_OUT_OF_RANGE = 2;
  CREATE_FAILURE_SESSION_LIMIT_REACHED = 3;
  CREATE_FAILURE_DAEMON_DRAINING = 4;
}
message SessionAttachFailed {
  oneof reason {
    AttachFailure attach = 1;
    CreateFailure create = 2;
  }
  string detail = 3;
}
message SpawnRefused {
  CreateFailure reason = 1;
  string detail = 2;
}

Numbering is dense and unreserved, the same policy #339 set for the pre-release schema; SessionAttachFailed.detail moves from 2 to 3 so the oneof arms are contiguous. Decoding: an unset reason oneof is WireError::MissingOneof("SessionAttachFailed.reason") (the #342 rule: absence never means an arm); *_UNSPECIFIED is UnspecifiedEnum as today; an unknown enum number takes the existing unknown-enum path. There are three request kinds and two halves, and the reply names the half, so each request's admissible set is exact without a third enum: Attach → attach only (5 values); Create → create (the 4 spawn-half values; nothing executed) or attach = SESSION_ENDING (spawn succeeded, the child ended before the subscription landed and the pool entry was rolled back; today's SessionEnding on Create, serve.rs:1160-1172); OpsMsg::Spawn → SpawnRefused.reason: CreateFailure (the same 4 values, exactly what create_session can produce; Spawn never subscribes so SessionEnding is not in its vocabulary). No impossible value is in any request's vocabulary: Spawn's type has no SessionEnding, and Create's race is the attach arm, not a CreateFailure value. Admissibility is checked at value level, in one place in felis-client-core (the connector knows which request it sent, including the target form and live_only), against exactly what serve.rs can produce: an Attach by exact id admits attach ∈ {UnknownSession, SessionEnding}, plus SessionExited only when live_only was set; an Attach by prefix admits attach ∈ {NoMatch, Ambiguous, SessionEnding}, plus SessionExited only when live_only; a Create admits any create value or attach = SessionEnding only (the one attach-half failure a just-spawned session can have; the pool entry was rolled back). Every other arm/value combination is a protocol error surfaced through the existing protocol-failure path (exit 2, protocol), never a typed refusal, so a non-conforming daemon cannot make felis report a merits decision it did not take. The rule is one fn admits(ctx: SessionRefusalContext, reason: &AttachRefusal) -> bool with enum SessionRefusalContext { Attach { by_prefix: bool, live_only: bool }, Create }, and a table test over every (context, arm, value) triple. OpsMsg::Spawn needs no runtime check: SpawnRefused.reason is typed CreateFailure, so admissibility there is enforced by the type. Where it runs: ControlRequest::interpret is static today and sees only the reply, so it cannot know the target form; the trait changes to fn interpret(&self, reply) (into_msg takes &self or clones, AttachSessionReq already holds target and live_only, and the create request holds its own kind), so AttachSessionReq::interpret derives the context from itself. A refusal that fails admits is ConnectError::Protocol-class (the existing protocol-failure variant), and the connection that carried it is not reusable: the session link is closed as every attach failure closes it today, and for the reusable Ops link request marks the connection poisoned on any interpreter protocol error so the next call fails fast instead of reading a stream whose framing it no longer trusts; a test issues a second request after a context-invalid refusal and asserts it is refused without touching the socket. buf breaking is not the gate here (pre-release; just proto-compat with an own base: line in crates/felis-protocol/proto/BREAKING.md, base = main tip at branch time, proto unchanged since #343).

Rust domain: enum AttachFailure { UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous }, enum CreateFailure { SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining }, SessionMsg::AttachFailed { reason: AttachRefusal, detail } with enum AttachRefusal { Attach(AttachFailure), Create(CreateFailure) }; SpawnOutcome::Refused { reason: CreateFailure, detail }. felis-client-core's public error follows the split: ConnectError::AttachFailed { reason: AttachFailure, detail } and a new ConnectError::CreateFailed { reason: CreateFailure, detail }. connect_and_attach can only produce AttachFailed; create can produce either (its attach half is AttachFailed { SessionEnding }); spawn_session (Ops Spawn) can only produce CreateFailed, so a caller of spawn never sees an attach-only variant. is_transient treats CreateFailed like the non-transient create refusals today; dial.rs keeps matching the three attach-side reasons on AttachFailed. cli_output.rs gains from_create_failure beside from_attach_failure, and from_connect_error (now exhaustive) maps CreateFailed through it; the golden gains the [CreateFailure -> kind] table and the CreateFailed row of [ConnectError -> kind]. CLI ErrorKind tokens are unchanged. felis-json v1 is not retyped. docs/reference/ipc.md "Compatibility within a version" makes a retype felis_json: 2 with dual serving, and #336 recorded that rule for consumers outside this repository (.fcast files, the browser view), so it holds regardless of the tag list. The v1 JSON keeps the flat nine-value AttachFailureJson on SessionJson::AttachFailed.reason, and json_v1/session.rs adapts: From<AttachRefusal> flattens both arms onto the nine tokens (the attach half's five and the spawn half's four are disjoint, so no token is ambiguous), and the reverse conversion picks the arm from the token (session_ending is always the attach half; the four spawn-half tokens are always create). The schema and goldens are unchanged, and a JSON consumer keeps reading exactly what it reads today. Add golden cases anyway (the session-family golden today covers only SessionMsg::Attached, crates/felis-grid/tests/json_v1.rs): one AttachFailed with an attach-half reason and one with a create-half reason, so the flattening is pinned. The arm discrimination reaches JSON consumers only in felis_json: 2, which nothing here needs. Also re-export ThemeActionJson from json_v1 (missed in #342; a consumer cannot name GridJson::ThemeColor { action } today).

Rejected: keep one enum and document per-operation subsets (the tracker's test: a variant meaningful for one operation must not be in the other's vocabulary; a doc note does not remove it from the type a consumer matches, and dial.rs today must list attach variants by hand out of a nine-way enum). Rejected: a separate SessionCreateFailed family arm (a new arm and ArmMeta row for a message whose success twin SessionAttached is shared; the oneof on the existing reply gives the same reader-side discrimination without a routing change). Rejected: two distinct reply messages plus a second success message (doubles the create/attach surface to fix one enum).

Cascade (every current AttachFailure consumer, from grep -rn AttachFailure crates): felis.proto; convert/session.rs, convert/ops.rs, minor.rs gating rows; messages/session.rs, messages/ops.rs; felis-transport/src/framing.rs:676 (test fixture); daemon serve.rs (write_attach_failed takes an AttachRefusal; SubscribeFailure::refusal stays attach-side; create_session returns CreateFailure) and serve/tests.rs:7337+; client-core dial.rs, connector; felis-cli cli_output.rs tables (from_attach_failure → from_attach_failure + from_create_failure) + tests/golden/error_kinds.txt, cli_sessions.rs:152, cli_sessions.rs:2123 (spawn_on: match ConnectError::CreateFailed and map through from_create_failure), cli_bridge.rs:1614 (op_spawn: SpawnOutcome::Refused maps through from_create_failure), tests/cli_bridge.rs:2330; felis-grid/json_v1/session.rs (the flattening adapter between AttachRefusal and the unchanged nine-token AttachFailureJson; DTO, schema and existing goldens untouched, two golden cases added) and the json_v1.rs re-export list (add the missing ThemeActionJson); felis-protocol/src/messages.rs re-exports (AttachFailure, plus CreateFailure, AttachRefusal); the checked-in prost output under crates/felis-protocol/src/generated/ (regenerated, not hand-edited); the fuzz seed fuzz/seeds/ipc_body/proto-session-attach-failed-draining.bin re-encoded for the new shape (create = DAEMON_DRAINING, field 2) so it keeps covering a valid draining refusal; docs/reference/cli.md:384 (AttachFailure::DaemonDraining → CreateFailure::DaemonDraining); docs/reference/ipc.md (AttachFailure/SpawnArgs prose), docs/explanation/architecture/session-lifecycle.md, CHANGELOG.md, crates/felis-protocol/proto/BREAKING.md (own base: line), skills/felis if it names variants. Satellites, all five, gated on the first tag (a direct-wire client built before B negotiates 1.0 successfully and then misdecodes SessionAttachFailed; the crate-linking satellites are repinned so the release ships one consistent set even where, as in felis-web-component, no live refusal is decoded): felis.el (direct wire; felis-wire.el attach_failed reader gains the arm and the same per-request admissibility rule); felis-fcast, felis-web-gateway, felis-web-component, felis-tui (link the crates: repin to the post-B rev; their JSON is unchanged by the adapter above, but any ConnectError::AttachFailed match must add CreateFailed). The closing comment on #326 lists these five as the tag checklist, and each gets an issue on its own repository when B merges.

Landing

Two PRs, sequenced, both touching docs/reference/cli.md: (A) items 3–5 first: docs, the op_send comment fix, the bridge spawn fixes (env_base, u32 geometry, schema regeneration, their tests), the pre-dispatch framing fixes and Version in machine_output() with their test, and the send parser table; (B) item 6 proto split rebased on A, owning every wire/code/golden change plus item 4's ConnectError golden and exhaustive match. One follow-up issue for additive bridge keys/wait params. Then close #326 with the acceptance list updated (items 1–2 already decided in the triage comment).

## Decision for items 3–6 (reviewed: 3 luna rounds, 10 sol rounds, final PASS) Scope: only what the public freeze needs. Internal refactors already deferred by the triage comment (shared typed operation model, `Cmd::surface()` fold) stay deferred. ## Item 3 — the bridge is an explicitly smaller API, recorded as such Facts: every `sessions` verb and `notifications subscribe` exist on the bridge; the bridge alone has `cancel`; the CLI alone has the operator verbs (`daemon`, `config`, `doctor`, `version`), window launches (`attach`, bare `felis`, `frontend`, `ssh`, `window retarget`), and shell plumbing (`completions`, `__mangen`, `__complete-sessions`). Within shared ops the bridge differs in six places, four of which are policy and two of which are defects: `sessions.send` has no `--wait`/`--key` and requires `text`; `notifications.subscribe` has no `once`/`timeout` (`cancel` is the bridge's way to stop a stream); `sessions.switch` requires `from` where the CLI defaults it from `$FELIS_SESSION_ID`, and answers `queued == 0` with a successful result carrying `queued` where the CLI fails with `not_queued` (`op_switch`, `cli_sessions.rs` switch path). The two defects, both in `sessions.spawn`: the bridge never calls `env_base::fill_for_carrier`, so a local bridge-created child inherits the daemon's environment instead of the bridge process's, against REQ-912a and `ipc.md` (the CLI does, `cli_sessions.rs:2107`); and the bridge parses `rows`/`cols` as `u16` with a `65535` schema bound while the CLI parses `u32` and lets daemon admission refuse, so an out-of-range geometry is `malformed_request` (exit-2 class) on one surface and `invalid_request` on the other, and the comment justifying the `u16` (`Params::u16_opt`: a create refusal is terminal for the anchor) is stale since `op_spawn` moved to the correlated, reusable `OpsMsg::Spawn`. Decision: **the bridge is the session-automation surface, not a mirror of the CLI.** Its op set is exactly {`sessions.*` (10), `notifications.subscribe`, `cancel`}. Operator and host-process verbs are CLI-only as surface policy, not for a technical reason (several of them are correlated `Ops` requests on the wire, `Status`, `Stop`, `Switch`, exactly like the point verbs the bridge does expose): the bridge is the surface a program drives *sessions* through, and daemon lifecycle, config diagnosis, GUI launches and shell integration are the operator's, reached through the one-shot process whose exit status and exec they need. A non-Rust client that needs `daemon status` runs the point verb with `--format json`. The two intra-op gaps are a surface-policy choice, not an implementation constraint: the `op_send` comment claiming a chord needs client-side keyboard modes is false (`InputMsg::Key` is admitted on an attached Ops link and the daemon encodes it against the modes it owns, `felis.proto` arm `key = 10`, `session_task.rs`), and it is corrected in PR A. The bridge freezes the parameters its clients have needed; `keys` on `sessions.send` (the CLI's `--key`, same chord grammar, same `InputMsg::Key`) and `wait` are additive optional params under the `v:1` rule and are filed as one follow-up issue rather than added blind before the tag. `once`/`timeout` on `notifications.subscribe` stay out: `cancel` is the bridge's way to end a stream. The two `switch` differences are retained and frozen with their reasons: the bridge runs inside an editor, not a felis window, so `$FELIS_SESSION_ID` would name whatever session launched the editor, hence `from` is required; and `queued == 0` is data on the bridge because whether "nothing moved" is a failure is the editor's judgement, while the one-shot CLI has to pick an exit code and picks `not_queued`. Both are stated in the bridge reference today only as code comments; PR A writes them into `cli.md`'s bridge op table. The two spawn defects are fixed in PR A, not frozen: `op_spawn` fills `env_base` for a local carrier exactly as the CLI does (with a test that a local bridge spawn carries a captured base and a relay spawn carries none); geometry is parsed at the wire's `u32` width and admission is the daemon's on both surfaces (the `felis-bridge-v1` schema bound is regenerated to the wire width, a relaxation that no existing valid request notices), and the stale `u16_opt` comment goes with it. Deliverable: docs, the comment fix, and the two bridge spawn fixes (`op_spawn` fills `env_base` for a local carrier; `rows`/`cols` parsed at `u32`; `felis-bridge-v1.schema.json` regenerated; the stale `u16_opt` comment removed; tests: a local bridge spawn carries a captured base and a relay spawn none, and a geometry above `u16::MAX` reaches the daemon and comes back `invalid_request`). `docs/reference/cli.md` "felis bridge": state the op set is closed to session automation and list the CLI-only verb groups; note `text` is required on the bridge and that `keys`/`wait` are absent by policy, additive if added; record `sessions.switch`'s required `from` and its `queued == 0` success policy with the reasons above. `cli_bridge.rs` `op_send`: replace the false why-not with the policy (or drop the comment). `docs/explanation/architecture/control-surfaces.md`: the rationale above with the rejected alternative (a complete mirror: it would put process-lifecycle verbs behind a correlated stream and force every operator verb to grow a bridge twin, the very cost the tracker's "one operation, one definition" concern names). `skills/felis` already says "no `--wait` and no `--key`"; add the CLI-only groups in one line. ## Item 4 — one vocabulary, one error object, two transports: frozen Facts: `--format` is class-typed (`PointFormat`: `human|json`; `StreamFormat`: `human|jsonl`; the other value is a clap error, exit 2). CLI: machine framing starts only once clap has accepted the argv and a machine format is selected; a clap rejection (unknown flag, wrong `--format` value, missing positional) is human text on stderr, exit 2, before any format exists. After that, a point failure = one `{"v":1,"error":{kind,message}}` on stderr + exit code from `kind`; stream terminals on stdout. Bridge: every outcome goes to stdout with the same `error` object and `event` terminals; a request whose `id` could be recovered is answered on that `id` even when the rest of the line is malformed, and only a line with no usable `id`, an oversized line, or a startup failure is answered with `id: null` (`parse_request`, `serve`). The bridge process exits `0`; `1` for its own pipe failure and also when its initial Ops dial is refused `at_capacity` (`cli_bridge.rs` `serve`: a typed refusal, so the point-verb rule for exit `1` applies); `2` when the daemon is unreachable/lost or the protocol breaks (`cli.md` exit-code table). `ErrorKind` is the single vocabulary for both (`BridgeError` says so). Three of the four wire→kind tables are frozen in `tests/golden/error_kinds.txt`; `from_connect_error` is not. Issues #23/#29/#145 are uncited anywhere in the tree. Decision: **retain, and record as an intentional frozen distinction.** `json` = point (one object), `jsonl` = stream (one object per line, exactly one terminal); a verb advertises only the value its class admits, so the vocabulary is two words and the class is visible in `--help`. Errors: one object shape and one `kind` vocabulary everywhere; what differs is only *where* it goes, per class: a Point result is stdout and a Point failure is one error object on stderr with the exit code from `kind`; a Stream's items, `lag` events and its single terminal, clean or failing (`{"v":1,"event":"error",…}`, including a failure before the first item), are all stdout; on the bridge every reply and every per-request error is stdout correlated by `id`, and stderr is diagnostics only. That follows from the process model: a one-shot Point verb has an exit status and stderr to answer with; a stream is a stdout protocol whose consumer reads objects until the terminal; a multiplexed bridge has neither an exit status nor a stderr per request, so `id` on stdout is the only correlated channel. Unifying transports would mean either putting CLI errors on stdout (breaking `$(felis …)` capture and every shell contract) or giving the bridge per-request exit codes (impossible). Do not reopen #23/#29/#145; cite them once in the explanation so the record is reachable. Deliverable: `docs/explanation/architecture/control-surfaces.md` "Machine output" gets a short "Frozen for `v:1`" paragraph naming the two-transport rule, the rejected unification, and the source issues. `docs/reference/cli.md` "Machine output": state what is frozen for the epoch: the two format words and their class binding, the error object shape, the existing `kind` spellings and their exit codes, and the two transports, with the boundaries spelled out: point JSON error framing begins only after successful argument parsing (a clap rejection is human stderr, exit 2), and from there every failure is framed. Today some post-parse failures still escape as `anyhow` prose after a machine format was accepted: the three pre-dispatch steps (`resolve_config_source`, `dial_target`, `build_runtime` in `main.rs`, which the source comment at `main.rs:343-347` acknowledges), and inside a verb `sessions send ID -`'s stdin read (`collect_send_payload(..)?`, `cli_sessions.rs:578`); and `Cli::machine_output()` omits `Cmd::Version` although `version --format json` is a Point verb. PR A closes every such escape, not a list: it audits each `?` whose error reaches `main` on a machine-formatted verb (grep `anyhow`/`?` returning from `cli_*::run` and the pre-dispatch steps) and routes each through the verb's `Reporter` with the kind the table already defines (`usage` for the config path, `daemon_unreachable` for the target, `internal` for the runtime, `input_failed` for a failed stdin read), `machine_output()` covers `Version`, and integration tests pin one JSON error object on stderr with no stdout payload and exit 2 for an unresolvable `--config` and for `send ID - --format json` with a closed stdin; a bridge shape failure is correlated whenever a valid `id` was recovered and `id: null` otherwise; the bridge's exit `1` cases are its own pipe failure and an initial `at_capacity` refusal (add the latter to the exit-code table row, which today names only the pipe failure). `error.kind` stays additive within `v:1` and fields may be added, exactly as the page already says; the freeze is on spellings, mappings and transport, not on the set. Code: add the `[ConnectError -> kind]` table to `error_kinds.txt` and its golden test so every translation table is mechanically frozen (five after item 6: stream reason, attach failure, create failure, connection refusal, `ConnectError`; `from_connect_error` today is not in the golden), and make `from_connect_error` an exhaustive match (it ends in `_ => Protocol` today, so a new `ConnectError` variant would silently take that arm; an exhaustive match forces a mapping decision). This lands in PR B because that PR already rewrites the golden. ## Item 5 — `sessions send` stays one typed operation; the landed grammar is confirmed and frozen Facts: `send <ID> [TEXT] [--raw] [--key CHORD]... [--wait [--timeout SECS]]`; constraints: `--raw` requires TEXT, `--timeout` requires `--wait`, at least one of TEXT/`--key`/`--wait`. No `conflicts_with`. The skill's headline use is `send id "cmd" --key enter --wait --timeout N`. Decision: **do not split into `send`/`key`/`wait`; confirm the grammar that has landed.** The constraints below are already implemented in clap and `control-surfaces.md` already records why a separate wait verb races; the freeze adds the missing tests and the reference wording, nothing more. The operation is "deliver this input, then optionally await the next prompt mark"; with no payload it degenerates to "watch the command already running" (`--wait` alone attaches and waits without writing, which is what the help says and what `cmd_send` does). The send-and-wait form is the one that is not separable without a race: `cmd_send` subscribes to the session *before* injecting the input, so the mark a fast command emits is buffered on that subscription and `wait_for_command_end` reads it after admission; a separate `wait` verb issued after `send` opens its subscription after the mark may already have passed. A separate `key` verb has no race (a finished `send` has already enqueued its paste ahead of anything issued later), so the case for keeping `--key` on `send` is only that one call names one target, resolves it once, and states the intended input sequence in order. The cross-product is small and every combination is meaningful (payload kind × chords × wait), which is what "one explicitly typed operation" means. Freeze the grammar: no new flag may depend on another; the only dependencies are the two `requires` above. Anything new (a different wait condition, a different payload kind) is a new verb, not a flag. Deliverable: docs plus parser tests. `docs/explanation/architecture/control-surfaces.md`: record the decision, the race that rejects the split, and the "no new mutually-dependent flag" rule. `docs/reference/cli.md` "send": state the three constraints as the whole grammar (add the "at least one of" rule explicitly). Tests today pin `--raw` without TEXT (`raw_send_needs_a_payload`) and the valid wait forms (`sessions_send_reports_without_a_wait`), but not bare `sessions send <id>` nor `--timeout` without `--wait`; add one parser table in `crates/felis-cli/src/tests.rs` covering all three constraints both ways: rejected bare `send ID`, rejected `--timeout N` without `--wait`, rejected `--raw` without TEXT; accepted key-only, wait-only, `--wait --timeout N`, and TEXT with `--raw`, so the frozen grammar cannot regress silently. ## Item 6 — split `AttachFailure` into attach and create vocabularies Facts (verified in `serve.rs`): `AttachFailure` has 9 variants. `SessionMsg::Attach` produces `UnknownSession`, `NoMatch`, `Ambiguous` (`take_attach_handle`), and `SessionEnding` / `SessionExited` (`subscribe_to`, the latter only under `live_only`). `SessionMsg::Create` and `OpsMsg::Spawn` produce `SpawnFailed`, `GeometryOutOfRange`, `SessionLimitReached`, `DaemonDraining` (`create_session`), and Create additionally `SessionEnding` when the just-spawned child ends before `subscribe_to` lands (`serve.rs:1160-1172`, `live_only: false` so never `SessionExited`). `OpsMsg::Spawn` never subscribes, so it produces only the four `create_session` variants. Read per half rather than per request: the four `create_session` variants belong to the *spawn half* (shared by `Create` and `Spawn`), and `SessionEnding`/`SessionExited`/`UnknownSession`/`NoMatch`/`Ambiguous` belong to the *attach half* (`Attach`, and `SessionEnding` also on `Create`, whose attach half runs after its spawn half). No variant crosses halves. The enum rides on `SessionAttachFailed.reason` (answers both `Attach` and `Create`, since create is create+attach) and on `SpawnRefused.reason` (`OpsMsg::Spawn`). Consumers: `convert/session.rs`, `convert/ops.rs`, `minor.rs`, `client-core/dial.rs` (matches the three attach-side variants), `cli_output.rs` (7 of 9 collapse onto `invalid_request`/`at_capacity`), `felis-grid/json_v1/session.rs` (`AttachFailureJson`, all nine, published in `felis-json-v1.schema.json`), `felis.el` (`attach_failed`). Decision: **split into two wire enums, one per half, and let the reply name which half refused.** ```proto // Why the attach half (SessionAttach, or the attach after a SessionCreate's spawn) failed. enum AttachFailure { ATTACH_FAILURE_UNSPECIFIED = 0; ATTACH_FAILURE_UNKNOWN_SESSION = 1; ATTACH_FAILURE_SESSION_ENDING = 2; ATTACH_FAILURE_SESSION_EXITED = 3; ATTACH_FAILURE_NO_MATCH = 4; ATTACH_FAILURE_AMBIGUOUS = 5; } // Why the daemon refused the spawn half of a SessionCreate or an OpsSpawn. enum CreateFailure { CREATE_FAILURE_UNSPECIFIED = 0; CREATE_FAILURE_SPAWN_FAILED = 1; CREATE_FAILURE_GEOMETRY_OUT_OF_RANGE = 2; CREATE_FAILURE_SESSION_LIMIT_REACHED = 3; CREATE_FAILURE_DAEMON_DRAINING = 4; } message SessionAttachFailed { oneof reason { AttachFailure attach = 1; CreateFailure create = 2; } string detail = 3; } message SpawnRefused { CreateFailure reason = 1; string detail = 2; } ``` Numbering is dense and unreserved, the same policy #339 set for the pre-release schema; `SessionAttachFailed.detail` moves from 2 to 3 so the oneof arms are contiguous. Decoding: an unset `reason` oneof is `WireError::MissingOneof("SessionAttachFailed.reason")` (the #342 rule: absence never means an arm); `*_UNSPECIFIED` is `UnspecifiedEnum` as today; an unknown enum number takes the existing unknown-enum path. There are three request kinds and two halves, and the reply names the half, so each request's admissible set is exact without a third enum: `Attach` → `attach` only (5 values); `Create` → `create` (the 4 spawn-half values; nothing executed) or `attach = SESSION_ENDING` (spawn succeeded, the child ended before the subscription landed and the pool entry was rolled back; today's `SessionEnding` on Create, `serve.rs:1160-1172`); `OpsMsg::Spawn` → `SpawnRefused.reason: CreateFailure` (the same 4 values, exactly what `create_session` can produce; Spawn never subscribes so `SessionEnding` is not in its vocabulary). No impossible value is in any request's vocabulary: Spawn's type has no `SessionEnding`, and Create's race is the `attach` arm, not a `CreateFailure` value. Admissibility is checked at value level, in one place in `felis-client-core` (the connector knows which request it sent, including the target form and `live_only`), against exactly what `serve.rs` can produce: an `Attach` by exact id admits `attach ∈ {UnknownSession, SessionEnding}`, plus `SessionExited` only when `live_only` was set; an `Attach` by prefix admits `attach ∈ {NoMatch, Ambiguous, SessionEnding}`, plus `SessionExited` only when `live_only`; a `Create` admits any `create` value or `attach = SessionEnding` only (the one attach-half failure a just-spawned session can have; the pool entry was rolled back). Every other arm/value combination is a protocol error surfaced through the existing protocol-failure path (exit `2`, `protocol`), never a typed refusal, so a non-conforming daemon cannot make `felis` report a merits decision it did not take. The rule is one `fn admits(ctx: SessionRefusalContext, reason: &AttachRefusal) -> bool` with `enum SessionRefusalContext { Attach { by_prefix: bool, live_only: bool }, Create }`, and a table test over every (context, arm, value) triple. `OpsMsg::Spawn` needs no runtime check: `SpawnRefused.reason` is typed `CreateFailure`, so admissibility there is enforced by the type. Where it runs: `ControlRequest::interpret` is static today and sees only the reply, so it cannot know the target form; the trait changes to `fn interpret(&self, reply)` (`into_msg` takes `&self` or clones, `AttachSessionReq` already holds `target` and `live_only`, and the create request holds its own kind), so `AttachSessionReq::interpret` derives the context from itself. A refusal that fails `admits` is `ConnectError::Protocol`-class (the existing protocol-failure variant), and the connection that carried it is not reusable: the session link is closed as every attach failure closes it today, and for the reusable Ops link `request` marks the connection poisoned on any interpreter protocol error so the next call fails fast instead of reading a stream whose framing it no longer trusts; a test issues a second request after a context-invalid refusal and asserts it is refused without touching the socket. `buf breaking` is not the gate here (pre-release; `just proto-compat` with an own `base:` line in `crates/felis-protocol/proto/BREAKING.md`, base = `main` tip at branch time, proto unchanged since #343). Rust domain: `enum AttachFailure { UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous }`, `enum CreateFailure { SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining }`, `SessionMsg::AttachFailed { reason: AttachRefusal, detail }` with `enum AttachRefusal { Attach(AttachFailure), Create(CreateFailure) }`; `SpawnOutcome::Refused { reason: CreateFailure, detail }`. `felis-client-core`'s public error follows the split: `ConnectError::AttachFailed { reason: AttachFailure, detail }` and a new `ConnectError::CreateFailed { reason: CreateFailure, detail }`. `connect_and_attach` can only produce `AttachFailed`; `create` can produce either (its attach half is `AttachFailed { SessionEnding }`); `spawn_session` (Ops Spawn) can only produce `CreateFailed`, so a caller of spawn never sees an attach-only variant. `is_transient` treats `CreateFailed` like the non-transient create refusals today; `dial.rs` keeps matching the three attach-side reasons on `AttachFailed`. `cli_output.rs` gains `from_create_failure` beside `from_attach_failure`, and `from_connect_error` (now exhaustive) maps `CreateFailed` through it; the golden gains the `[CreateFailure -> kind]` table and the `CreateFailed` row of `[ConnectError -> kind]`. CLI `ErrorKind` tokens are unchanged. `felis-json` v1 is **not** retyped. `docs/reference/ipc.md` "Compatibility within a version" makes a retype `felis_json: 2` with dual serving, and #336 recorded that rule for consumers outside this repository (`.fcast` files, the browser view), so it holds regardless of the tag list. The v1 JSON keeps the flat nine-value `AttachFailureJson` on `SessionJson::AttachFailed.reason`, and `json_v1/session.rs` adapts: `From<AttachRefusal>` flattens both arms onto the nine tokens (the attach half's five and the spawn half's four are disjoint, so no token is ambiguous), and the reverse conversion picks the arm from the token (`session_ending` is always the attach half; the four spawn-half tokens are always `create`). The schema and goldens are unchanged, and a JSON consumer keeps reading exactly what it reads today. Add golden cases anyway (the session-family golden today covers only `SessionMsg::Attached`, `crates/felis-grid/tests/json_v1.rs`): one `AttachFailed` with an attach-half reason and one with a create-half reason, so the flattening is pinned. The arm discrimination reaches JSON consumers only in `felis_json: 2`, which nothing here needs. Also re-export `ThemeActionJson` from `json_v1` (missed in #342; a consumer cannot name `GridJson::ThemeColor { action }` today). Rejected: keep one enum and document per-operation subsets (the tracker's test: a variant meaningful for one operation must not be in the other's vocabulary; a doc note does not remove it from the type a consumer matches, and `dial.rs` today must list attach variants by hand out of a nine-way enum). Rejected: a separate `SessionCreateFailed` family arm (a new arm and ArmMeta row for a message whose success twin `SessionAttached` is shared; the oneof on the existing reply gives the same reader-side discrimination without a routing change). Rejected: two distinct reply messages plus a second success message (doubles the create/attach surface to fix one enum). Cascade (every current `AttachFailure` consumer, from `grep -rn AttachFailure crates`): `felis.proto`; `convert/session.rs`, `convert/ops.rs`, `minor.rs` gating rows; `messages/session.rs`, `messages/ops.rs`; `felis-transport/src/framing.rs:676` (test fixture); daemon `serve.rs` (`write_attach_failed` takes an `AttachRefusal`; `SubscribeFailure::refusal` stays attach-side; `create_session` returns `CreateFailure`) and `serve/tests.rs:7337`+; `client-core` `dial.rs`, connector; `felis-cli` `cli_output.rs` tables (`from_attach_failure` → `from_attach_failure` + `from_create_failure`) + `tests/golden/error_kinds.txt`, `cli_sessions.rs:152`, `cli_sessions.rs:2123` (`spawn_on`: match `ConnectError::CreateFailed` and map through `from_create_failure`), `cli_bridge.rs:1614` (`op_spawn`: `SpawnOutcome::Refused` maps through `from_create_failure`), `tests/cli_bridge.rs:2330`; `felis-grid/json_v1/session.rs` (the flattening adapter between `AttachRefusal` and the unchanged nine-token `AttachFailureJson`; DTO, schema and existing goldens untouched, two golden cases added) and the `json_v1.rs` re-export list (add the missing `ThemeActionJson`); `felis-protocol/src/messages.rs` re-exports (`AttachFailure`, plus `CreateFailure`, `AttachRefusal`); the checked-in prost output under `crates/felis-protocol/src/generated/` (regenerated, not hand-edited); the fuzz seed `fuzz/seeds/ipc_body/proto-session-attach-failed-draining.bin` re-encoded for the new shape (`create = DAEMON_DRAINING`, field 2) so it keeps covering a valid draining refusal; `docs/reference/cli.md:384` (`AttachFailure::DaemonDraining` → `CreateFailure::DaemonDraining`); `docs/reference/ipc.md` (AttachFailure/SpawnArgs prose), `docs/explanation/architecture/session-lifecycle.md`, `CHANGELOG.md`, `crates/felis-protocol/proto/BREAKING.md` (own `base:` line), `skills/felis` if it names variants. Satellites, all five, gated on the first tag (a direct-wire client built before B negotiates 1.0 successfully and then misdecodes `SessionAttachFailed`; the crate-linking satellites are repinned so the release ships one consistent set even where, as in felis-web-component, no live refusal is decoded): `felis.el` (direct wire; `felis-wire.el` `attach_failed` reader gains the arm and the same per-request admissibility rule); felis-fcast, felis-web-gateway, felis-web-component, felis-tui (link the crates: repin to the post-B rev; their JSON is unchanged by the adapter above, but any `ConnectError::AttachFailed` match must add `CreateFailed`). The closing comment on #326 lists these five as the tag checklist, and each gets an issue on its own repository when B merges. ## Landing Two PRs, sequenced, both touching `docs/reference/cli.md`: (A) items 3–5 first: docs, the `op_send` comment fix, the bridge spawn fixes (`env_base`, `u32` geometry, schema regeneration, their tests), the pre-dispatch framing fixes and `Version` in `machine_output()` with their test, and the `send` parser table; (B) item 6 proto split rebased on A, owning every wire/code/golden change plus item 4's `ConnectError` golden and exhaustive match. One follow-up issue for additive bridge `keys`/`wait` params. Then close #326 with the acceptance list updated (items 1–2 already decided in the triage comment).
Author
Owner

Items 3–6 landed:

  • #345 — item 6: AttachFailure (attach half) / CreateFailure (spawn half), SessionAttachFailed.reason oneof, value-level admissibility in client-core, ConnectError::{AttachFailed, CreateFailed}, felis-json v1 shape unchanged through an adapter, all five CLI translation tables frozen in the golden.
  • #346 — items 3–5: the bridge recorded as the session-automation surface with its policy differences; the two bridge spawn defects fixed (env_base per REQ-912a, u32 geometry); machine output and the two error transports frozen per class, every post-parse failure on a machine verb framed; sessions send grammar confirmed and pinned.

Items 1–2 were decided in the triage comment above. Remaining before the first tag, tracked elsewhere: #344 (additive bridge keys/wait), and the satellite repins: felis.el#3, felis-tui#3, felis-web-component#8 (felis-fcast and felis-web-gateway are at 6da5620d and repin once more after #345).

Items 3–6 landed: - #345 — item 6: `AttachFailure` (attach half) / `CreateFailure` (spawn half), `SessionAttachFailed.reason` oneof, value-level admissibility in client-core, `ConnectError::{AttachFailed, CreateFailed}`, felis-json v1 shape unchanged through an adapter, all five CLI translation tables frozen in the golden. - #346 — items 3–5: the bridge recorded as the session-automation surface with its policy differences; the two bridge spawn defects fixed (`env_base` per REQ-912a, `u32` geometry); machine output and the two error transports frozen per class, every post-parse failure on a machine verb framed; `sessions send` grammar confirmed and pinned. Items 1–2 were decided in the triage comment above. Remaining before the first tag, tracked elsewhere: #344 (additive bridge `keys`/`wait`), and the satellite repins: felis.el#3, felis-tui#3, felis-web-component#8 (felis-fcast and felis-web-gateway are at `6da5620d` and repin once more after #345).
Commenting is not possible because the repository is archived.
No description provided.