protocol: split the refusal into an attach half and a spawn half #345

Merged
natsukium merged 2 commits from issue-326-b into main 2026-09-22 16:40:32 +09:00
Owner

Part of #326 (item 6, plus item 4's ConnectError golden). Rebase onto #326's PR A before merge; the two touch
docs/reference/cli.md, CHANGELOG.md and crates/felis-cli/src/cli_output.rs in different places.

The wire

AttachFailure carried nine values across two unrelated situations. It keeps the five the attach half can answer
with — UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous — renumbered densely; the four the
spawn half answers with become CreateFailure (SpawnFailed, GeometryOutOfRange, SessionLimitReached,
DaemonDraining). SessionAttachFailed.reason is a oneof of the two with detail at tag 3, and an unset one is
MissingOneof("SessionAttachFailed.reason"); SpawnRefused.reason is a CreateFailure, since an OpsSpawn
subscribes to nothing. Dense numbering, no reserved, per the #339 policy. The break is acknowledged in
crates/felis-protocol/proto/BREAKING.md against 6da5620d with its skew outcome, and just proto-compat passes.

Domain: AttachRefusal { Attach(AttachFailure), Create(CreateFailure) } on SessionMsg::AttachFailed,
CreateFailure on SpawnOutcome::Refused. The daemon's create_session returns a CreateFailure;
SubscribeFailure::refusal stays attach-side, and a Create whose post-spawn subscribe loses the reap race is
answered on the attach arm. Every detail string is unchanged.

The admissibility rule

Which values each request admits is now checked at value level, in one place
(felis_client_core::admits(ctx, reason) over SessionRefusalContext): an attach by exact id admits
{UnknownSession, SessionEnding} plus SessionExited under live_only; an attach by prefix admits
{NoMatch, Ambiguous, SessionEnding} plus SessionExited under live_only; a create admits any create value or
attach = SessionEnding alone. Anything else is a protocol failure (exit 2, protocol), never a typed refusal, so
a non-conforming daemon cannot make felis report a merits decision it never took. Running it needs the request
beside the reply, so ControlRequest::interpret takes &self; a refusal that fails the check retires the connection,
and the next verb on a reusable Ops link is answered Abandoned without touching the socket. A table test covers
every (context, arm, value) triple, and one asserts the second verb is refused with a valid reply already on the wire.

The client-core error split

ConnectError::AttachFailed { reason: AttachFailure, .. } and a new
ConnectError::CreateFailed { reason: CreateFailure, .. }. connect_and_attach produces only AttachFailed, create
either, spawn_session only CreateFailed. is_transient is unchanged in effect. cli_output.rs gains
from_create_failure beside from_attach_failure; the kind tokens and exit codes do not move.

The felis-json v1 adapter

The published v1 shape is not retyped (ipc.md "Compatibility within a version"; #336 recorded the rule for
consumers outside this repository). AttachFailureJson keeps its nine flat tokens and
SessionJson::AttachFailed { reason, detail } keeps its shape; a From<AttachRefusal> flattens both arms onto them
and the reverse picks the arm from the token, which is unambiguous because the two halves are disjoint. just schema
produces no diff and every existing golden is byte-identical. Two golden cases are added (one refusal per half), and
the missing pub use ThemeActionJson from #342 is restored.

Gates

Gate Result
cargo fmt --all --check pass
cargo clippy --workspace --all-targets --all-features -- -D warnings pass
cargo nextest run --workspace --all-features --no-fail-fast 3408 passed; 5 pre-existing local-environment failures (bridge lost-daemon, bridge relative-cwd, bridge daemon-write-failure timeout, daemon racing-teardown, daemon parked-peer)
just schema no diff
just proto-compat pass (break acknowledged)
just prose-check 0 hits
just docs-links / just skill-check / just deny / just check-portable / just proto-compat-test pass
nix develop .#windows -c cargo clippy --workspace --all-targets --target x86_64-pc-windows-gnu -- -D warnings pass

Satellites (felis.el and the four crate-linking repos) are the tag checklist on #326 and are untouched here.

Part of #326 (item 6, plus item 4's `ConnectError` golden). Rebase onto #326's PR A before merge; the two touch `docs/reference/cli.md`, `CHANGELOG.md` and `crates/felis-cli/src/cli_output.rs` in different places. ## The wire `AttachFailure` carried nine values across two unrelated situations. It keeps the five the **attach half** can answer with — `UnknownSession`, `SessionEnding`, `SessionExited`, `NoMatch`, `Ambiguous` — renumbered densely; the four the **spawn half** answers with become `CreateFailure` (`SpawnFailed`, `GeometryOutOfRange`, `SessionLimitReached`, `DaemonDraining`). `SessionAttachFailed.reason` is a oneof of the two with `detail` at tag 3, and an unset one is `MissingOneof("SessionAttachFailed.reason")`; `SpawnRefused.reason` is a `CreateFailure`, since an `OpsSpawn` subscribes to nothing. Dense numbering, no `reserved`, per the #339 policy. The break is acknowledged in `crates/felis-protocol/proto/BREAKING.md` against `6da5620d` with its skew outcome, and `just proto-compat` passes. Domain: `AttachRefusal { Attach(AttachFailure), Create(CreateFailure) }` on `SessionMsg::AttachFailed`, `CreateFailure` on `SpawnOutcome::Refused`. The daemon's `create_session` returns a `CreateFailure`; `SubscribeFailure::refusal` stays attach-side, and a `Create` whose post-spawn subscribe loses the reap race is answered on the `attach` arm. Every `detail` string is unchanged. ## The admissibility rule Which values each request admits is now checked at value level, in one place (`felis_client_core::admits(ctx, reason)` over `SessionRefusalContext`): an attach by exact id admits `{UnknownSession, SessionEnding}` plus `SessionExited` under `live_only`; an attach by prefix admits `{NoMatch, Ambiguous, SessionEnding}` plus `SessionExited` under `live_only`; a create admits any `create` value or `attach = SessionEnding` alone. Anything else is a protocol failure (exit `2`, `protocol`), never a typed refusal, so a non-conforming daemon cannot make `felis` report a merits decision it never took. Running it needs the request beside the reply, so `ControlRequest::interpret` takes `&self`; a refusal that fails the check retires the connection, and the next verb on a reusable `Ops` link is answered `Abandoned` without touching the socket. A table test covers every (context, arm, value) triple, and one asserts the second verb is refused with a valid reply already on the wire. ## The client-core error split `ConnectError::AttachFailed { reason: AttachFailure, .. }` and a new `ConnectError::CreateFailed { reason: CreateFailure, .. }`. `connect_and_attach` produces only `AttachFailed`, `create` either, `spawn_session` only `CreateFailed`. `is_transient` is unchanged in effect. `cli_output.rs` gains `from_create_failure` beside `from_attach_failure`; the `kind` tokens and exit codes do not move. ## The felis-json v1 adapter The published v1 shape is **not** retyped (`ipc.md` "Compatibility within a version"; #336 recorded the rule for consumers outside this repository). `AttachFailureJson` keeps its nine flat tokens and `SessionJson::AttachFailed { reason, detail }` keeps its shape; a `From<AttachRefusal>` flattens both arms onto them and the reverse picks the arm from the token, which is unambiguous because the two halves are disjoint. `just schema` produces no diff and every existing golden is byte-identical. Two golden cases are added (one refusal per half), and the missing `pub use ThemeActionJson` from #342 is restored. ## Gates | Gate | Result | | ---- | ------ | | `cargo fmt --all --check` | pass | | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | pass | | `cargo nextest run --workspace --all-features --no-fail-fast` | 3408 passed; 5 pre-existing local-environment failures (bridge lost-daemon, bridge relative-cwd, bridge daemon-write-failure timeout, daemon racing-teardown, daemon parked-peer) | | `just schema` | no diff | | `just proto-compat` | pass (break acknowledged) | | `just prose-check` | 0 hits | | `just docs-links` / `just skill-check` / `just deny` / `just check-portable` / `just proto-compat-test` | pass | | `nix develop .#windows -c cargo clippy --workspace --all-targets --target x86_64-pc-windows-gnu -- -D warnings` | pass | Satellites (`felis.el` and the four crate-linking repos) are the tag checklist on #326 and are untouched here.
`AttachFailure` carried nine values across two unrelated situations: five
about a session that already exists, four about one that does not exist
yet. No value crossed the line, but the type did, so every reader of a
refusal carried a nine-way match and decided by hand which half of it
could ever arrive — `dial.rs` listed three attach values out of nine,
`OpsSpawn` could name a refusal it never produces, and the CLI collapsed
seven of nine onto two kinds. The freeze is the last point at which
splitting them is free.

The wire now carries one enum per half and `SessionAttachFailed.reason`
names the arm, so a create's spawn refusal and the attach race that can
follow a successful spawn are distinguishable without a third enum;
`SpawnRefused.reason` is typed `CreateFailure`, which is the whole
vocabulary a spawn can produce. `felis-client-core` then checks a refusal
against what its request asked for — an attach by prefix cannot be told
the id is unknown, an attach that did not set `live_only` cannot be told
the shell exited — and reports anything outside that set as a protocol
failure rather than as a verdict, so a non-conforming daemon cannot make
felis report a merits decision it never took. The check needs the request
alongside the reply, which is why `ControlRequest::interpret` takes
`&self`; a refusal that fails it retires the connection, since the next
verb would otherwise read a stream this side no longer trusts.

The published `felis_json` v1 shape is deliberately not retyped:
`ipc.md` "Compatibility within a version" makes that `felis_json: 2` with
dual serving, and #336 recorded the rule for consumers outside this
repository. The nine tokens are disjoint across the halves, so the v1 DTO
flattens both arms onto them and recovers the arm from the token.

Rejected: keeping one enum and documenting the per-operation subsets. A
note does not remove a value from the type a caller matches, so the
nine-way match survives, which is the mistake the note exists to prevent.

Part of #326.

Assisted-by: Claude Code
cli: freeze the ConnectError translation table in the golden
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m18s
pr / nix flake check (pull_request) Failing after 21s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m1s
pr / wire schema is compatible with the base (pull_request) Successful in 10s
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Successful in 1m46s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m3s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 5m3s
49e219437a
Four of the five wire-reason to error-kind maps were pinned by
`tests/golden/error_kinds.txt`; `from_connect_error` was not, and it
ended in `_ => Protocol`, so a new `ConnectError` variant took that arm
without anyone choosing it — the split refusal just added one, and two
more variants came with the admissibility check. An exhaustive match
forces the choice at the point the variant is added, and a golden row
makes the published answer a diff rather than a rediscovery.

The rows are representative rather than one per variant: the variants a
test cannot build without reaching into another crate's error types all
answer `protocol`, which the exhaustive match already guards. No mapping
moves, and the `kind` tokens and exit codes are unchanged.

Part of #326.

Assisted-by: Claude Code
natsukium force-pushed issue-326-b from 49e219437a
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m18s
pr / nix flake check (pull_request) Failing after 21s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m1s
pr / wire schema is compatible with the base (pull_request) Successful in 10s
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Successful in 1m46s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m3s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 5m3s
to 189314c8be
Some checks failed
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m3s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m0s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 5m13s
windows / frontend smoke (Windows) (pull_request) Successful in 2m14s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / nix flake check (pull_request) Failing after 42s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
bench / Criterion regression gate (pull_request) Failing after 2m21s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m16s
2026-09-22 15:29:38 +09:00
Compare
natsukium force-pushed issue-326-b from 189314c8be
Some checks failed
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m3s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m0s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 5m13s
windows / frontend smoke (Windows) (pull_request) Successful in 2m14s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / nix flake check (pull_request) Failing after 42s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
bench / Criterion regression gate (pull_request) Failing after 2m21s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m16s
to f25b98557b
Some checks failed
bench / Criterion regression gate (pull_request) Failing after 3m33s
darwin / detect relevant changes (pull_request) Successful in 17s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m1s
pr / wire schema is compatible with the base (pull_request) Successful in 11s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 54s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 51s
pr / nix flake check (pull_request) Successful in 36s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m38s
windows / cargo nextest (Windows) (pull_request) Successful in 4m31s
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / frontend smoke (Windows) (pull_request) Successful in 1m56s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
2026-09-22 16:15:38 +09:00
Compare
natsukium deleted branch issue-326-b 2026-09-22 16:40:32 +09:00
Commenting is not possible because the repository is archived.
No description provided.