[v0.1/P1] Reconcile frame-family taxonomy with routing #47
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#47
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The stated frame-family evolution rule and the implemented routing model disagree.
docs/explanation/architecture/ipc.mdsays a new one-shot or push gets a new framekind, that a mode admits/refuses a whole kind, and that kind-level routing eliminates nonsense guard arms. The current schema instead evolvesOpsMsgby adding one-shot variants (Tag,Status, and others) and groups unrelated daemon pushes inPushMsg.mode_admitsadmitsOpsbroadly for the window session picker, whileops_deniedperforms 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
since_minorare represented in one auditable routing table or equivalent metadata.ops_deniedare either removed or generated/checked against that authority.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.
Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.Claim check
Accurate against HEAD.
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"), andfelis.proto:87-91("One kind per surface, so the ConnectionMode ... admits or refuses a whole kind at a time");docs/reference/ipc.md:488-490repeats the whole-kind claim forSearch/Region.OpsMsggrewTag/TagsUpdated(tags 9-10) andStatus/StatusReply(tags 11-12, minor 5) as arms (felis.proto:1143-1160), not kinds.PushMsggroupsEvicted,Reattach,SessionExited,RetargetHost(:1585-1595), whose recipients differ by mode (docs/reference/ipc.md:1005-1010: three go only toWindow,Evictedto every subscriber), which is exactly an arm-level mode rule.Opsto any attach-capable mode (crates/felis-transport/src/driver.rs:628-646, the comment says so) and the daemon re-checks per arm inops_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 atdriver.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_minorexists 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).docs/reference/ipc.md:565-576) already lists arm-level rows (Ops::List,Ops::StatusvsOpsmutations), so the reference already concedes the arm is the routing unit; the explanation (:149-151) is also stale in sayingListis "the one query shared across modes" (Statusjoined 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_minorare 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:
Opsqueries vs mutations,Pushrecipients,Regionpoint vs stream), and making it true means a kind per verb:OpsList,OpsDestroy, ... That spends theu16kind space and aFrameKindenum row on every future Point/Stream verb #35 will add, for a property (onematchon the header word) that the decode has to do anyway to read the body.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.sole_senderforInput/Grid/Image/Pushatdriver.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.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;Opsgains 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 atipc.md:75-81),Push::RetargetHost(arm),Search(kind), a futureOps::Spawn(#20, arm), a future point verb (#35, arm), a future stream with its own opener/terminal semantics that aWindowmust never see (kind).Approach
felis-protocol(messages.rs:355-373): replaceDirected::routing+opens_streamwithEach family module's
routingmatch becomes ametamatch (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). AddMessageKind::admissible(mode, phase) -> boolderived by folding every arm of the kind (test_support::assert_covers_every_variantcase 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) becomesMessageKind::admissible;decode(:383-409) checksmeta.modesandmeta.phasesafter the direction check and returnsDelivery::RefuseRequest { reply }when attached (typedConn::Error { Request(id), InvalidRequest }) orDriverError::ModeDeniedwhen not. Deleteops_denied(serve.rs:787-790) and its two call sites;refuse_modestays as the writer of the pre-attach refusal.Schema (
felis.proto): a per-arm comment convention the Rust side parses, in the style ofPROTOCOL_MAJOR = 1(preface.rs:758-777shows the pattern):and a
felis-protocoltest that walks the schema's oneof arms and asserts each has anarm:line whose fields equalArmMetafor that variant. That is the "compatibility test fails when a new arm has no declaration" criterion, without a second generator.buf lintdoes not inspect comments, so the Rust test is the gate.Docs: rewrite
docs/explanation/architecture/ipc.md:55-61and:63-66to the kind-vs-arm rule with the criterion and examples above; fix:149-151(ListandStatus);docs/reference/ipc.md:484-493state 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-576soOpsandPushexceptions are listed rather than implied;felis.proto:87-91header.docs/reference/spec.md: check whether any REQ quotes "whole kind" (grepwhole kindbefore 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
FrameKind0-9 stay; oneof tags stay in source order. Reserve nothing new.Dependencies
Decide this first (#12 item 5, #52 item 1); #48 adds the
correlationcolumn, #46 thephasescolumn, #50 thesince_minorcolumn, all on the sameArmMeta. #20'sOps::Spawnis 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
modesreceive-side table wrong forPusharms (they are outbound from the daemon; the client's driver should refuse aReattacharriving on anOpsconnection, 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.