# Rebound depends on a new spt-core activity-subscription seam

## Status

accepted (2026-07-24) — the *contract* is fixed here; the *realization* is deferred to spt-core
(doyle) and will be recorded as an amendment when it lands.

## Context

**rebound** (see `CONTEXT.md`) exists to nudge an idle **bound endpoint** back into motion. Its
whole loop hinges on knowing *when* the bound endpoint crosses **busy→idle** (arm the **rebound
timer**) and **idle→busy** (disarm the timer, re-raise the **rebound latch**). rebound is a
`kind = "shell"` adapter built from the **published** spt surface only (the perri lane
constraint: public-surface-only, adapter-only, no spt-core source).

The problem: **nothing in the published surface exposes an endpoint's busy/idle activity to
another process.** Verified against spt-core 0.40.0:

1. `spt endpoint list`'s `state` field is the endpoint **type** (`live_agent` / `ready_agent` /
   `gateway`), not activity.
2. `spt api endpoint-info` omits activity entirely.
3. The `.idle` sentinel written by `spt api state busy|idle` is **in-process only**
   (`delivery::is_idle`, client-side) — no read surface, no push.
4. `spt endpoint digest --follow` streams **transcript** deltas, and the docs are explicit that
   *liveness comes from endpoint state, never from digest activity* — so the follow stream does
   not carry the edges either.

So rebound cannot observe the edges it is defined by. This is a genuine new-seam requirement, not
an oversight in how we're reading the surface.

<!-- [doc->REQ-PACER-ACTIVITY-SEAM] -->
## Decision

rebound consumes the bound endpoint's activity transitions through a **new spt-core seam**, whose
**contract** is frozen as four requirements (the *shape* is spt-core's to design, grounded in
spt-core's own model):

1. **link-scoped** — the shell's link token implies its owner (like `api emit` / `drive-poll`);
   no endpoint id is passed.
2. **both-direction transitions** — emits the owner's current activity state (`busy`/`idle`) on
   every transition; a redundant same-state resend is a harmless no-op (this is also how
   "restart-survivable" is satisfied: re-emit current state on re-link).
3. **event-driven** — pushed, not a fixed client poll (latency gates a countdown).
4. **restart-survivable** — survives a daemon bounce / relink.

rebound builds its activity source as a **swappable seam** (mock now → real contract later), so
every non-core part ships in parallel with spt-core's work.

**Rejected alternatives:**
- *Harness adapter drives the shell off its own `api state` calls* — couples rebound to each
  harness (claude-spt, others), breaking the harness-agnostic shell model.
- *rebound polls a read surface* — none exists; even a future `digest --json` activity field is
  edge-detection-laggy and would still be a new spt-core surface.
- *rebound is a native spt-core feature* — wrong layer, and it violates the adapter-only /
  public-surface-only constraint that defines this project.

Separately and at lower priority, exposing idle/busy in `endpoint digest --json` is requested as
an **independent seed** (general utility + a degraded-mode fallback), explicitly not allowed to
dilute the primary seam.

## Consequences

- rebound has a **hard external dependency** on spt-core: its core loop cannot ship until the
  seam lands. All non-core surface (latch, timer, ping, education, digest parse) builds now
  against the mock.
- New obligation: `REQ-PACER-ACTIVITY-SEAM` (activates with an `int` stage — it is
  cross-process).
- doyle has backlogged the seam as a named, operator-ranked seed; perri escalates only if it
  becomes the critical path.
- **This ADR fixes the contract, not the mechanism.** When doyle returns the chosen seam shape,
  amend this ADR (a new `## Amendment` note or a superseding ADR if the contract itself changes).

## Amendment — seam shape locked (2026-07-24)

<!-- [doc->REQ-PACER-ACTIVITY-SEAM] -->
doyle returned the chosen mechanism (operator-ruled). The four frozen requirements above all
hold; the lock adds the concrete shape:

1. **Home — the existing shell-link event stream.** Activity frames ride the stream rebound's
   drain already parses (`api poll --link`); link-scoped, owner implied by the link token, **no
   new verb**. The real `ActivitySource` is therefore one more frame decoder on the existing
   drain — no new channel.
2. **Semantics — drive-class ephemeral.** Latest-wins, current-state-carrying, a redundant
   same-state resend is a harmless no-op, never spooled or replayed. (rebound's state machine
   already no-ops same-state and is idempotent on re-emit.)
3. **Coverage — both directions + free resync.** `busy→idle` and `idle→busy`; the current state
   is emitted on every link establishment **and** re-link, so restart resync needs no extra code
   (re-feeding current state is an idempotent no-op).
4. **Latency — bounded observation, transition-timestamped.** Event-on-transition, sub-second
   class (not hard-real-time). Each frame carries the **instant the state took effect**, not
   emission time; rebound anchors its countdown to that timestamp, so emission/observation
   latency self-corrects. *(Built: the `ActivitySource` seam now yields `(Activity,
   transition_ts)` and the machine anchors the idle-edge / deadline to it.)*
5. **Pull avenue + digest dissolve.** Activity state also lands on `spt api endpoint-info` for
   non-shell consumers. The separately-seeded `endpoint digest` idle/busy rider **dissolves into
   that** (operator-ruled: the digest stays a pure content surface). rebound never read activity
   from the digest, so this is a no-op for its code — the digest reader is content-only.

**Frame vocabulary settled (2026-07-24), decoder built.** Leg A (the activity contract) is frozen:
`<EVENT type="activity" from="<owner>" state="idle|busy" since="<epoch_ms>"></EVENT>`, arriving on
the drive channel (`api drive-poll <shell-id> --link`), **not** MAC-stamped (the link token is the
whole-drain credential), latest-wins current-state-carrying (derive edges vs last-seen), `since` =
epoch-ms the state took effect. Built to it: `frame::parse_activity` decodes the frame,
`DrivePollActivitySource` drains the drive channel and derives edges, and the machine clock runs in
epoch-ms so `since` anchors the countdown with no timeline mapping. `REQ-PACER-ACTIVITY-SEAM` is
now `doc`+`impl`+`unit`.

**Still pending:** the live cross-process **E2E** against doyle's running seam (the `int` stage) —
doyle pings at seam publish for the mock→real swap on rebound's M1 rig. The frame vocabulary
publishes in the shell frames doc at that ship.
