# REBOUND-PLAN — M1: the rebound shell (JIT)

> The first milestone: a shipping `kind = "shell"` spt-core adapter that keeps an autonomous
> agent from silently stalling. Vocabulary is authoritative in `CONTEXT.md`; the cross-project
> dependency is `docs/adr/0001`. This plan is the build path for everything *non-core* plus the
> integration point for the one core dependency.

## Goal

Bind a **rebound** instance to an agent endpoint; while its **rebound latch** is raised, a
**busy→idle** edge arms the **rebound timer**, and on elapse rebound fires a **ping** (an
`spt send --idle-only --ephemeral` back to the **bound endpoint**) — repeating on a widening
backoff until the agent moves. The agent steers it with **rebound codes** (`!!done!!` → **pause**,
`!!wait=m!!` → **delay**) that rebound reads from the bound endpoint's session digest, or with the
`pause` / `delay` capability verbs. rebound (re)educates the agent on each context boundary.

## The state machine (per bound endpoint)

Tracked: `latch ∈ {raised, lowered}`, `activity ∈ {busy, idle}`, `timer ∈ {armed(deadline), off}`,
`backoff_step` (index into the curve), `acted_seq` (digest watermark).

| Event | Action |
|---|---|
| **busy→idle** edge | if latch raised → arm timer at `now + base(60s)`, reset `backoff_step=0`, start digest-poll (catch a trailing code). if lowered → stay quiet. |
| digest yields **`!!done!!`** / `pause` verb | lower latch, disarm timer, stop poll |
| digest yields **`!!wait=m!!`** / `delay m` | keep latch raised; set deadline = `idle_edge + clamp(m,1,120)·min` (supersedes the current interval, does not stack). **If the owner still reads busy** (the code committed at Stop, ahead of the idle edge — the normal case): **hold** it, and arm the next busy→idle edge at `edge + m·min` instead of base. `REQ-HAZARD-PACER-DELAY-PRE-IDLE` |
| **idle→busy** edge | disarm timer, **re-raise latch**, `backoff_step=0`, stop poll |
| **timer elapses** (idle, latch raised) | fire **ping**; then re-arm at `now + curve[min(++backoff_step, last)]` |

> **RULED (operator, 2026-07-26) — auto-undo on the next `idle→busy` edge is AS INTENDED.**
> `!!done!!` promises "quiet for *this* idle episode", not "quiet until the human returns",
> and the field window that raised the question was correct behavior, not a defect. The
> rationale is the premise the alternative got wrong: **a peer agent is just as likely to
> unblock an agent as its operator is**, so a peer message flipping it busy is not
> "incidental" — it is a real chance the block has cleared, and the pause should lift so the
> next quiet interval can nudge. Holding the latch down until the owner produces a new digest
> `Agent` entry would privilege one unblocking source over an equally likely one, and buy
> wedge risk for it. A still-blocked agent re-emits the code; that is cheap and cannot wedge.
> The table above ships as written. Background + the rejected option:
> `docs/FIELD-VERIFICATION-0.42.0.md`.

- **Backoff curve:** `[60s, 2m, 5m, 15m]`, last value is the cap. A **delay**-driven ping reverts
  to base backoff afterward (the delay bounds one wait, it is not a new cadence).
- **No hard give-up, no escalation** — a wedged agent decays to a 15m background poke; rebound
  never signals outward (ADR-0001 / Q1 purity).

## Timing (fixed — not runtime-configurable)

- base interval **60s**; backoff **60s→2m→5m→15m cap**.
- digest-poll runs from the idle edge until the last turn is **non-`partial` and unchanged for one
  tick**, capped **~30s**; cadence **~5s**. (Stop on *stability*, not a fixed clock — a late
  commit under load must not drop a code.)

## Digest parse rules

- **Source:** any bound-endpoint digest `Agent` entry carrying a committed `seq`; act on each code
  **at most once**, keyed on `seq` (watermark → idempotent across poll ticks and older episodes).
  No trailing-position requirement.
- **Quotation-guard:** a token inside backticks / a fenced code block is **ignored** (primary
  false-trigger guard).
- **Recognized forms only:** `!!done!!`, `!!wait=<int>!!`; anything else ignored.
- **`m` clamp** `[1, 120]` minutes. **Multiple new codes → highest-`seq` wins.**

## Manifest / binding

```toml
[adapter]
name = "rebound"
kind = "shell"
version = "0.1.0"
min_spt_core_version = "0.42.0"   # enforced floor; 0.42.0 carries the activity push

[shell]
spawn = "rebound-shell --link {link_token}"
persistent = true            # up whenever the owner is
ephemeral = false
command_receipt = "relay"    # drain `api poll --link`
broadcast = "none"           # owner-private; nobody discovers a rebound
max_instances_per_owner = 1  # two rebounds = duelling pings
require_approval = "none"    # only reads its own owner's local digest + sends to its own owner
# no wake_command — offline rebound means offline owner means nothing to nudge

[shell.capabilities]
pause = {}
delay = { args = ["minutes"] }
status = {}                  # read-only; reports state back to the bound endpoint

[update]                     # published manifest chapter, `[update]`
avenue = "gh_release"        # this repo's own releases carry the .spt
repo = "BigscreenVR/spt-rebound-tool"
transport = "gh"             # the home repo is PRIVATE — plain HTTPS 404s
asset = "adapter.spt"
```

- Verbs are `pause` / `delay` / `status`, driven at the **instance**:
  `spt shell cmd rebound-0 <verb>`. **Corrected 2026-07-26 (observed):** an earlier draft of
  this plan claimed the adapter name `rebound` works here as an instance alias. It does not —
  `spt shell cmd rebound status` returns `NO_SHELL:rebound: no such instance for perri`. The
  `<SHELL_REF>` argument is a canonical instance id or an instance alias; the adapter name is
  neither. (`spt shell spawn rebound` does take the adapter name — that is the one place it
  belongs, which is what made the claim plausible.)

<!-- [doc->REQ-PACER-STATUS] -->
## status — the read verb

`status` answers "what does this rebound think is going on?" — the question the 0.42.0 field
verification had no way to ask, and paid for in wall-clock (every "did it arm?" was answered by
waiting to see whether a ping arrived). It reports the **rebound latch**, the bound endpoint's
activity and idle duration, the **rebound timer** (off or a countdown), the backoff step and the
interval it re-arms at, and the last **rebound code** `seq` acted on.

- **Read-only.** It is not a `Control` — nothing in the machine mutates. It sits behind the same
  MAC gate as every other command frame (`REQ-HAZARD-PACER-FRAME-MAC`).
- **Channel:** `spt send --active-only <owner>`, the same inward channel as education. A
  broker-launched resident's stderr is discarded by design, so its own bound endpoint is the only
  place it can answer — and answering there keeps the pure-inward-nudge purity (ADR-0001 / Q1).

<!-- [doc->REQ-PACER-EDUCATION] -->
## Education (rebound-owned)

- Channel: `spt send --active-only <owner>` (surfaces on the agent's poll, never wakes).
- Trigger: once on first online with the owner, and again on **every new digest `Boundary`**
  (`clear` | `compact` | `boot`) — post-reset amnesia is exactly when the reminder is needed.
- Content: brief — a rebound is pacing you; idle >60s → a ping; `!!done!!` when blocked on a
  human, `!!wait=m!!` when waiting on a peer or an ETA-bounded task; resuming work re-arms it.
  Say plainly that `!!done!!` covers **this idle stretch only** — anything that puts the agent
  back to work, a peer message included, re-arms the pacing, and a still-blocked agent re-emits.
  The field agent that hit this had read the code as open-ended (2026-07-25 window); the wording
  is what has to carry the ruled meaning, since the behavior is not changing.

## Ping payload

> ⟳ **rebound** — idle ~{elapsed} (as of {HH:MM YYYY-MM-DD}, local tz). Proceed to check on any
> ongoing tasks or fellow agents whose work you're waiting on. If you're blocked on a human, emit
> `!!done!!` to stop these nudges for this idle stretch — going back to work re-arms them, so
> re-emit it if you are still blocked. If you're waiting on a peer or a task with an ETA, emit
> `!!wait=m!!`.

- Both **relative** (~elapsed) and **absolute** (`HH:MM YYYY-MM-DD`, local tz) time.
- **Never** an instruction to sign off (`REQ-HAZARD-PACER-NO-SIGNOFF`) — signoff is user-only.
- Self-trigger-safe: the ping arrives as an inbound (`Context`-kind) entry; rebound parses only
  `Agent`-produced entries, so it cannot read its own codes.

## The one core dependency

`REQ-PACER-ACTIVITY-SEAM` — the spt-core activity seam (ADR-0001). Build the activity source as
a **swappable interface** (mock edges now); drop in doyle's contract with one adapter. This is the
only thing that blocks the core loop; escalate to doyle only if it becomes critical path.

## Tasks

1. Repo skeleton: `Cargo.toml`, `manifest.toml` (above), resident-binary `src/main.rs` shell
   (bind via `api bind-shell --link`, drain `api poll --link`) — model on `spt-shell-notify`.
2. Activity-source trait + **mock** implementation (edge injection for tests).
3. State machine (latch / timer / backoff) against the activity source.
4. Ping emit (`spt send --idle-only --ephemeral`, timestamped body).
5. Digest reader + parser (`endpoint digest <owner> --last N --json`; seq-dedup; quotation-guard;
   clamp).
6. Capability verbs `pause` / `delay` / `status`.
7. Education sender (`--active-only`; boundary detection from digest).
8. Hazard tests: `FALSE-TRIGGER`, `CODE-REPLAY`, `NO-SIGNOFF`.
9. Integrate the real seam when it lands (`int` stage for `REQ-PACER-ACTIVITY-SEAM`).

## Requirement activation (do at build-start, not now)

Activate M1 reqs in `traceable-reqs.toml` to `["doc","impl","unit"]` (+`"int"` for
`REBOUND-ACTIVITY-SEAM` and the hazards' cross-process legs) as evidence lands. Doc-stage evidence
already exists (`CONTEXT.md`, ADR-0001, this plan) — tag it when activating.

## Gate

`cargo build` + `cargo test` green **and** `traceable-reqs check` green (activated reqs covered).
