[v0.1/P0] Reject preface acceptance for an unoffered major #45
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#45
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
At review snapshot
5077d74b, the client offers exactlyClientPreface::CURRENT, buthandshake_overmatchesDaemonPreface::Accept { minor, .. }and discards the returnedmajor. A peer can therefore accept major 9 after the client offered major 1, and the client proceeds to decode frames with the major-1 schema.The frozen decoder is correct to interpret the trailing words from the status byte alone. The negotiation layer still has to verify that an
Acceptselected a major the client offered. Without that check, an implementation error turns schema skew into a protobuf failure after negotiation instead of the required loud pre-frame refusal.Relevant code and contract:
crates/felis-client-core/src/connector.rs::handshake_overcrates/felis-protocol/src/preface.rs::DaemonPrefacecrates/felis-daemon/src/serve.rs::exchange_bootstrapdocs/reference/ipc.md"Version preface"Required decision
Keep the frozen preface layout, but make acceptance semantically exact: a client that offers one major accepts only that same major. Also make the daemon's selected schema and advertised minor one decision, so a future side-by-side major decoder cannot accidentally pair an older schema with the current major's minor.
Acceptance criteria
handshake_overrejectsAccept.major != ClientPreface.majorbefore constructing a framedConnection.Refuse; human and machine callers can report that the daemon sent an invalid acceptance.(major schema, daemon minor)together rather than calling an API that accepts an arbitrary major while always advertising the current global minor.Refuse, and unknown status.Why this is separate
#19 protects protobuf compatibility in CI, and #30 resets the public schema baseline. Neither validates the preface negotiation result at runtime. #42 covers SSH timeout/spawn policy; this bug exists on every carrier.
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 (
69076d42). Nothing in the three post-snapshot commits touched the preface.ClientPreface::CURRENT(crates/felis-client-core/src/connector.rs:280) and then matchesDaemonPreface::Accept { minor, .. }(connector.rs:282-284), discardingmajor. OnlyRefuse(:285-294) andUnknown(:295-301) are errors. A status-0 reply carrying major 9 therefore proceeds toFrameReader::newand theHello(:304-321) under the major-1 schema.DaemonPreface::decode(crates/felis-protocol/src/preface.rs:218-239) and the testthe_status_word_alone_discriminates_the_reply(preface.rs:539-554) pin that[0,0, 0,9, 0,9]decodes asAccept { major: 9, minor: 9 }. That layer is correct and must not change; the missing check is one layer up, exactly as the issue says.exchange_bootstrap(crates/felis-daemon/src/serve.rs:1718-1753) callsDaemonPreface::accept(major)(:1748), andDaemonPreface::accept(preface.rs:181-186) buildsAccept { major: client_major, minor: PROTOCOL_MINOR }for anyclient_majorit is handed.supports_major(:1728) is checked first, so today the pairing is right by construction, but the API accepts an arbitrary major while always advertising the global minor, which is the "one decision" gap the issue names.effective_minoris computed separately atserve.rs:1750.felis-transport/src/preface.rs:115-134(accept round trip),serve/tests.rs:401(daemon refuses an unsupported major),connector.rs:2030-2071(unknown status),connector.rs:2077(effective minor). No test sends a status-0 reply with a foreign major to the client.docs/reference/ipc.md:186-194says "the status word alone discriminates the reply" and "accept: agreed major, then the daemon's own minor" but never states that the agreed major must equal the offered one;docs/reference/spec.md:54-55(REQ-104/104a) likewise.docs/explanation/architecture/ipc.md:485-495argues for the self-describing reply; it does not distinguish "self-describing decode" from "exact negotiation".Nothing here was already fixed. The issue's file/function references all resolve.
Verdict
accept (as written). This is a correctness hole in a frozen layer, cheap to close, and it has to be closed before #30 regenerates the preface vectors. No principle question arises: it is IPC hygiene inside a surface principle 1 explicitly reserves as the extension surface.
Approach
Protocol crate (
felis-protocol, no tokio):preface.rs: replaceDaemonPreface::accept(client_major)with a selection that is one decision. Add and keepsupports_majoras the predicateselectuses (or derive it). A future side-by-side major decoder extendsselectwith a(major, minor_for_that_major)table; the minor is never taken from a global independent of the major. Deleteacceptor make itpub(crate)for tests only. Keep the doc onAccept(preface.rs:153-159) and add the negotiation invariant to the module doc (:29-33): "an accept names the offered major; a client that offered one major closes on an accept naming another."pub fn confirm_accept(offered: ClientPreface, reply: DaemonPreface) -> Result<u16 /* effective minor */, NegotiationError>next toeffective_minor, so the transport-free crate owns the rule and the connector just calls it.NegotiationError { AcceptedUnofferedMajor { offered, accepted }, Refused { min, max }, Unknown { status, words } }.Client (
felis-client-core/src/connector.rs):handshake_over(:281-302): matchAccept { major, minor }; onmajor != preface::PROTOCOL_MAJORreturn a newConnectError::AcceptedUnofferedMajor { offered: u16, accepted: u16 }(:29-60),is_transient() == false(a re-dial reproduces it). Distinct fromMajorMismatch(that is the daemon's honest refusal) and fromUnknownPrefaceStatus. Message: "daemon accepted protocol major {accepted} but this client offered {offered}; the daemon is not speaking felis's negotiation, rebuild both halves". Do this beforeFrameReader::new(:304).skills/felis/SKILL.md:54"protocol error / a daemon that does not serve this client's protocol major" already covers a preface refusal; keep exit class 2, extend the wording only if the CLI reference names the individual errors).Daemon (
felis-daemon/src/serve.rs):exchange_bootstrap(:1726-1752): callpreface::select(opened.preface);None→ the existing refuse path;Some(reply)→ write it and compute the effective minor fromreply's minor, not fromPROTOCOL_MINORdirectly, so the two cannot drift.Tests:
felis-protocolunit:selecttable (offered major, expected reply);confirm_accepton accept-same-major, accept-other-major, refuse, unknown.felis-transport/src/preface.rsduplex tests: add wrong-accepted-major (daemon writesAccept { major: 9, minor: 5 }, client-sideconfirm_accepterrors before any frame), and the offered major with daemon minor older/equal/newer asserting the effective minor.connector.rstests (beside:2030-2071): a fake listener writingAccept { major: PROTOCOL_MAJOR + 1, .. }and then aWelcome; assertAcceptedUnofferedMajor, non-transient, and that noHellowas written (read the server side and assert zero bytes).the_status_word_alone_discriminates_the_replyuntouched: it pins the decoder, which stays lenient on purpose.Docs cascade (doc-cascade skill):
docs/reference/ipc.md:186-206"Version preface": one paragraph after the status table: an accept's first word MUST equal the offered major; the client verifies it before reading any frame and closes on mismatch with no frame written; the decoder itself does not compare (unchanged).docs/reference/spec.md:54REQ-104: append "The client accepts only an accept naming the major it offered; any other accept closes the connection before a frame is written." Consider a REQ-104d rather than widening 104 if the sourcing table prefers one requirement per row.docs/explanation/architecture/ipc.md:485-495"Handshake bootstrap": add the why: a self-describing reply is a decoding property; negotiation is still exact, and a lenient negotiator would turn schema skew into a protobuf failure after the preface, which is the loud-early failure the frozen layer exists to give.CHANGELOG.mdUnreleased / Changed: "Wire: a client closes on a preface accept that names a major it did not offer" (user-affecting only in the sense of a new error string; one line).crates/felis-protocol/proto/felis.proto:24-33header comment: one clause on the echo invariant.preface.rs:488-490,:512-514) afterwards; this issue does not touch them.Dependencies
None to land. Sequencing: land before #30 (#30's "development-major peer is refused at the frozen preface" is the daemon half of this invariant; #45 is the client half, and #30's vectors should be regenerated once). #52's order ("resolve #45 before regenerating final preface vectors") still holds. Independent of #19 and #42.
Risk/effort
S (half a day). Main risk: choosing the error surface so the retry loop treats it as permanent (
is_transient) and so the bridge'sErrorKindmapping is not silently "protocol" with a misleading message; test both.Labels
Keep
priority/P0,release/v0.1.0. It is a frozen-layer correctness bug and blocks the final preface vectors.