# Field verification — the activity seam on stock spt-core 0.42.0

> **What this is:** the record of the live, end-to-end verification of
> `REQ-PACER-ACTIVITY-SEAM` against the *published* spt-core activity seam
> (ADR-0001 here; spt-core ADR-0048 there), run on 2026-07-25 on the M1 rig.
> This is the `int`-stage evidence: it is what promoted the requirement from
> `["doc","impl","unit"]` to `+["int"]`.
>
> **Why it is written down:** the seam is a cross-project contract. When it
> changes, the next agent needs to know exactly what was observed on the wire
> the day it was accepted — not a summary of what it was supposed to be.

## Environment

| | |
|---|---|
| `spt --version` | `0.42.0` (stock published; publish counter 76, `main @8492c75`) |
| binary | `C:\Users\decid\AppData\Local\spt-core\bin\spt.exe` |
| rig | `rebound` adapter registered from a scratch manifest whose `spawn` points at the built `rebound-shell.exe`; one instance `rebound-0`, owner `perri` |

<!-- [doc->REQ-PACER-ACTIVITY-SEAM] -->
## The seam, as observed

Leg A of the seam is a **push**: the daemon watches the owner's idle sentinel
and writes an activity frame into the shell's existing ephemeral drive slot,
which the shell drains with `api drive-poll <shell-id> --link <tok>`. It is
link-scoped — the owner is implied by the link token, so there is no addressing
and no new auth surface.

Verbatim stdout from a live drive-poll:

```
<EVENT type="activity" from="perri" state="busy" since="1784979822108"></EVENT>
```

That is the vocabulary settled on 2026-07-24, unchanged: `type="activity"`,
`from=<owner>`, `state=busy|idle`, `since=<epoch-ms the state took effect>`,
un-MAC-stamped, latest-wins, current-state-carrying. `frame::parse_activity`
decoded it with **no change from the mock-era code** — the swap ADR-0001
budgeted for was a one-line source substitution.

### Confirmed properties

- **Drive-class, not a transition log.** The frame carries current state; edges
  are ours to derive. `DrivePollActivitySource` filters a redundant same-state
  resend against last-seen.
- **Re-emit on (re-)link.** Re-running `api bind-shell --link <same tok>` made
  current state available on the very next poll. A restarted binary or daemon
  resynchronizes with no machinery on our side.
- **`since` is the take-effect instant, not the observation instant.** A poll at
  `1784980232098` returned `since=1784979822108` — a 410-second-old flip
  instant, which is when the owner actually went busy. Observation latency
  subtracts straight back out, so the countdown anchors to `since` with no
  timeline mapping (latency promise #4). A late-observed edge is therefore
  already overdue and self-corrects, as `machine::countdown_anchors_to_transition_timestamp`
  assumes.
- **Exactly-once.** A second poll after taking a frame returns nothing.

### Stream layout (the thing most likely to bite a consumer)

**stdout carries frames and nothing else.** The status lines are **stderr**:

| stream | content |
|---|---|
| stdout | `<EVENT …>` frame lines only; a poll with nothing pending writes **0 bytes** |
| stderr | `ACTIVITY_DELIVERED:rebound-0 owner=perri`, `DRIVE_EMPTY:rebound-0 (no pending drive)`, `BOUND_SHELL:…` |

A merged stream (`2>&1`) makes the status lines look like interleaved stdout
content and invites a parser that counts lines. Production is safe by
construction — `spt_capture` reads `Command::output().stdout` only — and the
durable defence is the documented posture: **switch on the `type=` attribute
and ignore every line you do not recognize.** That survives either layout.

## End-to-end: idle edge → ping

The point of the seam is not the frame, it is whether the loop closes. Run on
the owner's own turn boundary, with the resident binary bound and no other
process able to wake the owner:

1. The owner (`perri`) finished a turn and went quiet.
2. The daemon pushed the idle frame; the resident drained it, derived the
   busy→idle edge, and armed the rebound timer anchored to the frame's `since`.
3. ~60 s later (the base interval) the resident fired exactly one ping:

```
⟳ rebound — idle ~1m (as of 04:58 2026-07-25, local tz). Proceed to check on any
ongoing tasks or fellow agents whose work you're waiting on. …
```

4. The ping woke the owner, which flipped it busy again — disarming the timer
   and re-raising the latch, so that *one* idle window produced exactly one
   nudge rather than a stream.

**One turn-end produced exactly one ping: not zero, not several.** This is the
"seal on idle" fix doing its job — the idle state materialized on its own, with
no further command driven at the endpoint to shake it loose.

### It repeats, once per turn-end — verified separately

An earlier draft of this document said the nudge "did not repeat" and left that
unqualified. That was misleading: at the time, the resident had been killed to
free the drive-poll drain for another test, so nothing was left alive to ping
again. **Absence of a second ping was the rig, not the design.** The correct
statement is that a *single idle window* yields a single ping; a *new* turn-end
yields a new one, indefinitely, until the latch is lowered by `pause` or the
done-code.

Verified against one resident (pid 17056) across two consecutive turn-ends:

```
⟳ rebound — idle ~1m (as of 05:26 2026-07-25, local tz)
⟳ rebound — idle ~1m (as of 05:27 2026-07-25, local tz)
```

Each ping woke the owner; each wake flipped it busy, disarming the timer; each
subsequent turn-end re-armed and fired again about 60 s later. **Both fired at
the base interval** — had the backoff continued widening, the second would have
been ~2 m — which is the live confirmation that an idle→busy edge resets
`backoff_step` to 0, matching `machine::idle_busy_resets_backoff`.

The widening curve (60 s → 2 m → 5 m → 15 m cap) governs an owner that stays
idle *through* a ping, i.e. one that never woke. It is not reachable by this
test precisely because the ping succeeds at waking a live agent.

> **REACHED, unplanned, on 2026-07-26 — the backoff widens live.** The condition
> this test could not construct arrived on its own: the owner finished the v0.2.0
> release and went idle *blocked on its operator*, so the pings landed in an
> inbound nobody was turning on. Two consecutive nudges from the
> release-installed `rebound-0` on spt-core 0.43.0:
>
> ```
> ⟳ rebound — idle ~1m (as of 03:53 2026-07-26, local tz)
> ⟳ rebound — idle ~3m (as of 03:55 2026-07-26, local tz)
> ```
>
> First at the 60 s base; the re-arm took `CURVE_MS[1]` = 2 m, putting the second
> at ~3 m of elapsed idle. That is the curve, not the base — the discriminator
> the two-ping run above turned on, read the other way. `machine::unanswered_pings_widen_to_cap`
> now has its live counterpart, and `REQ-PACER-BACKOFF`'s widening leg is
> observed rather than only unit-proven.
>
> Both bodies carried the episode-scoped `!!done!!` wording, from the shipped
> binary — the third independent confirmation of the ruling's wording change in
> the release bytes (after the education blurb and the `grep` over all five
> assets).

### Every transition, exercised live

By the end of the window the whole machine had run against the real seam rather
than the mock:

| transition | live evidence |
|---|---|
| busy→idle arms the timer | ping fired ~60 s after a turn-end |
| timer elapse fires the ping | `idle ~1m` delivered to the owner, twice |
| idle→busy disarms + resets backoff | second ping at **base**, not the widened ~2 m |
| a **pause** code lowers the latch | bare done-code in turn text → ~2 min idle, **zero** pings, where 60 s had reliably produced one |
| a busy edge **auto-undoes** the pause | an unrelated peer message woke the owner; the next turn-end pinged again at base |

The pause leg is the one that had never been driven end to end before: the code
was read out of the owner's own session digest, matched, and acted on, with no
capability verb involved.

<!-- [doc->REQ-PACER-PAUSE] -->
### Open product question: what does the done-code actually promise?

The auto-undo is per the plan — `idle→busy` re-raises the latch — and it is
what stops a stale pause from muting rebound forever. But this window showed
the two readings of the code coming apart on a real agent:

`REBOUND-PLAN.md` documents the done-code as the **open-ended** "blocked on a
human" signal. What it delivers is "stop nudging me *for this idle episode*."
Those coincide only when the next busy edge means the agent genuinely resumed
work. Here it did not: the owner emitted the code while blocked on its operator,
was woken by an unrelated peer message, and was nudged again on the next
turn-end — still blocked, still waiting on the same human, latch re-raised by a
poke it did not ask for.

Neither behavior is obviously wrong, which is why this is a product call and not
a defect:

- **Keep auto-undo** (today): simple, self-healing, no way to wedge rebound
  permanently. Cost: an agent blocked on a human must re-emit after every
  incidental wake, and will not know it has to.
- **Make the pause survive a wake that is not the agent's own work** — e.g. hold
  the latch down until the owner produces a *new* digest `Agent` entry rather
  than merely flipping busy. Closer to the documented meaning; costs a sharper
  definition of "resumed work" and a way to guarantee the latch comes back up.

Until it is decided, prefer `!!wait=m!!` for a bounded wait — it is unambiguous
— and treat the done-code as episode-scoped, which is what it is.

> **RULED (operator, 2026-07-26): keep auto-undo. This window was correct
> behavior, not a defect.** The second option's premise is the part that does not
> hold — it treats a peer wake as "not the agent's own work" and therefore as
> noise, but **a peer agent is just as likely to unblock an agent as its operator
> is**. On that reading the peer message here was a genuine chance the block had
> cleared, so lifting the pause and nudging on the next quiet interval is the
> right response, not an unwanted poke. Holding the latch down until a new digest
> `Agent` entry appears would privilege one unblocking source over an equally
> likely one and buy wedge risk for it. The done-code is episode-scoped by design;
> a still-blocked agent re-emits, which is cheap and cannot wedge. What changes is
> only the **wording** that set the wrong expectation — the ping payload and the
> education blurb now say the code covers this idle stretch and that going back to
> work re-arms the pacing (`REBOUND-PLAN.md`, `CONTEXT.md`).

### A gap this exposed in rebound itself

There is no way to ask a running rebound what it thinks its state is — no
`status` verb, and the resident's stderr is discarded because broker-launched
shells are detached by design. Every question about "did it arm?" had to be
answered by waiting to see whether a ping arrived. A read-only `status`
capability (latch, timer deadline, backoff step, last-seen activity) would have
made this verification minutes rather than hours. Filed as future work.

> **CLOSED in 0.2.0.** `status` is a shipped capability verb
> (`REQ-PACER-STATUS`): `spt shell cmd rebound status` reports the latch, the
> endpoint's activity and idle duration, the timer countdown, the backoff step
> and its next interval, and the code watermark. It answers on the same
> `--active-only` channel education uses — the only place a broker-launched
> resident can answer — so the stderr problem is routed around rather than
> fixed. Re-running this verification should use it instead of waiting.
>
> **Driven live on 2026-07-26**, against a `rebound-0` installed *from the
> v0.2.0 release* (`spt adapter add --release … --gh`, release binary on PATH)
> on spt-core 0.43.0. `spt shell cmd rebound-0 status` spooled
> (`SHELL_SPOOLED:rebound-0 op=status`), the resident drained it on the relay
> and answered on the `--active-only` channel, verbatim:
>
> ```
> ⟳ **rebound** status — latch raised; endpoint busy; timer off; backoff step 0 of 3 (re-arms at 60s); last code acted: none yet.
> ```
>
> Correct for the moment it was asked — the owner was mid-turn, so the timer is
> off and the latch is raised. Round trip was under one poll tick.
>
> Two things fell out of that run and are recorded because they cost or would
> have cost time:
>
> - **`spt shell cmd` takes the INSTANCE, not the adapter.**
>   `spt shell cmd rebound status` → `NO_SHELL:rebound: no such instance for
>   perri`; `rebound-0` works. `REBOUND-PLAN.md` had claimed the adapter name
>   worked as an alias — corrected there.
> - **The education blurb that landed carried the new episode-scoped wording**,
>   from the release binary, which is independent confirmation that the ruling's
>   wording change is in the shipped bytes and not only in the source tree.

### Two contaminants worth knowing before re-running this

Both cost a false negative during this verification:

- **Background jobs of your own keep waking you.** A harness background task
  that completes re-enters the agent's turn, so the owner never stays idle long
  enough to cross the interval. Clear them before testing the edge.
- **Education fires on first online.** It is delivered `--active-only`, but it
  still lands at the turn boundary and can end the idle window early. Let the
  resident educate once, then test the edge on the *next* boundary.

## Pull surfaces (secondary, confirmed)

`spt endpoint list --json` carries an `activity` key with values exactly
`"busy"` / `"idle"` on entries in the **`local`** array. Entries under
`subnets` do **not** carry it — they carry `status` (`Active`/`Dormant`/
`Offline`) only. **Activity is a local-node observable.**

`activity` is not a liveness signal: endpoints with `alive: false` were observed
reporting `activity: "idle"` (`chert`, `webbie`). Gate on `alive` separately.

A 7-minute passive sample of that surface (read-only, nothing driven at any of
the endpoints) caught four independent agents sealing on idle by themselves:

```
{"t":1784979987005,"id":"todlando","from":"busy","to":"idle"}
{"t":1784980030887,"id":"doyle","from":"busy","to":"idle"}
{"t":1784980081910,"id":"flynn","from":"busy","to":"idle"}
{"t":1784980225147,"id":"liam","from":"busy","to":"idle"}
```

## Re-running the live check

The repeatable form of this verification is the `int`-tagged test in
`src/main.rs`, which drives the real `DrivePollActivitySource` against a live
bound shell. It is opt-in so env-free CI skips it (see `docs/CI.md`):

```sh
REBOUND_LIVE_SHELL=rebound-0 REBOUND_LIVE_LINK=<link token> cargo test live_seam -- --nocapture
```

Take the link token from the running instance's command line — the broker mints
it at launch and passes it to the spawn template.

## spt-core defects filed from this window

Neither blocks rebound; both are recorded because they cost real time here.

1. **Dead-on-arrival shell child leaves no trace.** `spt shell spawn` reports
   `SHELL_SPAWNED:… pid=<N> status=offline (online at bind)` even when the
   spawned child exits immediately. `CreateProcess` succeeded, so the launch
   "worked"; the child died on its own and, because broker-launched shells are
   detached with stdio discarded by design, nothing observes it afterwards. The
   perch just sits offline with no exit code, no stderr, and no diagnostic.
   Reproduced with an interpreter+script spawn template
   (`powershell.exe -NoProfile -File <abs>.ps1 {link_token}`, both slash
   flavors); a native `.exe` target worked first try. Seeded in spt-core as
   `SHELL-SPAWN-LAUNCH-TRUTH`.
2. **A killed resident wedges its instance `online`.** After killing the shell
   process, `spt shell list --json` still reported `status: "online"` and
   `spt shell relink rebound-0` refused with
   `SHELL_ALREADY_ONLINE:rebound-0: relink is the online switch — nothing to do`.
   There is no recovery path back to a running binary short of
   `teardown` + `spawn`. Same missing post-spawn liveness truth as (1), on the
   other end of the lifecycle.

**Adapter-author note (not a defect):** `[adapter].min_spt_core_version` is a
mandatory manifest field and has been since the schema landed —
`adapter add` refuses a manifest without it
(`ADAPTER_ADD_FAIL: manifest invalid: … missing field min_spt_core_version`).
This repo's `manifest.toml` was missing it and could never have registered;
fixed in the same change as this document.
