[v0.1/P1] Make correlation an exact per-arm identity #48
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#48
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 correlation schema can express states the documented request/stream model does not allow, and validation is split across handlers.
At review snapshot
5077d74b:Correlationcontains two independent scalar fields. The conversion rejects an envelope with neither id but accepts one carrying bothrequest_idandstream_id.ConnectionDriver::account_forhandles onlystream_id. Required request ids are recovered later byops_request_of,stream_of, or family-specific branches.match_replychecks onlyrequest_id, so an envelope can also name an unrelated live stream.This weakens field 100 from an exact identity into an optional-field bag. A public non-Rust implementation has to reproduce call-site behavior rather than one schema rule.
Required change
Make correlation an exclusive identity and centralize exact per-arm validation in the shared protocol/driver layer.
The protocol-2.0 baseline should use a protobuf
oneofand a domain enum such asCorrelation::Request(RequestId) | Correlation::Stream(StreamId). If a future operation truly needs both concepts, it should define a deliberate conversation shape rather than making every current envelope ambiguous.Acceptance criteria
Correlation; zero/unset and unknown oneof arms are malformed.ConnectionDriver::decodevalidates that declaration before returning a deliverable message. Handlers no longer recover mandatory correlation ad hoc.felis.proto,docs/reference/ipc.md, and the explanation rationale agree on the exclusive shape.Ops, then land the breaking schema in #30.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.
message Correlation { uint64 request_id = 1; uint64 stream_id = 2; }(crates/felis-protocol/proto/felis.proto:461-467), two independent scalars. Domain:Correlation { request_id: Option<RequestId>, stream_id: Option<StreamId> }(crates/felis-protocol/src/messages.rs:421-447). The conversion (crates/felis-protocol/src/convert.rs:263-275) rejects neither-set and accepts both-set;codec::peek_correlation(codec.rs:110-115) lifts it viaCorrelationSlot(:96-100).docs/reference/ipc.md:356-364assigns exactly one id kind per arm.ConnectionDriver::account_for(crates/felis-transport/src/driver.rs:414-434) looks only atstream_id; arequest_idon an arm that should carry none passes silently. Required request ids are recovered afterwards byops_request_of(crates/felis-daemon/src/serve.rs:720-731, called at:628and:1221),stream_of(:1247-1258, called at:643,:1154,:1188), and the inlineRegion::Requestbranch (:1162-1171).match_reply(driver.rs:255-275) reads onlyrequest_id; a reply naming an outstanding request and a live stream is accepted as a reply.request_idsequentially (issue_request,driver.rs:244-253); the daemon'sStreamTable.next_request(:147) is never advanced or compared on the daemon side. The reference promises "two independent sequences that start at 1, count strictly upward" (docs/reference/ipc.md:373-379), and the explanation says both ends keep the counters in step (docs/explanation/architecture/ipc.md:613-615) but only the stream counter is (accept_open,driver.rs:438-471).an_empty_correlation_envelope_closes_the_connection(driver/tests.rs:470),a_stream_id_on_an_arm_that_opens_none_closes_the_connection(:658),replies_match_their_requests_in_any_order(:593); nothing on both-set, wrong class, or request-sequence gaps.Verdict
accept-with-changes: the decision is requested; recommend the issue's oneof, with one implementation detail the issue does not mention (below) that is needed for its "the old both-ids encoding is rejected" criterion to be true.
Decision:
Correlationbecomes a protobufoneof id { uint64 request_id = 1; uint64 stream_id = 2; }; the domain type becomesenum Correlation { Request(RequestId), Stream(StreamId) }; every arm declares oneCorrelationClassin the #47ArmMeta; the driver validates envelope against class indecode.Why the oneof rather than a wider redesign: keeping tags 1 and 2 makes every honest frame on the wire today byte-identical after the change (a oneof of scalars encodes exactly as the single scalar did), so #30's golden conversations need no regeneration for this item; a sender physically cannot set both; and the two independent sequences, the ABA argument (
docs/explanation/architecture/ipc.md:599-607), andSubject(messages.rs:451-456, already an exclusive enum) all stay as they are. The "deliberate conversation shape" for a hypothetical arm needing both (a request that also opens a stream) is a future arm carrying a second, named field, not a widened envelope.The detail: prost decodes a oneof with last-field-wins, so a both-set body would silently parse as
Stream. Keep the privateCorrelationSlotmirror incodec.rs:96-100as two plain scalars (it is already separate from the generated type) and havepeek_correlationrejectrequest_id != 0 && stream_id != 0asWireError::AmbiguousCorrelation. The public schema forbids both; the peek proves a peer did not send both anyway. Document that in theCorrelationSlotcomment.Approach
Protocol (
felis-protocol):felis.proto:461-467: oneof; comment states exclusivity and that 0/unset is malformed on every correlated arm that declares a class other thanUncorrelated. Regenerate (just proto).messages.rs:417-447:enum Correlation { Request(RequestId), Stream(StreamId) }; keep therequest()/stream()constructors asconst fnfor call-site compatibility;describe(driver.rs:648-656) prints the variant.convert.rs:252-275:From/TryFromover the oneof; unset arm →MissingOneof("Correlation.id"); zero value in the set arm →MissingField.CorrelationClass { Uncorrelated, RequestOpener, RequestReply, StreamOpener, StreamItem }as thecorrelationcolumn ofArmMeta(#47). Fill it from the table atdocs/reference/ipc.md:356-364:OpsverbsRequestOpener,OpsrepliesRequestReply;Region::Request/Replylikewise,Region::RowsStreamOpener,Row/RowsDoneStreamItem;Notify::Subscribeopener,Subscribed/Event/Laggeditems;Search::Queryopener,Matchitem;SessionallUncorrelated(until #20 decides; if #20 makesSession::Createcorrelated the class changes there);Input/Grid/Image/Push/ConnUncorrelated(their wrappers reserve 100, so the class is also asserted byMessageKind::is_correlated,lib.rs:114-119; add a test that no arm of a non-correlated kind declares a class other thanUncorrelated).Driver (
felis-transport/src/driver.rs):decode(:383-409): after direction,match (meta.correlation, payload.correlation):(Uncorrelated, None)deliver;(RequestOpener, Some(Request(id)))daemon side →accept_request(id): must equaltable.next_request, advance it, insert intooutstanding_requests(the daemon's set of requests it owes a reply to;Opsreply writers thenretire_request(id)likeretire_stream);(RequestReply, Some(Request(id)))client side →match_reply;(StreamOpener, Some(Stream(id)))→accept_open;(StreamItem, Some(Stream(id)))→accept_item; anything else →DriverError::Correlation { expected: class name, found: describe(..) }.Delivery::Deliverthen carries the validated identity:Delivery::Deliver { msg, correlation: Correlation }or aDelivered<M>struct, soops_request_of,stream_of, and theRegion::Requestbranch are deleted and the daemon readsdelivered.request()/.stream()typed accessors that cannot beNonefor the class the arm declared.match_reply(:255-275): takesCorrelation, matchesRequest(id)only; aStreamenvelope on a reply arm is now unreachable because step 5 rejects it first.is_reply_topeek (connector.rs:868-898) keeps working on the enum (Correlation::Request(named)).Tests (driver/tests.rs, plus protocol unit tests):
CorrelationSlotbytes) rejected atpeek_correlation; zero in the set arm; wrong class on every family (Ops::Listwith a stream id,Search::Querywith a request id,Region::Rowwith a request id,Notify::Eventwith a request id,Grid::RowDeltawith any envelope); daemon request sequence: skipped id (1 then 3), reused id, out-of-order valid replies (existing:593), stream cancel racing a terminal (existing:170,:197); client rejects a duplicate reply for a retired id (existing:613covers unattributable; add duplicate).serve/tests.rs:3623an_uncorrelated_region_request_closes_the_connectionmoves to the driver table.Docs:
docs/reference/ipc.md:338-352(Correlationshape: oneof, exclusive),:356-364table gains the class column,:373-379add "the daemon checks the request sequence like the stream sequence";docs/explanation/architecture/ipc.md:544-615add the rejected alternative (two optional scalars: an optional-field bag a non-Rust peer must reproduce by call-site behavior) and the prost last-wins note as the reason the peek keeps two scalars;felis.proto:446-467. CHANGELOG: "Wire: correlation is an exclusive request-or-stream identity; both-ids envelopes are malformed" (under the #30 protocol-2.0 entry).skills/felis: none (bridge JSON exposes ids? checkdocs/reference/control-surfaces.mdbridge epoch; if the bridge JSON echoesrequest_id/stream_idas two keys, the JSON shape is unchanged because each object still carries one key).Dependencies
Decide with #47 (the class lives on
ArmMeta). #20 changes theSessionclasses; land #20 after this decision and before #46. #30 freezes last. #52's order (item 1) holds.Risk/effort
M (1-2 days). Main risk: the daemon's new
outstanding_requestsset must be retired on every reply path (ops_replyin both loops,Region::Reply,Conn::Error { Request }), or the set grows for the connection's life; add aretire_requestcounterpart toretire_streamand a test that a reply for an unknown request is a driver error on the daemon's own writer path.Labels
Keep
priority/P1,release/v0.1.0.