# TURNKEY W3 — lane 2 (#159), adapter floors vs the NEW core — JIT plan

**Status: BUILT AND SHIPPED TO DOYLE'S GATE 2026-08-22.**
Lane `fix/159-adapter-floor-staged-core` @ **`e46d94c5`**, two commits off `f7d26dd5`, pushed.
Worktree `.worktrees/fix-159`, pool claimed from it.

  e46d94c5  fix(update): the floor a composite enforces belongs to the core it will activate
  27b63069  test(update): a composite judges adapter floors against the core it will activate

Diff vs base: cli.rs 398, traceable-reqs.toml 9, CONTEXT.md 4, docs/MANIFEST.md 11.

**Gates at the tip, all unpiped:** nextest `-p spt --bins --no-fail-fast` **658 run / 658 passed
/ 0 skipped**, 23.5s, no wedge · clippy `--all-targets -D warnings` exit 0, 0 warnings ·
`traceable-reqs check` **797 / 797 complete, 0 incomplete, 0 findings**, exit 0, the new REQ at
+doc +impl +unit · `xtask check` OK, exit 0.

**Witnessed red at commit 1:** 657 run / 655 passed / **2 failed** / 0 skipped. Arithmetic is the
inertness proof — 657 = 653 baseline + 4 new cells; 655 = 653 pre-existing + 2 green-by-design;
2 = the intended reds. Every pre-existing cell passes, unfiltered, zero skipped.

**Three cells were green-in-both-commits and all three have a witnessed failure on record**
(doyle's standard, applied to two cells he did not name): the peek/record property pin, the
no-stage degrade, and the (c) unreadable arm. Each scratched, observed, reverted from a pre-edit
copy, zero residue verified. The peek/record demo reproduced doyle's own ruled shape before its
correction.

**Owed at ship:** the deferred FIELD ACCEPTANCE leg — one real composite roll on a real box
landing new-core AND new-adapter — is ONE proof owed by #213 and #159 together, for the head's
never-executed-cells list. Neither lane gates it.

**Open with doyle:** the two operator markers shipped as `STAGED_CORE:` / `STAGED_CORE_UNREADABLE:`
(underscored, house style) where his confirmed wording used spaces. Sentences are his verbatim.

---

## Original plan below, kept as the record of how the shape was reached

**Base:** stacks on #213, NOT on main — doyle's cross-lane sequencing: a floor refusal must ride
#213's distinct-exit contract, and #213 is what gave `AdapterUpdateOutcome` its `Refused` variant.

**#213 IS GATE-APPROVED (doyle 2026-08-22)** — `fix/153-update-refusal-exit` @ **`f7d26dd5`**, all
four commits picked; assembly head **`f6a7e644`**, nineteen off `c62904e7`, treqs exit 0. Legs, no
exclusions: red @`652567ed` exit 101, 65 passed / 3 failed / 585 filtered = **68 matched** · green
at the tip **68 matched / 68 passed / 0 failed** · full unfiltered sweep **653 run / 653 passed /
0 skipped**, exit 0, 22.2s under nextest · clippy 0/0 · treqs 0 unpiped · xtask docs-drift 0.

**OPEN — which base:** the Refused variant now exists in TWO places, the lane tip `f7d26dd5` and
the assembly head `f6a7e644`, and those diverge the moment #173 is picked. Asked doyle; my
inclination is `f7d26dd5` for thin-lane diff readability, since all #159 needs from #213 is the
Refused variant and the distinct-exit contract. **Do not branch until he answers.**

**#173 is the last thing before #159** — gate next, rig is small (wiring cell + firewall units).

### Test-harness discipline adopted from #213's gate (doyle, IR-55)

- **Run the legs under `cargo nextest run -p spt --bins --no-fail-fast`, not `cargo test`.** This
  is not a workaround for a green — it is the discriminating measurement: `cargo test -p spt
  --bins` wedged that box indefinitely TWICE, the same 653 tests under nextest finished in 22s all
  green. The suite was never broken; the SINGLE-PROCESS harness is what lets one test's leaked
  `findstr` child block every test after it. Golden already runs nextest, which is likely why this
  never surfaced there.
- **Prebuild the `translate_proof_fixture` artifact** rather than carrying it as a known-red. That
  is the whole of my 652/1 vs his 653/0: same suite, 652 + 1 = 653.
- **Still unexplained and deliberately left open in IR-55:** why my `cargo test` run completed at
  all when his wedged twice. The scrub hypothesis is DEAD (isolation runs passed WITH the scrub).
  Not closed just because a run completed for someone.

## The defect

`spt update --restart` plans Fetch → Adapters → Finish. The adapters leg's floor gate
`staged_floor_ok` (cli.rs:19836) compares an adapter's `min_spt_core_version` against
`env!("CARGO_PKG_VERSION")` — the version of the RUNNING cli (cli.rs:19847-19848) — while
the core being installed in the same run is the STAGED one. An adapter flooring on the new
core is refused in-cycle; the finish then activates that core and leaves the old adapter on
it, ungated. One product call site: cli.rs:20555.

## Ruled shape (doyle)

Floors are evaluated against the NEW core — the fetched version the finish will activate —
in the composite sweep. NOT a post-finish re-sweep (single-pass truth beats a second pass
that can itself fail half-rolled).

- **`cmd_adapter_update` takes the comparison version as a PARAMETER**, never a baked
  `env!`. The composite passes the staged version; bare `spt adapter update` keeps passing
  the running CLI's. Pin both.
- **BOTH composites are in scope** (doyle's Correction 2, confirmed by measurement below):
  the non-`--restart` plan (FetchApply → Adapters) applies the core in-process and never
  re-execs, so `env!` is stale for its adapters leg in the same way. Same bug, different
  flag.
- **FAIL OPEN on the NEW input only.** `staged_floor_ok` is deliberately FAIL-CLOSED for an
  unverifiable STAGED MANIFEST floor (its doc + REQ-ADAPTER-FLOOR-ENFORCE) — *do not touch
  that*. The fail-open discipline applies to the fetched-core-version input this lane adds.
- **Q1 ruling — ONE ARM, TWO DIAGNOSTICS.** An empty `product_version` and an absent stage
  take the SAME action (degrade loudly to the running-CLI comparison; never compare `""`
  against a floor, never refuse everything) but must NOT share a message: "your staged
  release predates the field that carries the semver" vs "there is nothing staged". Same
  code path, distinct words.
- **The "never a trust input" warning, resolved (doyle, unasked ruling).**
  `product_version`'s doc forbids using it to decide WHICH RELEASE IS NEWER — the monotonic
  `version` counter is the ordering authority. It does NOT forbid reading it as the semver a
  floor is compared against, provided it is read from the VERIFIED signed metadata. State
  this in the REQ: the next reader meets the warning before they meet the use.

## Measurements already done (do not re-derive)

- **The staged version IS readable before the finish activates it.**
  `ReleaseCache::staged_update` (relcache.rs:330) returns `StagedUpdate::{Single,Set}`
  carrying `metadata_json`; `UpdateSetMetadata` (release.rs:137) and `ReleaseMetadata`
  (release.rs:44) each carry monotonic `version: u64` + human `product_version: String`.
- **`product_version` can be EMPTY.** `#[serde(default)]` on both (release.rs:68, :156) —
  pre-v0.3.2 metadata parses to `""`. The degrade arm is real field behaviour, not
  decoration.
- **The stage SURVIVES the apply**, so the version is readable at the adapters leg on BOTH
  composites: nothing removes `release.json` in relcache.rs or applyhost.rs (the removals
  are stage-time artifacts at :221/:272/:275, docs at :313, and applyhost's stale *aside* at
  :262). Corroborated structurally: `ApplyStagedOutcome::AlreadyApplied` (applyhost.rs:62,
  REQ-UPDATE-APPLY-ALREADY-APPLIED) exists *because* the stage persists across runs — a
  consumed stage would make that idempotence arm unreachable.

## Build order

1. Branch off #213's gated tip once the verdict lands. Own worktree `.worktrees/fix-159`,
   own pool, `pool-claim` from the lane worktree. df first; reap a finished pool before
   firing.
2. **Witnessed red first:** rig an adapter manifest whose floor equals the FETCHED core
   version, run the composite plan, pin the in-cycle refusal red. Green = adapter admitted,
   post-finish state new-core + new-adapter.
3. Pin the non-composite path unchanged (bare `spt adapter update` still compares against
   the running CLI).
4. Pin both degrade diagnostics separately (empty `product_version`, absent stage).
5. REQ: check `REQ-ADAPTER-FLOOR-ENFORCE` first — amend BY REPLACEMENT if its title states
   the running-CLI comparison, since that is the sentence this lane falsifies. Registry
   first, then satisfy.
6. clippy + `traceable-reqs check` **unpiped**, then ship the sha to doyle.

## Standing discipline carried into this lane

- Report filter AND match count together on every test figure.
- Read source at the MEASURED sha, never the default tree.
- A brief's shape is authored from the issue, not the seam: grep tagged invariants before
  building, and refuse a shape that collides with one (this has now caught two — #190's
  pump deadline, #173's binder-pinned verdict).

---

## AMENDMENT 2026-08-21 — a SECOND comparison site; the ruled shape as written regresses

Measured read-only at **`3d69f77c`** (#213's tip). Sent to doyle as a pre-build refusal and
**APPROVED AS PROPOSED** (doyle 2026-08-22); he re-verified every link at the blob himself. Line numbers are that blob's (the earlier numbers in this file were
taken at `c62904e7` and now sit ~33 lines lower).

`cmd_adapter_update` compares an adapter floor against `env!("CARGO_PKG_VERSION")` **TWICE**,
and only the first one is `staged_floor_ok`:

| # | site | when | comparison basis |
|---|------|------|------------------|
| 1 | `staged_floor_ok` (cli.rs:19869, `env!` at :19880) | PRE-swap peek | running CLI |
| 2 | `registry::register` (cli.rs:20677 → registry.rs:300-306 → `version_meets_floor` :328) | POST-swap re-register | running CLI |

Fixing only site 1 makes an adapter flooring on the NEW core walk:
peek admits → crc-swap puts the NEW bits in the live pointer-mode home → `register()` refuses
with `CoreFloor` against the OLD running CLI → `ADAPTER_UPDATE_FAIL:{name}: re-register:` →
`AdapterUpdateOutcome::Failed`.

End state: **live files new, registry record old**, `adapter_applied` false so the post-step
runs against the stale `source_dir`. That is the record/reality disagreement F-5's own doyle
bind names when it rules the register-only gate insufficient — recreated from the other side —
and it reports FAILED where #213 just minted `Refused`. Today's behaviour is a clean pre-swap
refusal, byte-untouched, so this would be a REGRESSION the lane introduces, not a residual.

**Correction (proposed):** the threaded version reaches BOTH sites. No new seam needed —
`registry::register_with_core(adapters_dir, source, now_ms, core_version)` is already public
(registry.rs:313) and `register()` is only its `env!`-baking wrapper. So cli.rs:20677 becomes
`register_with_core(adapters, &dest, now_ms(), core_version)` with the same parameter
`staged_floor_ok` receives. One value, two consumers ⇒ the Q1 fail-open degrade stays a single
decision and the peek cannot disagree with the record about which core it judged.

### Scope facts confirmed by the same read (unchanged from the ruled shape)

- **One composite entry covers both plans.** `plan_update_legs` pushes `UpdateLeg::Adapters`
  for both, and `cmd_update_composite` dispatches it at cli.rs:9264 — the only composite site.
- **The bare verb has TWO call sites to pin unchanged**, not one: cli.rs:2357
  (`spt update adapters`, the alias — "one engine") and cli.rs:19799 (`spt adapter update`).
- `spt adapter add` (`register` at cli.rs:19532) is untouched — no staged core exists there.
- `staged_floor_ok`'s FAIL-CLOSED arm for an unverifiable STAGED MANIFEST is untouched.

### REQ work (drafted before branching, registry-first)

- `REQ-ADAPTER-FLOOR-ENFORCE`'s title does NOT state the UPDATE arm's comparison basis — its
  (3) clause only says the peek "parses its manifest floor", so it is UNDER-SPECIFIED, not
  false. Its (2) clause about `register()` delegating with `env!` stays TRUE: `register()`
  itself is unchanged; the composite call site moves to the seam beneath it.
- **Amend by replacement:** the (3) clause + `staged_floor_ok`'s doc comment
  (cli.rs:19860-19867, "refuse if the installed core is below the floor" — under this lane the
  composite compares against the core being INSTALLED).
- **Mint** the new REQ for the staged-version comparison, stating the `product_version`
  "never a trust input" warning before the use, per doyle's unasked ruling.

### Build-order delta

Add a pinned site to step 2's green: the post-swap **record admits too**, and the registered
version matches the files on disk. A green that only asserts the peek admitted would pass with
this regression live.

## Craft carried in from doyle's #213 gate incident (2026-08-21)

- **Stream a test leg to a file and tail the FILE.** His script wrote `cargo test … | tail -N
  > file`, so a leg that wedged for 24 min produced an EMPTY file and the wedging test could
  not be named — the process table was the only source. (Same family as the truncation-pipe
  exit-status trap; the pipe eats the evidence, not just the status.)
- **`--test-threads=1` names a wedger**: the test name prints BEFORE it runs, so the last
  incomplete line is the culprit.
- **Known wedge shape:** the harness spawning `findstr .` with no file argument under headless
  conhost — `findstr` reads STDIN and blocks forever, so the PTY child never returns.
- **Open, unproven:** doyle's gate scrubs `OWL_SESSION_ID`/`SPT_AGENT_ID`/`SPT_ENDPOINT_ID`
  (to kill `DAEMON_STOP_REFUSED` false reds); a scrubbed identity may push a test that would
  otherwise be REFUSED down a live PTY path. He will vary it deliberately. **Do not build on
  this hypothesis.**

---

## Implementation sketch + test plan (authored 2026-08-21, read-only; correction APPROVED by doyle)

### Where the staged version comes from

`ReleaseCache::open(&perch::spt_home().join("releases"))` (the cli.rs:9146 / :10375 pattern) →
`cache.staged_update() -> Option<StagedUpdate>` (relcache.rs:330) → `StagedUpdate::metadata_json()`
(relcache.rs:127) hands back the JSON for BOTH variants, so the caller does not match on
Single/Set. Parse it the way `staged_update` itself does — `UpdateSetMetadata` first, then
`ReleaseMetadata` — and take `product_version`.

### Shape

Keep `cmd_adapter_update` a pure consumer of a `&str`. The staged read and the loud degrade
live at the composite leg, so the two diagnostics are rendered once, where the knowledge is:

```rust
/// Which core version this run's adapter floors are judged against.
enum FloorBasis { Staged(String), NoStage, StagedVersionAbsent }

/// PURE over the staged metadata JSON — unit-testable with no cache on disk.
fn floor_basis(metadata_json: Option<&str>) -> FloorBasis
```

`NoStage` and `StagedVersionAbsent` both degrade to `env!("CARGO_PKG_VERSION")` (Q1: one arm,
two diagnostics) and each render their own line before doing so. `Staged(v)` is passed through.

Then:
- cli.rs:9264 `UpdateLeg::Adapters` → `cmd_adapter_update(&adapters_dir(), None, &basis_version)`
- cli.rs:2357 and :19799 (bare verb + its alias) → pass `env!("CARGO_PKG_VERSION")`, unchanged
- cli.rs:19880 `staged_floor_ok(.., core_version)` — the `env!` becomes the parameter
- cli.rs:20677 `registry::register(..)` → `registry::register_with_core(.., core_version)`

### Tests (each pinned with filter AND match count reported)

1. **RED-first, the defect itself.** Composite plan, adapter manifest floor == the FETCHED
   core version. Pre-fix: refused in-cycle. Post-fix: ADMITTED.
2. **The record admits too** — same run, the re-registered version matches the swapped files.
   Without this a green would pass with the cli.rs:20677 regression live: the peek would admit,
   the swap would land, and the record would refuse. **This is the assertion that makes the
   second site load-bearing rather than tidy.**
3. **Bare verb unchanged, BOTH call sites** — `spt adapter update` (:19799) AND the
   `spt update adapters` alias (:2357). One pin would leave the alias unpinned, and the code's
   own "one engine" comment is what invites the reader to assume a single site.
4. **Degrade: empty `product_version`** — falls back to the running CLI and renders the
   predates-the-field diagnostic. Not the absent-stage one.
5. **Degrade: absent stage** — falls back and renders the nothing-staged diagnostic. Not the
   empty-version one. (4 and 5 must assert on the DISTINCT strings; a shared message passes
   both and erases the ruling.)
6. **`staged_floor_ok` fail-CLOSED arm untouched** — an unverifiable STAGED MANIFEST still
   refuses. Regression pin on REQ-ADAPTER-FLOOR-ENFORCE, since this lane teaches the
   neighbouring input to fail OPEN.

### Registry — drafted, sent, APPROVED (`W3-159-REQ-DRAFT.md`)

doyle 2026-08-22 approved the mint, the id `REQ-ADAPTER-FLOOR-VS-STAGED-CORE` (it names the
COMPARISON, not the mechanism, so it survives a re-implementation), and the (3)-clause
replacement shape (fail-closed sentence kept INTACT, comparison-basis sentence appended — the
two disciplines are about different inputs and the paragraph now says so in its own words).

**int is NOT activated on either requirement, and #159 does not inherit #213's row.** The owed
proof is FIELD ACCEPTANCE — one real composite roll landing new-core AND new-adapter on a real
box. No lane gates it; a rig that simulates it proves nothing. **Owed at ship: name that leg in
the FULFILLMENT for both lanes** so deployah gets it in the head's never-executed-cells list
rather than meeting an unproven claim at field time.

### Doc surfaces the census found (beyond the two code sites)

- **`docs/MANIFEST.md:43-46`**, tagged `[doc->REQ-ADAPTER-FLOOR-ENFORCE]` — "REFUSE when the
  INSTALLED spt-core is below this version". Doc-STAGE evidence on a CI-drift-gated surface.
  Amend by replacement in the same commit.
- **`CONTEXT.md:682`**, tagged `[doc->REQ-UPDATE-REFUSAL-EXIT-DISTINCT]` — **#213's OWN doc-stage
  evidence** — "the floor gate declining an adapter whose `min_spt_core_version` EXCEEDS THIS
  CORE". #159 falsifies a sentence #213 is landing. Not a conflict (it is true today), but it
  **couples the batch order #213 → #159**.
- **`CONTEXT.md:76`** (untagged glossary prose, authoritative for meaning): "when spt-core
  self-updates, RE-VERIFY ADAPTERS STILL SATISFY (coordinate core + adapter updates when
  needed)". **This lane delivers a CONTEXT.md sentence the code never satisfied** — a dead rule,
  the same shape F-5 itself was. Caution raised with doyle: its "re-verify ... when spt-core
  self-updates" phrasing reads as the POST-update re-sweep he ruled against, so the cadence
  wants amending too. Awaiting his word — CONTEXT.md is authoritative for meaning.
- **Clean, leave alone:** docs-site `manifest.md` (60/67/77), `quickstart/adapter.md`,
  `integration-checklist.md`, `install-on-demand.md`, `shells/getting-started.md`, PRD.md
  R-UPD-5 — all say "compat gate checked before install/update" without naming the basis.
  Under-specified, not false.

### Code census, re-derived rather than taken from doyle

`version_meets_floor` has exactly TWO product enforcement sites tree-wide (registry.rs:328,
cli.rs:19882); every other hit is the pure fn, its doc, or unit tests. `registry::register` has
exactly two product call sites (cli.rs:19532 add, cli.rs:20677 update) — every spt-daemon hit
is test fixture setup. Agrees with doyle's count; measured independently.

### All open items now RULED (doyle 2026-08-22)

- **All three doc surfaces: AMEND BY REPLACEMENT, in the SAME COMMIT as the parameter threading.**
  Not a follow-up commit in the same lane — the same commit. The sentences are true today and go
  false at the instant the parameter lands, so together there is no false window and every way
  they could be separated is a way someone would create one. Assembly order unchanged
  (#213 → #159); doyle does not need to change the batch.
- **`CONTEXT.md:76` cadence: AMEND.** A glossary sentence whose CADENCE a future implementer could
  build against is worse than a merely stale one — it would authorize the post-update re-sweep
  doyle refused, with the glossary's authority behind it.
- **`doc` is ACTIVATED on the new requirement.** doyle's catch: the amended CONTEXT.md clause IS
  the authoritative statement of the ruled cadence, so the requirement depends on a sentence this
  lane writes. Landing it untagged would leave that dependency pointing at nothing — the same
  omission the lane is fixing, one level up.
- **Section 3b: RESOLVED and LANDED**, not on this lane. See below.
- **Registry dates in UTC.** This box: 2026-08-21 22:03 PDT == 2026-08-22T05:03Z. A reader seeing
  08-21 then 08-22 is seeing a UTC boundary, not a day of deliberation.

### DONE this session, off-lane: #213's fourth commit @ `f7d26dd5`

`fix/153-update-refusal-exit` is now FOUR commits (tip `f7d26dd5`, pushed). Comment-only, one line
of `traceable-reqs.toml`, no code, no stage change; `traceable-reqs check` 796/796 complete, 0
findings, exit 0, unpiped. #213's landed stage note said its deferred `int` leg "rides
releases#159"; the 2026-08-22 ruling made that false, so the row now states the leg
lane-independently as field acceptance.

**doyle's discriminant for WHERE a correction goes — ask what made the sentence false:**
a lane's own change ⇒ the amendment rides the lane, same commit (`CONTEXT.md:682`); a RULING
already made ⇒ the correction belongs **at the record, now**, because it is false today and stays
false whether or not the lane ever branches. Second half: **the replacement names no lane at all**
— repointing the cross-reference at another issue reproduces the defect with a fresher dependency.

### doyle's own census ruling from this exchange (2026-08-21)

All three shapes of his I have refused today are ONE error: #190 (a bound honoured at one arm
of two), #173 (a record written by a process that could not pin it), #159 (a comparison fixed
at one of two sites that enforce it). Each time the fix was ruled at the site THE ISSUE NAMES
without censusing the other enforcers of the same invariant. He has made "census every site
that enforces the rule before ruling a shape" standing, and asked that the refusals keep coming
until it is demonstrably internalised. **Corollary for me: on any lane, census the enforcers of
the invariant before accepting the brief's site list — including my own.**
