[v0.1/P1] Reconcile frame-family taxonomy with routing #47

Closed
opened 2026-09-03 16:43:17 +09:00 by natsukium · 1 comment
Owner

Problem

The stated frame-family evolution rule and the implemented routing model disagree.

docs/explanation/architecture/ipc.md says a new one-shot or push gets a new frame kind, that a mode admits/refuses a whole kind, and that kind-level routing eliminates nonsense guard arms. The current schema instead evolves OpsMsg by adding one-shot variants (Tag, Status, and others) and groups unrelated daemon pushes in PushMsg. mode_admits admits Ops broadly for the window session picker, while ops_denied performs a second arm-level mode check. Bidirectional families likewise require arm-level direction routing after decode.

The implementation can be valid, but the public growth rule cannot simultaneously promise whole-kind routing and recommend extending existing families. This matters before freeze because it decides whether future additions consume frame-kind numbers or oneof tags, how effective-minor gates are attached, and what matrix a non-Rust peer must implement.

Required decision

Choose and document one cohesion rule for a family. The likely smaller contract is: a new conversation/surface gets a kind, while a new operation inside an existing conversation gets a fresh oneof arm; direction and mode are then explicit per-arm metadata. If whole-kind admission is retained instead, split the existing mixed families until that claim is true.

Acceptance criteria

  • The reference and explanation define a testable criterion for "new kind" versus "new arm", with examples for Ops queries/mutations, Region/Search conversations, lifecycle pushes, and future point/stream operations.
  • Frame-kind admission, arm direction, arm mode, correlation class, and since_minor are represented in one auditable routing table or equivalent metadata.
  • Ad hoc policy functions such as ops_denied are either removed or generated/checked against that authority.
  • The normative message-family table exposes the arm-level exceptions if mixed families remain; it does not claim a whole-kind property the driver cannot enforce.
  • Protocol-2.0 reserves/renumbers kinds and oneof tags according to the selected rule before #30 freezes them.
  • Compatibility and routing tests fail when a new schema arm has no direction, mode, correlation class, or minor-introduction declaration.

#35 classifies proposed verbs as Point/Stream/Exempt after v0.1. This issue freezes the lower-level family/routing rule that those verbs will use.

## Problem The stated frame-family evolution rule and the implemented routing model disagree. `docs/explanation/architecture/ipc.md` says a new one-shot or push gets a new frame `kind`, that a mode admits/refuses a whole kind, and that kind-level routing eliminates nonsense guard arms. The current schema instead evolves `OpsMsg` by adding one-shot variants (`Tag`, `Status`, and others) and groups unrelated daemon pushes in `PushMsg`. `mode_admits` admits `Ops` broadly for the window session picker, while `ops_denied` performs a second arm-level mode check. Bidirectional families likewise require arm-level direction routing after decode. The implementation can be valid, but the public growth rule cannot simultaneously promise whole-kind routing and recommend extending existing families. This matters before freeze because it decides whether future additions consume frame-kind numbers or oneof tags, how effective-minor gates are attached, and what matrix a non-Rust peer must implement. ## Required decision Choose and document one cohesion rule for a family. The likely smaller contract is: a new conversation/surface gets a kind, while a new operation inside an existing conversation gets a fresh oneof arm; direction and mode are then explicit per-arm metadata. If whole-kind admission is retained instead, split the existing mixed families until that claim is true. ## Acceptance criteria - The reference and explanation define a testable criterion for "new kind" versus "new arm", with examples for Ops queries/mutations, Region/Search conversations, lifecycle pushes, and future point/stream operations. - Frame-kind admission, arm direction, arm mode, correlation class, and `since_minor` are represented in one auditable routing table or equivalent metadata. - Ad hoc policy functions such as `ops_denied` are either removed or generated/checked against that authority. - The normative message-family table exposes the arm-level exceptions if mixed families remain; it does not claim a whole-kind property the driver cannot enforce. - Protocol-2.0 reserves/renumbers kinds and oneof tags according to the selected rule before #30 freezes them. - Compatibility and routing tests fail when a new schema arm has no direction, mode, correlation class, or minor-introduction declaration. ## Related work #35 classifies proposed verbs as Point/Stream/Exempt after v0.1. This issue freezes the lower-level family/routing rule that those verbs will use.
Author
Owner

Triage plan (2026-09-03)

Source-grounded triage against main at 69076d42, reviewed through seven rounds of an independent reviewer (pi sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.

Claim check

Accurate against HEAD.

  • The stated rule: docs/explanation/architecture/ipc.md:55-61 ("a new one-shot or push rides a new frame kind, never a new variant bolted onto an existing family"), :63-66 ("the mode gate admits or refuses a whole kind"), and felis.proto:87-91 ("One kind per surface, so the ConnectionMode ... admits or refuses a whole kind at a time"); docs/reference/ipc.md:488-490 repeats the whole-kind claim for Search/Region.
  • The practice: OpsMsg grew Tag/TagsUpdated (tags 9-10) and Status/StatusReply (tags 11-12, minor 5) as arms (felis.proto:1143-1160), not kinds. PushMsg groups Evicted, Reattach, SessionExited, RetargetHost (:1585-1595), whose recipients differ by mode (docs/reference/ipc.md:1005-1010: three go only to Window, Evicted to every subscriber), which is exactly an arm-level mode rule.
  • The driver admits Ops to any attach-capable mode (crates/felis-transport/src/driver.rs:628-646, the comment says so) and the daemon re-checks per arm in ops_denied (crates/felis-daemon/src/serve.rs:783-790), at two call sites (:631, :1329). Bidirectional families need arm direction after decode (Directed::routing, crates/felis-protocol/src/messages.rs:355-373; checked at driver.rs:396-403), while one-way families are checked by kind (sole_sender, driver.rs:615-626). opens_stream (messages.rs:370) is a second per-arm fact, held on the same trait. since_minor exists only as prose (felis.proto:209, 215, 222, 523, 578, 1097, 1155, 1204; docs/reference/ipc.md:1738-1752) and as scattered constants (preface.rs:62-80, connector.rs:138).
  • The modes table (docs/reference/ipc.md:565-576) already lists arm-level rows (Ops::List, Ops::Status vs Ops mutations), so the reference already concedes the arm is the routing unit; the explanation (:149-151) is also stale in saying List is "the one query shared across modes" (Status joined it in minor 5).

So the two documents disagree with each other and with the code; the issue's description is exact.

Verdict

accept-with-changes: the decision is requested, and the recommendation is the issue's "likely smaller contract", made concrete:

Rule: a frame kind is a conversation surface; an operation inside an existing surface is a new oneof arm; direction, mode set, correlation class, phase set, and since_minor are per-arm metadata, held in one table the driver enforces. Kind-level admission stays as a coarse pre-decode filter derived from that table ("no arm of this kind is admissible here"), never as an independent policy.

Why this and not splitting families until whole-kind is true:

  1. Whole-kind admission is already false in three places (Ops queries vs mutations, Push recipients, Region point vs stream), and making it true means a kind per verb: OpsList, OpsDestroy, ... That spends the u16 kind space and a FrameKind enum row on every future Point/Stream verb #35 will add, for a property (one match on the header word) that the decode has to do anyway to read the body.
  2. The driver already checks per arm after decode (routing, opens_stream); the direction check is per arm for six of ten families. Making mode and phase per arm adds two columns to a table that exists; splitting families adds new families to every generator, the bridge's JSON view, and the docs.
  3. The genuine value of the kind is the pre-decode fast path (sole_sender for Input/Grid/Image/Push at driver.rs:617-618) and the mode refusal before an attach (Refused + close). Both survive: they become the projection of the arm table onto the kind.
  4. "New conversation gets a kind" keeps the explanation's real argument (ipc.md:31-46: a conversation's request and reply halves live together, so a phase can refuse the whole thing). Search and Region remain the worked examples; Ops gains arms; a future streaming surface with its own lifecycle (say a PTY tap) gets a kind.

Testable criterion for the docs: new kind iff the messages form a conversation with their own opener/reply/item roles that no existing kind's phase set fits; new arm iff it is another request/reply or item shape inside an existing conversation and inherits that kind's phase set. Examples to write down: Ops::Status (arm), Region::Rows (arm: a second reply shape on the region conversation, already argued at ipc.md:75-81), Push::RetargetHost (arm), Search (kind), a future Ops::Spawn (#20, arm), a future point verb (#35, arm), a future stream with its own opener/terminal semantics that a Window must never see (kind).

Approach

felis-protocol (messages.rs:355-373): replace Directed::routing + opens_stream with

pub struct ArmMeta {
    pub name: &'static str,
    pub direction: Direction,
    pub correlation: CorrelationClass,   // from #48: Uncorrelated | RequestOpener | RequestReply | StreamOpener | StreamItem
    pub modes: ModeSet,                  // bitset over ConnectionMode, receive-side legality
    pub phases: PhaseSet,                // from #46; until #46 lands, a single Steady bit
    pub since_minor: u16,                // from #50; 0 at the 2.0 baseline
}
pub trait Directed { fn meta(&self) -> ArmMeta; }

Each family module's routing match becomes a meta match (mechanical; ops.rs:247-260, session.rs:78-85, region.rs:80-90, notify.rs:55-64, search.rs:47-54, push.rs:47-52, image.rs:149-162, and the grid/input/conn modules). Add MessageKind::admissible(mode, phase) -> bool derived by folding every arm of the kind (test_support::assert_covers_every_variant case lists at each module's tests already enumerate arms; expose a #[cfg(test)] fn every_arm() per family and assert the fold equals the hand-written kind table so the two cannot drift).

felis-transport/src/driver.rs: mode_admits (:631-646) becomes MessageKind::admissible; decode (:383-409) checks meta.modes and meta.phases after the direction check and returns Delivery::RefuseRequest { reply } when attached (typed Conn::Error { Request(id), InvalidRequest }) or DriverError::ModeDenied when not. Delete ops_denied (serve.rs:787-790) and its two call sites; refuse_mode stays as the writer of the pre-attach refusal.

Schema (felis.proto): a per-arm comment convention the Rust side parses, in the style of PROTOCOL_MAJOR = 1 (preface.rs:758-777 shows the pattern):

    // arm: to_daemon request_opener modes=window,ops since=0
    OpsList list = 1;

and a felis-protocol test that walks the schema's oneof arms and asserts each has an arm: line whose fields equal ArmMeta for that variant. That is the "compatibility test fails when a new arm has no declaration" criterion, without a second generator. buf lint does not inspect comments, so the Rust test is the gate.

Docs: rewrite docs/explanation/architecture/ipc.md:55-61 and :63-66 to the kind-vs-arm rule with the criterion and examples above; fix :149-151 (List and Status); docs/reference/ipc.md:484-493 state that the kind is admitted when any arm is, and that the arm table is normative; add an "arm" column (or a per-family arm table) to the modes table at :565-576 so Ops and Push exceptions are listed rather than implied; felis.proto:87-91 header. docs/reference/spec.md: check whether any REQ quotes "whole kind" (grep whole kind before editing). CHANGELOG: none unless the post-attach mode denial reply changes shape. skills/felis: none.

Protocol-2.0 numbering (#30): with this rule no kinds are split, so FrameKind 0-9 stay; oneof tags stay in source order. Reserve nothing new.

Dependencies

Decide this first (#12 item 5, #52 item 1); #48 adds the correlation column, #46 the phases column, #50 the since_minor column, all on the same ArmMeta. #20's Ops::Spawn is the first arm added under the rule. #30 freezes after.

Risk/effort

Decision S; implementation M (the metadata table and the schema-comment test are a day; the doc rewrite is the other day). Main risk: getting the modes receive-side table wrong for Push arms (they are outbound from the daemon; the client's driver should refuse a Reattach arriving on an Ops connection, which no test covers today).

Labels

Keep priority/P1, release/v0.1.0. It determines what #30 freezes and what a non-Rust peer must implement.

## Triage plan (2026-09-03) Source-grounded triage against `main` at `69076d42`, reviewed through seven rounds of an independent reviewer (`pi` sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. ## Claim check Accurate against HEAD. - The stated rule: `docs/explanation/architecture/ipc.md:55-61` ("a new one-shot or push rides a new frame kind, never a new variant bolted onto an existing family"), `:63-66` ("the mode gate admits or refuses a whole kind"), and `felis.proto:87-91` ("One kind per surface, so the ConnectionMode ... admits or refuses a whole kind at a time"); `docs/reference/ipc.md:488-490` repeats the whole-kind claim for `Search`/`Region`. - The practice: `OpsMsg` grew `Tag`/`TagsUpdated` (tags 9-10) and `Status`/`StatusReply` (tags 11-12, minor 5) as arms (`felis.proto:1143-1160`), not kinds. `PushMsg` groups `Evicted`, `Reattach`, `SessionExited`, `RetargetHost` (`:1585-1595`), whose recipients differ by mode (`docs/reference/ipc.md:1005-1010`: three go only to `Window`, `Evicted` to every subscriber), which is exactly an arm-level mode rule. - The driver admits `Ops` to any attach-capable mode (`crates/felis-transport/src/driver.rs:628-646`, the comment says so) and the daemon re-checks per arm in `ops_denied` (`crates/felis-daemon/src/serve.rs:783-790`), at two call sites (`:631`, `:1329`). Bidirectional families need arm direction after decode (`Directed::routing`, `crates/felis-protocol/src/messages.rs:355-373`; checked at `driver.rs:396-403`), while one-way families are checked by kind (`sole_sender`, `driver.rs:615-626`). `opens_stream` (`messages.rs:370`) is a second per-arm fact, held on the same trait. `since_minor` exists only as prose (`felis.proto:209, 215, 222, 523, 578, 1097, 1155, 1204`; `docs/reference/ipc.md:1738-1752`) and as scattered constants (`preface.rs:62-80`, `connector.rs:138`). - The modes table (`docs/reference/ipc.md:565-576`) already lists arm-level rows (`Ops::List`, `Ops::Status` vs `Ops` mutations), so the reference already concedes the arm is the routing unit; the explanation (`:149-151`) is also stale in saying `List` is "the one query shared across modes" (`Status` joined it in minor 5). So the two documents disagree with each other and with the code; the issue's description is exact. ## Verdict **accept-with-changes**: the decision is requested, and the recommendation is the issue's "likely smaller contract", made concrete: **Rule: a frame kind is a conversation surface; an operation inside an existing surface is a new oneof arm; direction, mode set, correlation class, phase set, and `since_minor` are per-arm metadata, held in one table the driver enforces. Kind-level admission stays as a coarse pre-decode filter derived from that table ("no arm of this kind is admissible here"), never as an independent policy.** Why this and not splitting families until whole-kind is true: 1. Whole-kind admission is already false in three places (`Ops` queries vs mutations, `Push` recipients, `Region` point vs stream), and making it true means a kind per verb: `OpsList`, `OpsDestroy`, ... That spends the `u16` kind space and a `FrameKind` enum row on every future Point/Stream verb #35 will add, for a property (one `match` on the header word) that the decode has to do anyway to read the body. 2. The driver already checks per arm after decode (`routing`, `opens_stream`); the direction check is per arm for six of ten families. Making mode and phase per arm adds two columns to a table that exists; splitting families adds new families to every generator, the bridge's JSON view, and the docs. 3. The genuine value of the kind is the pre-decode fast path (`sole_sender` for `Input`/`Grid`/`Image`/`Push` at `driver.rs:617-618`) and the mode refusal before an attach (`Refused` + close). Both survive: they become the projection of the arm table onto the kind. 4. "New conversation gets a kind" keeps the explanation's real argument (`ipc.md:31-46`: a conversation's request and reply halves live together, so a phase can refuse the whole thing). Search and Region remain the worked examples; `Ops` gains arms; a future *streaming* surface with its own lifecycle (say a PTY tap) gets a kind. Testable criterion for the docs: **new kind** iff the messages form a conversation with their own opener/reply/item roles that no existing kind's phase set fits; **new arm** iff it is another request/reply or item shape inside an existing conversation and inherits that kind's phase set. Examples to write down: `Ops::Status` (arm), `Region::Rows` (arm: a second reply shape on the region conversation, already argued at `ipc.md:75-81`), `Push::RetargetHost` (arm), `Search` (kind), a future `Ops::Spawn` (#20, arm), a future point verb (#35, arm), a future stream with its own opener/terminal semantics that a `Window` must never see (kind). ## Approach `felis-protocol` (`messages.rs:355-373`): replace `Directed::routing` + `opens_stream` with ```rust pub struct ArmMeta { pub name: &'static str, pub direction: Direction, pub correlation: CorrelationClass, // from #48: Uncorrelated | RequestOpener | RequestReply | StreamOpener | StreamItem pub modes: ModeSet, // bitset over ConnectionMode, receive-side legality pub phases: PhaseSet, // from #46; until #46 lands, a single Steady bit pub since_minor: u16, // from #50; 0 at the 2.0 baseline } pub trait Directed { fn meta(&self) -> ArmMeta; } ``` Each family module's `routing` match becomes a `meta` match (mechanical; `ops.rs:247-260`, `session.rs:78-85`, `region.rs:80-90`, `notify.rs:55-64`, `search.rs:47-54`, `push.rs:47-52`, `image.rs:149-162`, and the grid/input/conn modules). Add `MessageKind::admissible(mode, phase) -> bool` derived by folding every arm of the kind (`test_support::assert_covers_every_variant` case lists at each module's tests already enumerate arms; expose a `#[cfg(test)] fn every_arm()` per family and assert the fold equals the hand-written kind table so the two cannot drift). `felis-transport/src/driver.rs`: `mode_admits` (`:631-646`) becomes `MessageKind::admissible`; `decode` (`:383-409`) checks `meta.modes` and `meta.phases` after the direction check and returns `Delivery::RefuseRequest { reply }` when attached (typed `Conn::Error { Request(id), InvalidRequest }`) or `DriverError::ModeDenied` when not. Delete `ops_denied` (`serve.rs:787-790`) and its two call sites; `refuse_mode` stays as the writer of the pre-attach refusal. Schema (`felis.proto`): a per-arm comment convention the Rust side parses, in the style of `PROTOCOL_MAJOR = 1` (`preface.rs:758-777` shows the pattern): ``` // arm: to_daemon request_opener modes=window,ops since=0 OpsList list = 1; ``` and a `felis-protocol` test that walks the schema's oneof arms and asserts each has an `arm:` line whose fields equal `ArmMeta` for that variant. That is the "compatibility test fails when a new arm has no declaration" criterion, without a second generator. `buf lint` does not inspect comments, so the Rust test is the gate. Docs: rewrite `docs/explanation/architecture/ipc.md:55-61` and `:63-66` to the kind-vs-arm rule with the criterion and examples above; fix `:149-151` (`List` and `Status`); `docs/reference/ipc.md:484-493` state that the kind is admitted when any arm is, and that the arm table is normative; add an "arm" column (or a per-family arm table) to the modes table at `:565-576` so `Ops` and `Push` exceptions are listed rather than implied; `felis.proto:87-91` header. `docs/reference/spec.md`: check whether any REQ quotes "whole kind" (grep `whole kind` before editing). CHANGELOG: none unless the post-attach mode denial reply changes shape. `skills/felis`: none. Protocol-2.0 numbering (#30): with this rule no kinds are split, so `FrameKind` 0-9 stay; oneof tags stay in source order. Reserve nothing new. ## Dependencies Decide this first (#12 item 5, #52 item 1); #48 adds the `correlation` column, #46 the `phases` column, #50 the `since_minor` column, all on the same `ArmMeta`. #20's `Ops::Spawn` is the first arm added under the rule. #30 freezes after. ## Risk/effort Decision **S**; implementation **M** (the metadata table and the schema-comment test are a day; the doc rewrite is the other day). Main risk: getting the `modes` receive-side table wrong for `Push` arms (they are outbound from the daemon; the *client's* driver should refuse a `Reattach` arriving on an `Ops` connection, which no test covers today). ## Labels Keep `priority/P1`, `release/v0.1.0`. It determines what #30 freezes and what a non-Rust peer must implement.
Sign in to join this conversation.
No description provided.