[v0.1/P1] Final interface simplification audit before public freeze #326
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
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#326
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?
Background
The first public release is the last inexpensive point at which to simplify the public IPC and CLI surfaces. The current wire architecture is sound, but several contracts still have duplicated authorities or expose combinations that the implementation does not use.
This issue is a final simplification audit. It does not reopen the already-settled protocol baseline or the correlation-shape decisions in #47, #48, #147, and #313.
Findings to resolve before the public freeze
1. Remove the unused
SessionMsgcorrelation capabilitySessionMsgcurrently carries the field-100 correlation envelope and implements the correlated-message path, but no session arm uses correlation. This exposes an impossible capability to domain and external implementations and permits future accidental use.Either remove the field and correlated implementation, or document and add a real correlated session conversation. The preferred outcome is removal.
2. Make routing metadata have one authority
The routing contract is now machine-readable, but Rust
ArmMetatables and schema metadata still need to remain synchronized. Keep the protobuf custom options as the cross-language authority established by #303, and generate or mechanically verify the Rust tables instead of maintaining two independent descriptions.3. Unify the CLI and bridge operation model
The CLI and persistent JSONL bridge expose overlapping operations but use separate parsing, validation, schema, and dispatch paths. Decide whether the bridge is a complete machine API or an explicitly smaller API. Prefer a shared typed operation model from which clap parsing, bridge deserialization, validation, dispatch, and JSON Schema are derived.
A new operation should not require independent updates to the CLI grammar, bridge parameter allow-list, bridge
Paramstype, dispatch match, and schema definitions.4. Normalize machine output and error transport
Reconfirm the final choice for point/stream machine output and the
json/jsonlvocabulary. The current class-specific format names and the difference between CLI stderr errors and bridge stdout errors increase the number of contracts a machine client must implement. If retained, document this as an intentional frozen distinction; otherwise use one machine format vocabulary and one error-envelope policy.Coordinate this decision with #23, #29, and #145 rather than creating another parallel output contract.
5. Reduce the
sessions sendgrammar cross-productsendcurrently combines text, raw input, key input, waiting, and timeout behavior. Decide whether this should be split into focused verbs such assend,key, andwait, or represented by one explicitly typed operation. Avoid adding more mutually-dependent flags to the current command.6. Separate operation-specific error vocabularies
Review shared enums such as
AttachFailurewhen they are used by both attach and spawn. Variants that are meaningful for only one operation should not become part of the other operation's public error vocabulary. Share only genuinely common error categories.Acceptance criteria
SessionMsgcorrelation decision is implemented and covered by protocol tests.sessions sendand operation-specific error types have an explicit final design before the first public tag.CHANGELOG.md, andskills/felisare updated if the public surface changes.Related issues
The audit confirms this issue is the right tracking item; no duplicate issue is needed.
Additional concrete observation from the current tree:
docs/reference/cli.mdstill showsprotocol.minor: 10in the daemon status JSON example, while the current first-release schema is protocol1.0. The example should be corrected or made non-version-specific as part of the documentation cascade.cargo test -p felis-protocol --lib --quietpasses (183 tests).Pre-freeze interface/proto/CLI review completed against the current tree (
felis.protoat PROTOCOL 1.0, CLI placement matrix, config path table). Findings that overlap this tracker's six items are not re-filed:reservedfragmentation from pre-baseline breaks → belongs to the baseline workflow in #115 / #313 (densify field numbers beforeproto-baseline/lands, then pruneBREAKING.mdbase:lines).sessions sendcross-product, shared error enums → items 3–6 above already track them.docs/reference/cli.mddaemon-status example still showsprotocol.minor: 10against the 1.0 schema — doc cascade for whichever issue touchescli.mdnext.New child issues for what this audit does not cover:
RetargetTarget.carrierunset,OpsStop.modeunset,SessionAttachid/prefix siblings, theme-vs-palette reset spellings).Attachment.idu64-vs-string,ResourceReportabsent≠0,env/env_baseduality,pull_pacedbool besideConnectionMode).%APPDATA%\felis\config\config.toml.The underlying architecture needs no change: frozen preface, effective-minor send-side contract, single protobuf encoding with the bridge for JSON, field-100 correlation with shared
ConnEnd/Errorterminals, atomic create+attach, and daemon-side prefix resolution are all worth freezing as-is.Two child issues filed from a pre-freeze interface review:
reservednumbers and history comments,ConnWelcome.build_id, 86×since: 0)SessionAttach,RetargetTarget,OpsStop)One more finding that belongs under this tracker's #3 (unify the CLI and bridge operation model) rather than a separate issue: the
Cmdenum is matched exhaustively in four places that all encode per-verb metadata —Cli::machine_output(crates/felis-cli/src/main.rs:295),verb_reading_no_config(:592),verb_refusing_carrier(:637), anddials(:508). A new CLI verb therefore touches four match sites beyond the clap grammar, each restating part of the placement matrix indocs/reference/cli.md. Folding them into oneCmd::surface(&self) -> Surface(format, reads-config, carrier refusal, dials) would make a verb addition a one-line change and keep the matrix and the code in one place. The window-launch path still forwards globals to the frontend, so clap cannot own the whole matrix, but the rest can collapse.Correction to the comment above: #331 was a duplicate of #327 (same three unions) and is now closed. The surviving new child from this review is #330 (pre-release
reservednumbers,build_id, andsince: 0).The final wire/CLI/config freeze review split the newly verified contract findings into focused issues:
FRLYcarrier envelope;config.toml;Existing issues already own two findings:
SessionMsgcorrelation a real use, resolving this issue's first decision in favor of retention.ArmMetafrom the protobuf custom-option authority established by #303.#193 fixes the nested row representation but does not replace #336: the latter decides whether the surrounding Rust-serde shape is a public versioned format at all.
Correction: #334 duplicated #327 and is closed. #327 is the surviving issue for explicit oneof/default semantics. #333 was narrowed to semantic minor-reset residue not owned by #330: session-sequence fallback, dead CLI error vocabulary, duplicate status identity, and the unknown-mode codec exception.
Triage decision (reviewed, 6 rounds of independent design review)
This tracker stays open, narrowed to items 3–6. Items 1 and 2 are decided here and need no further work before the freeze.
SessionMsgfield 100: retained.MessageKind::Session.is_correlated()is true and the family is wired as correlation-capable end to end; every base arm isCORRELATION_UNCORRELATED, and the connection driver refuses an envelope on an uncorrelated arm per the arm table (driver tests cover it). An unset field costs nothing on the wire, and removing it would make #306 a wire break later. #306 is not raised to P1 for this. The retention is recorded in the IPC explanation as "the family is correlation-capable; no base arm uses it yet" (lands with the #330 doc cascade).the_schema_declares_the_same_arm_tableparsesfelis.protoand asserts the declared arm options equal the RustArmMetatables, so drift fails the build. That is source-level verification, which satisfies "drift is rejected mechanically". Generation instead of verification is a post-v0.1 refinement.Cmd::surface()fold from the comment above, is internal refactoring that does not change the public contract and may land after v0.1.protocol.minor: 10example incli.mdis fixed by whichever PR touchescli.mdfirst (#333).Child decisions are recorded on #327, #328, #329 (closed, already decided), #330, #332, #333, #335, #336. Landing order: #329, #335 first (independent); then the proto batch strictly sequentially, one PR per issue, each rebased on the previous merge and carrying its own
base:line: #330 → #328 (case 2) → #327 → #333; #332 and #336 are independent of the proto batch; items 3–6 here last.Decision for items 3–6 (reviewed: 3 luna rounds, 10 sol rounds, final PASS)
Scope: only what the public freeze needs. Internal refactors already deferred by the triage comment (shared typed operation model,
Cmd::surface()fold) stay deferred.Item 3 — the bridge is an explicitly smaller API, recorded as such
Facts: every
sessionsverb andnotifications subscribeexist on the bridge; the bridge alone hascancel; the CLI alone has the operator verbs (daemon,config,doctor,version), window launches (attach, barefelis,frontend,ssh,window retarget), and shell plumbing (completions,__mangen,__complete-sessions). Within shared ops the bridge differs in six places, four of which are policy and two of which are defects:sessions.sendhas no--wait/--keyand requirestext;notifications.subscribehas noonce/timeout(cancelis the bridge's way to stop a stream);sessions.switchrequiresfromwhere the CLI defaults it from$FELIS_SESSION_ID, and answersqueued == 0with a successful result carryingqueuedwhere the CLI fails withnot_queued(op_switch,cli_sessions.rsswitch path). The two defects, both insessions.spawn: the bridge never callsenv_base::fill_for_carrier, so a local bridge-created child inherits the daemon's environment instead of the bridge process's, against REQ-912a andipc.md(the CLI does,cli_sessions.rs:2107); and the bridge parsesrows/colsasu16with a65535schema bound while the CLI parsesu32and lets daemon admission refuse, so an out-of-range geometry ismalformed_request(exit-2 class) on one surface andinvalid_requeston the other, and the comment justifying theu16(Params::u16_opt: a create refusal is terminal for the anchor) is stale sinceop_spawnmoved to the correlated, reusableOpsMsg::Spawn.Decision: the bridge is the session-automation surface, not a mirror of the CLI. Its op set is exactly {
sessions.*(10),notifications.subscribe,cancel}. Operator and host-process verbs are CLI-only as surface policy, not for a technical reason (several of them are correlatedOpsrequests on the wire,Status,Stop,Switch, exactly like the point verbs the bridge does expose): the bridge is the surface a program drives sessions through, and daemon lifecycle, config diagnosis, GUI launches and shell integration are the operator's, reached through the one-shot process whose exit status and exec they need. A non-Rust client that needsdaemon statusruns the point verb with--format json. The two intra-op gaps are a surface-policy choice, not an implementation constraint: theop_sendcomment claiming a chord needs client-side keyboard modes is false (InputMsg::Keyis admitted on an attached Ops link and the daemon encodes it against the modes it owns,felis.protoarmkey = 10,session_task.rs), and it is corrected in PR A. The bridge freezes the parameters its clients have needed;keysonsessions.send(the CLI's--key, same chord grammar, sameInputMsg::Key) andwaitare additive optional params under thev:1rule and are filed as one follow-up issue rather than added blind before the tag.once/timeoutonnotifications.subscribestay out:cancelis the bridge's way to end a stream. The twoswitchdifferences are retained and frozen with their reasons: the bridge runs inside an editor, not a felis window, so$FELIS_SESSION_IDwould name whatever session launched the editor, hencefromis required; andqueued == 0is data on the bridge because whether "nothing moved" is a failure is the editor's judgement, while the one-shot CLI has to pick an exit code and picksnot_queued. Both are stated in the bridge reference today only as code comments; PR A writes them intocli.md's bridge op table. The two spawn defects are fixed in PR A, not frozen:op_spawnfillsenv_basefor a local carrier exactly as the CLI does (with a test that a local bridge spawn carries a captured base and a relay spawn carries none); geometry is parsed at the wire'su32width and admission is the daemon's on both surfaces (thefelis-bridge-v1schema bound is regenerated to the wire width, a relaxation that no existing valid request notices), and the staleu16_optcomment goes with it.Deliverable: docs, the comment fix, and the two bridge spawn fixes (
op_spawnfillsenv_basefor a local carrier;rows/colsparsed atu32;felis-bridge-v1.schema.jsonregenerated; the staleu16_optcomment removed; tests: a local bridge spawn carries a captured base and a relay spawn none, and a geometry aboveu16::MAXreaches the daemon and comes backinvalid_request).docs/reference/cli.md"felis bridge": state the op set is closed to session automation and list the CLI-only verb groups; notetextis required on the bridge and thatkeys/waitare absent by policy, additive if added; recordsessions.switch's requiredfromand itsqueued == 0success policy with the reasons above.cli_bridge.rsop_send: replace the false why-not with the policy (or drop the comment).docs/explanation/architecture/control-surfaces.md: the rationale above with the rejected alternative (a complete mirror: it would put process-lifecycle verbs behind a correlated stream and force every operator verb to grow a bridge twin, the very cost the tracker's "one operation, one definition" concern names).skills/felisalready says "no--waitand no--key"; add the CLI-only groups in one line.Item 4 — one vocabulary, one error object, two transports: frozen
Facts:
--formatis class-typed (PointFormat:human|json;StreamFormat:human|jsonl; the other value is a clap error, exit 2). CLI: machine framing starts only once clap has accepted the argv and a machine format is selected; a clap rejection (unknown flag, wrong--formatvalue, missing positional) is human text on stderr, exit 2, before any format exists. After that, a point failure = one{"v":1,"error":{kind,message}}on stderr + exit code fromkind; stream terminals on stdout. Bridge: every outcome goes to stdout with the sameerrorobject andeventterminals; a request whoseidcould be recovered is answered on thatideven when the rest of the line is malformed, and only a line with no usableid, an oversized line, or a startup failure is answered withid: null(parse_request,serve). The bridge process exits0;1for its own pipe failure and also when its initial Ops dial is refusedat_capacity(cli_bridge.rsserve: a typed refusal, so the point-verb rule for exit1applies);2when the daemon is unreachable/lost or the protocol breaks (cli.mdexit-code table).ErrorKindis the single vocabulary for both (BridgeErrorsays so). Three of the four wire→kind tables are frozen intests/golden/error_kinds.txt;from_connect_erroris not. Issues #23/#29/#145 are uncited anywhere in the tree.Decision: retain, and record as an intentional frozen distinction.
json= point (one object),jsonl= stream (one object per line, exactly one terminal); a verb advertises only the value its class admits, so the vocabulary is two words and the class is visible in--help. Errors: one object shape and onekindvocabulary everywhere; what differs is only where it goes, per class: a Point result is stdout and a Point failure is one error object on stderr with the exit code fromkind; a Stream's items,lagevents and its single terminal, clean or failing ({"v":1,"event":"error",…}, including a failure before the first item), are all stdout; on the bridge every reply and every per-request error is stdout correlated byid, and stderr is diagnostics only. That follows from the process model: a one-shot Point verb has an exit status and stderr to answer with; a stream is a stdout protocol whose consumer reads objects until the terminal; a multiplexed bridge has neither an exit status nor a stderr per request, soidon stdout is the only correlated channel. Unifying transports would mean either putting CLI errors on stdout (breaking$(felis …)capture and every shell contract) or giving the bridge per-request exit codes (impossible). Do not reopen #23/#29/#145; cite them once in the explanation so the record is reachable.Deliverable:
docs/explanation/architecture/control-surfaces.md"Machine output" gets a short "Frozen forv:1" paragraph naming the two-transport rule, the rejected unification, and the source issues.docs/reference/cli.md"Machine output": state what is frozen for the epoch: the two format words and their class binding, the error object shape, the existingkindspellings and their exit codes, and the two transports, with the boundaries spelled out: point JSON error framing begins only after successful argument parsing (a clap rejection is human stderr, exit 2), and from there every failure is framed. Today some post-parse failures still escape asanyhowprose after a machine format was accepted: the three pre-dispatch steps (resolve_config_source,dial_target,build_runtimeinmain.rs, which the source comment atmain.rs:343-347acknowledges), and inside a verbsessions send ID -'s stdin read (collect_send_payload(..)?,cli_sessions.rs:578); andCli::machine_output()omitsCmd::Versionalthoughversion --format jsonis a Point verb. PR A closes every such escape, not a list: it audits each?whose error reachesmainon a machine-formatted verb (grepanyhow/?returning fromcli_*::runand the pre-dispatch steps) and routes each through the verb'sReporterwith the kind the table already defines (usagefor the config path,daemon_unreachablefor the target,internalfor the runtime,input_failedfor a failed stdin read),machine_output()coversVersion, and integration tests pin one JSON error object on stderr with no stdout payload and exit 2 for an unresolvable--configand forsend ID - --format jsonwith a closed stdin; a bridge shape failure is correlated whenever a valididwas recovered andid: nullotherwise; the bridge's exit1cases are its own pipe failure and an initialat_capacityrefusal (add the latter to the exit-code table row, which today names only the pipe failure).error.kindstays additive withinv:1and fields may be added, exactly as the page already says; the freeze is on spellings, mappings and transport, not on the set. Code: add the[ConnectError -> kind]table toerror_kinds.txtand its golden test so every translation table is mechanically frozen (five after item 6: stream reason, attach failure, create failure, connection refusal,ConnectError;from_connect_errortoday is not in the golden), and makefrom_connect_erroran exhaustive match (it ends in_ => Protocoltoday, so a newConnectErrorvariant would silently take that arm; an exhaustive match forces a mapping decision). This lands in PR B because that PR already rewrites the golden.Item 5 —
sessions sendstays one typed operation; the landed grammar is confirmed and frozenFacts:
send <ID> [TEXT] [--raw] [--key CHORD]... [--wait [--timeout SECS]]; constraints:--rawrequires TEXT,--timeoutrequires--wait, at least one of TEXT/--key/--wait. Noconflicts_with. The skill's headline use issend id "cmd" --key enter --wait --timeout N.Decision: do not split into
send/key/wait; confirm the grammar that has landed. The constraints below are already implemented in clap andcontrol-surfaces.mdalready records why a separate wait verb races; the freeze adds the missing tests and the reference wording, nothing more. The operation is "deliver this input, then optionally await the next prompt mark"; with no payload it degenerates to "watch the command already running" (--waitalone attaches and waits without writing, which is what the help says and whatcmd_senddoes). The send-and-wait form is the one that is not separable without a race:cmd_sendsubscribes to the session before injecting the input, so the mark a fast command emits is buffered on that subscription andwait_for_command_endreads it after admission; a separatewaitverb issued aftersendopens its subscription after the mark may already have passed. A separatekeyverb has no race (a finishedsendhas already enqueued its paste ahead of anything issued later), so the case for keeping--keyonsendis only that one call names one target, resolves it once, and states the intended input sequence in order. The cross-product is small and every combination is meaningful (payload kind × chords × wait), which is what "one explicitly typed operation" means. Freeze the grammar: no new flag may depend on another; the only dependencies are the tworequiresabove. Anything new (a different wait condition, a different payload kind) is a new verb, not a flag.Deliverable: docs plus parser tests.
docs/explanation/architecture/control-surfaces.md: record the decision, the race that rejects the split, and the "no new mutually-dependent flag" rule.docs/reference/cli.md"send": state the three constraints as the whole grammar (add the "at least one of" rule explicitly). Tests today pin--rawwithout TEXT (raw_send_needs_a_payload) and the valid wait forms (sessions_send_reports_without_a_wait), but not baresessions send <id>nor--timeoutwithout--wait; add one parser table incrates/felis-cli/src/tests.rscovering all three constraints both ways: rejected baresend ID, rejected--timeout Nwithout--wait, rejected--rawwithout TEXT; accepted key-only, wait-only,--wait --timeout N, and TEXT with--raw, so the frozen grammar cannot regress silently.Item 6 — split
AttachFailureinto attach and create vocabulariesFacts (verified in
serve.rs):AttachFailurehas 9 variants.SessionMsg::AttachproducesUnknownSession,NoMatch,Ambiguous(take_attach_handle), andSessionEnding/SessionExited(subscribe_to, the latter only underlive_only).SessionMsg::CreateandOpsMsg::SpawnproduceSpawnFailed,GeometryOutOfRange,SessionLimitReached,DaemonDraining(create_session), and Create additionallySessionEndingwhen the just-spawned child ends beforesubscribe_tolands (serve.rs:1160-1172,live_only: falseso neverSessionExited).OpsMsg::Spawnnever subscribes, so it produces only the fourcreate_sessionvariants. Read per half rather than per request: the fourcreate_sessionvariants belong to the spawn half (shared byCreateandSpawn), andSessionEnding/SessionExited/UnknownSession/NoMatch/Ambiguousbelong to the attach half (Attach, andSessionEndingalso onCreate, whose attach half runs after its spawn half). No variant crosses halves. The enum rides onSessionAttachFailed.reason(answers bothAttachandCreate, since create is create+attach) and onSpawnRefused.reason(OpsMsg::Spawn). Consumers:convert/session.rs,convert/ops.rs,minor.rs,client-core/dial.rs(matches the three attach-side variants),cli_output.rs(7 of 9 collapse ontoinvalid_request/at_capacity),felis-grid/json_v1/session.rs(AttachFailureJson, all nine, published infelis-json-v1.schema.json),felis.el(attach_failed).Decision: split into two wire enums, one per half, and let the reply name which half refused.
Numbering is dense and unreserved, the same policy #339 set for the pre-release schema;
SessionAttachFailed.detailmoves from 2 to 3 so the oneof arms are contiguous. Decoding: an unsetreasononeof isWireError::MissingOneof("SessionAttachFailed.reason")(the #342 rule: absence never means an arm);*_UNSPECIFIEDisUnspecifiedEnumas today; an unknown enum number takes the existing unknown-enum path. There are three request kinds and two halves, and the reply names the half, so each request's admissible set is exact without a third enum:Attach→attachonly (5 values);Create→create(the 4 spawn-half values; nothing executed) orattach = SESSION_ENDING(spawn succeeded, the child ended before the subscription landed and the pool entry was rolled back; today'sSessionEndingon Create,serve.rs:1160-1172);OpsMsg::Spawn→SpawnRefused.reason: CreateFailure(the same 4 values, exactly whatcreate_sessioncan produce; Spawn never subscribes soSessionEndingis not in its vocabulary). No impossible value is in any request's vocabulary: Spawn's type has noSessionEnding, and Create's race is theattacharm, not aCreateFailurevalue. Admissibility is checked at value level, in one place infelis-client-core(the connector knows which request it sent, including the target form andlive_only), against exactly whatserve.rscan produce: anAttachby exact id admitsattach ∈ {UnknownSession, SessionEnding}, plusSessionExitedonly whenlive_onlywas set; anAttachby prefix admitsattach ∈ {NoMatch, Ambiguous, SessionEnding}, plusSessionExitedonly whenlive_only; aCreateadmits anycreatevalue orattach = SessionEndingonly (the one attach-half failure a just-spawned session can have; the pool entry was rolled back). Every other arm/value combination is a protocol error surfaced through the existing protocol-failure path (exit2,protocol), never a typed refusal, so a non-conforming daemon cannot makefelisreport a merits decision it did not take. The rule is onefn admits(ctx: SessionRefusalContext, reason: &AttachRefusal) -> boolwithenum SessionRefusalContext { Attach { by_prefix: bool, live_only: bool }, Create }, and a table test over every (context, arm, value) triple.OpsMsg::Spawnneeds no runtime check:SpawnRefused.reasonis typedCreateFailure, so admissibility there is enforced by the type. Where it runs:ControlRequest::interpretis static today and sees only the reply, so it cannot know the target form; the trait changes tofn interpret(&self, reply)(into_msgtakes&selfor clones,AttachSessionReqalready holdstargetandlive_only, and the create request holds its own kind), soAttachSessionReq::interpretderives the context from itself. A refusal that failsadmitsisConnectError::Protocol-class (the existing protocol-failure variant), and the connection that carried it is not reusable: the session link is closed as every attach failure closes it today, and for the reusable Ops linkrequestmarks the connection poisoned on any interpreter protocol error so the next call fails fast instead of reading a stream whose framing it no longer trusts; a test issues a second request after a context-invalid refusal and asserts it is refused without touching the socket.buf breakingis not the gate here (pre-release;just proto-compatwith an ownbase:line incrates/felis-protocol/proto/BREAKING.md, base =maintip at branch time, proto unchanged since #343).Rust domain:
enum AttachFailure { UnknownSession, SessionEnding, SessionExited, NoMatch, Ambiguous },enum CreateFailure { SpawnFailed, GeometryOutOfRange, SessionLimitReached, DaemonDraining },SessionMsg::AttachFailed { reason: AttachRefusal, detail }withenum AttachRefusal { Attach(AttachFailure), Create(CreateFailure) };SpawnOutcome::Refused { reason: CreateFailure, detail }.felis-client-core's public error follows the split:ConnectError::AttachFailed { reason: AttachFailure, detail }and a newConnectError::CreateFailed { reason: CreateFailure, detail }.connect_and_attachcan only produceAttachFailed;createcan produce either (its attach half isAttachFailed { SessionEnding });spawn_session(Ops Spawn) can only produceCreateFailed, so a caller of spawn never sees an attach-only variant.is_transienttreatsCreateFailedlike the non-transient create refusals today;dial.rskeeps matching the three attach-side reasons onAttachFailed.cli_output.rsgainsfrom_create_failurebesidefrom_attach_failure, andfrom_connect_error(now exhaustive) mapsCreateFailedthrough it; the golden gains the[CreateFailure -> kind]table and theCreateFailedrow of[ConnectError -> kind]. CLIErrorKindtokens are unchanged.felis-jsonv1 is not retyped.docs/reference/ipc.md"Compatibility within a version" makes a retypefelis_json: 2with dual serving, and #336 recorded that rule for consumers outside this repository (.fcastfiles, the browser view), so it holds regardless of the tag list. The v1 JSON keeps the flat nine-valueAttachFailureJsononSessionJson::AttachFailed.reason, andjson_v1/session.rsadapts:From<AttachRefusal>flattens both arms onto the nine tokens (the attach half's five and the spawn half's four are disjoint, so no token is ambiguous), and the reverse conversion picks the arm from the token (session_endingis always the attach half; the four spawn-half tokens are alwayscreate). The schema and goldens are unchanged, and a JSON consumer keeps reading exactly what it reads today. Add golden cases anyway (the session-family golden today covers onlySessionMsg::Attached,crates/felis-grid/tests/json_v1.rs): oneAttachFailedwith an attach-half reason and one with a create-half reason, so the flattening is pinned. The arm discrimination reaches JSON consumers only infelis_json: 2, which nothing here needs. Also re-exportThemeActionJsonfromjson_v1(missed in #342; a consumer cannot nameGridJson::ThemeColor { action }today).Rejected: keep one enum and document per-operation subsets (the tracker's test: a variant meaningful for one operation must not be in the other's vocabulary; a doc note does not remove it from the type a consumer matches, and
dial.rstoday must list attach variants by hand out of a nine-way enum). Rejected: a separateSessionCreateFailedfamily arm (a new arm and ArmMeta row for a message whose success twinSessionAttachedis shared; the oneof on the existing reply gives the same reader-side discrimination without a routing change). Rejected: two distinct reply messages plus a second success message (doubles the create/attach surface to fix one enum).Cascade (every current
AttachFailureconsumer, fromgrep -rn AttachFailure crates):felis.proto;convert/session.rs,convert/ops.rs,minor.rsgating rows;messages/session.rs,messages/ops.rs;felis-transport/src/framing.rs:676(test fixture); daemonserve.rs(write_attach_failedtakes anAttachRefusal;SubscribeFailure::refusalstays attach-side;create_sessionreturnsCreateFailure) andserve/tests.rs:7337+;client-coredial.rs, connector;felis-clicli_output.rstables (from_attach_failure→from_attach_failure+from_create_failure) +tests/golden/error_kinds.txt,cli_sessions.rs:152,cli_sessions.rs:2123(spawn_on: matchConnectError::CreateFailedand map throughfrom_create_failure),cli_bridge.rs:1614(op_spawn:SpawnOutcome::Refusedmaps throughfrom_create_failure),tests/cli_bridge.rs:2330;felis-grid/json_v1/session.rs(the flattening adapter betweenAttachRefusaland the unchanged nine-tokenAttachFailureJson; DTO, schema and existing goldens untouched, two golden cases added) and thejson_v1.rsre-export list (add the missingThemeActionJson);felis-protocol/src/messages.rsre-exports (AttachFailure, plusCreateFailure,AttachRefusal); the checked-in prost output undercrates/felis-protocol/src/generated/(regenerated, not hand-edited); the fuzz seedfuzz/seeds/ipc_body/proto-session-attach-failed-draining.binre-encoded for the new shape (create = DAEMON_DRAINING, field 2) so it keeps covering a valid draining refusal;docs/reference/cli.md:384(AttachFailure::DaemonDraining→CreateFailure::DaemonDraining);docs/reference/ipc.md(AttachFailure/SpawnArgs prose),docs/explanation/architecture/session-lifecycle.md,CHANGELOG.md,crates/felis-protocol/proto/BREAKING.md(ownbase:line),skills/felisif it names variants. Satellites, all five, gated on the first tag (a direct-wire client built before B negotiates 1.0 successfully and then misdecodesSessionAttachFailed; the crate-linking satellites are repinned so the release ships one consistent set even where, as in felis-web-component, no live refusal is decoded):felis.el(direct wire;felis-wire.elattach_failedreader gains the arm and the same per-request admissibility rule); felis-fcast, felis-web-gateway, felis-web-component, felis-tui (link the crates: repin to the post-B rev; their JSON is unchanged by the adapter above, but anyConnectError::AttachFailedmatch must addCreateFailed). The closing comment on #326 lists these five as the tag checklist, and each gets an issue on its own repository when B merges.Landing
Two PRs, sequenced, both touching
docs/reference/cli.md: (A) items 3–5 first: docs, theop_sendcomment fix, the bridge spawn fixes (env_base,u32geometry, schema regeneration, their tests), the pre-dispatch framing fixes andVersioninmachine_output()with their test, and thesendparser table; (B) item 6 proto split rebased on A, owning every wire/code/golden change plus item 4'sConnectErrorgolden and exhaustive match. One follow-up issue for additive bridgekeys/waitparams. Then close #326 with the acceptance list updated (items 1–2 already decided in the triage comment).Items 3–6 landed:
AttachFailure(attach half) /CreateFailure(spawn half),SessionAttachFailed.reasononeof, value-level admissibility in client-core,ConnectError::{AttachFailed, CreateFailed}, felis-json v1 shape unchanged through an adapter, all five CLI translation tables frozen in the golden.env_baseper REQ-912a,u32geometry); machine output and the two error transports frozen per class, every post-parse failure on a machine verb framed;sessions sendgrammar confirmed and pinned.Items 1–2 were decided in the triage comment above. Remaining before the first tag, tracked elsewhere: #344 (additive bridge
keys/wait), and the satellite repins: felis.el#3, felis-tui#3, felis-web-component#8 (felis-fcast and felis-web-gateway are at6da5620dand repin once more after #345).