"serialization is the invariant — refusing a second driver", flush=True) return None holder_note.write_text(str(os.getpid()) + "\n", encoding="utf-8") return handle # The W2 nextest population, as ONE expression so the gate reads it as one fact. # kind(lib) = the unit cells compiled into each crate's own lib target; the # integration binaries under crates/spt/tests are named individually because W2 # adds its own and every other one is out of this lane's scope. W2_CRATES = ("spt-proto", "spt-store", "spt-daemon", "spt") # Integration binaries are kind(test) and enter ONLY by name — a lesson the # gater caught before this ran blind: `webserve_cross_node_e2e` carries the F1 # authorization conjuncts and the FILE_ACCESS_HELPER int arm, and without it # named here the battery would have passed the floor while skipping the two # things the gate most wants to read. W2_TEST_BINS = ("webserve_attachment_e2e", "webserve_cross_node_e2e") # kind(lib) + kind(bin), NOT kind(lib) alone: `spt` is a BINARY crate, so every # cell in cli.rs, nowsignal.rs, attach.rs, fetchverb.rs and msgverb.rs compiles # into its bin target and a lib-only filter would silently select NONE of them — # a filter that matches nothing runs green and proves nothing. NEXTEST_FILTER = "({crates}) & (kind(lib) + kind(bin)) + {bins}".format( crates=" + ".join(f"package({name})" for name in W2_CRATES), bins=" + ".join(f"binary({name})" for name in W2_TEST_BINS), ) # Below this the population is not a population. The number is deliberately a # FLOOR rather than an exact count (cells are added in this lane), and it exists # because the failure it catches — a filter expression that selects nothing — # looks exactly like a battery that passed. MIN_SELECTED_TESTS = 200 def population(root, output, xtask): """List the filtered population and REFUSE a suspiciously empty one.""" argv = ["cargo", "nextest", "list", "-E", NEXTEST_FILTER] if not leg(root, output, "population", argv, True, xtask): return False raw = (output / "population.raw").read_text(encoding="utf-8", errors="replace") # COUNT THE ROWS THEMSELVES, never a summary phrase: `nextest list` prints # one " " line per selected test and no total, so a # meter keyed on prose reads zero and refuses a healthy battery. Measured # against a real listing (1673 selected) after the first version did exactly # that — the instrument was wrong, not the filter. selected = sum(1 for line in raw.splitlines() if re.match(r"^\S+ \S*::", line)) verdict = "PASS" if selected >= MIN_SELECTED_TESTS else "REFUSE" print(f"NEXTEST_POPULATION:{verdict}: selected={selected} floor={MIN_SELECTED_TESTS} " f"filter={NEXTEST_FILTER}", flush=True) (output / "population.verdict").write_text( f"{verdict} selected={selected}" + chr(10), encoding="utf-8" ) return verdict == "PASS" === CONTEXT.md diff === (no diff = untouched) === CONTEXT.md terms === 55:spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply. 106:Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps), **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it), or **gh_release** (the adapter ships its updates from its own GitHub releases). After initial bootstrap, the plugin no longer self-manages updates; spt-core conducts them. The **gh_release** avenue (since v0.8.0) declares `repo = "user/repo"` (plus an optional release `asset`, default `adapter.spt`, and an optional Ed25519 `signing_key`): spt-core compares the repo's latest GitHub release version against the installed adapter version and, when newer, fetches the release `.spt` (the same archive primitive as `spt adapter add --release`), then re-extracts and re-registers. Trust mirrors first-acquisition — HTTPS + GitHub when no key is declared; when a `signing_key` is declared, the fetched `.spt` is verified **fail-closed** against a detached signature published as a sibling release asset `.sig` (lowercase-hex Ed25519 over the raw archive bytes), and the new `.spt` is verified against the **installed** manifest's key (key continuity). A bad or missing signature refuses the update — the staged bytes are deleted, never extracted. The gh_release update is driven by the `spt adapter update [name]` command (with no name it sweeps every registered gh_release adapter); the network fetch lives in the CLI, never the daemon. 110:A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `/` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters may still split per-platform. The `gh_release` fetch transport is **`auto`** by default — the pre-authorized `gh` CLI when available (the path for **private** adapter repos: `gh` honors both OAuth and `GH_TOKEN`, so spt never custodies a token), else direct HTTPS (public). An adapter update is **live and daemon-coordinated**, the adapter analog of brain self-update: for an endpoint with a running **resident adapter binary**, the daemon stops it (releasing the OS file lock that otherwise fails an overwrite on Windows), swaps **only files whose CRC changed**, **refreshes the endpoint's in-memory manifest** (binaries and manifest stay on the same page), then restarts it — the endpoint itself never restarts. 412:**BUILT (M12 W2.5).** The controller/viewer model is implemented end-to-end. Attach intent is **three-valued** (`AttachIntent = Viewer | Control | Take`, wire-default `Control`): `Control` to a FREE endpoint becomes controller; `Control` to a CONTROLLED endpoint is **refused with guidance** (`--view` to watch, `--take` to control) — never auto-viewer, never silent-displace; `Take` (`spt rc --take` / picker "Kick") kicks the incumbent with a **loud `Displaced{by}` notice** and full detach (not demote). The broker's per-session `OutputLog` is the fan-out hub: ONE authoritative **controller** (advances the brain-resume cursor `delivered_through`) plus ANY NUMBER of read-only **viewers**, each an isolated bounded queue + writer thread evicted on overflow (a wedged viewer never stalls the drain, controller, or child — `REQ-HAZARD-VIEWER-ISOLATION`). The controller is **no longer a *blocking* writer**: since b4 it is a NON-BLOCKING `try_send` that DROPS on a full channel (`CONTROLLER_CHANNEL_DEPTH`), so a slow controller can never throttle the drain and starve a concurrent viewer (`REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE`). Exactly-once for the controller is preserved by RE-FETCH, not by blocking: a controller that falls behind its own echo and drops frames hits a forward `output gap`, and the serve loop RESUMES-FROM-FLOOR — re-subscribes from the frozen `delivered_through` so the broker replays the dropped frames from the ring (`REQ-HAZARD-CONTROLLER-GAP-RESUME`, a re-fetch — NOT the viewer's snap, which would skip frames and violate the controller's exactly-once resume). This holds while the ring still retains `delivered_through`; a controller that falls behind a **ring-exceeding** flood (the dropped frames have rolled out of the ring) surfaces a clearly-marked data-loss rather than a silent skip or a hang (`REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND`, deferred for full graceful handling). An evicted viewer **skips to live** instead of dying silently: the broker signals the eviction (a marker distinct from session-exit EOF) and the viewer re-subscribes from the current ring floor — rate-limited so a hopelessly-behind `--view` under a sustained output flood sees intermittent live bursts (tail -f reconnect), never a frozen viewport or an evict→resubscribe CPU spin. Viewer-only, so it never touches the authoritative resume cursor (`REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT`). A viewer also tolerates a forward ring-roll gap **before** any eviction: if it falls behind the live ring under a hard flood and reads a seq past its cursor (the ring rolled the intervening frames out between reads, with no eviction marker), it **snaps to that live seq** (accept-and-advance via snap-above, armed at the initial viewer attach) instead of fataling on the gap — composing with skip-to-live, which recovers *after* eviction, and staying viewer-only so the controller keeps its strict exactly-once reject-gap (`REQ-HAZARD-VIEWER-RING-ROLL-SNAP`). Resize is **controller-exclusive** (the broker rejects a viewer's resize). The **broker is the single writer** of the perch's `driven_by` (controller node) + `viewer_count`, resolving the displaced-controller clear-race. **Controller identity is keyed on the operator node (`by`)**: a same-`by` re-subscribe (a successor re-taking the slot after a brain restart) silently re-takes — `Displaced` fires ONLY on a genuine cross-operator `Take` (the gate-#7 self-kick guard; a brain update never kicks attached operators). **Dormancy keys on the controller only** (viewer attach/detach is wake-neutral; a viewer may watch a dormant endpoint as-is). **v1: viewing is gated identically to driving** (a viewer runs the same `access_check(Unsolicited)`; the lighter distinct watch-gate is the future seam). The picker is status-conditional: a CONTROLLED endpoint offers **View + Kick** only (no plain Attach), pinned `controlled by (+N viewing)`; all of View/Attach/Kick ride the SAME rc dispatch (intent is a parameter — single-bringup-path). (rc viewer letterboxing is a one-line size indicator in v1; true clip/pad needs a client grid model — deferred.) 566:- **Two access modes.** **Snapshot pull** — `spt endpoint digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's "latest session output" pane; a Gateway's agent-window). **Structured-delta stream** — a subscriber (live Shell pane, GUI, Gateway) receives only *changes* (new turn, new tool entry, collapse update), keyed to log records, for resource-efficient near-realtime reflection. **Access is address-gated** — fetch/subscribe is allowed for anyone who can address the endpoint (visible + resolvable per the resolution policy), the same gate as messaging. **Cross-node reach is pull-first** (ruled 2026-07-24): the snapshot pull (with `--after ` incremental polling — trustworthy precisely because sealed `seq`s are stable) crosses nodes under that same address gate; the delta stream stays node-local until a real cross-node subscriber exists. 681:**release channel (private, gh-carried)** — the release channel is a **private** GitHub repo (`BigscreenVR/spt-bs-releases`, ADR-0036); the **gh CLI is the mandated carrier** for release discovery and asset download (each node authenticates via org membership). A node without an authed `gh` cannot fetch — refused loud with OS-specific install hints, never a silent hang. Signature verification is carrier-independent: bytes are verified after download exactly as before; counter, signing key, and update-set format are unchanged from the public-channel era. 683:**docs bundle** — every release ships a platform-independent archive of the **built docs** (HTML + `llms.txt` + `llms-full.txt` + raw markdown + `manifest.schema.json`) as a **signed update-set asset**; apply lands it at `$SPT_HOME/docs`, so a node's docs always match its installed version. A docs-asset failure never fails the binary update (skip loud, retry next fetch). Consumed by the *docs server* (below). 688:**update composite (`spt update`)** — the plain verb is the primary form: `update fetch --apply` then `update adapters` (core-first order); with core already current, only adapters update. `--core-only`/`-c` skips adapters; `spt update adapters [[,…]]` is the adapters leg alone (alias over `spt adapter update`). The composite's invoker always survives, because a routine apply cycles only the **brain** — the *restart-required* message on broker-side releases is a notice, not a restart. `spt update --restart` is the one-step **full cycle**: fetch → adapters → `apply --finish` last (the finish restarts the whole daemon, so it is the final act). **A REFUSAL IS NOT A FAILURE AND NEITHER IS A SUCCESS** (releases#153): every update surface answers with **`0` applied · `3` refused, nothing done · `1` failed**, and the summary line says REFUSED rather than FAILED where a guard declined. A refusal means a rule held and the box is byte-for-byte as it was — the floor gate declining an adapter whose `min_spt_core_version` exceeds **the core it is judged against** — the running core for the adapters leg alone, the core the run will activate under the composite (REQ-ADAPTER-FLOOR-VS-STAGED-CORE), or the endpoint guard declining a broker-killing finish. That guard is the *expected* outcome on any node whose daemon hosts endpoints, so a caller gating on exit status reads a refused fleet roll as a completed one unless the three answers stay distinct. The `3` is a **codification of what the tree already did** at its refusal sites, not a new contract, so there is no migration to look for. 697:**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate). 743:**served name** — the name a registry entry answers to under its node's prefix. Stable for the 748:_Avoid_: filename (a served name is not the file's path or basename); slug. 750:**attachment** — a file sent with a message by registering it as a served resource and carrying 752:it wants; no unsolicited bytes ever land on a receiver. An attachment is a **snapshot** — the 757:attachment as a live view of the sender's file. 759:**message short-ID** — the eight-character, node-scoped token a message is referred to by: in a 761:(`//m/`). Derived from the message's own hash, so it survives a store rebuild; 765:**fetch** — the receiving side's act of obtaining a local copy of a served resource from its 766:URL (`spt fetch [dest]`); the one verb the FILE_ACCESS_HELPER signal names. 767:_Avoid_: download (a fetch is addressed by served URL, never by a path on the owner). 925:_Avoid_: any node-side admin-code fetch; treating the member key as an admin credential. 929:**One per node.** A locked-down **agent endpoint** (harness-adapter-backed, spt-hosted, has a mind — so it can reason about the node's access posture) that is the designated surface for setting the **node's** control-surface modes, and — via *empower* — a **subnet's** modes. Bring-online + controller-attach requires a **same-node CLI call + a member-or-admin TOTP** for the engine-room's anchor subnet (no OS elevation). Structural locks: refuses all inbound except replies to its own outbound (**knocks and knock-codes ARE accepted**) — and that refusal reaches **every spt-authored delivery path on this node, not only the wire** (amended 2026-08-22, releases#209): a local `spt send`, an `spt ring`, or a subnet notify aimed at the seat meets the same lock, because admission is asked **once, where the message is AUTHORED**, and the seat's own session briefing is exempt by being written on a path that never crosses that site rather than by carrying anything a sender could wear. **What the lock holds, stated rather than implied:** the check runs in the **authoring process**, so it governs spt's own delivery verbs — an old or modified binary, a direct write into the spool database, or a raw TCP connect to a relay listener is same-user local code, which is outside what any spt gate claims to hold, and rows spooled before the flip drain ungated exactly once; empowered **only while a controller is attached** — detach drops its **access posture**, not its process (it refuses all inbound, drops every empowerment and stops being advertised, while the hosted session lives on and can be re-attached through the same TOTP gate; amended 2026-07-29, the literal "detach kills it" reading would re-break the attach-lifecycle invariant); `rc --view` denied even locally; remote attach denied; **local `rc --take` allowed** — on two grounds, neither of them a restart (a take displaces a broker lease and restarts nothing): the displacing controller must pass the **same bring-up gate** the incumbent did, so a take is itself a gate attempt bounded by the same failure ledger, and the displacement is **loud**, so an incumbent human cannot be silently unseated. **Revoking empowerments on take and on detach is an explicit step**, never a consequence of a restart; **not registry-advertised by default** (advertised only to endpoints it has whitelisted); **its role is SERVED FROM CORE and has no writer at all** (ratified releases#179, skeleton releases#165, KEYSTONE #182 W3): what the seat *is* — its seat line, its rule tiers, its control-surface vocabulary, its discipline — is a static, immutable value composed in-core and identical on every node, so **both** readers of role text (the resume path's `` slice and `spt endpoint role`) serve that value for the reserved id, an on-disk `live-role.md` planted under it is ignored dead weight, and the sole writer (`spt endpoint role --overwrite`) **refuses** that id loudly and names the in-core role as the reason. This is not an exception to *role is durable identity, never written per session* but the degenerate case of it: the seat is minted by the node rather than chosen by a person, so there is nobody to author it and nothing to race. The role **composes the control-surface vocabulary from the same table, through the same composer, as the CLI's `--help` sections** (DOORBELL, releases#73; carrier moved 2026-08-19), so the seat answering node-tier requests reads the surfaces it grants in the same words the asker read them, and a surface added to the table reaches every rendering with no second edit — and it prescribes only verbs the grammar actually has, walked over its whole span. Every session start still delivers a briefing message, now carrying **per-session weather only**: the node's exact posture, pending advisory deltas, the ruleset, what *this* session was empowered for with the verbs that makes spendable (`empower`, `access-refresh`), and the seat-authority statements (one-seat lifetime, no cross-node reach, no unchosen advertisement) — and that briefing is enqueued **once per session, not once per attach** (releases#177): the endpoint keeps running between attachments, so a human who detaches and takes the seat again on the SAME live session is handed no second copy of a posture statement they already read, while a fresh session (a new bring-up, or a daemon restart that re-hosts one) briefs exactly as before. The bound is on the ENQUEUE only — a briefing whose delivery missed is still retained and re-offered at the next seat-taking attach — and its RETENTION is **session-scoped** (releases#208): a briefing states one session's weather, so when a NEW session opens, the undelivered briefings earlier sessions left behind are DROPPED at that same new-session moment, before this session's own is written. Undelivered rows only — a delivered one is history and cannot reach anybody — and the count is stated rather than deleted in silence. Without it they accumulate forever (the spool's default TTL is none) and the next session's drain hands a human every one of them at once, oldest first: measured in the field as six briefings spanning sixteen days, three asserting an empty ruleset in retired vocabulary. The ephemeral-message axis is deliberately NOT the mechanism — its deletion set is exactly the retained-row rescue's retention set, so it would destroy the briefing that failed to present, which is the one case that rescue exists for; and a time TTL is the wrong axis in both directions, since the honest scope is a session and not a duration. The intra-session rescue is untouched: the drop fires when the session is new, never between seats of one session; **the node's admin command center for access** (amended 2026-07-31, knock grill): beyond modes and node-tier rules it may read and edit **endpoint-scope** access entries across the node — the one seat for access questions and bulk rule management — though it never answers another endpoint's knocks. `endpoint purge` against it **requires elevation and resets rather than deletes**. **Ordinary lifecycle boundaries never wedge the seat** (ratified 2026-08-04 bag grill, releases#142): a harness exit (the user typing the harness's own quit) leaves the endpoint cleanly offline and re-bringable through the same TOTP gate; a session-clear/sid-rotation boundary survives re-attach; and bring-up announces empowerment only **after** the controller's attach is established — a grant line describing a controller that never attached must be impossible. Its anchor subnet and harness adapter are settable only by the **create/reset ceremony** (`spt endpoint engine-room --adapter ` — one code path for both): **creation is unelevated** (the window closes at the first run; run the ceremony early), **reset requires elevation**, and **both refuse invocation by an SPT agent** (env + process-identity deny, ruled 2026-07-30 fast-follow grill). 938:The unit of access-control granularity: a named remote-reachable operation class on an endpoint. **Open string vocabulary, CONSTANT_CASE ids** (like capability ids — new surfaces mint ids without schema change). v1 set = the existing gate families: `MSG`, `RC_VIEW`, `RC_ATTACH`, `DIGEST`, `WAKE`, `SUSPEND`, `XFER`, `SHELL_LINK`, `DISCOVER` — plus **`FORK`** (HANDRAIL, releases#29), the first id minted *with* its capability and so the proof that the open vocabulary grows the way it was ratified to: one row, no schema change, no new tier/subject/authority/decision. Later waves (remote endpoint-info, adapter package serving, webservice facets) mint their ids when the capability itself is built — **`WEB`** is the first such mint (WEBSERVE, ADR-0060): it gates every served resource reached across the subnet, default-allow within the subnet because registry exposure is already a deliberate act; and **`XFER` is retired, not renamed**, at the #246 close (the pull-model attachment supersedes the transfer it gated; a stored rule naming a retired surface is reported at load, never silently dropped). An access rule is (target endpoint × surface × subject-chain) → allow/deny, the subject chain per the *endpoint access whitelist* ruling. Each row also carries **an operator-language description** (DOORBELL, releases#73) naming the traffic in the words someone choosing surfaces would use, and every rendering — the `Control surfaces:` section on the surface-naming verbs' `--help`, and the engine room's durable in-core role (carrier corrected 2026-08-19, releases#179: it rode the per-session bring-up briefing until the ER's static teaching moved to its role, and the briefing now carries no surface section at all) — **composes that section from the table at render time**, through one composer over one row text, so a new surface appears in every rendering by the sole act of existing. The **subject consequence** printed beside each description (*a grant binds the single sender* vs *a grant admits the whole machine*) is **derived from the row's attributability flag, never authored per row**: the sentence an operator reads about who a grant admits cannot drift from the flag the gate enforces, and the day a surface grows a sender stamp that one row flips both together. Teaching the widening BEFORE the refusal is the point. The line stops at the consequence and names **no remedy**: the acknowledgment is already sited with the flag that owns it (`--admit-node` on `knock approve` and on `endpoint access allow|deny|remove`, plus the store's write-time refusal), and the shared section renders at seats that flag does not bind — at `spt daemon access` a remedy sentence would be false, not merely unactionable. 1076:**fetch-code-from-any-node (per-subnet, QR-optional)**: 1077:Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: with several subnets and no name given, the CLI prompts *"Show the code for which subnet?"*; `spt subnet show-code [name]` bypasses the prompt (the scripted path). Minting a new subnet is its own verb (`spt subnet create `), not a fetch flag. The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.). 1079:**Node-bound code fetch — and every subnet-membership mutation — is gated behind OS privilege elevation** (Windows UAC / Linux root-or-equivalent): retrieving a subnet's code *from the node*, minting a new subnet (`subnet create` — a seed reveal), and joining one (`subnet join` — enrolling the machine into a trust fabric) all require either hardware/elevated access OR an **elevated endpoint** (an agent whose process is elevated can surface it). The join gate exists because membership is a trust-boundary change: an unprivileged process must not be able to enroll the machine into an attacker's subnet without the user's consent. Read-only subnet views (`subnet status`) are ungated — they reveal no secrets. This means the node-bound path proves real possession of the machine; everyone else falls back to **their own authenticator-app TOTP store** (where the seed was stored at pairing). The gate narrows the multi-subnet exposure — without elevation, mere CLI/agent presence on a node no longer yields *any* subnet's join-code; with it, node compromise still implies full trust loss for that node's subnets (unchanged baseline). 1098:**subnet attachment (attached / detached)**: 1210:**Installer form (gh bootstrap, ADR-0036):** install gh → `gh auth login` (org membership) → `gh release download` the platform binary from the private channel → one **self-install verb** in the binary places it at the canonical install path and registers the *user* PATH (so adapters call `spt api …` cross-OS); first-run identity gen + daemon start stay the existing idempotent unattended first-run. Hosted one-liner scripts are retired with the public channel; first-fetch trust = gh's authenticated TLS + org membership (full ed25519 verification is `spt update`'s job thereafter). The downloaded exe keeps its `spt-*` asset name and the verb lives inside spt (Windows installer-detection: no install/setup/update words in exe names). **OS-service registration is deferred** (daemon auto-start on `spt` invocation covers dev-stage use; gap: node unreachable after reboot until something invokes `spt`). **PLACING THE BINDER RECONCILES THE INBOUND-UDP FIREWALL RULE, IN THE SAME OPERATION** (releases#173): create it when missing, REPOINT it when it names a different image, and say which was done. A program-scoped rule admits exactly one path, so the moment the binder can move is the moment the rule can go stale — and a stale rule reads GREEN BY NAME while inbound is dead on the Public profile. Repoint-if-different is the load-bearing arm; create belongs to first install, since an update swaps the binary in place at the same canonical path and changes no program scope. Only the PRODUCT-NAMED rule is ever touched: dev and CI rules naming spt images on the same box can be load-bearing for runner jobs, and a delete-by-image sweep would read as tidying while eating one. **Unelevated degrades LOUD and never fatal** — the rule is left exactly as it was and the operator is handed the exact command, because a placed binder with a stated reachability problem beats a refused installation. The installer does NOT write the durable inbound verdict: that record is pinned to the binder's pid and image, so one authored by a short-lived installer re-derives as unknown for every reader — the verdict stays the daemon's to write when it binds. **The install path must be non-interactive** (it doubles as every adapter's pack-in on-demand install — no second mechanism); if the one-liner ever grows interactive elements, a flagged non-interactive mode is mandatory. First-run identity gen + daemon start are already unattended; pairing stays a separate explicit step. Chosen for the dev-tool audience, cross-platform reach, and because the binary self-updates after. **Marketplace-repackaging-friendly:** nodes are foreseen on novel platforms (Android, medium-power Linux handhelds), so the install must be easy to repackage for platform marketplaces (e.g. PortMaster for handhelds, F-Droid-style for Android) — a relocatable binary + minimal, non-OS-entangled install logic. 1219:How a node comes to *know* an adapter — harness or shell. An explicit **`spt adapter add `** (or **`--github `**) validates the manifest against the published JSON Schema and writes a registration record under `{SPT_HOME}/…/adapters/` — a **copy** of the files for `file_pull`-update adapters (spt-core owns what it later swaps) or a **pointer** for `delegated`-update adapters (the plugin owns + updates its own files). One command + one dir for both `kind="harness"` and `kind="shell"`; the `kind` field differentiates. The `--github` form **fetches the manifest first** (readable-before-install — same rule as the `min_spt_core_version` readable-before-update gate), checks compatibility, then completes the install via the manifest's own `[update]` avenue: **install is the first update** (one fetch/swap mechanism, not two). A third acquisition source, **`--release ` (+ optional `--tag` / `--asset`)**, fetches a **`.spt` archive** asset (a tar whose root holds `manifest.toml` + `strings/` + the pointed-at binaries) from the repo's GitHub release, extracts it to the durable `adapters/_github/` home, and registers the root — shipping **built binaries, source-free and versioned by tag**. It is the path for a dev **monorepo** whose adapter lives in a subdir, where the root-only `--github` clone does not fit (the release CI packs the archive from the existing repo). Like the installer's first binary fetch, first-acquisition trusts **HTTPS + GitHub**; signed verification stays with the `file_pull` *update* avenue. All three sources (local path, `--github`, `--release`) conduct the manifest's own `[update]` avenue once — install is the first update — so **acquisition is distinct from, and does not alter, the automatic ripple-update route**; an eager-extract acquisition (`--release` / `gh_release`) reports **`ADAPTER_INSTALLED`** (the files are already extracted + registered; the `[update]` avenue merely conducts on the update engine, not at add time), distinct from a `file_pull`-no-payload-yet add which is genuinely **`ADAPTER_INSTALL_PENDING`** (the payload arrives later over the update engine) — the two are no longer conflated under one "deferred" label. the release-archive fetch is the natural transport the deferred `file_pull` update would later reuse (with signature verification added). Harness-bootstrapped install (path a) calls it from the plugin's bootstrap; standalone install (path b) calls it for shell-only / Pi nodes. Registration is **node-local** — it means *"this node can drive/launch this adapter,"* distinct from advertising an endpoint into a subnet. The **registered-adapter set** the self-updater ripple-updates (see Self-update) is exactly this record set. **`adapter add` is non-destructive:** re-adding an already-registered adapter (any source landing at the same `_github/` home) is **refused** (`ADAPTER_ADD_ALREADY_REGISTERED` → use `adapter update` to refresh in place, or `adapter remove` then re-add to replace) rather than clobbering the live install; and when it does (re)populate a home it **stages-then-swaps** (fetch/clone to a sibling staging dir, swap into place only on success), so a failed fetch never strands the prior manifest+binaries as a dangling pointer — the same never-strand discipline `adapter update` already uses. .