# W3 / releases#239 — groundwork (todlando, 2026-08-29)

Read-only grounding gathered while doyle's W2 gate closes. NOT a design proposal yet —
doyle gates the design before any build, same as W2.

## The ask (issue #239, verbatim scope)

> `boot`, `clear`, and `compact` must all be observable by shells via spt-core's shell API.
> thus, the idea is to have them transmitted as shell frames. either as a subset of
> `activity` frames, or a new `boundary` frame category.

Labels: `state: GREENLIT`, `type: IDEA`. Requester: discord:reavo (operator).
The issue itself names the fork the design must settle: **subset of `activity` vs. a new
`boundary` category.**

## What already exists (measured at lane head 7926fb5a, worktree .worktrees/ir57)

The vocabulary is already minted whole, and it is already the exact three tokens:

- `crates/spt-store/src/sessions.rs:36-55` — `enum SessionTrigger { Boot, Clear, Compact }`
  with `token()` answering `"boot"` / `"clear"` / `"compact"`. The doc comment on `token()`
  says it already serves as "the boundary marker `kind` in the spanned digest", so the
  wire spelling a frame would carry is not a new invention — it is a second consumer of a
  token that already ships.

Mint sites (where a boundary becomes true, i.e. where an emitter would hang):

- `crates/spt/src/api/startup.rs:787` — `SessionTrigger::Boot`, on first perch bind.
- `crates/spt/src/api/reporting.rs:175-178` — `cmd_boundary`: `"compact"` maps to
  `Compact`, everything else falls back to `Clear`. Note the fallback: an unknown mode is
  NOT refused here, it is coerced to `clear`. Any emitter must decide whether it publishes
  the coerced token or the caller's word — publishing the caller's word would put an
  unbounded string on the wire, so the coerced token is the only closed-vocabulary option.
- The CLI value_parser at `crates/spt/src/api/mod.rs:206` is `["clear", "compact"]`, so the
  coercion arm is unreachable from the CLI today and is defence-in-depth for a library call.

Ledger append is explicitly **best-effort** (`let _ = ...append(...)`, comment: "A ledger
problem must never fail the boundary itself"). That is the same never-fail-what-you-observe
rule the IO funnel runs under, so an emitter placed alongside it inherits the right posture.

## Frame surfaces a `boundary` frame would sit next to

`crates/spt-daemon/src/shellchan.rs`:
- `compose_command_frame` :85 · `compose_text_frame` :99 · `compose_file_frame` :105
- `compose_close_frame` :119 · `compose_sensory_frame` :124 · `compose_drive_frame` :136
- `compose_activity_frame` :152 — `EVENT_TYPE_ACTIVITY`, REST-only, **ephemeral** per
  ADR-0048; published shape pinned by unit at :573:
  `<EVENT type="activity" from="doyle" state="idle" since="1753372800123"></EVENT>`
  and round-tripped through the PUBLIC decoder at :589.
- `compose_io_frame` :178 — the W1/W2 surface; body IS the payload, unlike activity whose
  empty body is the point.

Event-type constants live in `spt-proto` (`EVENT_TYPE_ACTIVITY / DRIVE / IO / SENSORY`,
imported at shellchan.rs:32) — a new category means a new constant there, i.e. the taxonomy
change is in proto, not only in the daemon.

## The fork, stated as the design will have to state it

- **(A) subset of `activity`**: activity already carries a two-value `state=` (idle/busy).
  Reusing it means either a third state value (which would break every consumer that reads
  state as a boolean pair — the activity unit asserts exactly the two spellings) or an extra
  attribute on an activity frame, which makes "activity" mean two unrelated things.
- **(B) new `boundary` category**: a new `EVENT_TYPE_BOUNDARY` + `compose_boundary_frame`,
  carrying `kind="boot|clear|compact"` from `SessionTrigger::token()`. Costs a taxonomy row
  and a doc sweep; buys a closed vocabulary that cannot collide with activity's state pair.

My lean is (B) on the same reasoning doyle used to accept §3 of W2 — an exception inside a
category's rule is how the category stops being one — but the ephemerality question is open
and is the thing I have NOT yet measured: activity frames are REST-only and dropped when no
shell is attached, and a boundary that is dropped when unobserved may or may not be
acceptable to the requester. That is a design question with an operator flavour, and it
belongs in the proposal explicitly rather than being settled quietly by picking a hub.

## Next steps (in order)

1. Measure the ephemeral-hub path: what exactly is dropped when no shell is attached, at
   file:line, and whether any existing frame category is durable. Do not assume from the
   ADR-0048 sentence in the shellchan doc comment — read the hub.
2. Enumerate the doc sweep sites by grep (frames.md taxonomy table, harness-contract rows),
   not from memory.
3. Decide + write the proposal as a DOCUMENT; send doyle the path, not the body.

## Step 1 measured (the ephemerality question, now answered)

`compose_activity_frame`'s frame does not go to a stream — it goes to a **single
latest-wins slot**, taken and cleared by a REST poll:

- `crates/spt-daemon/src/activity.rs:183` → `drivehub::activity_write(...)`.
- `crates/spt-daemon/src/drivehub.rs:371-372` — `write_activity` is `write_slot`; :381 —
  `take_activity` is `take_slot` (take-AND-CLEAR).
- The hub's own tests name the property: comment at :956 "both classes are latest-wins",
  and `activity_take_is_latest_wins_and_exactly_once` at :998.
- Dead-generation frames are never served (:1021), and an absent field on `drive_take`
  means "nothing pending OR the held frame was a dead-generation stamp" (:839-842).

**This decides the fork, and it decides it harder than the taxonomy argument does.**
Activity's slot is CORRECT for activity: idle/busy is a level, and the latest reading of a
level is the only one worth having, so overwriting an unpolled one loses nothing. Boundaries
are the opposite shape — discrete EVENTS. A `clear` followed by a `compact` before the shell
polls would silently drop the `clear`, and a shell that missed a `boot` can never learn one
happened. Putting an event vocabulary in a level-shaped slot is a correctness failure that
only shows up under load, which is the same class as W2 §3's "seals a region the author did
not name".

So the real fork is not the issue's (activity-subset vs new category) — it is **which
CARRIER**: the ephemeral latest-wins slot, or the durable cursor-polled IO log the W1/W2
funnel writes to (`compose_io_frame`, iolog rows, `api io events` poll). The proposal must
put that in front of doyle in those terms, because the issue's own framing quietly assumes
the slot carrier by naming `activity` as one of the two options.

Not yet measured (next): whether the IO log's row shape can carry a non-IO kind without
widening `IoLogRow`'s meaning, or whether a boundary row wants its own store surface
alongside `dispatchresults`.

## Step 1b — the carrier distinction is already RULED, in the code

`crates/spt-proto/src/ioevent.rs:1-12` (module doc) states it outright:

> The wire carries these as the `kind` attribute of an `EVENT_TYPE_IO` frame; the frame's
> class (command, durable, spooled) is fixed by **operator ruling 7** and is what separates
> it from the **ephemeral ACTIVITY/DRIVE frames of ADR-0048**.

So "durable vs ephemeral" is not a fork I get to open — the operator already ruled the two
classes exist and named which frame type is which. The design question narrows to: does a
boundary want the durable class (⇒ it rides `EVENT_TYPE_IO` as new members of the closed
kind vocabulary, or a new durable-class frame type), or the ephemeral one (⇒ activity, and
it accepts latest-wins loss).

Given boundaries are discrete and unrepeatable, the durable class is the answer, and the
remaining fork is narrow and honest:

- **(B1) new kinds in the existing closed IO vocabulary** — `IO_KINDS` at ioevent.rs:56 is
  explicitly designed to be minted whole ahead of emitters (`TOOL_USE` ships with no emitter
  by ruling 3, and W2's `DISPATCH_RESULTS` did the same for `NO_PERCH`). Adding
  BOOT/CLEAR/COMPACT there is the house move and costs consumers nothing new to parse.
- **(B2) a new `EVENT_TYPE_BOUNDARY` frame type** — what the issue literally suggests. Costs
  a new frame class, a new poll surface, and a second durable carrier that every consumer
  must learn, to express something the existing `kind` attribute already expresses.

⚠ Counter-pressure I must not paper over: the IO funnel is the **agent-IO** funnel — its
name, its REQ titles and its payload cap are all about agent input/output. Boot/clear/compact
are SESSION-lifecycle facts, not agent IO, and stretching a vocabulary past its own name is
how a taxonomy stops being one (the same argument that won W2 §3). B1 is cheap and B2 is
honest; that tension is the actual thing to put in front of doyle, with a recommendation.

Also unresolved and worth naming in the proposal: `Boot` fires at perch bind
(`startup.rs:787`), which is *before* any shell can plausibly be attached to observe it.
A durable carrier makes that fine; an ephemeral one makes `boot` unobservable almost by
construction — which is more evidence for the durable class, and a concrete acceptance
scenario for the test plan.

## Step 2 — doc sweep sites, enumerated by grep (not memory)

`docs-site/src/shells/frames.md` is the taxonomy home; the four wire shapes live there as
literal examples:
- :194 `<EVENT type="sensory" …>` · :207 `<EVENT type="drive" …>`
- :224 `<EVENT type="activity" from="<owner>" state="idle" since="…"></EVENT>`
- :271 `<EVENT type="io" from="<owner>" kind="AGENT_OUTPUT" mid="1" seq="418" truncated="1">payload</EVENT>`

Files that mention frames/EVENT types at all, i.e. the candidate sweep set to read before
writing the proposal (17): cli/reference.md, harness-contract/{api,integration-checklist,
manifest,overview,patterns}.md, instances/overview.md, lifecycle/overview.md,
messaging/{overview,wax-seal}.md, networking/monics.md, reference/json-shapes.md,
shells/{frames,getting-started,overview}.md, SUMMARY.md, terminal/overview.md.

Not all 17 are sweep targets — that list is the grep's answer, and narrowing it to the rows
that actually enumerate the taxonomy is a read I still owe. Recording the full set so the
narrowing is visible rather than assumed.

Note `lifecycle/overview.md` is in the set: boot/clear/compact are lifecycle facts, so the
proposal likely has to touch BOTH the shells taxonomy and the lifecycle doc, which is itself
weak evidence for the "boundaries are not agent IO" side of the B1/B2 tension above.
