# W3 / releases#239 — endpoint boundaries as observable frames (DESIGN PROPOSAL)

Author: todlando, 2026-08-29. Gated by doyle BEFORE any build, per the W2 shape.
Every cite measured at `7926fb5a` (main, W2 landed) in `.worktrees/ir57`.
Raw measurements: `W3-239-GROUNDWORK.md` beside this file.

## 0.0 GATE VERDICT (doyle, 2026-08-29) — READ THIS BEFORE THE PROPOSAL BELOW

**Build GREENLIT on C1.** The gate took §4's own separability clause and shipped the simpler
carrier: boundary rows ride the io log with NO trim change. Everything below is preserved as
the record of what was proposed and why; where it recommends C2 it is SUPERSEDED, and the
sections the build actually follows are marked inline.

What the gate decided, and the two things that decided it — both verified by me at
`7926fb5a` rather than taken on the verdict's word:

- **C2's price, unmeasured by me, lands in the module the no-knob argument was defending.**
  `iolog.rs:287-289` verbatim: "Seqs are contiguous — assigned +1 per append and trimmed only
  from the front — so the row count is `last - first + 1` and needs no scan to compute."
  Skip-boundary eviction leaves retained rows below the contiguous tail, so `first_seq_at`
  returns a boundary's seq, `rows = last - first + 1` (`trim_locked`, `:296`) overcounts by
  every hole, and the trigger then fires on EVERY append past that point — a whole-file
  rewrite per append, which is exactly the cost `IO_LOG_TRIM_SLACK` exists to amortize.
  Repairing that needs a scan per trigger or second bookkeeping: "a second place to be wrong"
  in the refusal's own spirit. **My kind-vs-degree distinction (§4.1) SURVIVES — an ordering
  is not a cap — but I priced it at zero and it is not zero.** That is what §10.2 asked for.
- **The default consumer never reads the rows C2 protects.** The poll seeds SILENTLY at head;
  only an explicit `--after` reader reaches an old boundary row, and a live poller (seconds-
  scale loop against a ≥~1.85h window) sees every boundary under plain C1 anyway. The residual
  loss is an `--after` historical reader asking for a boundary more than ~2h of peak busy work
  later — which sits beside the module's own dead-cursor philosophy.

C2 stays available on a measured need, and its next proposal must price the trigger arithmetic
as a first-class section rather than inherit this gate's pass. C3 stays refused on the grounds
§4 already gave. §5's no-new-poll-verb is gated, and follows from C1 exactly as from C2.

## 0. What is RULED, and what this proposal is allowed to decide

Operator ruling, releases#239 comment 5461768503, read at the source and quoted verbatim:

> boot/clear/compact ship as a NEW boundary frame category — event-class semantics (edges,
> ordered, each occurrence matters), NOT an activity subset (activity is drive-class
> latest-wins by its own doc and would drop edges by construction). Three variants minted
> whole.

CLOSED by that ruling, restated here and not reopened: a new frame **category** (not an
`activity` subset, not a kind inside another vocabulary); **event-class semantics** — edges,
ordered, **each occurrence matters**; **three variants minted whole**.

OPEN, and what this document decides: the **carrier** underneath the ruled wire category —
does a boundary event ride the shipped durable io-log plumbing, or its own store? — plus
the emitter sites, the poll surface, retention, and the doc sweep.

⚠ Recorded because it nearly reached the gate: an earlier draft of §2 argued for three new
kinds inside the existing IO vocabulary, on the grounds that `MSG_IN`/`MSG_OUT` and
`COMMUNE`/`COMMUNE_FAIL` are already non-agent-IO members, so the funnel is already a
session-observation funnel. Those facts are true and the argument is void: it argues against
a decision the operator already made. Withdrawn. The ruling is the input, not the fork.

## 1. The ruling's premise, corroborated at the implementation

The ruling refuses the activity arm because "activity is drive-class latest-wins **by its own
doc**". A ruling resting on a doc claim is worth exactly one measurement, so:

- `crates/spt-daemon/src/activity.rs:183` → `drivehub::activity_write(...)`.
- `crates/spt-daemon/src/drivehub.rs:371-372` — `write_activity` is `write_slot`;
  `:381-382` — `take_activity` is `take_slot`, which takes **and clears**.
- The hub's own tests name it rather than implying it: comment `:956` "both classes are
  latest-wins", `activity_take_is_latest_wins_and_exactly_once` `:998`,
  `dead_generation_activity_frame_is_never_served` `:1021`.

The premise holds at the code, not only in the doc that asserts it. Concretely, the edges it
would drop: a `clear` followed by a `compact` before the shell polls loses the `clear`
outright. And `boot` is worse than the ruling's general case — it fires at perch bind
(`crates/spt/src/api/startup.rs:787`), *before* any shell can plausibly be attached, so on
the ephemeral carrier `boot` is unobservable close to always, not merely lossy under load.

This section is corroboration, not a decision. It matters for §4, where the same question —
can this carrier drop an occurrence? — comes back wearing a slower disguise.

## 2. Wire shape

A new event type beside `EVENT_TYPE_ACTIVITY / DRIVE / IO / SENSORY` (declared in
`spt-proto`, imported at `crates/spt-daemon/src/shellchan.rs:32`), with the three variants
minted whole in one closed list on the `IO_KINDS` pattern (`crates/spt-proto/src/ioevent.rs`
— including the `TOOL_USE` precedent at `:46-54` that a constant with no emitter costs
nothing while a rival spelling minted later costs every consumer):

```
<EVENT type="boundary" from="<owner>" kind="boot"    seq="418"></EVENT>
<EVENT type="boundary" from="<owner>" kind="clear"   seq="419"></EVENT>
<EVENT type="boundary" from="<owner>" kind="compact" seq="420"></EVENT>
```

Empty body, on the `activity` precedent that an empty body is load-bearing: a boundary's
whole content is *which* edge and *when*. `compose_boundary_frame(owner, kind, seq)` lands
beside `compose_activity_frame` (`shellchan.rs:152`) and `compose_io_frame` (`:178`).

**The three tokens are not new spellings.** `SessionTrigger::token()`
(`crates/spt-store/src/sessions.rs:36-55`) already answers exactly `"boot"` / `"clear"` /
`"compact"`, and its own doc comment says it already serves as the boundary marker `kind` in
the spanned digest. The frame is a second consumer of a shipped token, which is what keeps
the wire and the ledger from drifting into two vocabularies for one concept.

## 3. Emitter — the two sites, and two riders that fall out of them

- `crates/spt/src/api/startup.rs:787` — `SessionTrigger::Boot` at perch bind.
- `crates/spt/src/api/reporting.rs:175-178` — `cmd_boundary`: `"compact"` maps to `Compact`,
  **everything else falls back to `Clear`**.

1. **Publish the COERCED token, never the caller's word.** That fallback means an unknown
   mode is silently read as `clear`; an emitter publishing the raw argument would put an
   unbounded caller string on a closed-vocabulary wire. Emit from `SessionTrigger::token()`.
   (The CLI `value_parser` at `api/mod.rs:206` is `["clear","compact"]`, so the coercion arm
   is unreachable from the CLI today — this is defence-in-depth for a library call.)
2. **Best-effort, never failing the boundary.** The ledger append at that site is already
   `let _ = ...` with the comment "A ledger problem must never fail the boundary itself" —
   the same never-fail-what-you-observe rule the IO funnel runs under. The emitter inherits
   it verbatim: a boundary that cannot be published is still a boundary.

## 4. CARRIER — the open decision

The ruled category is a **wire** decision. Storage is separable, and three carriers are
available:

- **(C1) share the io log as-is** — a boundary row in `IoLogRow`, served by the existing
  `api io events` poll. One cursor, no new store, no new verb.
- **(C2) share the io-log plumbing with a trim ORDERING** — C1 plus one rule: the trim evicts
  the oldest **non-boundary** row first, and evicts a boundary row only when no non-boundary
  row remains.
- **(C3) a sibling durable store** — `boundary-events.jsonl` with its own retention, on the
  `dispatch-results.jsonl` precedent (`crates/spt-store/src/dispatchresults.rs:27`), served
  by its own cursor.

**C1 is refused, and the ruling's own words are what refuse it.** The io log is capped at
`IO_LOG_MAX_ROWS = 1000` with `IO_LOG_TRIM_SLACK = 250` (`crates/spt-store/src/iolog.rs:98-107`),
a cap re-derived in W2 against **agent-output** emission (~540/hr at the finest quantum, a
~1.85h peak horizon). Boundary rows sharing that budget have the opposite economics: agent
output is frequent and individually cheap, a boundary is rare and individually precious. Under
a chatty turn a `boot` row is trimmed inside the hour by rows that matter less — which is
"each occurrence matters" failing, just slower and quieter than the latest-wins slot the
operator already refused. Same failure, longer fuse.

**Recommendation: (C2), stated as an ORDERING and carrying NO second knob.**
⛔ **SUPERSEDED — the gate chose C1. See §0.0; this paragraph is the record of what was
recommended, not what is being built.** One cursor and one poll surface is the whole ergonomic
win of sharing the plumbing, and the ordering is what pays for the ruling.

### 4.1 The module's standing refusal, and why this is not it

`iolog.rs:74-80` refuses exactly the shape a careless version of C2 would take:

> **It is also the byte bound, derived rather than declared as a second knob.** … the real
> ceiling … is 1250 rows, i.e. 20MB pathological … **A second byte-cap knob would be a second
> place to be wrong.**

An earlier draft of this section proposed a boundary-row *floor* of 64 — and that is precisely
the refused shape: a second declared retention number, hand-picked, expressing a bound the
module already derives elsewhere. Withdrawn.

The rule as it now stands **adds no knob at all**. `IO_LOG_MAX_ROWS` and `IO_LOG_TRIM_SLACK`
stay the only retention numbers; C2 changes *which row the trim picks*, never *how many rows
are kept*. The consequences fall out rather than being declared:

- the pathological all-boundary case is bounded by the SAME cap — 1000 boundary rows, not a
  new limit that has to be right;
- the derived byte ceiling is untouched and can only shrink, since a boundary row's payload
  is `""` where the derivation assumed the 16KB class;
- and the module's "this is a FLOOR on retention, not a ceiling" framing (`:84-86`) is
  preserved verbatim — a consumer still reasons about the same floor.

So the distinction is real and it is a distinction of KIND, not of degree: the refusal is
against a second *cap*; this is an *ordering within the existing cap*.

### 4.2 The boundary rate, measured — "rare" as a number, not an adjective

The io-log cap carries its basis because the module demands it ("a wave that adds an emitter
re-derives this figure rather than inheriting it", `iolog.rs:88-92`). Same discipline here.

**Meter, stated first because it decides how much the numbers are worth:** the first record's
`timestamp` in every harness transcript under
`~/.claude/projects/C--Users-decid-Documents-projects-spt-core/`, 2678 of 2783 files parsed
(105 empty or without a leading timestamp). One transcript ≈ one session, and a session is
what a boundary produces: `clear` and `compact` rotate INTO a new session id, and a cold start
is a `boot`. Cross-checked against file mtimes independently — same day-shape, same peak hour
within one count, so the reading is not an artifact of either clock.

- **Peak day: 723 sessions. Peak hour: 65.** Next hours 58, 53, 50, 49.
- Corpus spans **9 active days** (723 / 694 / 583 / 304 / 297 / 52 / 12 / 12 / …), i.e. it is
  a heavy-use corpus, not an idle one.

⚠ Both error directions, so the figure is not read as tighter than it is. It **over-counts per
endpoint**: 65/hr is project-wide across every agent working this cwd concurrently, while the
io log is PER ENDPOINT, so one endpoint's boundary rate is a fraction of it. It **under-counts
boundaries**: a boundary that does not rotate a session id leaves no new transcript.

**What that buys, against the same cap:** the log holds 1000 rows, and at the W2-derived
worst case (~540 agent-output events/hr) that is a ~1.85h window. Even taking the
project-wide 65/hr as if one endpoint carried all of it, boundaries are **≲12% of the rows in
that window**, and at the realistic per-endpoint share they are low single-digit percent. So
boundary rows do not threaten the budget — the cap does not need to move, and C3's separate
retention is not needed to make room.

**But the eviction risk is not about volume, and the number confirms it rather than dissolving
it.** Under C1 a `boot` row is evicted once ~1000 rows of agent output follow it — roughly two
hours of busy work at the measured worst case. The boundary is rare, cheap to keep, and the
thing that displaces it is the row class the ruling explicitly says must not displace it
("each occurrence matters"). That is exactly the case an ORDERING fixes and a bigger cap does
not: at any cap, the last thing you want evicted is the rarest row.

C3 is the runner-up and it is honestly close: it gives boundaries independent retention with a
precedent already in the tree. It loses on the second cursor every adapter must carry, and on
splitting one ordered observation stream into two that a consumer must merge — awkward
precisely because the ruling asks for **ordered** semantics.

⚠ C2 is the call I am least sure of, and it is separable: C1's wire, poll shape and tests are
identical, so shipping C1 first and pulling C2 in on a measured need costs no consumer-visible
change. If the gater prefers that, nothing in §2-3, §5 or §7 moves.

## 5. Poll surface

Under C2 the rows are io-log rows, so `api io events` serves them and no new verb is minted.
`IoLogRow` (`iolog.rs:113-135`) needs no new field: `kind` carries the boundary token and the
existing `payload` is `""`.

That empty payload is worth naming rather than leaving as an oddity. `shellchan.rs`'s own
comment on `compose_io_frame` says an IO frame "exists to carry content", unlike the activity
frame "whose empty body is load-bearing". A boundary row is the first legitimate exception on
the durable side, and the doc sweep (§8) says so out loud.

The poll's JSON projection is an explicit allowlist (`crates/spt/src/api/ioevents.rs:237-262`)
emitting `seq`/`at_ms`/`kind`/`payload` unconditionally, so a boundary row renders as an
ordinary event with an empty payload and a new `kind` value.

⛔ **CORRECTION (build, 2026-08-29): this section originally read "no reader change required",
and that was FALSE in the one place it mattered.** True at the row parse (`IoLogRow` carries no
`deny_unknown_fields`, `iolog.rs:368`) and true at the JSON projection — but `read_bounded`
applies a VOCABULARY ALLOWLIST, `filter(|r| is_io_kind(&r.kind))`, whose own doc says an
unknown kind is *ignored*. Boundary kinds were unknown to it, so a boundary row was written,
stored, cursored past, and then **silently dropped on the way out**.

Caught by the `int` leg on its first execution, not by a re-read: `--after 0` answered
`{"cursor":2,"seeded":false,"more":false,"events":[]}` — an empty page over a log whose own
cursor proved two rows were in it. That contradiction is the arm §7 put there to keep the seed
assertion non-vacuous, and it earned its place immediately.

The fix is the shape the module already prescribes rather than an exception to it — the
vocabulary is applied by the READER, so the reader learns the SECOND closed list:
`is_io_kind(&r.kind) || boundary::is_boundary_kind(&r.kind)`. Forward compatibility is
untouched: a token in NEITHER list is still ignored, never refused. Pinned both ways in
`a_boundary_row_is_served_while_an_unknown_kind_is_still_ignored`.

§5's gated claim — *no new poll verb* — stands: no verb was added and no consumer-visible
shape changed.
`render_text` (`:277-292`) prints `<seq> <kind> <head>`; a boundary prints an empty head,
which is correct and needs no special case.

## 6. Requirements to mint

One new REQ, `REQ-IO-BOUNDARY-EVENTS`, `required_stages = ["doc","impl","unit","int"]`,
activated in this lane. It owns: the `boundary` frame category and its three variants, the two
emitter sites, the coerced-token rider, the empty-payload exception, and the boot-observation
contract of §8.1. It owns NO trim change: the withdrawn floor and the un-gated C2 ordering are
both out of scope for this REQ. `REQ-IO-EVENT-TAXONOMY` is untouched — this is a new category beside it, which is
exactly what the ruling asked for.

## 7. Tests

- `unit` — the category's three variants are the closed list, and each maps 1:1 from
  `SessionTrigger::token()`. Total over all three arms in one test.
- `unit` — the coerced-token rider, both polarities: `"compact"` publishes `compact`, and an
  unknown mode publishes `clear` (never the caller's raw string).
- `unit` — `compose_boundary_frame` shape is the published one, and it parses back through
  the PUBLIC decoder — mirroring `activity_frame_shape_is_the_published_one`
  (`shellchan.rs:573`) and `activity_frame_parses_back_through_the_public_decoder` (`:589`).
- `unit` — a boundary row round-trips `parse_line` → `render_json` with an empty payload and
  no spurious fields.
- ~~`unit` (C2) — the trim ordering~~ **DROPPED with C2 at the gate.** The trim is unchanged,
  so there is nothing here to pin; a test asserting today's front-only eviction already exists
  in the module.
- `int` — the real e2e leg per IR-67 (`io_events_poll_e2e.rs` is the model), never a
  `-p spt --bins` compile leg: drive a real `api boundary clear` and poll the row back.
  ⚠ Rig note: `activity_link_push_e2e` is IR-67's other model test and is currently RED under
  parallel load on kitsubito — a clock-shaped 30s bringup bound, classified 2026-08-29 and
  routed to hertz. Do NOT copy its `wait_for_ready_pid` shape into a new leg; bound on
  progress, not on a clock.

## 8. Doc sweep

`docs-site/src/shells/frames.md` is the taxonomy home; the wire shapes live there literally at
`:194` (sensory), `:207` (drive), `:224` (activity), `:271` (io). The boundary category gets a
fifth block plus the sentence naming the empty-payload exception. `docs-site/src/lifecycle/
overview.md` gets the pointer in the other direction — a lifecycle boundary is observable on
the io poll. The full 17-file grep candidate set is in the groundwork doc; narrowing it to the
rows that actually enumerate the taxonomy is a read owed before the build, not a guess now.

### 8.1 The boot-observation contract — stated, not implied (gate rider, binding)

The sweep must say out loud what a consumer can and cannot see, because #224's lesson is that
an undiscoverable shipped surface reads as MISSING and gets re-filed as a bug:

> A shell attached live receives the pushed `boundary` frame. A session-cursor poller sees
> every boundary from its seed forward. History before first contact is reachable only by an
> explicit `--after`, and only within the retention window. In particular `boot` is observable
> live and by an early `--after`, and is NOT replayed to a consumer that seeds silently.

That paragraph is a DELIVERABLE of this lane (the `doc` stage of `REQ-IO-BOUNDARY-EVENTS`),
not a nicety — without it, the C1 carrier's one real limit is discoverable only by someone who
already knows it, which is exactly how #239 would re-file itself.

## 9. Build order

1. Mint `REQ-IO-BOUNDARY-EVENTS`; add the `boundary` event type + three variants with units.
2. `compose_boundary_frame` beside its siblings, with the shape + public-decoder units.
3. Emitters at the two sites, best-effort, coerced token; mapping units.
4. ~~C2 trim work~~ **REMOVED at the gate — the trim is untouched by this lane.**
5. The `int` leg.
6. Doc sweep, including the §8.1 contract sentence, + `xtask check`.
7. Full battery both arms, then hand the sha to the gate.

## 10. What I am asking the gate to decide

*(Answered — see §0.0. Kept as the record of what was asked.)*

1. **§4 — C2 over C1 and C3.** → **C1 GATED.** C2's price was unmeasured and lands on the
   trim's contiguity arithmetic; the default consumer never reads the rows C2 protects.
2. **§4.1 — the ordering distinguishable from the refused second knob.** → **Distinction
   CONFIRMED (kind, not degree); the PRICING refuted.** The section was right to invite it.
3. **§5 — no new poll verb.** → **GATED**, and it follows from C1 the same as from C2.
