# 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. 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.** 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 — **no reader change required**.
W2 already measured and pinned the two properties that make that safe: `IoLogRow` carries no
`deny_unknown_fields` (so a row from a newer binary is read, not skipped —
`iolog.rs:368`), and an unknown *kind value* is tolerated by the test at `ioevents.rs:416`.
`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 C2 trim priority
plus floor. `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, both polarities in one file: a full log evicts a span
  before a boundary, AND a full log holding ONLY boundaries does evict the oldest boundary. A
  rule that never evicts a boundary would pass the first half alone and leave the log
  unbounded — which is the arm that proves the ordering did not become a second cap.
- `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.

## 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 priority + floor, both-polarity units.
5. The `int` leg.
6. Doc sweep + `xtask check`.
7. Full battery both arms, then hand the sha to the gate.

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

1. **§4 — C2 (shared io-log plumbing + a trim ORDERING, no second knob) over C1 and C3.**
   The load-bearing claim is that C1 fails the ruling's own "each occurrence matters" on a
   longer fuse; attack that if it is wrong.
2. **§4.1 — that the ordering is distinguishable from the module's refused second knob.**
   I claim it is a difference of kind (ordering within the cap, not a second cap) and that
   the derived byte ceiling is untouched. This is the argument I most want attacked, because
   a rule that is *nearly* a knob is worse than one that plainly is.
3. **§5 — no new poll verb.** Follows from C2; falls if C3 is gated in instead.
