protocol: version connection modes through the minor ledger #319

Merged
natsukium merged 3 commits from connection-mode-minor into main 2026-09-21 15:53:47 +09:00
Owner

Option 1 from the issue: a connection mode is an additive minor, recorded in the ledger and gated on the effective minor.

What lands

  • ConnectionMode::since_minor() plus a Downgrade impl that withholds rather than maps: a mode decides which families
    a connection speaks at all, so answering with another one would open a surface the caller did not ask for. Every
    shipped mode is a base-schema one (since = 0).
  • ConnMsg::Hello folds its mode's requirement into MinorGated, so the send gate reads it like any other addition,
    and the ledger's schema walk now covers every ConnectionMode value: a mode added later cannot reach the wire
    without a ledger row.
  • Client side: check_mode_minor refuses the dial with ConnectError::MinorTooOld before the Hello is written, so
    the skew is reported instead of lost in an EOF (CLI exit 2, daemon_unreachable).
  • Daemon side: a Hello whose mode this build cannot read is answered Refused { UnknownMode } and then closed,
    rather than dropped as a corrupt body. Widening Role was rejected: re-reading an existing enum value is a semantic
    change the minor rule does not admit.
  • Protocol minor 11 for RefusalReason::UNKNOWN_MODE, with its ledger row in MINOR_LEDGER, felis.proto and
    docs/reference/ipc.md. A peer below the minor is closed unanswered, as AtCapacity already is.

Tests

  • felis-protocol: a_hello_costs_the_minor_that_defined_its_mode, a_mode_is_withheld_rather_than_downgraded, plus
    the existing ledger-coverage test, which now fails if a mode is added without a row.
  • felis-client-core: a_mode_above_the_effective_minor_is_refused — a synthetic mode one minor past this build is
    refused at the negotiated minor, and the refusal is not transient.
  • felis-daemon: a_hello_naming_an_unknown_mode_is_refused_by_name — a hand-encoded future-mode Hello (the shape a
    newer client writes) gets the typed refusal and then a clean close.

Gates run: cargo nextest run for felis-protocol, felis-client-core, felis-daemon, felis-cli (--all-features),
felis-transport and the workspace guards; cargo clippy --workspace --all-targets (clean); just proto,
just proto-compat (wire-compatible with the base), just schema, just prose-check, just docs-links.

Left undone

An end-to-end mixed-version test on the client side is not possible in-tree: every mode this build ships is since = 0,
so no ConnectionMode value exists that a real connect() could refuse. The client half is pinned at the gate
function with a synthetic minor; the daemon half uses a hand-encoded wire value, which is exactly what a newer client
would write.

Closes #312

Option 1 from the issue: a connection mode is an additive minor, recorded in the ledger and gated on the effective minor. ## What lands - `ConnectionMode::since_minor()` plus a `Downgrade` impl that withholds rather than maps: a mode decides which families a connection speaks at all, so answering with another one would open a surface the caller did not ask for. Every shipped mode is a base-schema one (since = 0). - `ConnMsg::Hello` folds its mode's requirement into `MinorGated`, so the send gate reads it like any other addition, and the ledger's schema walk now covers every `ConnectionMode` value: a mode added later cannot reach the wire without a ledger row. - Client side: `check_mode_minor` refuses the dial with `ConnectError::MinorTooOld` before the `Hello` is written, so the skew is reported instead of lost in an EOF (CLI exit `2`, `daemon_unreachable`). - Daemon side: a `Hello` whose mode this build cannot read is answered `Refused { UnknownMode }` and then closed, rather than dropped as a corrupt body. Widening `Role` was rejected: re-reading an existing enum value is a semantic change the minor rule does not admit. - Protocol minor 11 for `RefusalReason::UNKNOWN_MODE`, with its ledger row in `MINOR_LEDGER`, `felis.proto` and `docs/reference/ipc.md`. A peer below the minor is closed unanswered, as `AtCapacity` already is. ## Tests - `felis-protocol`: `a_hello_costs_the_minor_that_defined_its_mode`, `a_mode_is_withheld_rather_than_downgraded`, plus the existing ledger-coverage test, which now fails if a mode is added without a row. - `felis-client-core`: `a_mode_above_the_effective_minor_is_refused` — a synthetic mode one minor past this build is refused at the negotiated minor, and the refusal is not transient. - `felis-daemon`: `a_hello_naming_an_unknown_mode_is_refused_by_name` — a hand-encoded future-mode `Hello` (the shape a newer client writes) gets the typed refusal and then a clean close. Gates run: `cargo nextest run` for felis-protocol, felis-client-core, felis-daemon, felis-cli (`--all-features`), felis-transport and the workspace guards; `cargo clippy --workspace --all-targets` (clean); `just proto`, `just proto-compat` (wire-compatible with the base), `just schema`, `just prose-check`, `just docs-links`. ## Left undone An end-to-end mixed-version test on the *client* side is not possible in-tree: every mode this build ships is since = 0, so no `ConnectionMode` value exists that a real `connect()` could refuse. The client half is pinned at the gate function with a synthetic minor; the daemon half uses a hand-encoded wire value, which is exactly what a newer client would write. Closes #312
protocol: version connection modes through the minor ledger
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
f5fe7e14e0
A mode is the extension path for a whole surface: the design says a new
surface earns a new mode rather than a per-feature bit, so mode growth
is an expected additive change, not a corner case. Until now the gate
covered `ConnWelcome.identity` alone, `ConnectionMode` carried no
`since_minor`, and a client naming a mode an older daemon predates lost
the connection at that daemon's decode with no sender-side verdict and
no frame back.

Every mode now carries a ledger row the schema walk enforces, the
`Hello` folds its mode's requirement into what the send gate reads, and
the client refuses the dial locally when the effective minor does not
define the mode it asks for. The receiving half is a typed refusal:
`RefusalReason::UnknownMode` (minor 11) names the skew before the close,
so the peer reports a daemon too old instead of an EOF it would retry.
Widening `Role` to cover it was rejected, since re-reading an existing
enum value is a semantic change the minor rule does not admit.

No mode down-maps: answering a peer with a mode it did not name would
wire up a surface it did not ask for, so the send gate and the refusal
are the whole accommodation.

Assisted-by: Claude Code
daemon: answer UnknownMode only for a mode a newer peer could have sent
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) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
44d9dc91ce
Recovering from any driver error over a re-decode made two frames a
newer peer never writes look like one: `CONNECTION_MODE_UNSPECIFIED`,
which no build ever names, and an unknown mode under a `Correlation`
envelope, which the second decode cannot see because field 100 is
reserved on the Conn family. Both are corruption, and answering them by
name tells the peer its daemon is old when its frame is wrong. The
recovery now requires an uncorrelated body and a nonzero unknown mode,
so every other undecodable `Hello` keeps the bare close.

The gate's doc comment loses the note that it takes a minor rather than
a `ConnectionMode`: the reason is that no shipped mode is above the
base schema, so a test needs a minor the enum cannot spell.

Assisted-by: Claude Code
protocol: say in the schema that Hello.mode is minor-gated
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 6s
darwin / darwin docs-only no-op (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 3m5s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m0s
pr / nix flake check (pull_request) Successful in 1m10s
pr / cargo build / clippy / test / deny (pull_request) Successful in 3m33s
pr / wire schema is compatible with the base (pull_request) Successful in 40s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m27s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 12s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 7m46s
windows / frontend smoke (Windows) (pull_request) Successful in 2m20s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 15s
0907edf1e2
The `ConnHello` and `ConnWelcome` comments still promised that nothing
in the handshake is a compatibility signal and that any `Hello` the
daemon read was decodable, which a non-Rust client generating its codec
from the schema would take at face value and skip the one send gate the
handshake has.

The ledger row overstated the old client's position too: an EOF it
reads as transient is one it retries, and no number of retries makes a
daemon define a mode it does not have, so the row now says the retry
settles nothing and names who can act.

Assisted-by: Claude Code
natsukium deleted branch connection-mode-minor 2026-09-21 15:53:47 +09:00
Commenting is not possible because the repository is archived.
No description provided.