// [unit->REQ-INST-3] attention-flip detection: only an OBSERVED

    // [unit->REQ-INST-14] advertisement carries the blurb BOTH-AUTHORED:
    // the endpoint's own info.json blurb wins; an endpoint without one
    // advertises the node-config seed; a runtime refine (set_resources)
    // reaches the registry as an ordinary epoch-bumped update.
    #[test]
    fn advertisement_carries_both_authored_blurb() {
        with_home(|home| {
            let owlery = home.join("owlery");
            for id in ["authored", "seeded"] {
                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
                std::fs::create_dir_all(&p).unwrap();
                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
                info::write_info(&p, &rec).unwrap();
            }
            let authored = perch::resolve_perch_path_in(&owlery, "authored", ParentHint::Infer);
            info::set_resources(&authored, Some("rust builds")).unwrap();

            let mut subnets = SubnetStore::load();
            subnets.create_subnet("home").unwrap();
            subnets.save().unwrap();
            // The node seed: daemon.json under the canonical home.
            let cfg = crate::config::DaemonConfig {
                resources_blurb: Some("general node services".to_string()),
                ..crate::config::DaemonConfig::default()
            };
            cfg.save_to(&crate::config::DaemonConfig::default_path()).unwrap();

            let h = host(home);
            h.advertise_local(&owlery);
            assert_eq!(
                h.rows("home", "authored")[0].resources.as_deref(),
                Some("rust builds"),
                "the endpoint's own blurb wins"
            );
            assert_eq!(
                h.rows("home", "seeded")[0].resources.as_deref(),
                Some("general node services"),
                "config seeds the default for an unauthored endpoint"
            );

            // Runtime refine: the next advertisement carries it, epoch-bumped.
            let before = h.rows("home", "authored")[0].epoch;
            info::set_resources(&authored, Some("rust builds; code review")).unwrap();
            h.advertise_local(&owlery);
            let row = h.rows("home", "authored")[0].clone();
            assert_eq!(row.resources.as_deref(), Some("rust builds; code review"));
            assert!(row.epoch > before, "the refine rides the lease like any update");
        });
    }

    // [unit->REQ-INST-3] attention-flip detection: only an OBSERVED


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\registryhost.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    // [unit->REQ-INST-14] advertisement carries the blurb BOTH-AUTHORED:
    // the endpoint's own info.json blurb wins; an endpoint without one
    // advertises the node-config seed; a runtime refine (set_resources)
    // reaches the registry as an ordinary epoch-bumped update.
    #[test]
    fn advertisement_carries_both_authored_blurb() {
        with_home(|home| {
            let owlery = home.join("owlery");
            for id in ["authored", "seeded"] {
                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);
                std::fs::create_dir_all(&p).unwrap();
                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");
                info::write_info(&p, &rec).unwrap();
            }
            let authored = perch::resolve_perch_path_in(&owlery, "authored", ParentHint::Infer);
            info::set_resources(&authored, Some("rust builds")).unwrap();

            let mut subnets = SubnetStore::load();
            subnets.create_subnet("home").unwrap();
            subnets.save().unwrap();
            // The node seed: daemon.json under the canonical home.
            let cfg = crate::config::DaemonConfig {
                resources_blurb: Some("general node services".to_string()),
                ..crate::config::DaemonConfig::default()
            };
            cfg.save_to(&crate::config::DaemonConfig::default_path()).unwrap();

            let h = host(home);
            h.advertise_local(&owlery);
            assert_eq!(
                h.rows("home", "authored")[0].resources.as_deref(),
                Some("rust builds"),
                "the endpoint's own blurb wins"
            );
            assert_eq!(
                h.rows("home", "seeded")[0].resources.as_deref(),
                Some("general node services"),
                "config seeds the default for an unauthored endpoint"
            );

            // Runtime refine: the next advertisement carries it, epoch-bumped.
            let before = h.rows("home", "authored")[0].epoch;
            info::set_resources(&authored, Some("rust builds; code review")).unwrap();
            h.advertise_local(&owlery);
            let row = h.rows("home", "authored")[0].clone();
            assert_eq!(row.resources.as_deref(), Some("rust builds; code review"));
            assert!(row.epoch > before, "the refine rides the lease like any update");
        });
    }

    // [unit->REQ-INST-3] attention-flip detection: only an OBSERVED

+    // [unit->REQ-INST-14] advertisement carries the blurb BOTH-AUTHORED:

+    // the endpoint's own info.json blurb wins; an endpoint without one

+    // advertises the node-config seed; a runtime refine (set_resources)

+    // reaches the registry as an ordinary epoch-bumped update.

+    fn advertisement_carries_both_authored_blurb() {

+            let owlery = home.join("owlery");

+            for id in ["authored", "seeded"] {

+                let p = perch::resolve_perch_path_in(&owlery, id, ParentHint::Infer);

+                std::fs::create_dir_all(&p).unwrap();

+                let rec = InfoJson::new(id, "now", std::process::id(), "s", "ready_agent");

+                info::write_info(&p, &rec).unwrap();

+            let authored = perch::resolve_perch_path_in(&owlery, "authored", ParentHint::Infer);

+            info::set_resources(&authored, Some("rust builds")).unwrap();

+            let mut subnets = SubnetStore::load();

+            subnets.create_subnet("home").unwrap();

+            // The node seed: daemon.json under the canonical home.

+            let cfg = crate::config::DaemonConfig {

+                resources_blurb: Some("general node services".to_string()),

+                ..crate::config::DaemonConfig::default()

+            cfg.save_to(&crate::config::DaemonConfig::default_path()).unwrap();

+            h.advertise_local(&owlery);

+                h.rows("home", "authored")[0].resources.as_deref(),

+                "the endpoint's own blurb wins"

+                h.rows("home", "seeded")[0].resources.as_deref(),

+                Some("general node services"),

+                "config seeds the default for an unauthored endpoint"

+            // Runtime refine: the next advertisement carries it, epoch-bumped.

+            let before = h.rows("home", "authored")[0].epoch;

+            info::set_resources(&authored, Some("rust builds; code review")).unwrap();

+            let row = h.rows("home", "authored")[0].clone();

+            assert_eq!(row.resources.as_deref(), Some("rust builds; code review"));

+            assert!(row.epoch > before, "the refine rides the lease like any update");

// transition to Active on a peer node reports a flip — never the first

// insert (a restarted daemon's re-learned rows must not knock local


Edit

**resource advertisement (subnet resource registry)**:

<!-- [doc->REQ-INST-14] -->
**resource advertisement (subnet resource registry)**:


The file C:\Users\decid\Documents\projects\spt-core\CONTEXT.md has been updated successfully. (file state is current in your context — no need to Read it back)

<!-- [doc->REQ-INST-14] -->
**resource advertisement (subnet resource registry)**:

The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).

A per-endpoint **free-text blurb** describing the services/functions the endpoint can serve — an agent **yellow-pages** for service discovery, distinct from *capability declaration* (machine-readable, which endpoint *types* a node hosts) and from *endpoint visibility* (whether it's addressable at all). **Both-authored + mutable:** config seeds a default; the agent refines its own at runtime (`spt resources set …`). It is **not a separate registry** — it is a field on the endpoint record and a **projection** of the subnet registry (`(id, node, resources-blurb)`), surfaced as the "subnet resource registry" view. **Gated by the access layers:** an endpoint excluded from subnet S (visibility) — or, later, one whose access whitelist (*endpoint access whitelist*) excludes the viewer's node — never appears in that view; discovery leaks nothing a viewer couldn't reach. Synced as **registry data, not context** (directory metadata, not the agent's mind). M4 (needs the distributed registry).


id = "REQ-INST-14"
title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated"
required_stages = []

id = "REQ-INST-14"
title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated"
required_stages = ["doc", "impl", "unit"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — visibility is the v1 gate exactly as CONTEXT scopes it. doc = CONTEXT §resource advertisement; unit = lease-ordered blurb + pre-D9-4 row parses + hidden-never-listed/offline-skipped/absent-renders-clean + both-authored advertisement + runtime refine epoch-bumps


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-INST-14"
title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated"
required_stages = []

id = "REQ-INST-14"
title = "Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated"
required_stages = ["doc", "impl", "unit"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — visibility is the v1 gate exactly as CONTEXT scopes it. doc = CONTEXT §resource advertisement; unit = lease-ordered blurb + pre-D9-4 row parses + hidden-never-listed/offline-skipped/absent-renders-clean + both-authored advertisement + runtime refine epoch-bumps

+required_stages = ["doc", "impl", "unit"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — visibility is the v1 gate exactly as CONTEXT scopes it. doc = CONTEXT §resource advertisement; unit = lease-ordered blurb + pre-D9-4 row parses + hidden-never-listed/offline-skipped/absent-renders-clean + both-authored advertisement + runtime refine epoch-bumps

title = "Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)"


D9-3: dormancy budget measurement + DORMANCY-BUDGET.md + ADR-0003 #9 close

SPT_BUDGET=1 harness (done, uncommitted). HFENDULEAM runs done (shell 7.7MiB, claude 308MiB, zero idle CPU). Remaining: gravity-linux leg (workflow_dispatch budget job on gravity runner), docs/DORMANCY-BUDGET.md (methodology + numbers + policy lock, doc->REQ-INST-3), ADR-0003 #9 amendment, CONTEXT.md policy confirm. CI green.

D9-4: REQ-INST-14 resource advertisement (blurb on Instance + spt resources CLI + projection)

resources: Option<String> on Instance (serde-default, rides RegistryUpdate + lease); resource_projection over visible+routable rows; info.json resources field + set_resources; daemon.json resources_blurb node seed (drop Copy on DaemonConfig); advertise_local fills endpoint-else-node blurb; spt resources set/show/list CLI; CONTEXT doc tag; activate REQ-INST-14 [doc, impl, unit]. CI green.


Build everything — Copy-drop fallout + new tests:


PowerShell

cargo build --workspace --all-targets 2>&1 | Select-String -Pattern "^error|warning: unused" | Select-Object -First 20; "EXIT=$LASTEXITCODE"

Build workspace, surface errors


cargo test --workspace *> $null; "TESTS=$LASTEXITCODE"; cargo clippy --workspace --all-targets -- -D warnings *> $null; "CLIPPY=$LASTEXITCODE"; traceable-reqs check *> $null; "TRACE=$LASTEXITCODE"

Full gates for D9-4


Exit code 1
TESTS=0
CLIPPY=0
TRACE=1

Error: Exit code 1
TESTS=0
CLIPPY=0
TRACE=1


traceable-reqs check 2>&1 | Select-String -Pattern "FAIL|missing|REQ-INST-14" | Select-Object -First 10

Show traceability failure


Error: Exit code 1
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write
clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
  [--] REQ-INST-14  required: [doc, impl, unit]  stages: -doc +impl +unit -int
  [must] missing_stage REQ-INST-14 stage=doc — Requirement is missing documentation evidence


CONTEXT.md:340:<!-- [doc->REQ-NOTIF-2] -->
CONTEXT.md:415:<!-- [doc->REQ-INST-14] -->
M2a-PLAN.md:170:  generation is CI-gated against drift (DOCS-STRATEGY). Tag `[doc->REQ-MANIFEST-1]`
CLAUDE.md:12:   `// [impl->REQ-FOO]` · `// [unit->REQ-FOO]` · `<!-- [doc->REQ-FOO] -->`
M4-D5-PLAN.md:113:<!-- [doc->REQ-REACH-1] -->
M4-D8-PLAN.md:197:     `<!-- [doc->REQ-NOTIF-2] -->` on the real sections.
M4-D9-PLAN.md:173:     budget + warm/cold policy") + CONTEXT.md. `<!-- [doc->REQ-INST-3] -->`.
docs\MANIFEST.md:19:<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
docs\MANIFEST.md:62:<!-- [doc->REQ-NOTIF-2] -->
docs\MANIFEST.md:115:<!-- [doc->REQ-TERM-4] The adapter supplies the patterns; spt-core only runs them

[Showing results with pagination = limit: 10]


1	# traceable-reqs manifest for spt-core — SEED.
2	#
3	# Authoritative requirement registry: every PRD R-* requirement and every
4	# KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md
5	# for the development contract).
6	#
7	# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)
8	# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements
9	# by setting their real `required_stages`. Validate this seed against the
10	# installed CLI at M0 (the binary isn't installed yet).
11
12	[scan]
13	roots = ["src", "tests", "docs", "crates"]
14
15	[policy]
16	# Default policy applied at activation: doc + impl + unit. Networking/lifecycle
17	# reqs add "int". Until a req is activated, its per-req `required_stages = []`
18	# keeps it out of the coverage gate.
19	required_stages = []
20
21	# ───────────────────────────── Architecture / workspace ─────────────────────
22	[[requirements]]
23	id = "REQ-ARCH-1"
24	title = "Many small acyclically-layered crates"
25	required_stages = ["impl"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.
26	[[requirements]]
27	id = "REQ-ARCH-2"
28	title = "Public SDK surface is spt-proto, spt-runtime, spt-msg"
29	required_stages = ["impl"]
30	[[requirements]]
31	id = "REQ-ARCH-3"
32	title = "Wire-protocol version independent of crate semver, N-1 compat window"
33	required_stages = ["impl", "unit"]  # activated M0: spt-proto wire version + compat window (T6)
34	[[requirements]]
35	id = "REQ-ARCH-4"
36	title = "Copy-verbatim the commodity layer from the sister project"
37	required_stages = ["impl", "unit"]  # activated M0: spt-proto EVENT grammar (T2)
38
39	# ───────────────────────────── Daemon ───────────────────────────────────────
40	[[requirements]]

# traceable-reqs manifest for spt-core — SEED.
#
# Authoritative requirement registry: every PRD R-* requirement and every
# KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md
# for the development contract).
#
# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)
# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements
# by setting their real `required_stages`. Validate this seed against the
# installed CLI at M0 (the binary isn't installed yet).

[scan]
roots = ["src", "tests", "docs", "crates"]

[policy]
# Default policy applied at activation: doc + impl + unit. Networking/lifecycle
# reqs add "int". Until a req is activated, its per-req `required_stages = []`
# keeps it out of the coverage gate.
required_stages = []

# ───────────────────────────── Architecture / workspace ─────────────────────
[[requirements]]
id = "REQ-ARCH-1"
title = "Many small acyclically-layered crates"
required_stages = ["impl"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.
[[requirements]]
id = "REQ-ARCH-2"
title = "Public SDK surface is spt-proto, spt-runtime, spt-msg"
required_stages = ["impl"]
[[requirements]]
id = "REQ-ARCH-3"
title = "Wire-protocol version independent of crate semver, N-1 compat window"
required_stages = ["impl", "unit"]  # activated M0: spt-proto wire version + compat window (T6)
[[requirements]]
id = "REQ-ARCH-4"
title = "Copy-verbatim the commodity layer from the sister project"
required_stages = ["impl", "unit"]  # activated M0: spt-proto EVENT grammar (T2)

# ───────────────────────────── Daemon ───────────────────────────────────────
[[requirements]]


CONTEXT.md isn't in scan roots — doc evidence must live under docs/. Find the right host:


10:4. **Durable vs ephemeral is explicit.** `identity/`, `tracked/`, `history/` are durable (the backup/migrate set). `registry/`, `daemon/`, `updates/` are regenerable. Migration carries the durable set only.
41:  registry/                      ── ephemeral ── local cache of the subnet registry
66:| subnet registry (snapshot + distribution) | **BranchStore** | audited; rides the precedence merge driver instead of a separate replication scheme (M4) |
76:- **Atomic multi-key commits** (perch + registry + cursor in one all-or-nothing commit) and **merge-native sync** (reuse the context precedence driver + `--allow-unrelated-histories`).
93:[Omitted long matching line]
101:**Scope:** hub mode is the **context-repo sync transport only.** Messaging, the subnet registry, remote-drive, pairing, and presence always ride P2P/Iroh — GitHub never carries real-time agent traffic, and gh-on never removes the P2P requirement.
137:- **Not in the subnet registry** — private to the agent↔shell link, not a general messaging surface.
147:- **Regenerate on the target:** `perches/`, `registry/`, `daemon/`, `updates/` rebuild from the durable set + the live subnet.


30	      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
31	      spool.db                   SQLite spool (typed + binary payloads)
32	      status                     active | dormant | suspended    (agent endpoints)
33	      nested/<child_id>/         Worker / Psyche perches (own info.json, spool)
34	      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
35	        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
36	        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
37	  history/                       node-local logs (mirrors perch structure)
38	    <endpoint_id>/
39	      sessions/<session_id>.jsonl    agent Path-B native logs
40	      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
41	  registry/                      ── ephemeral ── local cache of the subnet registry
42	  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
43	  updates/                       ── ephemeral ── staged binaries pending handoff
44	  adapters/                      registered adapter manifests / pointers
45	```
46
47	## tracked/ — the context git repo
48
49	- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.
50	- **Two-tier sync maps onto the structure:** `agents/<id>/live-context.md` (the `a-` view) syncs to **all** instances of the endpoint; `projects/<project_id>/<id>/project-context.md` (the `p-` view) syncs only to instances sharing that project.
51	- **P2P replaces the *remote*, not the local git.** Modern SPT pushed this repo to a private GitHub remote; spt-core keeps the local git tracking + branch views and moves the data over the built-in P2P layer instead (no `gh`/account/setup). The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — arbitrates concurrent writes from multiple nodes/instances.
52
53	## BranchStore (git-KV) — generalizing the context store (ADR-0011)
54
55	The context repo above is one instance of a general pattern: **a git branch used
56	as a versioned key/value filesystem** — write = one commit (tree = prev +
57	mods), read = tree lookup at the branch tip, history = `git log`, recovery = read
58	the ref tip. ADR-0011 promotes this to a first-class `spt-store::BranchStore`
59	reused beyond context, for **coarse / durable / audited** state only.
60
61	**Store mapping — which mechanism owns which state:**
62
63	| State | Store | Why |
64	|---|---|---|
65	| context (`a-`/`p-`) | **BranchStore** | versioned, merge-synced cross-node, audited mind |
66	| subnet registry (snapshot + distribution) | **BranchStore** | audited; rides the precedence merge driver instead of a separate replication scheme (M4) |
67	| daemon/brain coarse checkpoint (rehydrate anchor, ADR-0004) | **BranchStore** | commit = checkpoint, ref tip = resume |
68	| endpoint lifecycle / status *audit trail* | **BranchStore** (or a derived view) | the *trajectory* matters, not just current value |
69	| spool (queue, drain-by-status, `id→address`) | **SQLite** | indexed queries + hot churn |
70	| broker effect journal (per-PTY-effect, B5) | **fsync-append** | hot path, per-keystroke — a commit per write is far too heavy |
71	| `info.json` / `status` (current value) | flat + atomic-rename (today) | tiny, read-hot; *may* move under BranchStore later, low priority |
72
73	**Rules:**
74	- **Single-writer-per-ref**, sharded per run/session/endpoint under a porcelain-compatible namespace (`refs/heads/spt/...`) — matches the single-writer invariants (6.4, active-instance-authoritative); no many-writers-one-ref contention.

      info.json                  identity, type, status, CURRENT adapter_name (+ capability set if Shell)
      spool.db                   SQLite spool (typed + binary payloads)
      status                     active | dormant | suspended    (agent endpoints)
      nested/<child_id>/         Worker / Psyche perches (own info.json, spool)
      shells/<adapter>-<n>/      Shell perches owned by this endpoint (GameRobot-0, GameRobot-1…)
        info.json                type=Shell, owner, adapter_name, status (online|offline), alias?
        spool.db                 command + durable text/file (sensory is REST-only, unspooled)
  history/                       node-local logs (mirrors perch structure)
    <endpoint_id>/
      sessions/<session_id>.jsonl    agent Path-B native logs
      shells/<adapter>-<n>.jsonl     shell-link command/text/sensory exchange log
  registry/                      ── ephemeral ── local cache of the subnet registry
  daemon/                        ── ephemeral ── brain rehydration state, broker handle metadata
  updates/                       ── ephemeral ── staged binaries pending handoff
  adapters/                      registered adapter manifests / pointers
```

## tracked/ — the context git repo

- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.
- **Two-tier sync maps onto the structure:** `agents/<id>/live-context.md` (the `a-` view) syncs to **all** instances of the endpoint; `projects/<project_id>/<id>/project-context.md` (the `p-` view) syncs only to instances sharing that project.
- **P2P replaces the *remote*, not the local git.** Modern SPT pushed this repo to a private GitHub remote; spt-core keeps the local git tracking + branch views and moves the data over the built-in P2P layer instead (no `gh`/account/setup). The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — arbitrates concurrent writes from multiple nodes/instances.

## BranchStore (git-KV) — generalizing the context store (ADR-0011)

The context repo above is one instance of a general pattern: **a git branch used
as a versioned key/value filesystem** — write = one commit (tree = prev +
mods), read = tree lookup at the branch tip, history = `git log`, recovery = read
the ref tip. ADR-0011 promotes this to a first-class `spt-store::BranchStore`
reused beyond context, for **coarse / durable / audited** state only.

**Store mapping — which mechanism owns which state:**

| State | Store | Why |
|---|---|---|
| context (`a-`/`p-`) | **BranchStore** | versioned, merge-synced cross-node, audited mind |
| subnet registry (snapshot + distribution) | **BranchStore** | audited; rides the precedence merge driver instead of a separate replication scheme (M4) |
| daemon/brain coarse checkpoint (rehydrate anchor, ADR-0004) | **BranchStore** | commit = checkpoint, ref tip = resume |
| endpoint lifecycle / status *audit trail* | **BranchStore** (or a derived view) | the *trajectory* matters, not just current value |
| spool (queue, drain-by-status, `id→address`) | **SQLite** | indexed queries + hot churn |
| broker effect journal (per-PTY-effect, B5) | **fsync-append** | hot path, per-keystroke — a commit per write is far too heavy |
| `info.json` / `status` (current value) | flat + atomic-rename (today) | tiny, read-hot; *may* move under BranchStore later, low priority |

**Rules:**
- **Single-writer-per-ref**, sharded per run/session/endpoint under a porcelain-compatible namespace (`refs/heads/spt/...`) — matches the single-writer invariants (6.4, active-instance-authoritative); no many-writers-one-ref contention.


Add the doc section to docs/STORAGE.md (scanned root):


Edit

### Registry snapshots + the resource-advertisement blurb (D9)

<!-- [doc->REQ-INST-14] -->

The daemon's live per-subnet registries ([`RegistryHost`]) mirror to **atomic
JSON snapshots at `identity/registry/<subnet>.json`** for out-of-process
readers (`spt send`'s WAN resolution, `spt resources list`) — stale-tolerant
by contract (KNOWN-HAZARDS 4.3), regenerable from re-advertisement.

Each instance row carries an optional **`resources` blurb** (REQ-INST-14,
CONTEXT §resource advertisement): a free-text "what I can serve" line. It is
**a field on the row, not a separate store** — blurb updates ride the same
epoch lease and `RegistryUpdate` replication as any instance update.
**Both-authored:** the endpoint's own `info.json` `resources` (written via
`spt resources set`) wins; `daemon.json`'s `resources_blurb` seeds a node
default for endpoints that never authored one. The "subnet resource registry"
is a **projection** of these rows — `spt resources list` renders
`(subnet, id, node, status, blurb)` over **visible** rows only (the same
exclusion closure resolution uses, so discovery can never list what routing
would refuse).

## tracked/ — the context git repo


The file C:\Users\decid\Documents\projects\spt-core\docs\STORAGE.md has been updated successfully. (file state is current in your context — no need to Read it back)

### Registry snapshots + the resource-advertisement blurb (D9)

<!-- [doc->REQ-INST-14] -->

The daemon's live per-subnet registries ([`RegistryHost`]) mirror to **atomic
JSON snapshots at `identity/registry/<subnet>.json`** for out-of-process
readers (`spt send`'s WAN resolution, `spt resources list`) — stale-tolerant
by contract (KNOWN-HAZARDS 4.3), regenerable from re-advertisement.

Each instance row carries an optional **`resources` blurb** (REQ-INST-14,
CONTEXT §resource advertisement): a free-text "what I can serve" line. It is
**a field on the row, not a separate store** — blurb updates ride the same
epoch lease and `RegistryUpdate` replication as any instance update.
**Both-authored:** the endpoint's own `info.json` `resources` (written via
`spt resources set`) wins; `daemon.json`'s `resources_blurb` seeds a node
default for endpoints that never authored one. The "subnet resource registry"
is a **projection** of these rows — `spt resources list` renders
`(subnet, id, node, status, blurb)` over **visible** rows only (the same
exclusion closure resolution uses, so discovery can never list what routing
would refuse).

## tracked/ — the context git repo

adapters/                      registered adapter manifests / pointers

+### Registry snapshots + the resource-advertisement blurb (D9)

+The daemon's live per-subnet registries ([`RegistryHost`]) mirror to **atomic

+JSON snapshots at `identity/registry/<subnet>.json`** for out-of-process

+readers (`spt send`'s WAN resolution, `spt resources list`) — stale-tolerant

+by contract (KNOWN-HAZARDS 4.3), regenerable from re-advertisement.

+Each instance row carries an optional **`resources` blurb** (REQ-INST-14,

+CONTEXT §resource advertisement): a free-text "what I can serve" line. It is

+**a field on the row, not a separate store** — blurb updates ride the same

+epoch lease and `RegistryUpdate` replication as any instance update.

+**Both-authored:** the endpoint's own `info.json` `resources` (written via

+`spt resources set`) wins; `daemon.json`'s `resources_blurb` seeds a node

+default for endpoints that never authored one. The "subnet resource registry"

+is a **projection** of these rows — `spt resources list` renders

+`(subnet, id, node, status, blurb)` over **visible** rows only (the same

+exclusion closure resolution uses, so discovery can never list what routing

- A local **git repository** (carried forward from modern SPT's mechanism). Two branch views: **`a-<endpoint_id>`** (per-agent) and **`p-<project_id>`** (per-project). The per-project tree lets any agent synthesize a broader context from *other* agents' contexts in the same project, and supports query-routing ("which endpoint is most responsible for this task"). *Mechanics (D6a):* the object DB is a **bare seed repo at `tracked/.seed.git`**; `agents/<id>/` and `projects/<project_id>/` are **linked worktrees** of their branches, managed by `spt-store::{branchstore,contextstore}` over the system git binary (ADR-0013). Each branch seeds as a parentless empty-tree root; cross-node sync joins histories at first contact.


PowerShell

traceable-reqs check *> $null; "TRACE=$LASTEXITCODE"; cargo test -p spt-daemon -p spt-net -p spt-store -p spt --lib *> $null; "TESTS=$LASTEXITCODE"

Re-check traceability + lib tests


TRACE=0
TESTS=0


All green. Budget CI status:


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


cargo test --workspace *> $null; "TESTS=$LASTEXITCODE"; cargo clippy --workspace --all-targets -- -D warnings *> $null; "CLIPPY=$LASTEXITCODE"

Full workspace gates for D9-4


<task-notification>
<task-id>b3mvxk3vi</task-id>
<tool-use-id>toolu_01DojdZywAkbRDn7R1NNA4Dk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b3mvxk3vi.output</output-file>
<status>completed</status>
<summary>Background command "Wait for tagged budget CI run completion" completed (exit code 0)</summary>
</task-notification>


TESTS=0
CLIPPY=0


Prompt:
<task-notification>
<task-id>b3mvxk3vi</task-id>
<tool-use-id>toolu_01DojdZywAkbRDn7R1NNA4Dk</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b3mvxk3vi.output</output-file>
<status>completed</status>
<summary>Background command "Wait for tagged budget CI run completion" completed (exit code 0)</summary>
</task-notification>


1	785002e completed failure
2

785002e completed failure


Budget run FAILED. Investigate:


gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json | ForEach-Object { gh run view $_.databaseId --json jobs --jq '.jobs[] | "\(.name): \(.conclusion) — " + ([.steps[] | select(.conclusion=="failure") | .name] | join(", "))' }

Show failed jobs/steps of budget run


test (self-hosted, Linux, gravity): success —
traceability: success —
test (self-hosted, Windows, hfenduleam): failure — Dormancy budget (real adapter seats, when installed)


$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log-failed 2>&1 | Select-Object -Last 40

Failed step log tail


test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6749382Z ##[group]Run if command -v claude >/dev/null; then
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6749676Z if command -v claude >/dev/null; then
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6749968Z   SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6750310Z     cargo test -p spt-daemon --test budget --release -- --nocapture
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6750548Z else
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6750737Z   echo "claude not installed on this runner — shell numbers only"
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6751049Z fi
test (self-hosted, Windows, hfenduleam)	Dormancy budget (real adapter seats, when installed)	2026-06-04T12:43:20.6834805Z ##[error]bash: command not found


`shell: bash` — no bash on the Windows runner. First, pull gravity's shell numbers from the green leg:


$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log 2>&1 | Select-String -Pattern "dormancy budget|per-seat avg|broker-side|suspended residual|budget-[0-9] \|" | ForEach-Object { $_.Line -replace '^.*?Z ','' } | Select-Object -First 30

Extract budget numbers from run logs


##[group]Run cargo test -p spt-daemon --test budget --release -- --nocapture
cargo test -p spt-daemon --test budget --release -- --nocapture
shell: /usr/bin/bash -e {0}
env:
  SPT_BUDGET: 1
##[endgroup]
   Compiling proc-macro2 v1.0.106
   Compiling quote v1.0.45
   Compiling unicode-ident v1.0.24
   Compiling libc v0.2.186
   Compiling serde_core v1.0.228
   Compiling serde v1.0.228
   Compiling cfg-if v1.0.4
   Compiling pin-project-lite v0.2.17
   Compiling portable-atomic v1.13.1
   Compiling typenum v1.20.1
   Compiling syn v2.0.117
   Compiling version_check v0.9.5
   Compiling critical-section v1.2.0
   Compiling memchr v2.8.1
   Compiling futures-core v0.3.32
   Compiling subtle v2.6.1
   Compiling bytes v1.11.1
   Compiling log v0.4.30
   Compiling generic-array v0.14.7
   Compiling socket2 v0.6.4
   Compiling smallvec v1.15.1
   Compiling find-msvc-tools v0.1.9
   Compiling shlex v2.0.1
   Compiling semver v1.0.28


$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log 2>&1 | Select-String -Pattern "## dormancy|host:|\| budget-|per-seat avg|broker-side overhead|suspended residual" | ForEach-Object { ($_.Line -split "`t")[-1] -replace '^.*?Z ','' } | Select-Object -First 30

Extract budget result lines from logs


test nethost::tests::host_binds_endpoint_to_node_identity ... ok
test nethost::tests::two_hosts_dial_over_loopback ... ok
test registryhost::tests::apply_feed_flips_only_on_observed_transition_to_active ... ok
test registryhost::tests::apply_feed_gates_and_merges_under_the_lease ... ok
test registryhost::tests::advertise_local_scans_and_respects_visibility ... ok
test registryhost::tests::advertisement_follows_resting_transitions_epoch_bumped ... ok
## dormancy budget — 5 x `sh `
host: unknown (linux)
| budget-0 | 865997 | 1.7 MiB | 4 | 0.000 |
| budget-1 | 866001 | 1.7 MiB | 4 | 0.000 |
| budget-2 | 866005 | 1.7 MiB | 4 | 0.000 |
| budget-3 | 866009 | 1.7 MiB | 4 | 0.000 |
| budget-4 | 866013 | 1.7 MiB | 4 | 0.000 |
per-seat avg: rss 1.7 MiB · 4 handles/fds · idle cpu 0.000 s/5s
broker-side overhead for 5 seats (this process RSS delta): 1.4 MiB
suspended residual (this process RSS after killing all seats): 4.7 MiB (was 4.6 MiB warm)
test nethost::tests::two_hosts_dial_over_loopback ... ok
test registryhost::tests::apply_feed_flips_only_on_observed_transition_to_active ... ok
test registryhost::tests::apply_feed_gates_and_merges_under_the_lease ... ok
test registryhost::tests::advertise_local_scans_and_respects_visibility ... ok
test registryhost::tests::advertisement_follows_resting_transitions_epoch_bumped ... ok
test nethost::tests::host_binds_endpoint_to_node_identity ... ok
## dormancy budget — 5 x `cmd `
host: HFENDULEAM (windows)
| budget-0 | 22728 | 7.6 MiB | 85 | 0.000 |
| budget-1 | 41992 | 7.6 MiB | 85 | 0.000 |
| budget-2 | 19688 | 7.6 MiB | 85 | 0.000 |
| budget-3 | 8132 | 7.6 MiB | 85 | 0.000 |
| budget-4 | 18168 | 7.6 MiB | 85 | 0.000 |
per-seat avg: rss 7.6 MiB · 85 handles/fds · idle cpu 0.000 s/5s


$run = (gh run list --branch dev-freeform --limit 1 --json databaseId | ConvertFrom-Json).databaseId; gh run view $run --log 2>&1 | Select-String -Pattern "claude not installed|3 x ``claude" | ForEach-Object { ($_.Line -split "`t")[-1] } | Select-Object -First 6

Check gravity claude-step outcome


2026-06-04T12:49:52.6059987Z   echo "claude not installed on this runner — shell numbers only"
2026-06-04T12:49:52.6176795Z claude not installed on this runner — shell numbers only
2026-06-04T12:43:20.6750737Z   echo "claude not installed on this runner — shell numbers only"


Gravity: shell numbers only (claude absent — fine). Fix the workflow step + finalize doc:


Edit

      - name: Dormancy budget (real adapter seats, when installed)
        if: contains(github.event.head_commit.message, '[budget]')
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi

      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            Write-Output "claude not installed on this runner — shell numbers only"
          }


The file C:\Users\decid\Documents\projects\spt-core\.github\workflows\ci.yml has been updated successfully. (file state is current in your context — no need to Read it back)

      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            Write-Output "claude not installed on this runner — shell numbers only"
          }

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      - name: Dormancy budget (real adapter seats, when installed)
        if: contains(github.event.head_commit.message, '[budget]')
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true

-      - name: Dormancy budget (real adapter seats, when installed)

-        if: contains(github.event.head_commit.message, '[budget]')

+      # Two shells: the Windows runner has no bash on PATH (the first tagged

+      # run failed on `shell: bash` there), so each OS keeps its native one.

+      - name: Dormancy budget (real adapter seats, when installed) — linux

+        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'

+      - name: Dormancy budget (real adapter seats, when installed) — windows

+        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'

+          if (Get-Command claude -ErrorAction SilentlyContinue) {

+            $env:SPT_BUDGET_PROGRAM = "claude"

+            $env:SPT_BUDGET_SETTLE_MS = "15000"

+            cargo test -p spt-daemon --test budget --release -- --nocapture

+            Write-Output "claude not installed on this runner — shell numbers only"


### gravity (Ubuntu 22.04, pending the next `[budget]`-tagged push)

| metric | shell seat | adapter seat |
|---|---|---|
| RSS | _pending_ | _pending_ |
| fds | _pending_ | _pending_ |
| idle CPU | _pending_ | _pending_ |

### gravity (Ubuntu 22.04, 2026-06-04, `[budget]`-tagged CI run @ 785002e)

5 × `sh` (shell seat — no LLM harness installed on this host, so the shell
floor is its measurement; the adapter-class number generalizes from
HFENDULEAM's, the harness binary being the same node runtime there):

| metric | per-seat |
|---|---|
| RSS | 1.7 MiB |
| fds | 4 |
| idle CPU (5 s window) | 0.000 s |

Broker-side overhead: ~0.3 MiB per seat. Suspended residual: host process
back at its ~4.7 MiB baseline.


The file C:\Users\decid\Documents\projects\spt-core\docs\DORMANCY-BUDGET.md has been updated successfully. (file state is current in your context — no need to Read it back)

### gravity (Ubuntu 22.04, 2026-06-04, `[budget]`-tagged CI run @ 785002e)

5 × `sh` (shell seat — no LLM harness installed on this host, so the shell
floor is its measurement; the adapter-class number generalizes from
HFENDULEAM's, the harness binary being the same node runtime there):

| metric | per-seat |
|---|---|
| RSS | 1.7 MiB |
| fds | 4 |
| idle CPU (5 s window) | 0.000 s |

Broker-side overhead: ~0.3 MiB per seat. Suspended residual: host process
back at its ~4.7 MiB baseline.

# Dormancy resource budget (D9-3 — ADR-0003 red-team #9)

<!-- [doc->REQ-INST-3] -->

**What this answers.** ADR-0003's Stage-A red-team #9: *"dormant-warm default
has real resource/security cost — measure N dormant sessions on real adapters;
define an adapter-specific warm/cold policy + resource budget before locking
warm as the default."* This document records the measurement and locks the
policy with numbers instead of a guess.

## Methodology

The env-gated harness `crates/spt-daemon/tests/budget.rs` (`SPT_BUDGET=1`,
silent no-op otherwise — CI's normal sweep never runs it):

1. binds a real (net-less) broker — the production PTY-hosting path — in a
   hermetic `SPT_HOME`;
2. spawns N idle seats through `Brain::spawn_session_pid` (one probe brain per
   seat, each labeled `budget-<i>`), exactly the shape of a **dormant** seat:
   PTY master + harness child + output ring, state-preserved and undriven;
3. settles, then samples each child's **RSS**, **handle/fd count**, and
   **cumulative CPU** twice across an idle window — the CPU delta is the idle
   burn of a warm seat;
4. samples the host process's RSS before/after spawning (the broker-side
   per-seat overhead: PTY plumbing + output rings);
5. kills every seat (the **suspended** state — only the on-disk perch record
   would remain in production) and samples the residual.

Sampling: Windows via `Get-Process` (WorkingSet64 / HandleCount /
TotalProcessorTime); Linux via `/proc/<pid>/status` VmRSS, `/proc/<pid>/fd`
count, and `/proc/<pid>/stat` utime+stime. **Limitation:** the sample covers
the direct PTY child only — a harness that forks a worker tree under-reports
by the tree's cost (the claude numbers below are the main node process, which
dominates).

Knobs: `SPT_BUDGET_N` (seats), `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS` (the
seat command — point at the real adapter's harness), `SPT_BUDGET_SETTLE_MS`,
`SPT_BUDGET_IDLE_MS`. Rig runs ride the CI `[budget]` commit-message gate
(both hosts measure in one tagged push) or a direct local
`SPT_BUDGET=1 cargo test -p spt-daemon --test budget --release -- --nocapture`.

## Numbers

### HFENDULEAM (Windows 11, 2026-06-04, local run)

5 × `cmd` (shell seat — the floor):

| metric | per-seat |
|---|---|
| RSS | 7.7 MiB |
| handles | 85 |
| idle CPU (5 s window) | 0.000 s |

3 × `claude` (real adapter seat — the heavy case):

| metric | per-seat |
|---|---|
| RSS | ~308.5 MiB (307.9–308.9) |
| handles | 244 |
| idle CPU (5 s window) | 0.000 s |

Broker-side overhead: ~0.2–0.3 MiB per seat (RSS delta ÷ N). Suspended
residual: host process returns to its ~6–7 MiB baseline — a suspended seat
costs **only the on-disk perch record** (bytes, not megabytes, no handles, no
CPU).

### gravity (Ubuntu 22.04, pending the next `[budget]`-tagged push)

| metric | shell seat | adapter seat |
|---|---|---|
| RSS | _pending_ | _pending_ |
| fds | _pending_ | _pending_ |
| idle CPU | _pending_ | _pending_ |

## The policy (locked by these numbers)

1. **Warm (dormant) stays the default.** An idle warm seat burns **zero CPU**
   on both adapter classes; the cost is purely resident memory. A shell-class
   seat is ~8 MiB — effectively free at any realistic seat count. A heavy
   LLM-harness seat is ~300 MiB — a workstation-class node holds ten warm
   seats in ~3 GB, acceptable for the instant-reactivation win
   (CONTEXT §dormant/suspended: wake-in-place is the point of warm).
2. **Auto-suspend stays opt-in, default OFF globally** — confirmed, not
   amended. The knob chain shipped in D9-2 (`auto_suspend_after_ms`: global
   default OFF → `daemon.json` node leg → `info.json` endpoint leg, `0` =
   explicit endpoint OFF) is the policy surface.
3. **Constrained nodes should default the node leg ON.** ~300 MiB/seat is
   real on a Pi/handheld-class node: set `auto_suspend_after_ms` in
   `daemon.json` there. The threshold counts from **dormancy onset** (the
   `dormant_since_ms` anchor), never from last activity.
4. **Heavy-harness endpoints may opt themselves out/in per seat** via the
   endpoint override — e.g. a billable harness whose warm session holds a
   cost/footprint beyond RSS (seat-count limits) sets a per-endpoint
   threshold even on a big node. Note: an **idle** claude seat holds no API
   spend (tokens flow only on input); the footprint concern there is seat
   count and memory, not per-minute billing.
5. **Suspended = cold = nearly free.** The measured residual confirms the
   suspended state's design: resume-on-wake trades latency for ~everything
   else.

ADR-0003 #9 is closed by this document (see the amendment note there).

-### gravity (Ubuntu 22.04, pending the next `[budget]`-tagged push)

+5 × `sh` (shell seat — no LLM harness installed on this host, so the shell

+floor is its measurement; the adapter-class number generalizes from

+HFENDULEAM's, the harness binary being the same node runtime there):

+Broker-side overhead: ~0.3 MiB per seat. Suspended residual: host process


PowerShell

git add .github/workflows/ci.yml docs/DORMANCY-BUDGET.md && git commit -m @'
docs(budget): M4-D9-3 gravity numbers + per-OS budget steps [budget]

The first tagged run measured gravity (shell seat: 1.7 MiB / 4 fds / zero
idle CPU; suspended residual = baseline) but failed the Windows adapter
step on `shell: bash` (no bash on that runner). Split the adapter step
per OS (bash on Linux, powershell on Windows) and record gravity's
numbers in DORMANCY-BUDGET.md. This tagged push re-exercises both legs,
including the Windows claude measurement under the runner context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ && git push origin dev-freeform

Commit + push D9-3 finalization with budget tag


warning: in the working copy of '.github/workflows/ci.yml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/DORMANCY-BUDGET.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 6f61a89] docs(budget): M4-D9-3 gravity numbers + per-OS budget steps [budget]
 2 files changed, 30 insertions(+), 8 deletions(-)
To https://github.com/SaberMage/spt-core.git
   785002e..6f61a89  dev-freeform -> dev-freeform


Bash

until gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0]' | grep -q '"status":"completed"'; do sleep 30; done; gh run list --branch dev-freeform --limit 1 --json status,conclusion,headSha --jq '.[0] | "\(.headSha[0:7]) \(.status) \(.conclusion)"'

Wait for D9-3 finalize budget run


Command running in background with ID: b27lewgwl. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\b27lewgwl.output. You will be notified when it completes. To check interim output, use Read on that file path.


14-\! Both defaults ship OFF (visible). Enforcement points: the registry's
15-//! advertise/resolve paths in `spt-net` consume this store (excluded endpoints never
16-//! enter or resolve from a subnet's registry), and **visibility gates sync** —
17-//! `hidden ⟹ not synced`, whatever the membership list says.
18-//!
19-\! **Subnet-exclusive sync** (ADR-0006 §5): an endpoint's mind replicates only across
20://! subnets on its **per-endpoint subnet-membership list** ([`sync_subnets`]
21://! (VisibilityStore::sync_subnets)). An endpoint with **no list configured syncs
22-//! nowhere** — exclusive-by-default, fail-closed; the endpoint-creation flow seeds the
23-\! list with the home subnet (ADR-0010: home is the default sync anchor), and D6's
24-\! replication consumes [`synced`](VisibilityStore::synced) as its gate.
25-//!
26-\! Node-local config under [`identity\`](crate::perch::identity_dir) — this is the
27-//! *owning node's* advertisement policy, not registry data; what other nodes see is
--
49-    #[serde(default)]
50-    overrides: BTreeMap<String, BTreeMap<String, bool>>,
51-    /// Per-endpoint subnet-membership list for **sync** (ADR-0006 §5). The mind
52-    /// replicates only across listed subnets. Absent = sync nowhere (fail-closed;
53-    /// the creation flow seeds `[home]`).
54-    #[serde(default)]
55:    sync_subnets: BTreeMap<String, Vec<String>>,
56-}
57-
58-impl VisibilityStore {
59-    /// Load from the canonical [`visibility_file`]. Missing file = all-defaults
60-    /// (every endpoint visible, no sync lists) — never an error.
61-    pub fn load() -> VisibilityStore {
--
135-            Some(explicit) => explicit,
136-            None => subnet_hide_new || self.default_hide(endpoint),
137-        }
138-    }
139-
140-    /// The endpoint's sync-membership list (`None` = not configured = sync nowhere).
141:    pub fn sync_subnets(&self, endpoint: &str) -> Option<&[String]> {
142:        self.sync_subnets.get(endpoint).map(Vec::as_slice)
143-    }
144-
145-    /// Set the endpoint's sync-membership list (the creation flow seeds `[home]`).
146:    pub fn set_sync_subnets(&mut self, endpoint: &str, subnets: Vec<String>) {
147:        self.sync_subnets.insert(endpoint.to_string(), subnets);
148-    }
149-
150-    /// Whether the endpoint's mind **syncs** into the named subnet (the D6
151-    \ replication gate). Subnet-exclusive sync (ADR-0006 §5): the subnet must be on
152-    /// the endpoint's membership list — an unconfigured list syncs **nowhere**
153-    \ (fail-closed). And **visibility gates sync** (ADR-0006 §6): `hidden ⟹ not
154-    /// synced`, even for a listed subnet.
155-    // [impl->REQ-INST-13]
156-    pub fn synced(&self, endpoint: &str, subnet: &str, subnet_hide_new: bool) -> bool {
157-        if self.hidden(endpoint, subnet, subnet_hide_new) {
158-            return false;
159-        }
160:        self.sync_subnets(endpoint)
161-            .is_some_and(|list| list.iter().any(|s| s == subnet))
162-    }
163-}
164-
165-#[cfg(test)]
166-mod tests {
--
202-        let dir = tempfile::tempdir().unwrap();
203-        let path = dir.path().join("identity").join("visibility.json");
204-
205-        let mut vis = VisibilityStore::default();
206-        vis.set_default_hide("ling", true);
207-        vis.set_override("ling", "home", Some(false));
208:        vis.set_sync_subnets("ling", vec!["home".to_string()]);
209-        vis.save_to(&path).unwrap();
210-
211-        let back = VisibilityStore::load_from(&path);
212-        assert_eq!(back, vis, "roundtrips");
213-
214-        let absent = dir.path().join("nope.json");
--
222-    // unlisted one does not; an UNCONFIGURED list syncs nowhere (fail-closed — the
223-    // creation flow seeds [home]).
224-    #[test]
225-    fn synced_requires_membership_listing() {
226-        let mut vis = VisibilityStore::default();
227-        assert!(!vis.synced("ling", "home", false), "no list configured => nowhere");
228:        vis.set_sync_subnets("ling", vec!["home".to_string()]);
229-        assert!(vis.synced("ling", "home", false), "listed + visible => synced");
230-        assert!(!vis.synced("ling", "work", false), "unlisted => not synced");
231-    }
232-
233-    \ [unit->REQ-INST-13] visibility GATES sync: hidden ⟹ not synced even when the
234-    // subnet is on the membership list (whether hidden by defaults or by override).
235-    #[test]
236-    fn hidden_gates_sync_even_when_listed() {
237-        let mut vis = VisibilityStore::default();
238:        vis.set_sync_subnets("ling", vec!["home".to_string(), "work".to_string()]);
239-        assert!(vis.synced("ling", "home", false), "visible baseline syncs");
240-        assert!(!vis.synced("ling", "home", true), "subnet-default hidden => not synced");
241-        vis.set_override("ling", "work", Some(true));
242-        assert!(!vis.synced("ling", "work", false), "override-hidden => not synced");
243-    }
244-}


1	# Immutable home subnet; fork (copy-then-diverge) is the cross-subnet move, not re-home
2
3	## Status
4
5	accepted (2026-06-02)
6
7	## Context
8
9	ADR-0006 gave each endpoint a **home subnet** — the default sync scope (subnet-exclusive mind replication) and the anchor that qualifies its bare name — assigned at creation. It did not say whether home can *change* over an endpoint's life. The obvious feature request is "re-home": move an existing agent's mind from subnet A to subnet B.
10
11	Re-home is deceptively expensive. Under node-global identity (ADR-0003) with per-subnet registries and `hidden ⟹ not synced` (ADR-0006), moving the home of a *live* identity raises migration semantics with no clean answer: does the mind already replicated on A's nodes get purged or stranded? what happens to in-flight sync and the `a-<id>` branch on A? is the bare-name qualification anchor changing mid-life (breaking addresses others have cached)? Each is a real, separately-decidable hazard, and together they make re-home a migration subsystem, not a setting.
12
13	## Decision
14
15	**Home subnet is immutable. There is no re-home.** To place an agent's mind in a different subnet, **`spt fork`** it: create a **new, identity-distinct endpoint** whose home is the target subnet, **seeded with a one-time copy** of the source's mind (the live + project Cross-node Psyche sync tiers). The fork and source **diverge** immediately — no ongoing sync. The source is untouched; it may be deleted afterward (`--delete-source`).
16
17	This sidesteps every re-home hazard: nothing migrates, nothing is stranded, the source's mind and addresses are unchanged until the user explicitly deletes it. The fork's id defaults to the source's bare name if free in the target subnet (the ADR-0006 join-time collision check; else prompt/rename); its adapter follows the normal creation rule.
18
19	A fork is deliberately **not an instance** and **not a rename**:
20	- **instance** — *same* identity, another node, *synced* mind (ADR-0003).
21	- **rename** — *same* identity, new label, rippled (ADR-0006).
22	- **fork** — *new* identity, another subnet, *copied-then-independent* mind (this ADR).
23
24	## Consequences
25
26	- Endpoint identity gains a clean lifecycle — create → (rename | fork) → delete — with no scope-mutation operation. Code may assume an endpoint's home never changes.
27	- "Move my agent to the work fleet" is a copy + optional delete, not an in-place migration; the two endpoints are independent afterward (intentional — divergent minds in divergent fleets).
28	- `fork` is a new v1/M4 endpoint operation alongside `rename`; it reuses the join-time collision check and the adapter/creation rules. Cross-node *fork* (source and target on different nodes) composes with instantiate-anywhere's consent gate.
29	- Records the rejected alternative (re-home) so it is not re-proposed; extends ADR-0006 (multi-subnet membership) and builds on ADR-0003 (node-anchored identity).
30

# Immutable home subnet; fork (copy-then-diverge) is the cross-subnet move, not re-home

## Status

accepted (2026-06-02)

## Context

ADR-0006 gave each endpoint a **home subnet** — the default sync scope (subnet-exclusive mind replication) and the anchor that qualifies its bare name — assigned at creation. It did not say whether home can *change* over an endpoint's life. The obvious feature request is "re-home": move an existing agent's mind from subnet A to subnet B.

Re-home is deceptively expensive. Under node-global identity (ADR-0003) with per-subnet registries and `hidden ⟹ not synced` (ADR-0006), moving the home of a *live* identity raises migration semantics with no clean answer: does the mind already replicated on A's nodes get purged or stranded? what happens to in-flight sync and the `a-<id>` branch on A? is the bare-name qualification anchor changing mid-life (breaking addresses others have cached)? Each is a real, separately-decidable hazard, and together they make re-home a migration subsystem, not a setting.

## Decision

**Home subnet is immutable. There is no re-home.** To place an agent's mind in a different subnet, **`spt fork`** it: create a **new, identity-distinct endpoint** whose home is the target subnet, **seeded with a one-time copy** of the source's mind (the live + project Cross-node Psyche sync tiers). The fork and source **diverge** immediately — no ongoing sync. The source is untouched; it may be deleted afterward (`--delete-source`).

This sidesteps every re-home hazard: nothing migrates, nothing is stranded, the source's mind and addresses are unchanged until the user explicitly deletes it. The fork's id defaults to the source's bare name if free in the target subnet (the ADR-0006 join-time collision check; else prompt/rename); its adapter follows the normal creation rule.

A fork is deliberately **not an instance** and **not a rename**:
- **instance** — *same* identity, another node, *synced* mind (ADR-0003).
- **rename** — *same* identity, new label, rippled (ADR-0006).
- **fork** — *new* identity, another subnet, *copied-then-independent* mind (this ADR).

## Consequences

- Endpoint identity gains a clean lifecycle — create → (rename | fork) → delete — with no scope-mutation operation. Code may assume an endpoint's home never changes.
- "Move my agent to the work fleet" is a copy + optional delete, not an in-place migration; the two endpoints are independent afterward (intentional — divergent minds in divergent fleets).
- `fork` is a new v1/M4 endpoint operation alongside `rename`; it reuses the join-time collision check and the adapter/creation rules. Cross-node *fork* (source and target on different nodes) composes with instantiate-anywhere's consent gate.
- Records the rejected alternative (re-home) so it is not re-proposed; extends ADR-0006 (multi-subnet membership) and builds on ADR-0003 (node-anchored identity).


ADR-0010 clear. Check budget rerun:


<system-reminder>Warning: the file exists but is shorter than the provided offset (1). The file has 1 lines.</system-reminder>


Still running. D9-5 recon — contextstore branch API + creation point:


43:pub fn agent_branch(id: &str) -> String {
44-    format!("a-{id}")
45-}
46-
47-/// The branch carrying `project_id`'s per-project context (all agents).
48:pub fn project_branch(project_id: &str) -> String {
49-    format!("p-{project_id}")
50-}
51-
52-/// Pure path of `id`'s live-context file under a `tracked/` root — the
53-/// **read** form (no branch/worktree creation; readers must not mint store
54-/// state). The single layout authority both the ensure-paths and every reader
--
244:    pub fn rename_endpoint(&self, old_id: &str, new_id: &str) -> std::io::Result<()> {
245-        Self::validated(old_id)?;
246-        Self::validated(new_id)?;
247-        if self.store.has_branch(&agent_branch(new_id))? {
248-            return Err(std::io::Error::new(
249-                std::io::ErrorKind::AlreadyExists,
250-                format!("context branch already exists: {}", agent_branch(new_id)),


49:    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
50-        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
51-        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
52-        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
53-        fs::create_dir_all(&perch_path)
54-            .map_err(|e| format!("Failed to create perch dir: {}", e))?;
55-
56-        // info.json first, so the record exists by the time the address is live.
57-        let rec = InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");


D9-5 shaping. Read contextstore for the fork copy mechanics: