refactor(protocol): make the oneof arm the routing unit #72

Merged
natsukium merged 3 commits from refactor/arm-meta-routing-47 into main 2026-09-04 16:15:03 +09:00
Owner

Fixes #47.

The documented growth rule — a new one-shot or push rides a new frame
kind, and a mode admits or refuses a whole kind — had already come apart
from the schema (Ops grew Tag and Status as arms, Push groups
arms whose recipients differ by mode, Region carries both a point reply
and a row stream). Rather than split a family per verb, the routing unit
becomes the oneof arm before the protocol freezes its numbering.

  • felis-protocol: one ArmMeta table holds each arm's direction,
    correlation class, mode set, phase set, and introducing minor.
    Kind-level admission survives only as the fold of that table
    (MessageKind::modes / ::phases / ::sole_direction /
    ::admissible) — a pre-decode filter on the header word — and a test
    asserts the fold against the arms so the two cannot drift.
    felis.proto restates every arm's row in an // arm: comment so a
    non-Rust peer can implement the same matrix; a test compares the two,
    since buf lint does not read comments. Phase moves here from
    felis-transport (re-exported there) so the table and its enforcement
    cannot drift apart.
  • felis-transport: admit_arm is the single authority — it applies
    the decoded arm's direction, phase and mode columns, replacing the
    daemon's separate ops_denied policy. account_for now enforces the
    correlation column instead of reading it as a label, so a reply with no
    request_id, a request carrying a stream id, or an uncorrelated arm
    carrying either ends the connection (REQ-114); previously any frame
    without a stream_id was delivered unchecked. A mode denial surfaces as
    DriverError::ArmDenied so the caller picks the wire shape the phase
    requires (pre-attach: typed Conn::Refused and close; attached: refuse
    through the request envelope and keep streaming). A refused stream
    opener advances next_stream without taking a slot, so neither end
    misnumbers nor leaks.
  • Discarded paths: the CLI bridge dropped uncorrelated payloads unread
    and the sessions drain loop skipped frames outside its family, so the
    mode column never ran where pushes actually arrive. admit_discarded
    puts the arm's row in front of the drop, envelope shape included.
  • Client: an Ops connection refuses a Push::Reattach instead of
    acting on a window-management push, and the connector reads its session
    round trip through the driver rather than decoding the body itself.

The wire format is unchanged and a well-behaved daemon sends nothing new;
what changes is that a misroute is reported under the protocol error
kind instead of passing unnoticed.

Doc cascade: docs/reference/ipc.md gains the normative arm table and
states that the kind gate is its fold, and its modes table splits into
admission (the arms' modes column) versus the daemon's output shaping,
so a peer implementing routing from the schema does not read a refusal
into a message the daemon merely never composes;
docs/explanation/architecture/ipc.md carries the kind-versus-arm
criterion, the worked examples, and the rejected alternative (splitting
families until whole-kind admission is literally true); REQ-114 in
docs/reference/spec.md names the mode column alongside phase, direction
and correlation; docs/reference/glossary.md,
docs/reference/testing.md, and
docs/explanation/architecture/control-surfaces.md follow. CHANGELOG.md
records the user-visible half.

Verified: just check green (fmt, clippy, nextest, deny); reviewed by
plan/correctness/docs lenses and pi sol

Fixes #47. The documented growth rule — a new one-shot or push rides a new frame kind, and a mode admits or refuses a whole kind — had already come apart from the schema (`Ops` grew `Tag` and `Status` as arms, `Push` groups arms whose recipients differ by mode, `Region` carries both a point reply and a row stream). Rather than split a family per verb, the routing unit becomes the oneof arm before the protocol freezes its numbering. - **felis-protocol**: one `ArmMeta` table holds each arm's direction, correlation class, mode set, phase set, and introducing minor. Kind-level admission survives only as the fold of that table (`MessageKind::modes` / `::phases` / `::sole_direction` / `::admissible`) — a pre-decode filter on the header word — and a test asserts the fold against the arms so the two cannot drift. `felis.proto` restates every arm's row in an `// arm:` comment so a non-Rust peer can implement the same matrix; a test compares the two, since `buf lint` does not read comments. `Phase` moves here from felis-transport (re-exported there) so the table and its enforcement cannot drift apart. - **felis-transport**: `admit_arm` is the single authority — it applies the decoded arm's direction, phase and mode columns, replacing the daemon's separate `ops_denied` policy. `account_for` now enforces the correlation column instead of reading it as a label, so a reply with no `request_id`, a request carrying a stream id, or an uncorrelated arm carrying either ends the connection (REQ-114); previously any frame without a `stream_id` was delivered unchecked. A mode denial surfaces as `DriverError::ArmDenied` so the caller picks the wire shape the phase requires (pre-attach: typed `Conn::Refused` and close; attached: refuse through the request envelope and keep streaming). A refused stream opener advances `next_stream` without taking a slot, so neither end misnumbers nor leaks. - **Discarded paths**: the CLI bridge dropped uncorrelated payloads unread and the `sessions` drain loop skipped frames outside its family, so the mode column never ran where pushes actually arrive. `admit_discarded` puts the arm's row in front of the drop, envelope shape included. - **Client**: an `Ops` connection refuses a `Push::Reattach` instead of acting on a window-management push, and the connector reads its session round trip through the driver rather than decoding the body itself. The wire format is unchanged and a well-behaved daemon sends nothing new; what changes is that a misroute is reported under the `protocol` error kind instead of passing unnoticed. Doc cascade: `docs/reference/ipc.md` gains the normative arm table and states that the kind gate is its fold, and its modes table splits into admission (the arms' `modes` column) versus the daemon's output shaping, so a peer implementing routing from the schema does not read a refusal into a message the daemon merely never composes; `docs/explanation/architecture/ipc.md` carries the kind-versus-arm criterion, the worked examples, and the rejected alternative (splitting families until whole-kind admission is literally true); REQ-114 in `docs/reference/spec.md` names the mode column alongside phase, direction and correlation; `docs/reference/glossary.md`, `docs/reference/testing.md`, and `docs/explanation/architecture/control-surfaces.md` follow. `CHANGELOG.md` records the user-visible half. Verified: just check green (fmt, clippy, nextest, deny); reviewed by plan/correctness/docs lenses and pi sol
The stated growth rule (a new one-shot or push rides a new frame kind,
and a mode admits or refuses a whole kind) had already come apart from
the schema: `Ops` grew `Tag` and `Status` as arms, `Push` groups arms
whose recipients differ by mode, and `Region` carries both a point
reply and a row stream. A rule the driver cannot enforce is worse than
no rule before the protocol freezes the numbering, so fix the rule
rather than splitting a family per verb — which would spend a
`FrameKind` row and a kind word on every future verb to buy a property
the decode performs anyway.

A kind is now a conversation surface and an operation inside one is
another arm, with direction, correlation class, mode set, phase set,
and introducing minor held per arm in one `ArmMeta` table. Kind-level
admission survives only as the fold of that table
(`MessageKind::modes` / `::phases` / `::sole_direction` /
`::admissible`), a pre-decode filter on the header word, and a test
asserts the fold against the arms so the two cannot drift.

`felis.proto` restates every arm's row in an `// arm:` comment, so a
peer that cannot read Rust implements the same matrix from the schema
alone; a Rust test compares the two, which is what fails the build when
a new arm ships with no declaration or the wrong one. `buf lint` does
not read comments, so that test is the only gate on them.

Refs #47

Assisted-by: Claude Code
The driver checked direction per arm but mode per kind, and the daemon
re-checked mode per arm in `ops_denied`, a second policy the driver
could not see and the reason the documented rule and the implemented
one disagreed. There is now one authority: `admit_arm` applies the
decoded arm's direction, phase and mode columns, and the kind gate ahead
of it is the fold of the same table.

The correlation column is enforced the same way rather than read as a
label. `account_for` takes the arm's `CorrelationClass` and rejects an
envelope that contradicts it, so a reply with no `request_id`, a
request carrying a stream id, or an uncorrelated arm carrying either is
an unattributable frame and ends the connection (REQ-114). Before this,
any frame without a `stream_id` was delivered unchecked, which let a
fabricated `Region::Reply` reach a window that had asked for no region.

The mode column is decided before the stream accounting and reported
after it. A mode denial may be answered through the request envelope
with the connection kept alive, so a refused stream opener has to
advance `next_stream` exactly as a `TooManyStreams` refusal does, or
the two ends number differently and the peer's next well-formed open
kills the connection. It must not also take a slot in the active
table: nobody will serve that stream, so no terminal will ever retire
it, and the leak would answer later legitimate opens with
`TooManyStreams` on a connection holding no live stream.

The denial is reported as `DriverError::ArmDenied` rather than answered
inside the driver, because the two shapes the wire already specifies
depend on where the caller sits: pre-attach it still holds the writer
for a typed `Conn::Refused` and closes, attached it must answer through
the request envelope and keep streaming the session under the window.
Only the caller knows which, so `serve.rs` picks and the wire behavior
is unchanged.

Enforcement follows the frames the receivers throw away, too. The CLI
bridge dropped every uncorrelated payload unread and the `sessions`
drain loop skipped every frame outside the family it was draining, so
the mode column never ran on the paths that actually receive a push;
`admit_discarded` puts the arm's row in front of the drop, envelope
shape included, which is what "never skip-and-continue" asks for. The
stateful half stays out of it, so a payload the caller later decodes is
accounted exactly once.

`Phase` moves to `felis-protocol` so the table and its enforcement
cannot drift, and is re-exported from `felis-transport` for its callers.
The client side gains checks it never had: an `Ops` connection now
refuses a `Push::Reattach` instead of acting on a window-management push
addressed to a mode with no window, and the connector's session round
trip reads its reply through the driver rather than decoding the body
itself, so an attach answered by a wrong-direction or correlated
`Session` arm ends the connection instead of passing as an attach.

Refs #47
docs: state the kind-versus-arm rule the driver can enforce
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 / build felis (aarch64-darwin) (pull_request) Successful in 46s
bench / Criterion regression gate (pull_request) Failing after 2m35s
pr / cargo build / clippy / test / deny (pull_request) Failing after 2m51s
pr / wire schema is compatible with the base (pull_request) Successful in 15s
windows / frontend smoke (Windows) (pull_request) Successful in 2m51s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m41s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m3s
pr / nix flake check (pull_request) Successful in 38s
windows / cargo nextest (Windows) (pull_request) Successful in 6m55s
ead7faad1e
The reference and the explanation both promised whole-kind routing
while recommending that families grow by adding arms; the two claims
cannot hold at once, and the reference's own modes table already listed
arm-level rows, conceding which unit really routes.

Both now state the criterion, and it turns on surface ownership rather
than on the correlation lifetime a message happens to use: messages
addressing a surface no existing kind owns earn a kind, as does one
whose refusal must land before its body is decoded, while another
request, reply, or item shape on a surface that already exists earns an
arm, whatever correlation class it needs. Neither the phase set nor a
single lifetime can be the discriminator: every steady-state family
shares one phase set, which would have made `Search` an arm and left a
peer applying the rule literally unable to mint a kind at all, and one
surface may own several openers, which is why `Region::Rows` opens a
row stream of its own and is still an arm of the region family. The
worked examples, the columns each arm declares, and the rejected
alternative (splitting families until whole-kind admission is literally
true) ride with it.
The reference gains the normative arm table and says the kind gate is
its fold, so a reader cannot mistake the coarse pre-decode filter for
the whole rule. `Ops::List` is no longer described as the one query
shared across modes; `Status` joined it in minor 5.

The modes table splits in two for the same reason. Its rows are the
arm table's `modes` column, and what a `Ops` attach is *sent* on the
grid and image families is not that column but the daemon's output
shaping: every arm of both is legal on either attach-capable mode. A
peer implementing routing from the schema's `arm:` lines would
otherwise read a refusal into a message the daemon merely never
composes. The shaping table also records the one live image event an
`Ops` attach does receive: the session broadcasts an animation's
`ShowFrame` tick to every subscriber, which is what keeps the `Image`
arms legal on both modes.

REQ-114 names the mode column alongside phase, direction and
correlation, and says a frame the receiver has no use for passes the
table before it is dropped, so the requirement describes the driver
that now exists.

Refs #47
natsukium force-pushed refactor/arm-meta-routing-47 from ead7faad1e
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 / build felis (aarch64-darwin) (pull_request) Successful in 46s
bench / Criterion regression gate (pull_request) Failing after 2m35s
pr / cargo build / clippy / test / deny (pull_request) Failing after 2m51s
pr / wire schema is compatible with the base (pull_request) Successful in 15s
windows / frontend smoke (Windows) (pull_request) Successful in 2m51s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m41s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m3s
pr / nix flake check (pull_request) Successful in 38s
windows / cargo nextest (Windows) (pull_request) Successful in 6m55s
to 18d2fcae36
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 / build felis (aarch64-darwin) (pull_request) Successful in 47s
bench / Criterion regression gate (pull_request) Failing after 2m30s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m11s
pr / nix flake check (pull_request) Successful in 5s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m44s
pr / wire schema is compatible with the base (pull_request) Successful in 9s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m6s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 22s
windows / cargo nextest (Windows) (pull_request) Successful in 8m8s
windows / frontend smoke (Windows) (pull_request) Successful in 1m59s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / build felis (aarch64-darwin) (push) Successful in 13s
pr / nix flake check (push) Successful in 6s
pr / cargo build / clippy / test / deny (push) Successful in 1m46s
windows / cargo clippy (Windows cross) (push) Successful in 12s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m20s
pr / wire schema is compatible with the base (push) Successful in 9s
pr / frontend smoke (x86_64-linux) (push) Successful in 7s
pr / publish felis (x86_64-linux) (push) Successful in 11s
windows / cargo nextest (Windows) (push) Successful in 5m48s
windows / frontend smoke (Windows) (push) Successful in 1m58s
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 2m16s
2026-09-04 15:56:53 +09:00
Compare
natsukium deleted branch refactor/arm-meta-routing-47 2026-09-04 16:15:04 +09:00
Sign in to join this conversation.
No description provided.