# CORE-054-MIGRATION-PLAN — adapter surfaces refused by spt-core 0.54.0

> **Trigger:** deployah flagged at the 0.54.0 tag (2026-08-04), per the standing arrangement that
> core-side contract breaks are flagged at tag so the adapter update rides my schedule rather than
> being discovered by a refused agent. Tracks the private home's issue #9 (knock half) **plus a second surface
> that was not in #9's scope**: endpoint lifecycle.

## The hard constraint that shapes this plan

**This node serves spt-core 0.53.0** (`spt --version`, verified). 0.54.0 is *tagged*, not installed
here. Two consequences, both binding:

1. **Every shape below is READ, not OBSERVED.** It comes from deployah's message — the contract
   statement of the agent who tagged it — and not from a doc I can read or a command I can run. Per
   F-044, I will not read the 0.54.0 contract off my own `localhost:5474`: the docs server is the
   daemon, so it publishes 0.53.0's vintage. A zero or a shape read there is a claim about the
   installed release, never the current contract.
2. **Nothing here is verified until this node serves 0.54.0.** The gate for calling this done is a
   re-run of every changed shape against a 0.54.0 daemon, not a green `traceable-reqs check`.

## Why this cannot simply ship when it is written

Skill bodies are static text, so mixed-version is **symmetric badness**: teaching the old shapes
refuses on 0.54.0, teaching the new shapes refuses on 0.53.0. Two rejected options:

- **Floor-bump `min_spt_core_version` to 0.54.0** — REJECTED. It currently reads `0.27.0`; bumping it
  would refuse the adapter on this very node today, mid-rollout, to fix a break that has not reached
  the fleet yet. Bricking the present to pre-empt the future.
- **Ship on authorship** — REJECTED for the same reason in the other direction.

**Taken — REVISED 2026-08-04 after deployah's rollout answer, and the revision is the better plan.**
The original was "author now, hold the publish for the rollout slot." That is now **abandoned**: the
rollout is *days*, gated on operator availability neither of us controls, on top of a 0.53.0 roll that
is still half done — HFENDULEAM (this node) refused its finish with `UPDATE_FINISH_REFUSED`, because a
broker-stopping command is never run from an spt endpoint session and no flag overrides it. Holding a
release for a date nobody can name is worse than shipping text that serves both builds.

**So: no release is held.** Every surface here is made **version-agnostic** instead, and ships
immediately:

- Prose that merely *named* a verb drops the name (`ready.md`, `live-hosted.md`: "launched by the spt
  daemon" — the CLI verb was never the point). Correct on every build, past and future.
- The user-facing `[update].message` points at `spt endpoint --help` rather than naming a verb.
- `knock.md`, which genuinely must teach exact flags, teaches **both** spellings in a table keyed on
  0.54.0. There are no aliases in either direction, so the agent must pick by build anyway — and the
  `approve` change is behavioural, not cosmetic, so a single-version body would be wrong on one of
  them no matter which it picked.

⚠️ **Do not "finish" this by re-pinning the prose to the new verbs once rollout completes.** The
version-agnostic wording is the durable answer; re-pinning it just re-arms this same migration for the
next lifecycle rename.

**A note on the version predicate**, from deployah and worth carrying: the signal is the **RUNNING
broker image per node**, not the installed version. Two of five nodes have read one version and run an
older one — this node reads 0.53.0 while its broker runs 0.52.0. `spt --version` reports the install.

## Blast radius (measured by grep over the tree, 2026-08-04)

Confined to prose. **No consumer in the adapter binary** — verified: nothing shells `endpoint run`,
and nothing reads the `knock list --json` `mutual` key that 0.54.0 renames to `send_receive` (that
rename is silent for a reader of the old key, so it was worth searching for and is worth recording
as searched).

| Surface | Shape | On 0.54.0 |
|---|---|---|
| `adapter/strings/skills/knock.md` ×5 | bare `knock <target>`, `--mutual` (send), `--mutual`/`--one-way` (approve), bare `redeem` | **refuses** |
| `adapter/claude-spt.toml:821` — the `[update].message`, user-facing | "`spt endpoint run` — the spt CLI route" | **refuses if followed** |
| `adapter/strings/skills/ready.md:5`, `live-hosted.md:5` | descriptive "(daemon-launched, `spt endpoint run`)" | misleads only |
| `ci/launcher/bind-int.sh`, `ci/subnet/multi-subnet-bringup-int.sh`, `ci/idle-translate/wake-survival-int.sh` | `spt endpoint run --start` | **gates go red** |

Historical records (`docs/adr/`, `docs/plans/`, `docs/SPT-CORE-FINDINGS.md`) mention `endpoint run`
as the contract of their day. Those are **not** edited — a decision ledger records what was true when
it was decided, and rewriting it to match today's CLI destroys the record.

## The 0.54.0 shapes (as stated by deployah)

Knock — the flag describes the **asking** side; pre-authorizations armed under the old flags are
untouched and still honored:

- bare `spt knock <target>` requires exactly one of `--send-only` / `--send-receive`. No default.
- `--mutual` / `--one-way` are parse errors anywhere. No deprecation aliases.
- `approve` / `new-code` take **no** directionality flag at all — the counter-knock is *removed*, not
  renamed. Reaching someone you approved is now a knock **back**.
- bare `spt knock redeem` also requires one of the two flags.
- `knock list --json`: `mutual` → `send_receive`.

Endpoint lifecycle — `spt endpoint run` is retired, no aliases; one verb per step
(`create` mints only · `start` starts a session · `resume` resumes the most recent · `auto-start`
sets the startup default · `spt go` brings up and attaches). Also retired: `--save`, and
`--id`/`--subnet` on the later verbs. **Fresh bringup is now two commands** (`create`, then `start`
or `go`) where `run --start` was one.

## Status — 2026-08-04

- **T1 knock.md — DONE**, as a both-builds version table (not the 0.54.0-only rewrite first drafted).
- **T2 the fail-open gate — DONE**, and it was the highest-value item on the list. Pinned as
  `REQ-HAZARD-VERSION-GATE-FAIL-CLOSED` with a mutation-checked unit (RED against the old gate, GREEN
  against the fix, honest pre-v0.14.0 skip preserved).
- **T2b the int ports — DONE for two of three, and the split is deliberate.** Q3 answered, so
  `ci/lib/spt-bringup.sh` now holds one positive-control'd shape probe and one bringup for every
  caller. `bind-int.sh` and `wake-survival-int.sh` route through it, because there bringup is a
  **means** and any working spelling serves. `multi-subnet-bringup-int.sh` is **deliberately left
  failing closed**: its home-assignment *refusal semantics* ARE the test, and on 0.54.0 that decision
  moved to `create` — porting it means re-deriving which verb refuses what, which I cannot exercise
  below 0.54.0. Authoring a green-looking assertion I cannot run is how the original defect shipped.

  What Q3 settled, and it made the port far cheaper than feared: **`ENDPOINT_RUN_STARTED:<id>` is
  unchanged** — the verb moved, the token did not, so every confirm-match stands (same for the
  `ENDPOINT_RUN_FAIL:`/`_NOT_HARNESS:`/`_ADAPTER_UNREGISTERED:`/`_DAEMON_FAIL:`/`_SESSION_TIMEOUT:`/
  `_NOT_MEMBER:` family). `create` adds `ENDPOINT_CREATED:<id> adapter=<a>`. `--adapter` and `--cwd`
  are on both `create` and `start`; `--subnet` is create-only and **required on this node** (3
  subnets).

  ⚠️ **The `create-start` branch is AUTHORED, NOT EXERCISED** — every node runs < 0.54.0, so only the
  `run` branch executes today and it is byte-for-byte the previous behaviour. **The gotcha it encodes,
  which would otherwise have cost a confusing debug:** `start` lands on the endpoint's *remembered*
  project folder, never the caller's cwd, so `wake-survival-int.sh`'s `( cd "$CWD" && … )` would have
  silently stopped carrying and dropped the probe elsewhere, failing as though bringup were broken.
  The cwd is now passed explicitly. Also noted for later: a **`wake`** verb exists for an endpoint
  that is RESTING rather than offline — `start` is not the verb for those.
- **T3 manifest — DONE** for the `[session.resume]` comment (Q1 answered: role alive, `{session_id}`
  still filled) and the `[update].message`. Remaining `endpoint run` mentions in manifest comments
  are internal descriptions of a seam, carrying no refusal risk; they say `endpoint run` because that
  is what the seam was called when written.
- **T4 — DONE.** Q2 answered: `spt api listen --subnet` is untouched (`ApiCmd::Listen` still declares
  `subnet`, doc unchanged). **Keep teaching it** — the retirement is scoped to the lifecycle verbs,
  where `--subnet` is now create-only, "because the home is assigned at creation and is permanent, so
  a later verb must not appear to re-home."
- **NOT MINE TO FIX — reported, accepted, and it will ship stale on purpose.**
  `adapter/manifest.schema.json:228` repeats the false `--resume <session>` claim, but that file is
  **vendored from spt-releases** — spt-core's published surface, which our manifest gate validates
  against. Patching our copy would drift from upstream and hide their bug. Deployah traced it to a
  Rust doc comment (`spt-runtime/src/manifest.rs:245-248`) that generates the published schema
  description, confirmed it false at 0.54.0, and is filing it — but it will **not** respin v0.54.0,
  because a golden-compiled doc string is not worth a full golden window (the v0.51.0 precedent:
  severity decides urgency, never which sha carries the fix). **So the schema shipping with 0.54.0
  carries a known-stale sentence.** Do not "re-fix" it here when it shows up in a future audit; it is
  recorded, owned, and queued upstream.

## Found while verifying — NOT a 0.54.0 issue, and not this plan's to fix

`ci/idle-translate/translate-proof-int.sh` FAILS on this node, and it is **ours, pre-existing, and
unrelated to the migration**. Isolated before being reported: the pre-work version at `f187fe1` fails
identically when run **in place**, so no edit here caused it.

- The failing assertion is `[<int>->REQ-DIST-IDLE-MULTILINE]`, expecting a framed `<EVENT>…</EVENT>`
  envelope — `>\n` after the opening tag and `\n</EVENT>` before the close.
- What the adapter emits now is a single-line **stub**, `<msg from="ci"/>`. That is the type-agnostic
  stub delivery of v0.20.0 (ADR-0007) / v0.25.0: the body is parked and drained by the hook rather
  than injected whole. So the int asserts a contract the adapter **deliberately replaced**.
- It stayed invisible because `ci/run-gates.sh` runs only the idle-translate *build*, never this int —
  an acceptance int nobody had cause to run since the stub migration.

**RESOLVED same session — and the answer was neither "retire it" nor "the binary is broken".**
`REQ-DIST-IDLE-MULTILINE` is **still live**; the int was pointing a correct assertion at the wrong
input. Stub+park replaced full delivery as the DEFAULT, not as the only path:
`REQ-STUB-FULL-INJECT-OVERRIDE` states an overridden delivery is "byte-identical to the pre-stub world
— multi-line framed envelope, full choreography", and `REQ-STUB-GENERAL-EVENT` keeps a whole
ineligible class (EVENT-PART, from-less, empty/whitespace body, truncated) that "delivers full and
never parks". Framing is a property of FULL delivery, which still happens.

The int fed `<EVENT type="msg" from="ci">body</EVENT>` — now stub-eligible — and then asserted framing
that only full delivery produces. Verified empirically before changing anything: a from-less envelope
through the same `translate-proof` command emits
`"<EVENT type=\"msg\">\ntranslate-proof int probe\n</EVENT>"`, framed exactly as the requirement says.

Fixed by driving the framing assertion through a second, stub-INELIGIBLE run, and adding the two
assertions that were missing entirely: that the default path really does stub, and that the ineligible
path really does not. The assertion is not vacuous — pointed at a stub-eligible event it fails, which
is precisely the red this was found as. **Net: an int that had been red-but-unrun since the v0.20.0
stub migration now covers both paths, where before it covered neither correctly.**

⚠️ Note the general shape: **a requirement whose int is never run by the gate is not covered**, no
matter how green the suite reads. Sibling of the v0.26.1 finding, where a `[[hints]]` table counted as
evidence for a feature whose consumer call did not exist.

## CLOSED 2026-08-04 — verified on a fully-0.54.0 node

The daemon was restarted, so **both images now read 0.54.0** (`coordinator image: 0.54.0`,
`broker image: 0.54.0 (matches installed)`) — the running-image predicate, not the installed version.

1. **`create-start` VERIFIED END TO END.** A real bringup through `sptc_bringup` emitted
   `ENDPOINT_CREATED:<id> adapter=claude-spt`, then `ENDPOINT_RUN:<id> … pid=…`, then
   `ENDPOINT_RUN_STARTED:<id>` — the started-token unchanged across the rename, exactly as stated.
   The harness self-bound and the perch record landed (measured separately: `info.json` on disk ~2s
   after `start`).
2. **`multi-subnet-bringup-int.sh` PORTED and GREEN**, Cases 1/2a/2b/3 including the acceptance E2E.
   The `MULTI_SUBNET_HOME:<id>: this node holds 3 subnets … pass --subnet` refusal simply moved to
   `create`, token unchanged. **Note for any future caller: that refusal exits rc=0 on stdout** — an
   exit-status check reads it as success, so the token is the only sound test.
3. Nothing disagreed with what deployah reported off the tag.

### Two more stale probes found while closing this out — same class, neither about 0.54.0

Both were in `multi-subnet-bringup-int.sh`, and both would have reported a fact about the NODE that
was really a fact about the PROBE:

- **The multi-subnet detector** grepped `spt endpoint list` for `^SUBNET ` and counted field 2. That
  output shape is long gone (membership renders as `Joined subnets: A, B, C`), so it returned **0 on
  this 3-subnet node** and the whole suite would have skipped announcing "node holds 0 subnet(s)".
  Now reads `spt subnet status` and asserts the row SHAPE (name + two integer columns), and a zero
  count FAILS as unreadable rather than skipping as "single-subnet".
- **Case 3's bind poll** watched `spt endpoint list` for `alive=true` — a token that view never emits
  (it renders `ONLINE + CONTROLLED`); `alive=true` belongs to `spt daemon status`. So it matched
  nothing for 40s and reported "never bound — regressed to the no-perch gap" about a bringup that was
  fine. Its sibling `wake-survival-int` already read the right source; only this one drifted. Now
  polls `daemon status` AND asserts the perch record on disk.

**That is four stale probes in one file**, counting the bringup-shape gate deployah caught and these
two. The lesson is not "fix the greps" — it is that **a probe pinned to a rendered human view rots
silently every time that view is reworded**, and its rot always presents as a confident claim about
the system rather than an error. Prefer machine tokens (`MULTI_SUBNET_HOME:`, `ENDPOINT_CREATED:`)
and structural assertions over substrings of a display.

### Teardown rule adopted (operator, 2026-08-04)

Disposable endpoints are torn down with **`spt endpoint purge <id> --force`**, never `rm -rf` on the
owlery perch dir. Purge removes the whole record set (perch tree, context branches, registry + trust
rows) and stops the endpoint first; an `rm` deletes only the visible tree, strands the rest, and would
hide a FAILED purge by destroying the evidence. The legacy `rm -rf` was stripped from `bind-int.sh`
and `multi-subnet-bringup-int.sh`. Verified after this run: no probe endpoints and no orphan perch
dirs remain.

## Open questions — asked of deployah, blocking their own tasks only

- **Q1 (blocks T3, the manifest half).** My manifest declares `[session.resume]`, a *harness-contract
  role* that core selects over `[session.self]` on a resume bringup and substitutes `{session_id}`
  into. Deployah's "resume is latest-only, a session argument is REFUSED" is stated about the **CLI
  verb**. If it also governs the role, that role is now dead or wrong — and omitting it used to fall
  back to `[session.self]` silently, i.e. a fresh session with a blank transcript. Does 0.54.0 still
  populate `{session_id}` for `[session.resume]`?
- **Q2 (blocks T4).** Does the `--id`/`--subnet` retirement reach `spt api listen --subnet`? The live
  and ready skills teach `--subnet` there for `HOME_REFUSED` on multi-subnet nodes. Different noun,
  so probably untouched — but "probably" is the word this plan exists to avoid.

## Tasks

- **T1 — `knock.md` to the 0.54.0 shapes.** Rewrite the 5 lines. `REQ-SKILL-KNOCK` needs a **title
  amendment**, not just a body edit: its title enumerates "bare target = `send`" as the published
  CLI, which 0.54.0 falsifies. Record in the title that the directionality flag is mandatory and that
  the approve-side counter-knock is gone.
- **T2 — the three CI ints.** `endpoint run --start` → `create` + `start`. These self-gate on core
  version already (the subnet int skips on `<0.14.0`); extend that pattern so each int picks its
  bringup shape by version and stays green on **both** 0.53.0 and 0.54.0 rather than flipping. A gate
  that only passes on the version I happen to run is how a stale musl binary ships.
- **T3 — manifest.** The user-facing `[update].message` line, plus the `[session.self]` /
  `[session.resume]` comment blocks. **Held on Q1.**
- **T4 — descriptive mentions.** `ready.md:5`, `live-hosted.md:5`. **Held on Q2** only for the
  `--subnet` half; the `endpoint run` half is free.

## Gate

`sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0 before any commit lands — and, before this
plan is closed, **every changed shape re-run against a 0.54.0 daemon on this node**. A green gate
proves the tree is consistent with itself, not that the shapes are real.
