# F-018 — Adapter add non-destructive & idempotent-safe (REQ-INSTALL-13)

**doyle design → todlando build → doyle gate.** v0.14.1 fast-follow #1.

## The bug (perri, real claude-spt)

`spt adapter add --github user/repo` run over an adapter already installed via
`spt adapter add --release user/repo` git-cloned a SOURCE tree over the
extracted built binaries. The registered record is gh_release **pointer-mode**
(reads its manifest live from `_github/<safe>`); after the clone-over the
manifest + 3 `.exe` it expected are gone → bind/run hits `os error 2`
(now surfaced as `DeferredManifest`, but the install is still broken).

## Root cause (two defects, both in `cmd_adapter` Add — `crates/spt/src/cli.rs`)

Both add sources compute the **same** dest `adapters/_github/<safe>`
(`safe = spec.replace(['/','\\'], "-")`) and both **wipe-before-success**:

- `--github` arm, ~cli.rs:6111: `if dest.exists() { remove_dir_all(&dest) }` then `git clone` into the live dest.
- `fetch_release_adapter`, ~cli.rs:3229: same `remove_dir_all(&dest)` then write archive + `extract_release_archive` into the live dest.

Neither **refuses** when `_github/<safe>` already backs an active registered record.

Contrast: `cmd_adapter_update` (cli.rs:6507) is ALREADY safe — stages to
`<safe>.update.spt`, then `apply_release_crc_swap(&staged, &dest)` (W3d
content-hash swap, never-strand). The add path predates that pattern.

## Fix — two changes, both reuse existing safe primitives

### A. Collision refusal (the primary footgun guard)

In `cmd_adapter` Add, BEFORE building `source` for the `--github` / `--release`
arms, compute `dest = adapters.join("_github").join(&safe)` and check whether any
**active** registered record's `source_dir` canonicalises to that dest. Use
`spt_runtime::registry::registered(&adapters)` (or `all_records` filtered to
`active`), compare `record.source_dir == dest` (path-normalised — both are built
the same way, so a string/`Path` compare on the joined value is sufficient; if
unsure, compare via `std::path::Path`).

If matched → refuse, do NOT wipe:

```
ADAPTER_ADD_ALREADY_REGISTERED:<name>: already installed at <dir>.
Use `spt adapter update <name>` to refresh it in place (safe stage-then-swap),
or `spt adapter remove <name>` then re-add to replace it.
```

Return code `2` (refusal / bad-args class, matching ADAPTER_BAD_ARGS).

A local `<path>` add (first arg) is unaffected — it does not touch `_github`.

### B. Stage-then-swap (durability for the legit re-add / first-add-retry)

For the non-registered-but-dest-exists case (e.g. re-add after `remove`, or a
prior add that fetched but failed to register), stop wiping the live dir before
the new payload is proven:

- **`fetch_release_adapter`**: instead of `remove_dir_all(&dest)` + write-archive
  + `extract_release_archive(&archive, &dest)`, mirror the update path: stage the
  archive bytes to `_github/<safe>.add.spt`, `create_dir_all(&dest)`, then
  `apply_release_crc_swap(&staged_archive, &dest)` (the same never-strand
  content-hash swap update uses). Remove the staged archive after. On any error
  the live dest is untouched.
- **`--github` clone arm**: clone into a sibling staging dir
  `_github/<safe>.staging` (remove a stale one first). Only on clone SUCCESS:
  `remove_dir_all(&dest)` (if present) then `rename(staging → dest)`. The remove
  now happens strictly AFTER the new tree is fully materialised → a failed/partial
  clone never strands the old install. Clean up `staging` on any error path.

Keep both arms' existing bounded-subprocess timeouts and error codes.

## Evidence to land (ALL in one commit — flip REQ-INSTALL-13 stages same commit)

- `// [impl->REQ-INSTALL-13]` on the refusal guard + both stage-then-swap edits.
- **unit** (`crates/spt/src/cli.rs` tests, or a cli integration test):
  - `add_over_active_registered_refuses` — seed an active registered pointer
    record at `_github/<safe>`, run the add path, assert refusal + dest untouched.
  - `stage_then_swap_failed_fetch_preserves_install` — pre-populate dest, force the
    fetch/clone to fail, assert the old manifest+files survive (no dangling pointer).
    Tag `// [unit->REQ-INSTALL-13]`.
- **doc**: one line in `CONTEXT.md` §adapter registration — "adapter add is
  non-destructive: re-adding a registered adapter is refused (use `adapter
  update`); (re)population stages-then-swaps." Tag `<!-- [doc->REQ-INSTALL-13] -->`.
- Flip `required_stages = ["doc","impl","unit","int"]` for REQ-INSTALL-13.
  - int folds into REQ-INSTALL-4/9's deferred real-repo E2E (note it in the comment).

## Gates (todlando runs before reporting; doyle re-runs on gate)

1. `cargo clippy --workspace --all-targets` (CI denies warnings workspace-wide).
2. `cargo test -p spt --lib` + the new cli tests (named).
3. `traceable-reqs check` — exit 0 (REQ-INSTALL-13 evidence present).
4. `cargo run -p xtask -- gen` only if clap `///` help changed (it shouldn't — the
   refusal is runtime stderr, not help text). If unchanged, skip.

## Out of scope (batch later, fast-follow #2/#3)

- Win netstream redrive flake (netstream.rs:99) — separate.
- `ADAPTER_INSTALL_DEFERRED` label clarity — separate, low-pri.
