feat(protocol)!: name the four dimensions of a status resource row #75

Merged
natsukium merged 18 commits from feat/status-resource-scopes-26 into main 2026-09-04 17:46:52 +09:00
Owner

Fixes #26.

felis daemon status printed every resource as used / limit, although
some values are daemon-wide totals and some the deepest single subject,
while limits may be global, per-session, or per-subscriber. Every ratio
was dimensionally ambiguous, and the one that mattered — how close a
subject is to being refused — could not be told from the one that did
not.

The change

  • ResourceReport carries four named dimensions in place of two:
    total_used, max_subject_used, per_subject_limit, global_limit.
    Dimensions that do not apply are omitted, never nulled. LimitScope
    becomes SubjectScope and names the subject a row counts per.
  • The human rendering pairs each observation with the ceiling beside it
    and names the scope on every row, so no ratio crosses denominators.
    The row's shape is keyed on scope, so a reply from a daemon older
    than this repack renders max <scope> unavailable rather than
    collapsing into a daemon row.
  • sessions counts admitted slots, reservations included, so it cannot
    read below what the next create is charged against; each subscriber
    outbox is sampled once, so a row's total and its deepest subject
    agree.
  • A dropped self-generated PTY reply is now logged, once per saturation
    episode: the notice is claimed under the same lock that makes the drop
    decision, so a child that queries in a loop while refusing to read its
    stdin cannot grow the daemon's log.
  • The proto repack is acknowledged in
    crates/felis-protocol/proto/BREAKING.md, which the proto-compat
    gate requires, and the skew is documented as silent in both directions
    (restart the daemon after rebuilding).

Doc cascade

docs/reference/cli.md (the normative row table, the machine object,
the --format stability bullet), docs/reference/ipc.md (the
StatusReply entry and what the sample is), docs/reference/spec.md,
docs/explanation/architecture/control-surfaces.md (the argument for
naming the dimensions, and the corrected claims about what the deepest
subject predicts), docs/explanation/architecture/session-lifecycle.md,
skills/felis/SKILL.md, CHANGELOG.md, and
crates/felis-protocol/proto/BREAKING.md.

The machine surface keeps "v":1: the epoch is one number for every
verb, so moving it would invalidate every other object's parser over one
verb's rename. The pre-release rule that permits this — a rename lands
under the same epoch with a CHANGELOG entry — was implicit in the tree's
history (sessions.detachsessions.evict) and is now recorded in
every copy of the contract, with a "Revisit when" trigger at the first
tagged release.

Rebased onto current main; the commits this branch shared with the
already-merged #15 stack are dropped, as are the ones main superseded
with a different fix.

Verified: just check green (fmt, clippy, nextest, deny), pi review PASS
(sol, 4 rounds; two blockers in rounds 1–3 — the unacknowledged epoch
contradiction and an unbounded drop-warning flood — fixed, final round
PASS with two non-blocking doc corrections applied).

Fixes #26. `felis daemon status` printed every resource as `used / limit`, although some values are daemon-wide totals and some the deepest single subject, while limits may be global, per-session, or per-subscriber. Every ratio was dimensionally ambiguous, and the one that mattered — how close a subject is to being refused — could not be told from the one that did not. ## The change - `ResourceReport` carries four named dimensions in place of two: `total_used`, `max_subject_used`, `per_subject_limit`, `global_limit`. Dimensions that do not apply are omitted, never nulled. `LimitScope` becomes `SubjectScope` and names the *subject* a row counts per. - The human rendering pairs each observation with the ceiling beside it and names the scope on every row, so no ratio crosses denominators. The row's shape is keyed on `scope`, so a reply from a daemon older than this repack renders `max <scope> unavailable` rather than collapsing into a daemon row. - `sessions` counts admitted slots, reservations included, so it cannot read below what the next create is charged against; each subscriber outbox is sampled once, so a row's total and its deepest subject agree. - A dropped self-generated PTY reply is now logged, once per saturation episode: the notice is claimed under the same lock that makes the drop decision, so a child that queries in a loop while refusing to read its stdin cannot grow the daemon's log. - The proto repack is acknowledged in `crates/felis-protocol/proto/BREAKING.md`, which the `proto-compat` gate requires, and the skew is documented as silent in both directions (restart the daemon after rebuilding). ## Doc cascade `docs/reference/cli.md` (the normative row table, the machine object, the `--format` stability bullet), `docs/reference/ipc.md` (the `StatusReply` entry and what the sample is), `docs/reference/spec.md`, `docs/explanation/architecture/control-surfaces.md` (the argument for naming the dimensions, and the corrected claims about what the deepest subject predicts), `docs/explanation/architecture/session-lifecycle.md`, `skills/felis/SKILL.md`, `CHANGELOG.md`, and `crates/felis-protocol/proto/BREAKING.md`. The machine surface keeps `"v":1`: the epoch is one number for every verb, so moving it would invalidate every other object's parser over one verb's rename. The pre-release rule that permits this — a rename lands under the same epoch with a CHANGELOG entry — was implicit in the tree's history (`sessions.detach` → `sessions.evict`) and is now recorded in every copy of the contract, with a "Revisit when" trigger at the first tagged release. Rebased onto current `main`; the commits this branch shared with the already-merged #15 stack are dropped, as are the ones `main` superseded with a different fix. Verified: just check green (fmt, clippy, nextest, deny), pi review PASS (sol, 4 rounds; two blockers in rounds 1–3 — the unacknowledged epoch contradiction and an unbounded drop-warning flood — fixed, final round PASS with two non-blocking doc corrections applied).
A `ResourceReport` carried one `used` and one `limit` with a `scope`
label to say which denominator each was in. That put the
disambiguation on the reader for every row, and it hid a second
ambiguity the label never mentioned: `used` was a daemon-wide sum for
the per-session resources and the deepest single outbox for the
per-subscriber one, so `image_store_bytes 300 MiB / 256 MiB per
session` could not tell an operator whether any one session was near
the cap it is actually refused against.

The row now carries the dimensions by name — `total_used`,
`max_subject_used`, `per_subject_limit`, `global_limit` — and omits
the ones that do not apply, so a daemon-scope row states its total and
its one ceiling rather than repeating the total under a second name.
`LimitScope` becomes `SubjectScope`: it names what a subject *is*, now
that the two limit fields carry what each ceiling is charged against.
The two ratios a reader can form (total against the daemon-wide
budget, deepest subject against the per-subject ceiling) each keep
both halves in one denominator; the human renderer prints them as
separate columns and can no longer divide a sum by a per-subject cap.

The daemon takes both samples in the pass it already made, so no hot
path gains a counter and a row cannot pair one instant's total with
another's maximum. `SessionStats` gains `total_subscriber_backlog`
beside the max, since a session holds many subscribers and the deepest
one alone cannot say how much the daemon is holding.

Field numbers 3 and 4 are reserved rather than repacked, so no
pre-freeze build can read a new field out of an old number. This is a
wire break, and pre-freeze breaks change the bytes under major 1
unbumped — a build older than this one is refused by the shape, not by
the preface. No resource carries a daemon-wide byte budget yet, so
`global_limit` is set only where an aggregate cap already exists
(connections, sessions, in-flight decodes).

Refs #26

Assisted-by: Claude Code
The status surface now reports a total, a deepest subject, and the two
ceilings each is charged against, so the pages that told a reader to
check `scope` before treating `used / limit` as a ratio were
describing a shape that no longer exists — and the advice was the weak
half anyway: a qualifier the reader must notice is a trap on a report
whose purpose is to be read under pressure.

The reference pages state the field-presence rule (a daemon-scope row
omits the subject dimensions; an absent value is omitted, never null)
and which of the two ratios predicts an admission refusal. The
control-surfaces explanation records the decision with its rejected
alternative and a revisit trigger for the one thing a max-subject
sample cannot answer — which session is the deep one, a question
`sessions list` already owns. The changelog carries the break, since a
script reading `.used` or `.limit` has to move to the field matching
the denominator it meant.

Refs #26

Assisted-by: Claude Code
The PTY writer drops a self-generated reply (a DA or DSR answer, a
Kitty-graphics OK, a focus or color-scheme report) once a megabyte of
equally unreserved bytes is still pending. Nothing recorded that drop,
so a program hanging on an answer it never received looked exactly like
a program hanging on its own — and the drop happens at the one point a
sampled status row cannot show, which leaves #26's "admission refusals
are diagnosable" without evidence there.

The daemon now warns with the byte count when it drops, and the write
path's doc comment and session-lifecycle.md say which class the gauge
counts, so the rule reads the same where it is enforced and where it is
argued.

Refs #26
The minor ledger is the review gate for "is this really additive", and
rows 6 and 7 claimed an older peer keeps "the report as minor 5/6
defined it". That is no longer true: `ResourceReport` reserved tags 3
and 4 and moved the numbers to `total_used`, `max_subject_used`,
`per_subject_limit` and `global_limit`, so a peer built against the old
tags reads zeros on every row — a break, and the worst-shaped one,
since it renders as an idle daemon rather than an error.

Rows 6 and 7 now claim only what they deliver (the row is omitted,
nothing else changes), and the repack is stated under the table as a
non-additive change that no minor could honestly carry, admissible
only because the wire carries no compatibility promise before the
first release. The CHANGELOG already recorded the break; the two
documents contradicting each other is what made the gate useless.

Refs #26

Assisted-by: Claude Code
The row's CLI-visible contract — that it is emitted at all, its session
scope, both subject dimensions, and the per-session denominator the
human line names — was only exercised inside felis-daemon, so a
rendering or token change in felis-cli could drop the row and leave
every CLI test green while the documented sample output went stale.

Refs #26

Assisted-by: Claude Code
The comment narrated what fields 3 and 4 used to hold, which git log
already records and which a reader of the current message has no use
for. What must survive next to the tags is the reason they are reserved
rather than reused.

Refs #26

Assisted-by: Claude Code
`subscriber_queue_bytes` is the one status row whose two halves come
from different `SessionStats` fields — a per-session sum for
`total_used`, a per-session max for `max_subject_used` — and that
hand-written split was asserted nowhere: swapping the two, or turning
the stats sum into a max, left the whole suite green while the daemon
reported a maximum where the wire and the docs promise a daemon-wide
sum. The row is also the one whose meaning this branch inverted (`used`
used to be the deepest single subscriber), so it is exactly where a
regression would land.

Four outboxes of distinct depths across two sessions, driven through
`daemon_status` itself, make either mistake fail: the deepest single
subscriber is smaller than one session's two summed, so neither a
per-session sum nor the daemon-wide total can pass for the max.

Refs #26

Assisted-by: Claude Code
The paragraph this branch rewrote in ipc.md was left at the wrap width the edit happened to produce, wider than the rest of the file. Rewrapping it now keeps the next diff to it readable.
A create is admitted against registered sessions plus the slots creates
in flight already hold, and the refusal names that number (REQ-915).
The status row counted registrations alone, so a burst that filled the
cap with reservations printed room beside a refusal saying the daemon
was full — the one output an operator consults to explain the refusal
disagreed with it. Sampling the admission count in the same critical
section the ids come from keeps the row and the ids one observation.

Refs #26

Assisted-by: Claude Code
The row's total and its deepest subject came from two traversals of
gauges the writer tasks mutate, so a subscriber draining between the
passes could report a max the total does not contain — an impossible
row, and exactly the sum-versus-max confusion the four-dimension shape
exists to remove. One fold feeds both numbers from a single load.

Refs #26

Assisted-by: Claude Code
Both pages read a subject at its per-subject ceiling as predicting that
the next request is refused. Only the daemon-scope caps refuse: an
image store over budget evicts its oldest images (REQ-309), a full
outbox evicts its subscriber, and an exhausted input budget stops the
connection feeding that child. An operator acting on the old wording
would look for a refusal that never arrives.

Refs #26

Assisted-by: Claude Code
The daemon's connection- and input-budget tests were written against
`ResourceReport::used`/`limit` and landed on main after this branch
replaced those with the four named dimensions, so the two together do
not compile. Each assertion moves to the field carrying the denominator
it always meant: a connections row is daemon-scoped, so its count is
`total_used` against `global_limit`, while the single-session input
tests observe one subject and read `max_subject_used` against
`per_subject_limit` — which is also what keeps them honest the day a
second session makes the two differ.

Refs #26
The status rows' repack changes `ResourceReport`'s tags and retypes
`scope`, which `proto-compat` refuses unless BREAKING.md names the
revision the break is taken against. Without the line the gate blocks
every push; with it the break is reviewed where the ledger is read
rather than argued in a PR thread.

Refs #26
The machine-output contract says a field is never renamed without
bumping `v`, yet this branch renames `used`/`limit` on the status rows
and CHANGELOG history already carries the same move for `felis bridge`'s
`sessions.detach`. One of the two was wrong, and the gate is useless
while they contradict each other.

The rule now states what the tree actually does before the first tagged
release, with the reason a one-verb rename does not cost an epoch: `v`
is one number for every verb, so moving it would invalidate every other
object's parser over a change nothing in the epoch identifies. The
CHANGELOG is named as the ledger, and gains the reverse skew — a new
`felis` against a daemon still on the old build reads zeros too, which
renders as an idle daemon rather than an error, so the daemon has to be
restarted after a rebuild. BREAKING.md says the same for the wire.

The `sessions` row's description was also wrong in both the reference
and the proto: it reports admitted slots, reservations included, so it
can legitimately read above the roster while a create is in flight.

Refs #26
The daemon warns when the writer drops a self-generated reply, and the
child decides how often that happens: a program that queries in a loop
while refusing to read its stdin holds the unreserved gauge full and
earns a log line per query, without bound. The log rotates only at
daemon startup, so nothing else caps it.

`write_owned` now answers with a `WriteOutcome`, and a drop carries the
saturation episode's one notice, claimed under the same lock that made
the drop decision — a separate `take_drop_notice()` call could have the
gauge drain between the two and spend the *next* episode's notice on a
child that had recovered, leaving a later stall unreported. The episode
ends when the unreserved gauge empties, which is what makes the next
drop worth a line: it is the episode, not the individual reply, that an
operator acts on.

Refs #26
`render_row` treated a missing `max_subject_used` as "this is a
daemon-scope row". A daemon built before this repack answers a
subject-scoped row with tags 6-9 unset, so a new `felis` against one
printed a session row as `0 B / unlimited`: the row's scope vanished
and an absent per-subject ceiling was rendered as a daemon-wide one —
the ambiguity this whole change removes, reintroduced at the one moment
an operator is most likely to be reading status.

The scope decides the shape, and a subject-scoped row that arrives
without its maximum says `max <scope> unavailable` instead of borrowing
another row's meaning.

Refs #26
The machine-output contract is restated in three more places than the
one this branch corrected — the `--format` stability bullet, the
control-surfaces argument, and the shipped skill — and each still
promised that a field is never renamed without an epoch bump, which
this branch's own rename breaks. A rule that holds in one copy and not
the others is not a rule a script author can rely on.

Two claims about diagnosing pressure are corrected in the same pass:
the deepest-subject sample predicts a refusal only where the ceiling is
a global one (elsewhere it predicts eviction or blocking), and `felis
sessions list` does not name the deep subject either — it inventories
sessions without measuring any of these resources, which is what the
"Revisit if" trigger is for. The reference also states what a row
missing its subject fields renders as, since that is now a shape a
reader can meet.

Refs #26
docs: say what the status sample is and is not
All checks were successful
darwin / build felis (aarch64-darwin) (pull_request) Successful in 46s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m10s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m12s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m45s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 53s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 6m49s
pr / nix flake check (pull_request) Successful in 28s
windows / frontend smoke (Windows) (pull_request) Successful in 1m31s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
93d57e21b6
Two claims in the reference read stronger than the daemon delivers.
`ipc.md` called the report "one instant": the reply asks each session
actor in turn, so it is a set of per-subject samples taken during the
build, and what sampling each subject once actually buys is that one
row's `total_used` and `max_subject_used` agree with each other.
`cli.md` said which fields a row carries follows from `scope` alone,
which its own table contradicts — `scope` decides the row's shape,
while the two ceiling fields follow from which budgets felis charges
that resource against.

A reader who trusts either overstatement and then meets a total that
does not match the deepest subject's history has been told the wrong
thing about what the number is.

Refs #26
natsukium deleted branch feat/status-resource-scopes-26 2026-09-04 17:46:53 +09:00
Sign in to join this conversation.
No description provided.