[v0.1/Proto Review] SessionInfo.sequence の 0 sentinel と Attachment の時刻型を 1.0 で正規化 #144
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#144
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?
背景
felis.protoは 1.0 で wire 凍結するが、現行のSessionInfoとAttachmentに「0 sentinel が valid 値と衝突する」設計が残っている。minor 3 の ledger 行はこれを patch しているが、proto 上の型が optional 化されていないため、将来の breaking を避けるなら今しか直せない。1.
SessionInfo.sequence = 0sentinelsequenceは daemon 起動後の monotonic な作成順 counter で、switch 環の順序に使う。minor 3 未満の daemon は field を書かず、proto3 の default0が読まれる。client は0を「ring は id 順に fallback」として扱う(reference/ipc.mdThe minor ledger 行3)。問題:
sequenceは1起算だが、proto 上はuint64(non-optional) で0が valid か sentinel か区別できない。将来「sequence 0 を valid に使う」ことはないとしても、proto 上の default と sentinel が同一であるため、ledger の「old peer writes none → reads 0」記述が型で保証されず、テストも0を sentinel として特別扱いする分岐に依存する。同様に
SessionInfo.attachments: repeated Attachmentは minor 2 未満で空だが、これは repeated なので empty が自然に sentinel になる。一方sequenceは scalar のため optional 化しない限り sentinel を持てない。2.
Attachment.attached_at: string(RFC 3339)attached_atはreference/ipc.mdで「daemon が持つ clock に対する絶対時刻」と定義され、相対秒では cross-host reader が再導出できないため string で持つ。実装はchrono/timeで parse する。問題: 文字列の RFC 3339 は parse 失敗で session list 全体が decode error になるリスクを持つ。proto 上で
stringは UTF-8 保証のみで、format を強制できない。GridMsg::PromptMark.line: uint64やSessionInfo.idle_seconds: optional uint64は数値で持っており、attached_atだけが文字列である非対称がある。Timestamp型 (seconds + nanos) やuint64epoch millis にすれば validation が型で閉じる。3.
SessionInfo.idle_seconds: optional uint64の 0 意味0 while attachedとコメントにあるが、optionalの absent と0の両方が「attached」を意味する二重表現になっている。minor 0 の base schema から optional だったため、0 sentinel を避けようとした痕跡だが、結果として「field があれば0も valid」になり、ledger の old peer 記述が曖昧。問い
1.0 で以下を決める:
sequenceをoptional uint64(またはgoogle.protobuf.UInt64Valuewrapper) にし、absent = old daemon /0sentinel を廃止するか。1起算の monotonic を1..の required にし、absent だけが fallback トリガになるようにするか。Attachment.attached_atをstringのまま frozen にするか、Timestamp/uint64epoch に正規化するか。protocの well-knowngoogle.protobuf.Timestampを使うか、felis 独自のuint64 attached_at_millisにするか。idle_secondsの0/ absent 二重性を解消し、0= attached、>0= detached seconds に一本化するか。proto 上は non-optionaluint64にして0を attached とするか、optional のまま absent を attached とするか。提案
sequenceのみoptional uint64 sequence = 12にする。1.0 前の breaking としてBREAKING.mdにbase: <sha>を追加し、just proto-compatを green にする。client の fallback はNone → 0ではなくNone → id 順に変更し、0を valid sequence として扱わないことをreference/ipc.mdThe minor ledger に追記。attached_atとidle_secondsは 1.0 で frozen と明記し、将来の major で見直す Revisit trigger をexplanation/architecture/ipc.mdに残す。sequence: optional uint64,attached_at: uint64 attached_at_unix_millis,idle_seconds: uint64 idle_seconds(non-optional, 0=attached) に 1.0 で一括変更。BREAKING.mdに一括の base 行を追加。reference/ipc.md「Session (kind=4)」節の field 表を更新し、explanation/data-modelに「なぜ int epoch か」を記録。案A は
sequenceの optional 化だけを今やり、attached_atの string は実害が小さいため凍結する。案B は 3 field を一度に直すが、client のchronoparse が消える分、実装が簡潔になる。判定基準
felis.protoのSessionInfo.sequenceが optional か否かで 1.0 の ledger 行「old daemon writes none」が型で保証されること。Attachment.attached_atが string のままならreference/ipc.mdに「RFC 3339 固定、parse 失敗時の扱い」が凍結として明記されること。int にするならBREAKING.mdに base 行がありcargo test -p felis-protocolの round-trip が green であること。idle_secondsの0/ absent 意味がreference/ipc.md「Session (kind=4)」で一意に決まり、cli_output.rsの human 表示とjson出力が同じ解釈をすること。対象ファイル
crates/felis-protocol/proto/felis.proto(SessionInfo.sequence,Attachment.attached_at,SessionInfo.idle_seconds)crates/felis-protocol/src/messages.rs/convert/session.rs(SessionInfo,Attachmentの domain 型)crates/felis-protocol/src/preface.rs(MINOR_LEDGERの sequence 行)docs/reference/ipc.md「Session (kind=4)」「The minor ledger」docs/explanation/architecture/ipc.md「Session lifecycle」Parent: #12 および #52 / #47
Triage (2026-09-05)
Verdict: decision needed, P1. Recommendation: freeze the wire as-is (neither option A nor B), do the docs half.
sequence: after v0.1.0 every peer writessequence >= 1; the0fallback describes pre-release daemons no release will meet. Making itoptionalbuys a type-level statement about peers that do not exist.attached_at: the value is daemon-produced, so a parse failure is a daemon bug, not an input-validation gap; RFC 3339 stays.idle_seconds: a wording fix. State one meaning for absent and0inreference/ipc.mdand makecli_output.rsfollow it.If accepted: record the three semantics and a Revisit trigger in the same
reference/ipc.mdSession table #146 adds, then close this issue as folded into #146. If the user wants option A instead, it needs abase:line inBREAKING.mdand must land before #50 and #29. Parent: #52.最小breakingにこだわる必要はない
Triage plan (2026-09-05)
Source-grounded triage against
mainat07a77ae4, reviewed through six rounds of an independent reviewer (piluna then sol); every finding was verified against the source and folded in, and the design decisions below were settled with the maintainer on 2026-09-05. The order that supersedes the tracker's is posted on #12: this issue lands before #29 (schemas, goldens, and reference tables freeze there) and, because it breaks the wire, before #50.SessionInfo.sequence,Attachment.attached_at,idle_secondsClaim check
felis.proto:625-630uint64 sequence = 12(minor 3); the client ring(
felis-client-core/src/roster.rs:7-32,86) orders bysequencewith idas tiebreak, so "every row reads 0 → id order" is the implicit fallback
the ledger row 3 (
ipc.md:2118) describes.attached_at: produced bycrates/felis-daemon/src/timestamp.rs(
rfc3339_utc(SystemTime::now()),session_task.rs:1180); the CLIpasses the string through (
cli_output.rs:537,571,cli_sessions.rs:1038);no parse on the client side, so the "decode error" risk in the issue is
overstated (it is a string field; nothing parses it).
idle_seconds:optional uint64 idle_seconds = 5; // 0 while attached— the comment defines
0and leaves absent undefined.crates/felis-protocol/proto/BREAKING.mdholdsfirst-releaseand no
base:line; a wire break needs abase: <sha>line andjust proto-compatgreen (scripts/proto/compat.sh).Verdict
accept, option B (normalize on the wire), per the maintainer's note
"最小 breaking にこだわる必要はない". One
base:line; lands before #50(which turns the ledger into executable metadata and would otherwise
encode the old fields) and #29.
Decision (attached_at type) — settled 2026-09-05:
google.protobuf.Timestamp.The maintainer allows the import, so the well-known type wins over a
felis-private integer: it is the typed, range-defined (years 0001-9999,
nanosecond precision) representation every protobuf consumer already
has a native mapping for, which is what the cross-language reuse surface
wants.
felis.protogainsimport "google/protobuf/timestamp.proto"and
felis-protocolgainsprost-types(pure, no OS or tokiodependency, so the purity rule holds;
tests/crate_purity.rsshouldstill pass). Rejected:
uint64 attached_at_unix_ms(a privateconvention with a hand-written range cap) and keeping the string
(format enforced by nothing). The CLI surface does not change:
--format jsonkeepsattached_atas an RFC 3339 string and humanoutput renders the same; the formatter (
crates/felis-daemon/src/timestamp.rs)moves to
felis-cli, its only remaining user, and the daemon fills theTimestampfromSystemTimeviaprost_types::Timestamp::from.Decode rejects a
Timestampoutside the type's documented range or withnanosoutside0..1e9asMalformedField.Approach
felis.proto:optional uint64 sequence = 12(absent = the daemonpredates minor 3; the ring falls back to id order on
None, never on0;1-based stays). Model it asOption<NonZeroU64>inmessages/ops.rssoconvert/ops.rsrejectsSome(0)asMalformedField(a REQ-114 connection failure) instead of admittingthe retired sentinel into the ring; decode tests for
None,Some(1),and rejected
Some(0);Attachment.attached_atbecomesgoogle.protobuf.Timestampin field 2(reserve the old name);
idle_secondsstaysoptional uint64with thecomment rewritten: absent = attached, present = seconds since the last
detach (
0= under a second). The daemon writesSome(0)while attached today(
crates/felis-daemon/src/serve.rs:2422-2426); change it toNone, sopresence alone says "detached" and
0regains its one meaning.messages/ops.rs/convert/ops.rs(whereSessionInfoandAttachmentconvert; notconvert/session.rs):sequence: Option<NonZeroU64>everywhere in the domain (project withget()onlyat the ring key),
attached_at: Timestamp(domain type wrappingSystemTimeor theprost type);
just prototo regenerate the committedprost output; roster:
RingKey.sequence: Option<NonZeroU64>— next/previousorder
Someby sequence withNoneafter them by id, and theshell-exit pick (
roster.rs:86,abs_diffon the sequences today)uses sequence distance only when both anchor and candidate are
Some,else the existing id-order fallback; tests for both walks and the
exit pick with
Noneon each side;cli_output.rs/cli_sessions.rs:1009,1038: human rendering.BREAKING.md: onebase: 07a77ae4…line with the why. Expectbuf breaking(WIRE_JSON) to report theattached_atname/type/JSON-namechange and
sequence's implicit → explicit presence; record the actualdiagnostics in the ack's prose, and name the wire-silent
idle_secondssemantics there too. Skew posture, stated in
BREAKING.mdand
CHANGELOG.md: this is a pre-release semantic break on the 1.9tree (the #138 decision makes 1.9 the first public baseline); a daemon
and client from either side of it must be rebuilt together, and no
skew handling is written. State the actual skew outcome: field 2 of
Attachmentchanges wire type (string → varint), so prost rejects themismatch (string → embedded message is a wire-type change only for
consumers that read the old string; prost decodes a length-delimited
message where it expects a string as a UTF-8 failure or a garbage
string) and any roster carrying an attachment is a decode failure that
ends the connection in either direction; an old daemon's
Some(0)idle would read as "detached" where decoding succeeds. Not a minor
bump: no released peer exists to negotiate with.
sequence: 0/idle_seconds: Some(0)(13 sites incrates/felis-cli,including
cli_completions.rsfixtures):roster.rskey type(
Option<u64>,Nonesorts by id),cli_output.rs:486-571,cli_sessions.rs:1009,1038, anddocs/how-to/reap-sessions.md:25-31,which documents
idle_seconds == 0as "attached" and must switch to"absent" (
select(.idle_seconds != null and .idle_seconds > 3600)).crates/felis-daemon/src/timestamp.rs) movesto
felis-cli(the dependency direction forbids the CLI depending onthe daemon); the daemon no longer formats anything.
docs/reference/ipc.md"Session (kind = 4)": a field table row perfield with the frozen semantics; ledger row 2 (
ipc.md:2117, namesattached_at) and row 3 ("writes none → absent") both updated;docs/explanation/architecture/ipc.md"Session lifecycle": why integerTimestamp(typed, range-defined, no format to validate) and whysequenceis optional(presence, not a sentinel, says "old daemon");
docs/explanation/architecture/session-lifecycle.md: the switch-ringrationale that names the
0fallback is rewritten forNone.docs/reference/cli.mdmachine output forsessions list/infoisunchanged (
attached_atRFC 3339 string);skills/felisunchanged;CHANGELOG.mdfor the wire change and theidle_secondssemantics.None; CLI JSON golden foran attachment;
just proto-compatwith the ack.Dependencies / risk / labels
Before #50 and #29. M (touches daemon, protocol, client-core, CLI,
docs; mechanical). Keep labels. Parent #52.