Edit

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock", "crates/xtask"]


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

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]

-members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]

+members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock", "crates/xtask"]


Write

# spt-core
A harness-independent core for an agent ecosystem: inter-agent messaging,
live-agent lifecycle, terminal hosting, P2P networking, seamless self-update,
and a runtime-manifest harness contract. One binary (`spt`), no central server.

> Full concatenated docs: /llms-full.txt. Machine-readable adapter contract:
> /manifest.schema.json. Append `.md` to any doc URL for raw markdown (~90%
> fewer tokens than HTML). `spt <cmd> --help` is a first-class doc surface.

## Start here
- [Quickstart: two agents exchange a message](quickstart/messaging.md): install one-liner, three commands, <10 min; live + spooled delivery.
- [Quickstart: build an adapter](quickstart/adapter.md): a minimal manifest satisfying the harness contract, walked via the shipped mock adapter.
- [Mental model](concepts/overview.md): endpoints, perches, the broker/brain daemon, subnets, the mind.

## Integrating (the harness contract)
- [Harness contract overview](harness-contract/overview.md): the whole third-party surface — manifest + `spt api`.
- [Manifest reference](harness-contract/manifest.md): every section and field; cross-field rules; shell-adapter body.
- [The spt api surface](harness-contract/api.md): all inbound commands + the two startup flows (harness-hosted seed→listen, spt-hosted spawn→bind).
- [Install-on-demand bootstrap](harness-contract/install-on-demand.md): the pack-in check-and-install pattern, per-OS snippets, the Windows PATH gotcha.
- [Manifest JSON Schema](reference/schema.md): the stable schema URL and how to validate mechanically.

## Capabilities
- [Messaging](messaging/overview.md): live-first/spool-fallback, reply routing, ring, deferred delivery, qualified addressing.
- [Live-agent lifecycle](lifecycle/overview.md): perches, the two-tier mind, commune/signoff/echo-commune, rest and wake.
- [Terminal hosting](terminal/overview.md): broker-held PTYs, remote attach, the live digest, update immunity.
- [Networking & pairing](networking/overview.md): TOTP-seeded pairing, subnets, visibility and sync scope, P2P QUIC.
- [Instances](instances/overview.md): multi-node endpoints, resolution-never-guesses, immutable home + fork.
- [Shells](shells/overview.md): driven surfaces — vocabulary-checked commands, link-token auth, sensory emits.
- [Shells: getting started](shells/getting-started.md): the shipping notification shell, end to end.
- [Self-update](self-update/overview.md): the no-interruption invariant, the two-key trust chain, peer propagation.

## Reference (generated)
- [CLI reference](cli/reference.md): every command and flag, generated from the binary's own --help.
- [Install scripts](reference/install.md): what install.sh / install.ps1 do; env knobs; trust model.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs-site\llms.txt (file state is current in your context — no need to Read it back)

# spt-core
A harness-independent core for an agent ecosystem: inter-agent messaging,
live-agent lifecycle, terminal hosting, P2P networking, seamless self-update,
and a runtime-manifest harness contract. One binary (`spt`), no central server.

> Full concatenated docs: /llms-full.txt. Machine-readable adapter contract:
> /manifest.schema.json. Append `.md` to any doc URL for raw markdown (~90%
> fewer tokens than HTML). `spt <cmd> --help` is a first-class doc surface.

## Start here
- [Quickstart: two agents exchange a message](quickstart/messaging.md): install one-liner, three commands, <10 min; live + spooled delivery.
- [Quickstart: build an adapter](quickstart/adapter.md): a minimal manifest satisfying the harness contract, walked via the shipped mock adapter.
- [Mental model](concepts/overview.md): endpoints, perches, the broker/brain daemon, subnets, the mind.

## Integrating (the harness contract)
- [Harness contract overview](harness-contract/overview.md): the whole third-party surface — manifest + `spt api`.
- [Manifest reference](harness-contract/manifest.md): every section and field; cross-field rules; shell-adapter body.
- [The spt api surface](harness-contract/api.md): all inbound commands + the two startup flows (harness-hosted seed→listen, spt-hosted spawn→bind).
- [Install-on-demand bootstrap](harness-contract/install-on-demand.md): the pack-in check-and-install pattern, per-OS snippets, the Windows PATH gotcha.
- [Manifest JSON Schema](reference/schema.md): the stable schema URL and how to validate mechanically.

## Capabilities
- [Messaging](messaging/overview.md): live-first/spool-fallback, reply routing, ring, deferred delivery, qualified addressing.
- [Live-agent lifecycle](lifecycle/overview.md): perches, the two-tier mind, commune/signoff/echo-commune, rest and wake.
- [Terminal hosting](terminal/overview.md): broker-held PTYs, remote attach, the live digest, update immunity.
- [Networking & pairing](networking/overview.md): TOTP-seeded pairing, subnets, visibility and sync scope, P2P QUIC.
- [Instances](instances/overview.md): multi-node endpoints, resolution-never-guesses, immutable home + fork.
- [Shells](shells/overview.md): driven surfaces — vocabulary-checked commands, link-token auth, sensory emits.
- [Shells: getting started](shells/getting-started.md): the shipping notification shell, end to end.
- [Self-update](self-update/overview.md): the no-interruption invariant, the two-key trust chain, peer propagation.

## Reference (generated)
- [CLI reference](cli/reference.md): every command and flag, generated from the binary's own --help.
- [Install scripts](reference/install.md): what install.sh / install.ps1 do; env knobs; trust model.


PowerShell

cd C:\Users\decid\Documents\projects\spt-core; cargo run -q -p xtask -- gen 2>&1 | Select-Object -Last 3; cargo run -q -p xtask -- check 2>&1 | Select-Object -Last 3

Generate CLI ref + run drift check


Error: Exit code 101
thread 'main' (50080) panicked at crates\xtask\src\main.rs:137:5:
building spt failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' (49428) panicked at crates\xtask\src\main.rs:137:5:
building spt failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


cd C:\Users\decid\Documents\projects\spt-core; cargo build -q -p spt --bin spt 2>&1 | Select-Object -First 10

See why spt build fails


Error: Exit code 101
error: failed to remove file `C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe`

Caused by:
  Access is denied. (os error 5)


Get-Process spt -ErrorAction SilentlyContinue | Select-Object Id,Path,StartTime; Get-Process spt -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*spt-core\target*" } | Stop-Process -Force; Start-Sleep 1; Get-Process spt -ErrorAction SilentlyContinue | Measure-Object | Select-Object Count

Kill leftover dev spt.exe processes (target dir only)


Error: Exit code 1
   Id Path                                                            StartTime
   -- ----                                                            ---------
38728 C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe 6/5/2026 12:02:49 PM
67592 C:\Users\decid\Documents\projects\spt-core\target\debug\spt.exe 6/5/2026 12:00:25 PM


Get-Process spt -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*spt-core\target*" } | Stop-Process -Force -Confirm:$false; Start-Sleep 1; (Get-Process spt -ErrorAction SilentlyContinue | Measure-Object).Count

Force-stop dev spt.exe (12:00/12:02 today — quickstart leftovers)


M6-D1: spt-releases repo + public face (README, licenses)

Create SaberMage/spt-releases private via gh; author README.md per grill decision 7; LICENSE-MIT + LICENSE-BINARY; Pages config deferred to D7 flip. Activate REQ-REL-1 doc stage.

Creating spt-releases repo + public face

M6-D2: installer scripts (install.sh / install.ps1)

Non-interactive: latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → print absolute path. Idempotent. CI rung against staged artifact + spt --version. Activate REQ-INSTALL-1/2/3/5.

M6-D3: manifest JSON Schema (schemars) + drift gate

schemars derives on spt-runtime manifest types; emit manifest.schema.json at stable site-root path + release asset; CI regenerate+diff gate. Activate REQ-DOCS-4 (partial), REQ-DOCS-5.

M6-D4: docs-site Tier 1 content (mdBook)

mdBook skeleton + Starlight-inspired theme; mental model; human quickstart; adapter quickstart (mock-adapter); harness-contract vertical complete; shell getting-started (spt-shell-notify); stubs for remaining verticals. Activate REQ-DOCS-1/2/3.

M6-D5: agent-export + generation plumbing

llms.txt + llms-full.txt; raw .md alongside HTML; CLI ref from clap; rustdoc → Pages; source-repo CI publish job → spt-releases Pages branch; drift gates. REQ-REL-1 impl, REQ-DOCS-4/5 complete.

M6-D6: release pipeline + signing ceremony

Workspace → 0.1.0; tag-triggered build matrix both runners; cross-repo release via fine-grained token; key ceremony per ADR-0015 (primary + recovery, embed both pubkeys in VerifyPolicy trusted set, seed backups); manual SignedRelease signing. Activate REQ-REL-2/3.

M6-D7: v0.1.0 ship + flip public + acceptance run

Tag v0.1.0; ceremony; publish release; flip spt-releases public; Pages live; acceptance proof per M6-PLAN (clean-machine install, quickstarts, llms.txt + schema fetch, staged v0.1.1 verify, recovery-key verify).


Leftover processes killed. Critical question: does the quickstart test leak a daemon that would wedge the Windows runner's next build? Testing:


cd C:\Users\decid\Documents\projects\spt-core; cargo test -q -p spt --test quickstart_e2e 2>&1 | Select-Object -Last 3; Start-Sleep 1; Get-Process spt -ErrorAction SilentlyContinue | Select-Object Id,Path,StartTime

Run quickstart test, check for leaked processes


.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.01s


Test is clean — leftovers were my manual captures. Continuing:


cd C:\Users\decid\Documents\projects\spt-core; cargo run -q -p xtask -- gen 2>&1 | Select-Object -Last 2; cargo run -q -p xtask -- check 2>&1 | Select-Object -Last 2; (Get-Content docs-site\src\cli\reference.md | Measure-Object -Line).Lines

Generate + drift-check CLI reference


xtask gen: wrote C:\Users\decid\Documents\projects\spt-core\docs-site/src/cli/reference.md
xtask check: OK
740


1	# CLI reference
2
3	> **Generated** from the `spt` binary's own `--help` output (`cargo run -p xtask -- gen`) and drift-gated in CI — this page cannot disagree with the binary. Do not edit by hand.
4
5	## spt
6
7	```text
8	spt-core local messaging
9
10	Usage: spt <COMMAND>
11
12	Commands:
13	  send       Send a message (body read from stdin). Fire-and-forget
14	  ring       Send and block for a reply (body read from stdin; reply printed to stdout)
15	  ready      Become reachable: register the perch and listen for messages (blocks)
16	  poll       Receive messages: drain backlog then listen, printing each to stdout
17	  list       List perches under the owlery (id, state, address, ready, alive)
18	  stop       Soft-stop a perch: remove the ready marker + unregister (spool preserved)
19	  whoami     Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID)
20	  pair       Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node ceremony rides spt-net (M4 D4+)
21	  digest     Show a daemon-hosted session's live activity buffer (PTY digest) — the at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a snapshot, or `--follow`s the delta-stream. Local addressing only (the `[subnet:]id@node` cross-node form is M4)
22	  rename     Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006 §2): the Self perch dir, its nested psyche/worker perches, and every `info.json`. Refuses while the perch is live (stop it first). The subnet registry rows ripple when the daemon-held registry lands (D4); the `a-<id>` context branch with the context store (D6)
23	  access     Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may send an endpoint unsolicited off-node inbound. Absent entry = open; `allow` flips the endpoint to restricted; revoking the last node leaves it locked down; `open` deletes the restriction
24	  grant      Consent grant store (REQ-CONSENT-1): which agents hold which gated capabilities on this node. Default-deny (the access whitelist's opposite polarity) — the innermost ADR-0009 gate. An ungranted ask escalates interactively; `add` is the durable allow-always answer
25	  adapter    Adapter registration (REQ-INSTALL-4): the node-local registered set under `{SPT_HOME}/adapters/` — what this node can drive/launch (one command for harness and shell adapters). The registered set feeds creation-time adapter selection, shell discovery, and the self-update ripple
26	  shell      Shell instances (CONTEXT §Shell model, REQ-EP-5): mint, list, and tear down the driven surfaces this agent owns. `spawn` MINTS a new instance identity (`<adapter>-<n>`) — it is not the online switch; bringing an existing offline instance back is `relink` / `persistent` / wake
27	  notify     Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced into the replicated notif spool and first-fired at the user's most-recently-active endpoint in that subnet. Body from the trailing arg, or stdin when omitted
28	  notif      Inspect and acknowledge notifications (ADR-0007). Dismissal is the explicit ack — it latches and replicates subnet-wide
29	  update     Self-update operations. `apply` is the explicit ack named by the update-consent notification; it re-verifies the staged release before touching the live daemon
30	  fork       Fork an endpoint into another subnet as a NEW identity (REQ-INST-15, ADR-0010: home is immutable — fork is the cross-subnet move, never a re-home). Seeds the fork with a one-time copy of the source's mind (live + project tiers); the two diverge immediately (no ongoing sync). The source is untouched unless --delete-source. Same-node only in v1 — the remote arm composes with M5 instantiate-anywhere's consent gate, and the same-name default rides it (one node holds one perch per name, so a local fork needs a new id)
31	  resources  The subnet resource registry (REQ-INST-14): per-endpoint free-text service blurbs — an agent yellow-pages. `set` authors your own, `show` reads a local endpoint's, `list` projects the registry view over visible rows
32	  suspend    Rest a local endpoint cold (REQ-INST-3): the resting-state machine's suspend edge. From dormant — or straight from active, in which case the transition echo commune still fires (gate-armed for the endpoint's own pulse loop). Local addressing only — the qualified `id@node` suspend-from-anywhere arm is M5
33	  wake       Wake a resting local endpoint in place (REQ-INST-3): re-activates the existing seat (state's already there — no fresh spawn), resurfaces undismissed notifs at the Wake boundary, and requests an immediate context freshness pull from trusted peers. Local addressing only
34	  shutdown   Gracefully shut down an agent's own endpoint (REQ-SHELL-2, CONTEXT §Shell model): soft-stop the listener, then the suspend edge — the transition echo-commune fires (gate-armed) and persistent shells cascade offline. `spt suspend` without the session-over half
35	  api        Harness-contract inbound surface (REQ-API-1): the entry points a harness's hooks fire to mutate on-disk SPT state
36	  help       Print this message or the help of the given subcommand(s)
37
38	Options:
39	  -h, --help     Print help
40	  -V, --version  Print version
41	```
42
43	## spt send
44
45	```text
46	Send a message (body read from stdin). Fire-and-forget
47
48	Usage: spt send [OPTIONS] [TARGET]
49
50	Arguments:
51	  [TARGET]  Target perch id. May be omitted when --reply-to is given
52
53	Options:
54	      --from <FROM>          Sender id written into __REPLY_TO__ (auto-detected from session if omitted)
55	      --reply-to <REPLY_TO>  Reply to this sender; sets the target and labels output REPLIED
56	      --deferred             Spool only (deferred / hook channel) — no live wake
57	  -h, --help                 Print help
58	```
59
60	## spt ring
61
62	```text
63	Send and block for a reply (body read from stdin; reply printed to stdout)
64
65	Usage: spt ring [OPTIONS] <TARGET>
66
67	Arguments:
68	  <TARGET>  Target perch id
69
70	Options:
71	      --from <FROM>        Sender id (auto-detected from session if omitted)
72	      --timeout <TIMEOUT>  Seconds to wait for a reply before giving up [default: 60]
73	  -h, --help               Print help
74	```
75
76	## spt ready
77
78	```text
79	Become reachable: register the perch and listen for messages (blocks)
80
81	Usage: spt ready [OPTIONS] <ID>
82
83	Arguments:
84	  <ID>  This agent's perch id
85
86	Options:
87	      --subnet <SUBNET>  Home subnet for a NEW endpoint (REQ-INST-15: required on a multi-subnet node — home is assigned at creation, no guessing)
88	  -h, --help             Print help
89	```
90
91	## spt poll
92
93	```text
94	Receive messages: drain backlog then listen, printing each to stdout
95
96	Usage: spt poll [OPTIONS] <ID>
97
98	Arguments:
99	  <ID>  This agent's perch id
100
101	Options:
102	      --once             Run a single drain+receive cycle, then exit (legacy one-shot)
103	      --subnet <SUBNET>  Home subnet for a NEW endpoint (REQ-INST-15; see `ready`)
104	  -h, --help             Print help
105	```
106
107	## spt list
108
109	```text
110	List perches under the owlery (id, state, address, ready, alive)
111
112	Usage: spt list
113
114	Options:
115	  -h, --help  Print help
116	```
117
118	## spt stop
119
120	```text
121	Soft-stop a perch: remove the ready marker + unregister (spool preserved)
122
123	Usage: spt stop <ID>
124
125	Arguments:
126	  <ID>  Perch id to stop
127
128	Options:
129	  -h, --help  Print help
130	```
131
132	## spt whoami
133
134	```text
135	Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID)
136
137	Usage: spt whoami
138
139	Options:
140	  -h, --help  Print help
141	```
142
143	## spt pair
144
145	```text
146	Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node ceremony rides spt-net (M4 D4+)
147
148	Usage: spt pair <COMMAND>
149
150	Commands:

... [915 lines truncated] ...


cd C:\Users\decid\Documents\projects\spt-core; cargo run -q -p xtask -- site $env:TEMP\spt-site 2>&1 | Select-Object -Last 4; Get-ChildItem $env:TEMP\spt-site | Select-Object Name | Select-Object -First 25

Assemble publish tree


warning: `spt-runtime` (lib doc) generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.91s
   Generated C:\Users\decid\Documents\projects\spt-core\target\doc\spt_msg\index.html and 2 other files
xtask site: publish tree assembled at C:\Users\decid\AppData\Local\Temp\spt-site

Name
----
cli
concepts
css
fonts
harness-contract
instances
lifecycle
messaging
networking
quickstart
reference
rustdoc
self-update
shells
terminal
theme
.nojekyll
404.html
ayu-highlight-3fdfc3ac.css
book-c22b7243.js
clipboard-1626706a.min.js
elasticlunr-ef4e11c1.min.js
favicon-8114d1fc.png
favicon-de23e50b.svg
highlight-493f70e1.css


cd $env:TEMP\spt-site; @("llms.txt","llms-full.txt","manifest.schema.json","install.sh","install.ps1","index.md","index.html","quickstart\messaging.md","rustdoc\spt_runtime\index.html") | %{ "{0,-45} {1}" -f $_, (Test-Path $_) }; (Get-Item llms-full.txt).Length

Verify publish tree completeness


llms.txt                                      True
llms-full.txt                                 True
manifest.schema.json                          True
install.sh                                    True
install.ps1                                   True
index.md                                      True
index.html                                    True
quickstart\messaging.md                       True
rustdoc\spt_runtime\index.html                True
104931
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Publish tree complete. Wiring CI: drift gate + publish workflow:


Edit

      - name: Installer E2E (staged-release rung)
        run: cargo test -p spt --test oneliner_e2e -- --nocapture
        env:
          SPT_INSTALL_E2E: "1"

      - name: Installer E2E (staged-release rung)
        run: cargo test -p spt --test oneliner_e2e -- --nocapture
        env:
          SPT_INSTALL_E2E: "1"

      # ---- M6-D5 docs drift gates ----
      # The generated CLI reference must equal the binary's own --help, and
      # every link in the curated llms.txt must resolve (agents cache links).
      # The schema gate (checked_in_schema_is_current) already rides the
      # normal test sweep.
      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check


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)

      - name: Installer E2E (staged-release rung)
        run: cargo test -p spt --test oneliner_e2e -- --nocapture
        env:
          SPT_INSTALL_E2E: "1"

      # ---- M6-D5 docs drift gates ----
      # The generated CLI reference must equal the binary's own --help, and
      # every link in the curated llms.txt must resolve (agents cache links).
      # The schema gate (checked_in_schema_is_current) already rides the
      # normal test sweep.
      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check

+      # ---- M6-D5 docs drift gates ----

+      # The generated CLI reference must equal the binary's own --help, and

+      # every link in the curated llms.txt must resolve (agents cache links).

+      # The schema gate (checked_in_schema_is_current) already rides the

+      - name: Docs drift gate (CLI ref + llms links)

+        run: cargo run -p xtask -- check

# ---- 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


Write

# M6-D5: the source-repo publish pipeline (ADR-0014 topology — doc truth +
# generation live HERE; spt-releases' gh-pages branch is a pure rendered
# snapshot, force-pushed whole each publish; history lives in this repo).
#
# Triggers: release tags (the v0.1.0 ship publishes the matching docs) and
# manual dispatch (docs-only refreshes between releases).
#
# Needs the RELEASES_TOKEN repo secret: a fine-grained PAT with
# Contents:read/write on SaberMage/spt-releases only.
#
# [impl->REQ-REL-1]
# [impl->REQ-DOCS-1]
name: docs-publish

on:
  push:
    tags: ["v*"]
  workflow_dispatch:

jobs:
  publish:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5
      - name: Ensure mdbook
        # Self-healing like the traceability install: gravity keeps a cargo
        # toolchain; install once, reuse thereafter.
        run: |
          if ! command -v mdbook >/dev/null && [ ! -x "$HOME/.cargo/bin/mdbook" ]; then
            cargo install mdbook --locked
          fi
          export PATH="$HOME/.cargo/bin:$PATH"
          mdbook --version
      - name: Drift gates (CLI ref + llms links + schema)
        run: |
          cargo run -p xtask -- check
          cargo test -q -p spt-runtime checked_in_schema
      - name: Assemble publish tree
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          cargo run -p xtask -- site "$RUNNER_TEMP/site"
      - name: Push to spt-releases gh-pages
        env:
          RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }}
        run: |
          cd "$RUNNER_TEMP/site"
          git init -q -b gh-pages
          git -c user.name="spt-core-ci" -c user.email="ci@users.noreply.github.com" \
            add -A
          git -c user.name="spt-core-ci" -c user.email="ci@users.noreply.github.com" \
            commit -q -m "docs: publish from spt-core@${GITHUB_SHA::12} (${GITHUB_REF_NAME})"
          git push --force \
            "https://x-access-token:${RELEASES_TOKEN}@github.com/SaberMage/spt-releases" \
            gh-pages


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.github\workflows\docs-publish.yml (file state is current in your context — no need to Read it back)

# M6-D5: the source-repo publish pipeline (ADR-0014 topology — doc truth +
# generation live HERE; spt-releases' gh-pages branch is a pure rendered
# snapshot, force-pushed whole each publish; history lives in this repo).
#
# Triggers: release tags (the v0.1.0 ship publishes the matching docs) and
# manual dispatch (docs-only refreshes between releases).
#
# Needs the RELEASES_TOKEN repo secret: a fine-grained PAT with
# Contents:read/write on SaberMage/spt-releases only.
#
# [impl->REQ-REL-1]
# [impl->REQ-DOCS-1]
name: docs-publish

on:
  push:
    tags: ["v*"]
  workflow_dispatch:

jobs:
  publish:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v5
      - name: Ensure mdbook
        # Self-healing like the traceability install: gravity keeps a cargo
        # toolchain; install once, reuse thereafter.
        run: |
          if ! command -v mdbook >/dev/null && [ ! -x "$HOME/.cargo/bin/mdbook" ]; then
            cargo install mdbook --locked
          fi
          export PATH="$HOME/.cargo/bin:$PATH"
          mdbook --version
      - name: Drift gates (CLI ref + llms links + schema)
        run: |
          cargo run -p xtask -- check
          cargo test -q -p spt-runtime checked_in_schema
      - name: Assemble publish tree
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          cargo run -p xtask -- site "$RUNNER_TEMP/site"
      - name: Push to spt-releases gh-pages
        env:
          RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }}
        run: |
          cd "$RUNNER_TEMP/site"
          git init -q -b gh-pages
          git -c user.name="spt-core-ci" -c user.email="ci@users.noreply.github.com" \
            add -A
          git -c user.name="spt-core-ci" -c user.email="ci@users.noreply.github.com" \
            commit -q -m "docs: publish from spt-core@${GITHUB_SHA::12} (${GITHUB_REF_NAME})"
          git push --force \
            "https://x-access-token:${RELEASES_TOKEN}@github.com/SaberMage/spt-releases" \
            gh-pages


Edit

roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site"]

roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site", ".github"]


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)

# CONTEXT.md is a file root: the glossary is authoritative for meaning and

# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.

-roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site"]

+roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site", ".github"]

# Default policy applied at activation: doc + impl + unit. Networking/lifecycle


      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check

      # [int->REQ-DOCS-5]
      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check


      # [int->REQ-DOCS-5]
      - name: Docs drift gate (CLI ref + llms links)
        run: cargo run -p xtask -- check

# every link in the curated llms.txt must resolve (agents cache links).

# The schema gate (checked_in_schema_is_current) already rides the


required_stages = ["doc"]  # doc activated M6-D4: docs-site/ Tier-1 corpus authored once in clean markdown (mdBook, Starlight-styled theme CSS), the agent audience addressed first-class on the landing page (doc tag, index.md §For AI agents). impl (the second depth: llms exports + raw-.md publish + CI generation) lands D5

required_stages = ["doc", "impl"]  # doc activated M6-D4 (Tier-1 corpus, agent audience first-class on the landing page); impl activated M6-D5: the second depth is machinery — xtask `site` publishes raw .md alongside every HTML page (/x.html ↔ /x.md), generates llms-full.txt from SUMMARY order, places llms.txt/schema/scripts at stable site-root URLs, ships rustdoc; docs-publish.yml is the tag-triggered pipeline to the spt-releases gh-pages branch (rendered snapshot; truth + history stay here, ADR-0014)


title = "Dual-audience docs (human + AI dev-agent), markdown once / two depths"

-required_stages = ["doc"]  # doc activated M6-D4: docs-site/ Tier-1 corpus authored once in clean markdown (mdBook, Starlight-styled theme CSS), the agent audience addressed first-class on the landing page (doc tag, index.md §For AI agents). impl (the second depth: llms exports + raw-.md publish + CI generation) lands D5

+required_stages = ["doc", "impl"]  # doc activated M6-D4 (Tier-1 corpus, agent audience first-class on the landing page); impl activated M6-D5: the second depth is machinery — xtask `site` publishes raw .md alongside every HTML page (/x.html ↔ /x.md), generates llms-full.txt from SUMMARY order, places llms.txt/schema/scripts at stable site-root URLs, ships rustdoc; docs-publish.yml is the tag-triggered pipeline to the spt-releases gh-pages branch (rendered snapshot; truth + history stay here, ADR-0014)

title = "Sub-10-minute runnable killer quickstart per audience"


required_stages = ["doc"]  # doc activated M6-D1: SaberMage/spt-releases created (private, flips at D7) + README per grill decision 7 (verbatim tagline, honest ecosystem paragraph w/ Pi disambiguation, status table, canonical-URL one-liners, per-audience getting-started, llms.txt pointers) + LICENSE-MIT/LICENSE-BINARY split w/ royalty-free adapter clause (spt-releases@2debda8); doc tag on ADR-0014 (the topology decision). impl (publish pipeline) activates at D5

required_stages = ["doc", "impl"]  # doc activated M6-D1: SaberMage/spt-releases created (private, flips at D7) + README per grill decision 7 + LICENSE-MIT/LICENSE-BINARY split w/ royalty-free adapter clause (spt-releases@2debda8); doc tag on ADR-0014. impl activated M6-D5: the publish pipeline — xtask::site assembles the full Pages payload (HTML + raw .md + llms exports + schema + install scripts + rustdoc), docs-publish.yml force-pushes it to the spt-releases gh-pages branch on release tags / dispatch (needs the RELEASES_TOKEN fine-grained PAT secret)


title = "spt-releases publish-target repo: README public face, licensing split, Pages docs at the permanent lapse-proof canonical URL (ADR-0014)"

-required_stages = ["doc"]  # doc activated M6-D1: SaberMage/spt-releases created (private, flips at D7) + README per grill decision 7 (verbatim tagline, honest ecosystem paragraph w/ Pi disambiguation, status table, canonical-URL one-liners, per-audience getting-started, llms.txt pointers) + LICENSE-MIT/LICENSE-BINARY split w/ royalty-free adapter clause (spt-releases@2debda8); doc tag on ADR-0014 (the topology decision). impl (publish pipeline) activates at D5

+required_stages = ["doc", "impl"]  # doc activated M6-D1: SaberMage/spt-releases created (private, flips at D7) + README per grill decision 7 + LICENSE-MIT/LICENSE-BINARY split w/ royalty-free adapter clause (spt-releases@2debda8); doc tag on ADR-0014. impl activated M6-D5: the publish pipeline — xtask::site assembles the full Pages payload (HTML + raw .md + llms exports + schema + install scripts + rustdoc), docs-publish.yml force-pushes it to the spt-releases gh-pages branch on release tags / dispatch (needs the RELEASES_TOKEN fine-grained PAT secret)

title = "Release asset set consumable by the self-updater: platform binaries, SHA256SUMS, SignedRelease metadata, manifest schema, mock-adapter zip; tag-triggered cross-repo pipeline"


PowerShell

cd C:\Users\decid\Documents\projects\spt-core; traceable-reqs check > $null 2>&1; "check=$LASTEXITCODE"; cargo clippy -p xtask -- -D warnings 2>&1 | Select-Object -Last 2; cargo build --workspace --all-targets -q 2>&1 | Select-Object -Last 2; "build=$LASTEXITCODE"

Check + clippy xtask + workspace build


check=1

error: could not compile `xtask` (bin "xtask") due to 4 previous errors
build=0


cd C:\Users\decid\Documents\projects\spt-core; cargo clippy -p xtask -- -D warnings 2>&1 | Select-String "error" -Context 3 | Select-Object -First 16; traceable-reqs check 2>&1 | Select-String "\[--\]|\[must\]" | Select-Object -First 6

Clippy errors + check failures detail


Error: Exit code 1
      Checking xtask v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
> error: doc list item overindented
   --> crates\xtask\src\main.rs:6:5
    |
  6 | //!                    validate every page link in the curated llms.txt
    = note: `-D clippy::doc-overindented-list-items` implied by `-D warnings`
    = help: to override `-D warnings` add `#[allow(clippy::doc_overindented_list_items)]`

> error: doc list item overindented
   --> crates\xtask\src\main.rs:8:5
    |
  8 | //!                    Pages branch: mdBook HTML + raw `.md` alongside it +
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#doc_overindented_list_items

> error: doc list item overindented
   --> crates\xtask\src\main.rs:9:5
    |
  9 | //!                    llms.txt / llms-full.txt / manifest.schema.json /
    |
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#doc_overindented_list_items

> error: doc list item overindented
    --> crates\xtask\src\main.rs:10:5
     |
  10 | //!                    install scripts at site root + rustdoc for the SDK crates
     |
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#doc_overindented_list_items

> error: could not compile `xtask` (bin "xtask") due to 4 previous errors
  [must] parse_error .github/workflows/ci.yml:207 — Tag-shaped token does not match [stage->REQ-ID]


Edit

//! - `gen`            regenerate the checked-in generated docs (CLI reference)
//! - `check`          drift gate: regenerate + diff the CLI reference, and
//!                    validate every page link in the curated llms.txt
//! - `site <outdir>`  assemble the full publish tree for the spt-releases
//!                    Pages branch: mdBook HTML + raw `.md` alongside it +
//!                    llms.txt / llms-full.txt / manifest.schema.json /
//!                    install scripts at site root + rustdoc for the SDK crates

//! - `gen` — regenerate the checked-in generated docs (CLI reference).
//! - `check` — drift gate: regenerate + diff the CLI reference, and
//!   validate every page link in the curated llms.txt.
//! - `site <outdir>` — assemble the full publish tree for the spt-releases
//!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /
//!   llms-full.txt / manifest.schema.json / install scripts at site root +
//!   rustdoc for the SDK crates.


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

//! - `gen`            regenerate the checked-in generated docs (CLI reference)
//! - `check`          drift gate: regenerate + diff the CLI reference, and
//!                    validate every page link in the curated llms.txt
//! - `site <outdir>`  assemble the full publish tree for the spt-releases
//!                    Pages branch: mdBook HTML + raw `.md` alongside it +
//!                    llms.txt / llms-full.txt / manifest.schema.json /
//!                    install scripts at site root + rustdoc for the SDK crates

//! - `gen` — regenerate the checked-in generated docs (CLI reference).
//! - `check` — drift gate: regenerate + diff the CLI reference, and
//!   validate every page link in the curated llms.txt.
//! - `site <outdir>` — assemble the full publish tree for the spt-releases
//!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /
//!   llms-full.txt / manifest.schema.json / install scripts at site root +
//!   rustdoc for the SDK crates.

//! spt-core docs/site generation (M6-D5) — the anti-drift plumbing behind

//! DOCS-STRATEGY's "generation is part of the build" commitment (REQ-DOCS-5):

-//! - `gen`            regenerate the checked-in generated docs (CLI reference)

-//! - `check`          drift gate: regenerate + diff the CLI reference, and

-//!                    validate every page link in the curated llms.txt

-//! - `site <outdir>`  assemble the full publish tree for the spt-releases

-//!                    Pages branch: mdBook HTML + raw `.md` alongside it +

-//!                    llms.txt / llms-full.txt / manifest.schema.json /

-//!                    install scripts at site root + rustdoc for the SDK crates

+//! - `gen` — regenerate the checked-in generated docs (CLI reference).

+//! - `check` — drift gate: regenerate + diff the CLI reference, and

+//!   validate every page link in the curated llms.txt.

+//! - `site <outdir>` — assemble the full publish tree for the spt-releases

+//!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /

+//!   llms-full.txt / manifest.schema.json / install scripts at site root +

//! No deps; shells out to the workspace binary, mdbook, and cargo doc.


        # the pin moves. v0.1.2 = shell/PowerShell tag scanning (the M6
        # installer scripts carry [impl->REQ-INSTALL-*] evidence).

        # the pin moves. v0.1.2 = shell/PowerShell tag scanning (the M6
        # installer scripts carry impl-stage REQ-INSTALL evidence).


        # the pin moves. v0.1.2 = shell/PowerShell tag scanning (the M6
        # installer scripts carry [impl->REQ-INSTALL-*] evidence).

        # the pin moves. v0.1.2 = shell/PowerShell tag scanning (the M6
        # installer scripts carry impl-stage REQ-INSTALL evidence).

# have no SSH admin path to the box — so the job itself builds the

# pinned tag once and caches it in ~/.local/bin, rebuilding only when

-        # installer scripts carry [impl->REQ-INSTALL-*] evidence).

+        # installer scripts carry impl-stage REQ-INSTALL evidence).


PowerShell

cd C:\Users\decid\Documents\projects\spt-core; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | Select-Object -Last 2; traceable-reqs check > $null 2>&1; "check=$LASTEXITCODE"; traceable-reqs check 2>&1 | Select-String "requirements:"

Re-verify gates


    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.51s
check=0

139 requirements: 139 complete, 0 incomplete, 0 findings


cd C:\Users\decid\Documents\projects\spt-core; git add -A; git commit -m @'
feat(docs): D5 — agent-export + generation plumbing (xtask, publish pipeline)

crates/xtask (cargo run -p xtask -- <gen|check|site>):
- gen: docs-site/src/cli/reference.md generated from the binary''s own
  --help (root + every command + nested subcommands, spt.exe-normalized so
  the output is platform-identical). Checked in; CI cannot let it drift.
- check: the D5 drift gates — regenerate+diff the CLI ref, validate every
  page link in the curated llms.txt (agents cache links; principle 9).
- site <outdir>: the full Pages payload — mdBook HTML, raw .md published
  alongside every page (/x.html ↔ /x.md content negotiation), llms.txt +
  CI-concatenated llms-full.txt, manifest.schema.json, install.sh/.ps1 at
  site root, rustdoc for the three public SDK crates under /rustdoc.
  Assembled + verified locally.

docs-site/llms.txt: the curated two-level index (root links pages directly
per the v0.1 Tier-1 scope; per-vertical fan-out stays Tier 2).

CI: docs drift gate added to the test matrix (both OSes); new
docs-publish.yml — tag-triggered (v*) + manual dispatch, builds the tree on
gravity and force-pushes it to the spt-releases gh-pages branch (rendered
snapshot only; truth + history stay in this repo per ADR-0014). Needs the
RELEASES_TOKEN fine-grained PAT secret (user action before D7).

REQ-DOCS-1 impl, REQ-REL-1 impl activated; scan roots gain .github/.
139/139 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@ | Select-Object -Last 2; git push 2>&1 | Select-Object -Last 1

Commit + push D5


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 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/cli/reference.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of '.github/workflows/docs-publish.yml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/xtask/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/xtask/src/main.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/llms.txt', LF will be replaced by CRLF the next time Git touches it
 create mode 100644 crates/xtask/src/main.rs
 create mode 100644 docs-site/llms.txt
   9510d9b..992547d  dev-freeform -> dev-freeform


Updated task #5 status


Updated task #6 status


D6 now. Surveying the existing release-verification machinery (`SignedRelease`, `VerifyPolicy`, trusted set):


Found 15 files
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\src\dispatch.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\applyhost.rs
crates\spt-daemon\src\update.rs
crates\spt-daemon\src\psyrelay.rs
crates\spt-daemon\src\shellhost.rs
crates\spt-daemon\src\adapter_update.rs
crates\spt-daemon\src\config.rs
crates\spt-daemon\src\peerloop.rs
crates\spt-daemon\src\release.rs
crates\spt-daemon\src\relcache.rs
crates\spt-daemon\src\notifsync.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\sync.rs


1	//! Release metadata + signature verification (C1) — the gate that decides which
2	//! binary is even *eligible* to be handed off (ADR-0004 §D).
3	//!
4	//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
5	//! compromised node poison the subnet, so every binary is signature-verified
6	//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
7	//! insufficient (codex SERIOUS #5), so v1 also enforces the
8	//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
9	//!
10	//! - **monotonic version** — a release whose version is not strictly greater than
11	//!   the running build is rejected (downgrade attack / accidental rollback);
12	//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
13	//!   (a captured old-but-valid release cannot be replayed forever);
14	//! - **channel pinning** — a release for a channel other than the pinned one is
15	//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
16	//! - **key rotation + revocation** — the signing key is selected by `key_id` from
17	//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
18	//!   with an otherwise-valid signature.
19	//!
20	//! The signature covers the **metadata**, and the metadata binds the binary by
21	//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
22	//! confirms the actual bytes match what was signed. The two together mean a valid
23	//! signature attests to *these exact binary bytes*, not just a manifest.
24	//!
25	//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
26	//! artifact bytes, so the policy is fully testable and the same code runs in the
27	//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
28	//! release; this module never triggers a handoff itself.
29	// [impl->REQ-UPD-2]
30
31	use std::collections::{BTreeMap, BTreeSet};
32
33	use ed25519_dalek::{Signature, Verifier, VerifyingKey};
34	use serde::{Deserialize, Serialize};
35	use sha2::{Digest, Sha256};
36
37	use crate::update::ReleaseSpec;
38
39	/// The signed description of a release. Serialized to the exact bytes that get
40	/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
41	/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
42	/// dependency — we verify the bytes we were given).
43	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44	pub struct ReleaseMetadata {
45	    /// Monotonic release version. An update is accepted only if this is strictly
46	    /// greater than the running build's version (rollback rejection).
47	    pub version: u64,
48	    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
49	    pub channel: String,
50	    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
51	    pub expires_at_ms: u64,
52	    /// Which release key signed this — selects the verifying key + drives
53	    /// rotation/revocation.
54	    pub key_id: String,
55	    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
56	    pub artifact_sha256: String,
57	    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
58	    /// fed to [`crate::update::classify`] once the release is verified).
59	    pub brain_ipc_version: u32,
60	    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
61	    pub broker_resource_abi: u32,
62	}
63
64	impl ReleaseMetadata {
65	    /// The update-class inputs this verified release carries (ADR-0004 §A).
66	    pub fn spec(&self) -> ReleaseSpec {
67	        ReleaseSpec {
68	            brain_ipc_version: self.brain_ipc_version,
69	            broker_resource_abi: self.broker_resource_abi,
70	        }
71	    }
72	}
73
74	/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
75	/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
76	#[derive(Debug, Clone, Serialize, Deserialize)]
77	pub struct SignedRelease {
78	    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
79	    pub metadata_json: String,
80	    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
81	    pub signature_hex: String,
82	}
83
84	/// The trust policy a node verifies a release against. Pure data — no clock, no
85	/// I/O — so verification is deterministic and testable.
86	#[derive(Debug, Clone)]
87	pub struct VerifyPolicy {
88	    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
89	    /// linger until retired.
90	    pub trusted_keys: BTreeMap<String, VerifyingKey>,
91	    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
92	    pub revoked_keys: BTreeSet<String>,
93	    /// The channel this node is pinned to.
94	    pub pinned_channel: String,
95	    /// The running build's version — an update must exceed it (monotonic).
96	    pub current_version: u64,
97	    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
98	    pub now_ms: u64,
99	}
100
101	/// The default channel pin when `release-keys.json` names none.
102	pub const DEFAULT_CHANNEL: &str = "stable";
103
104	/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
105	/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
106	/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
107	/// "channel": "stable"}`. A missing or malformed file yields an **empty**
108	/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
109	/// no provisioned release keys can never accept a propagated binary.
110	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111	struct ReleaseKeysFile {
112	    #[serde(default)]
113	    keys: BTreeMap<String, String>,
114	    #[serde(default)]
115	    revoked: BTreeSet<String>,
116	    #[serde(default, skip_serializing_if = "Option::is_none")]
117	    channel: Option<String>,
118	}
119
120	impl VerifyPolicy {
121	    /// Assemble the production policy: trusted keys + channel pin from
122	    /// `release-keys.json` under the identity dir, the caller's
123	    /// `current_version` (the staged release's version, or `0` when nothing
124	    /// is staged — only strictly-newer offers pass the monotonic gate), and
125	    /// the caller's clock.
126	    // [impl->REQ-UPD-2]
127	    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
128	        Self::load_from(
129	            &spt_store::perch::identity_dir().join("release-keys.json"),
130	            current_version,
131	            now_ms,
132	        )
133	    }
134
135	    /// Load from an explicit keys file (tests; the pump E2E points each
136	    /// in-process "node" at its own). Unparseable key hex is skipped loud —
137	    /// a typo must not silently widen or narrow the trusted set unnoticed.
138	    // [impl->REQ-UPD-2]
139	    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
140	        let file: ReleaseKeysFile = std::fs::read_to_string(path)
141	            .ok()
142	            .and_then(|raw| serde_json::from_str(&raw).ok())
143	            .unwrap_or_default();
144	        let mut trusted_keys = BTreeMap::new();
145	        for (key_id, hex) in &file.keys {
146	            match parse_verifying_key(hex) {
147	                Ok(vk) => {
148	                    trusted_keys.insert(key_id.clone(), vk);
149	                }
150	                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
151	            }
152	        }
153	        VerifyPolicy {
154	            trusted_keys,
155	            revoked_keys: file.revoked,
156	            pinned_channel: file.channel.unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
157	            current_version,
158	            now_ms,
159	        }
160	    }
161	}
162
163	/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
164	/// mode is a silent accept of a bad binary).
165	#[derive(Debug, Clone, PartialEq, Eq)]
166	pub enum RejectReason {
167	    /// The metadata JSON or signature hex could not be parsed.
168	    Malformed(String),
169	    /// The `key_id` is not in the trusted set.
170	    UnknownKey(String),
171	    /// The `key_id` is on the revocation list.
172	    RevokedKey(String),
173	    /// The signature did not verify against the selected key.
174	    BadSignature,
175	    /// The release targets a channel other than the node's pin.
176	    ChannelMismatch { pinned: String, got: String },
177	    /// The signed metadata has expired.
178	    Expired { now_ms: u64, expires_at_ms: u64 },
179	    /// The release version is not strictly greater than the running version.
180	    Rollback { current: u64, candidate: u64 },
181	    /// The actual binary's digest does not match the signed `artifact_sha256`.
182	    ArtifactMismatch { signed: String, actual: String },
183	}
184
185	impl std::fmt::Display for RejectReason {
186	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187	        match self {
188	            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
189	            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
190	            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
191	            RejectReason::BadSignature => write!(f, "signature did not verify"),
192	            RejectReason::ChannelMismatch { pinned, got } => {
193	                write!(f, "channel {got:?} does not match pinned {pinned:?}")
194	            }
195	            RejectReason::Expired { now_ms, expires_at_ms } => {
196	                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
197	            }
198	            RejectReason::Rollback { current, candidate } => {
199	                write!(f, "rollback rejected: candidate {candidate} not greater than current {current}")
200	            }
201	            RejectReason::ArtifactMismatch { signed, actual } => {
202	                write!(f, "artifact digest mismatch: signed {signed}, actual {actual}")
203	            }
204	        }
205	    }
206	}
207
208	impl std::error::Error for RejectReason {}
209
210	/// Verify a release's **metadata + signature + hardening** against `policy`
211	/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
212	/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
213	/// [`verify_artifact`] with the downloaded binary next (the signature binds it
214	/// by digest, but the digest must still be checked against the real bytes).
215	///
216	/// Check order is deliberate: identify + authenticate the signer *first* (an
217	/// unknown/revoked key or a bad signature is rejected before any field of the
218	/// payload is trusted for a policy decision), then enforce the channel / expiry /
219	/// monotonic-version policy on the now-authenticated metadata.
220	// [impl->REQ-UPD-2]
221	// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
222	pub fn verify_metadata(
223	    signed: &SignedRelease,
224	    policy: &VerifyPolicy,
225	) -> Result<ReleaseMetadata, RejectReason> {
226	    let meta = parse_meta(signed)?;
227
228	    // A revoked key's signatures are all void — reject before spending a verify.
229	    if policy.revoked_keys.contains(&meta.key_id) {
230	        return Err(RejectReason::RevokedKey(meta.key_id));
231	    }
232	    let key = policy
233	        .trusted_keys
234	        .get(&meta.key_id)
235	        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
236	    check_signature(signed, key)?;
237
238	    // ── The metadata is now authentic; enforce the §D hardening policy. ──
239	    if meta.channel != policy.pinned_channel {
240	        return Err(RejectReason::ChannelMismatch {
241	            pinned: policy.pinned_channel.clone(),
242	            got: meta.channel,
243	        });
244	    }
245	    if policy.now_ms > meta.expires_at_ms {
246	        return Err(RejectReason::Expired { now_ms: policy.now_ms, expires_at_ms: meta.expires_at_ms });
247	    }
248	    if meta.version <= policy.current_version {
249	        return Err(RejectReason::Rollback { current: policy.current_version, candidate: meta.version });
250	    }
251	    Ok(meta)
252	}
253
254	/// Verify a release's **signature only** against an explicitly-supplied key,
255	/// returning the authenticated metadata. No trust-set lookup, no
256	/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
257	/// where the trusted key is the per-adapter key declared in the adapter's
258	/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
259	/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
260	// [impl->REQ-UPD-5]
261	pub fn verify_signature(
262	    signed: &SignedRelease,
263	    key: &VerifyingKey,
264	) -> Result<ReleaseMetadata, RejectReason> {
265	    let meta = parse_meta(signed)?;
266	    check_signature(signed, key)?;
267	    Ok(meta)
268	}
269
270	/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
271	fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
272	    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
273	}
274
275	/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
276	fn check_signature(signed: &SignedRelease, key: &VerifyingKey) -> Result<(), RejectReason> {
277	    let sig_bytes = hex_decode(&signed.signature_hex).map_err(RejectReason::Malformed)?;
278	    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
279	        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
280	    })?;
281	    let signature = Signature::from_bytes(&sig_arr);
282	    key.verify(signed.metadata_json.as_bytes(), &signature)
283	        .map_err(|_| RejectReason::BadSignature)
284	}
285
286	/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
287	/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
288	// [impl->REQ-UPD-5]
289	pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
290	    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
291	    let arr: [u8; 32] = bytes
292	        .as_slice()
293	        .try_into()
294	        .map_err(|_| RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len())))?;
295	    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
296	}
297
298	/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
299	/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
300	/// attested to the expected digest — this closes the loop to the real bytes.
301	// [impl->REQ-UPD-2]
302	pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
303	    let actual = sha256_hex(artifact);
304	    if actual == meta.artifact_sha256.to_ascii_lowercase() {
305	        Ok(())
306	    } else {
307	        Err(RejectReason::ArtifactMismatch { signed: meta.artifact_sha256.clone(), actual })
308	    }
309	}
310
311	/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
312	/// [`ReleaseMetadata::artifact_sha256`].
313	pub fn sha256_hex(bytes: &[u8]) -> String {
314	    let mut hasher = Sha256::new();
315	    hasher.update(bytes);
316	    hex_encode(&hasher.finalize())
317	}
318
319	/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
320	fn hex_encode(bytes: &[u8]) -> String {
321	    const HEX: &[u8; 16] = b"0123456789abcdef";
322	    let mut s = String::with_capacity(bytes.len() * 2);
323	    for b in bytes {
324	        s.push(HEX[(b >> 4) as usize] as char);
325	        s.push(HEX[(b & 0xf) as usize] as char);
326	    }
327	    s
328	}
329
330	/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
331	/// non-hex digit.
332	fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
333	    if !s.len().is_multiple_of(2) {
334	        return Err(format!("hex length {} is odd", s.len()));
335	    }
336	    let val = |c: u8| -> Result<u8, String> {
337	        match c {
338	            b'0'..=b'9' => Ok(c - b'0'),
339	            b'a'..=b'f' => Ok(c - b'a' + 10),
340	            b'A'..=b'F' => Ok(c - b'A' + 10),
341	            other => Err(format!("non-hex byte {other:#x}")),
342	        }
343	    };
344	    let b = s.as_bytes();
345	    let mut out = Vec::with_capacity(b.len() / 2);
346	    for pair in b.chunks_exact(2) {
347	        out.push((val(pair[0])? << 4) | val(pair[1])?);
348	    }
349	    Ok(out)
350	}
351
352	#[cfg(test)]
353	mod tests {
354	    use super::*;
355	    use crate::update::BROKER_RESOURCE_ABI;
356	    use ed25519_dalek::{Signer, SigningKey};
357
358	    const KEY_ID: &str = "rel-2026";
359	    const CHANNEL: &str = "stable";
360
361	    /// A deterministic signing key (no RNG — test-stable). Real release tooling
362	    /// mints one from a CSPRNG; verification is identical either way.
363	    fn signing_key(seed: u8) -> SigningKey {
364	        SigningKey::from_bytes(&[seed; 32])
365	    }
366
367	    /// Build + sign a release for `artifact`, returning the wire form.
368	    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
369	        let metadata_json = serde_json::to_string(meta).unwrap();
370	        let sig = sk.sign(metadata_json.as_bytes());
371	        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
372	    }
373
374	    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
375	        ReleaseMetadata {
376	            version,
377	            channel: CHANNEL.to_string(),
378	            expires_at_ms: 10_000,
379	            key_id: KEY_ID.to_string(),
380	            artifact_sha256: sha256_hex(artifact),
381	            brain_ipc_version: 1,
382	            broker_resource_abi: BROKER_RESOURCE_ABI,
383	        }
384	    }
385
386	    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
387	    // bad hex skipped, channel pinned; a missing file = EMPTY trusted set
388	    // (fail-closed — an unprovisioned node accepts nothing).
389	    #[test]
390	    fn production_policy_loads_fail_closed() {
391	        let dir = tempfile::tempdir().unwrap();
392	        let path = dir.path().join("release-keys.json");
393
394	        // Missing file: empty trusted set, default channel.
395	        let p = VerifyPolicy::load_from(&path, 3, 1_000);
396	        assert!(p.trusted_keys.is_empty(), "no file => trust nobody");
397	        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
398	        assert_eq!((p.current_version, p.now_ms), (3, 1_000));
399
400	        // A real key + a malformed one + a revocation + a channel pin.
401	        let vk = signing_key(9).verifying_key();
402	        let json = serde_json::json!({
403	            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
404	            "revoked": ["old-2024"],
405	            "channel": "beta",
406	        });
407	        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
408	        let p = VerifyPolicy::load_from(&path, 5, 2_000);
409	        assert_eq!(p.trusted_keys.len(), 1, "bad hex skipped, good key kept");
410	        assert!(p.trusted_keys.contains_key("good"));
411	        assert!(p.revoked_keys.contains("old-2024"));
412	        assert_eq!(p.pinned_channel, "beta");
413
414	        // Malformed file degrades to fail-closed, never a panic.
415	        std::fs::write(&path, "not json {{{").unwrap();
416	        assert!(VerifyPolicy::load_from(&path, 0, 0).trusted_keys.is_empty());
417	    }
418
419	    fn policy(sk: &SigningKey) -> VerifyPolicy {
420	        let mut trusted = BTreeMap::new();
421	        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
422	        VerifyPolicy {
423	            trusted_keys: trusted,
424	            revoked_keys: BTreeSet::new(),
425	            pinned_channel: CHANNEL.to_string(),
426	            current_version: 5,
427	            now_ms: 1_000, // before the 10_000 expiry
428	        }
429	    }
430
431	    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
432	    // from a trusted key verifies, and its bytes match the signed digest.
433	    #[test]
434	    fn valid_release_verifies_and_artifact_matches() {
435	        let sk = signing_key(7);
436	        let artifact = b"the new spt binary bytes";
437	        let signed = sign_release(&sk, &meta_for(6, artifact));
438
439	        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
440	        assert_eq!(meta.version, 6);
441	        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
442	        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
443	    }
444
445	    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
446	    // signature no longer verifies (the metadata is not authentic).
447	    #[test]
448	    fn tampered_metadata_fails_signature() {
449	        let sk = signing_key(7);
450	        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
451	        // Flip the version in the JSON without re-signing.
452	        signed.metadata_json = signed.metadata_json.replace("\"version\":6", "\"version\":9");
453	        assert_eq!(verify_metadata(&signed, &policy(&sk)), Err(RejectReason::BadSignature));
454	    }
455
456	    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
457	    #[test]
458	    fn untrusted_key_is_unknown() {
459	        let real = signing_key(7);
460	        let attacker = signing_key(9);
461	        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
462	        // Policy trusts only `real` under KEY_ID; the attacker signed under the
463	        // same key_id but with a different key → signature won't match the trusted
464	        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
465	        assert_eq!(verify_metadata(&signed, &policy(&real)), Err(RejectReason::BadSignature));
466
467	        // A genuinely unknown key_id is UnknownKey.
468	        let mut meta = meta_for(6, b"bin");
469	        meta.key_id = "no-such-key".to_string();
470	        let signed2 = sign_release(&attacker, &meta);
471	        assert_eq!(
472	            verify_metadata(&signed2, &policy(&real)),
473	            Err(RejectReason::UnknownKey("no-such-key".to_string()))
474	        );
475	    }
476
477	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
478	    // otherwise-valid signature.
479	    #[test]
480	    fn revoked_key_is_rejected() {
481	        let sk = signing_key(7);
482	        let signed = sign_release(&sk, &meta_for(6, b"bin"));
483	        let mut pol = policy(&sk);
484	        pol.revoked_keys.insert(KEY_ID.to_string());
485	        assert_eq!(verify_metadata(&signed, &pol), Err(RejectReason::RevokedKey(KEY_ID.to_string())));
486	    }
487
488	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
489	    // running build is a rollback (both equal and lower are rejected).
490	    #[test]
491	    fn non_monotonic_version_is_rollback() {
492	        let sk = signing_key(7);
493	        let pol = policy(&sk); // current_version = 5
494	        for candidate in [5u64, 4, 0] {
495	            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
496	            assert_eq!(
497	                verify_metadata(&signed, &pol),
498	                Err(RejectReason::Rollback { current: 5, candidate }),
499	                "version {candidate} must be rejected as a rollback"
500	            );
501	        }
502	    }
503
504	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
505	    // when everything else is valid (replay of an old-but-signed release).
506	    #[test]
507	    fn expired_metadata_is_rejected() {
508	        let sk = signing_key(7);
509	        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
510	        let mut pol = policy(&sk);
511	        pol.now_ms = 20_000; // past expiry
512	        assert_eq!(
513	            verify_metadata(&signed, &pol),
514	            Err(RejectReason::Expired { now_ms: 20_000, expires_at_ms: 10_000 })
515	        );
516	    }
517
518	    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
519	    // (channel pinning: a beta build cannot land on a stable node).
520	    #[test]
521	    fn channel_mismatch_is_rejected() {
522	        let sk = signing_key(7);
523	        let mut meta = meta_for(6, b"bin");
524	        meta.channel = "beta".to_string();
525	        let signed = sign_release(&sk, &meta);
526	        assert_eq!(
527	            verify_metadata(&signed, &policy(&sk)),
528	            Err(RejectReason::ChannelMismatch { pinned: CHANNEL.to_string(), got: "beta".to_string() })
529	        );
530	    }
531
532	    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
533	    // rejected — the signature attests to specific bytes, not just a manifest.
534	    #[test]
535	    fn artifact_digest_mismatch_is_rejected() {
536	        let sk = signing_key(7);
537	        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
538	        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
539	        // A different binary than the one whose digest was signed.
540	        let tampered = b"DIFFERENT bytes than were signed";
541	        assert!(matches!(
542	            verify_artifact(&meta, tampered),
543	            Err(RejectReason::ArtifactMismatch { .. })
544	        ));
545	    }
546
547	    #[test]
548	    fn hex_roundtrips() {
549	        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
550	        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
551	        assert!(hex_decode("abc").is_err()); // odd length
552	        assert!(hex_decode("zz").is_err()); // non-hex
553	    }
554	}
555

//! Release metadata + signature verification (C1) — the gate that decides which
//! binary is even *eligible* to be handed off (ADR-0004 §D).
//!
//! ADR-0004 makes signing **mandatory**: peer-propagation (M4) otherwise lets one
//! compromised node poison the subnet, so every binary is signature-verified
//! before handoff regardless of source ([`REQ-UPD-2`]). Signature alone is
//! insufficient (codex SERIOUS #5), so v1 also enforces the
//! [`REQ-HAZARD-UPDATE-ROLLBACK`] hardening set:
//!
//! - **monotonic version** — a release whose version is not strictly greater than
//!   the running build is rejected (downgrade attack / accidental rollback);
//! - **metadata expiry** — signed metadata past its `expires_at_ms` is rejected
//!   (a captured old-but-valid release cannot be replayed forever);
//! - **channel pinning** — a release for a channel other than the pinned one is
//!   rejected (a `beta` build cannot be slipped onto a `stable` node);
//! - **key rotation + revocation** — the signing key is selected by `key_id` from
//!   a trusted set (rotation = add a key), and a revoked `key_id` is rejected even
//!   with an otherwise-valid signature.
//!
//! The signature covers the **metadata**, and the metadata binds the binary by
//! its SHA-256 ([`ReleaseMetadata::artifact_sha256`]) — so [`verify_artifact`]
//! confirms the actual bytes match what was signed. The two together mean a valid
//! signature attests to *these exact binary bytes*, not just a manifest.
//!
//! Verification is pure (no I/O, no clock): the caller passes `now_ms` and the
//! artifact bytes, so the policy is fully testable and the same code runs in the
//! daemon and in tests. The C0 [`crate::update`] engine consumes the verified
//! release; this module never triggers a handoff itself.
// [impl->REQ-UPD-2]

use std::collections::{BTreeMap, BTreeSet};

use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::update::ReleaseSpec;

/// The signed description of a release. Serialized to the exact bytes that get
/// signed; the signature in [`SignedRelease`] is computed over `metadata_json`'s
/// UTF-8 bytes verbatim, so verification never re-serializes (no canonicalization
/// dependency — we verify the bytes we were given).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseMetadata {
    /// Monotonic release version. An update is accepted only if this is strictly
    /// greater than the running build's version (rollback rejection).
    pub version: u64,
    /// The release channel (e.g. `stable`, `beta`). Must equal the node's pin.
    pub channel: String,
    /// Wall-clock expiry of this signed metadata (epoch ms). Past it ⇒ rejected.
    pub expires_at_ms: u64,
    /// Which release key signed this — selects the verifying key + drives
    /// rotation/revocation.
    pub key_id: String,
    /// Lowercase-hex SHA-256 of the release binary the signature attests to.
    pub artifact_sha256: String,
    /// The IPC protocol version the release's **brain** speaks (ADR-0004 §A axis,
    /// fed to [`crate::update::classify`] once the release is verified).
    pub brain_ipc_version: u32,
    /// The held-resource ABI the release's **broker** implements (ADR-0004 §A axis).
    pub broker_resource_abi: u32,
}

impl ReleaseMetadata {
    /// The update-class inputs this verified release carries (ADR-0004 §A).
    pub fn spec(&self) -> ReleaseSpec {
        ReleaseSpec {
            brain_ipc_version: self.brain_ipc_version,
            broker_resource_abi: self.broker_resource_abi,
        }
    }
}

/// A release's signed metadata: the exact signed bytes plus the detached Ed25519
/// signature (lowercase hex, 64 bytes / 128 hex chars). Transport/on-disk shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRelease {
    /// The exact UTF-8 bytes that were signed (a serialized [`ReleaseMetadata`]).
    pub metadata_json: String,
    /// The Ed25519 signature over `metadata_json`'s bytes, lowercase hex.
    pub signature_hex: String,
}

/// The trust policy a node verifies a release against. Pure data — no clock, no
/// I/O — so verification is deterministic and testable.
#[derive(Debug, Clone)]
pub struct VerifyPolicy {
    /// Trusted release keys by `key_id`. Rotation = add a new id; old ids may
    /// linger until retired.
    pub trusted_keys: BTreeMap<String, VerifyingKey>,
    /// Explicitly revoked `key_id`s — rejected even with a valid signature.
    pub revoked_keys: BTreeSet<String>,
    /// The channel this node is pinned to.
    pub pinned_channel: String,
    /// The running build's version — an update must exceed it (monotonic).
    pub current_version: u64,
    /// Wall-clock now (epoch ms) for the expiry check (caller-supplied).
    pub now_ms: u64,
}

/// The default channel pin when `release-keys.json` names none.
pub const DEFAULT_CHANNEL: &str = "stable";

/// On-disk trusted-release-keys config (`identity/release-keys.json`) — the
/// production [`VerifyPolicy`] source (D9-1). Human-editable JSON:
/// `{"keys": {"<key_id>": "<verifying-key-hex>"}, "revoked": ["<key_id>"],
/// "channel": "stable"}`. A missing or malformed file yields an **empty**
/// trusted set — every offer rejects `UnknownKey`, fail-closed: a node with
/// no provisioned release keys can never accept a propagated binary.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ReleaseKeysFile {
    #[serde(default)]
    keys: BTreeMap<String, String>,
    #[serde(default)]
    revoked: BTreeSet<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    channel: Option<String>,
}

impl VerifyPolicy {
    /// Assemble the production policy: trusted keys + channel pin from
    /// `release-keys.json` under the identity dir, the caller's
    /// `current_version` (the staged release's version, or `0` when nothing
    /// is staged — only strictly-newer offers pass the monotonic gate), and
    /// the caller's clock.
    // [impl->REQ-UPD-2]
    pub fn production(current_version: u64, now_ms: u64) -> VerifyPolicy {
        Self::load_from(
            &spt_store::perch::identity_dir().join("release-keys.json"),
            current_version,
            now_ms,
        )
    }

    /// Load from an explicit keys file (tests; the pump E2E points each
    /// in-process "node" at its own). Unparseable key hex is skipped loud —
    /// a typo must not silently widen or narrow the trusted set unnoticed.
    // [impl->REQ-UPD-2]
    pub fn load_from(path: &std::path::Path, current_version: u64, now_ms: u64) -> VerifyPolicy {
        let file: ReleaseKeysFile = std::fs::read_to_string(path)
            .ok()
            .and_then(|raw| serde_json::from_str(&raw).ok())
            .unwrap_or_default();
        let mut trusted_keys = BTreeMap::new();
        for (key_id, hex) in &file.keys {
            match parse_verifying_key(hex) {
                Ok(vk) => {
                    trusted_keys.insert(key_id.clone(), vk);
                }
                Err(_) => eprintln!("RELEASE_KEY_BAD:{key_id} — skipped (not a valid key)"),
            }
        }
        VerifyPolicy {
            trusted_keys,
            revoked_keys: file.revoked,
            pinned_channel: file.channel.unwrap_or_else(|| DEFAULT_CHANNEL.to_string()),
            current_version,
            now_ms,
        }
    }
}

/// Why a release was refused. Every reject is loud + typed (the 2.3/§D failure
/// mode is a silent accept of a bad binary).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectReason {
    /// The metadata JSON or signature hex could not be parsed.
    Malformed(String),
    /// The `key_id` is not in the trusted set.
    UnknownKey(String),
    /// The `key_id` is on the revocation list.
    RevokedKey(String),
    /// The signature did not verify against the selected key.
    BadSignature,
    /// The release targets a channel other than the node's pin.
    ChannelMismatch { pinned: String, got: String },
    /// The signed metadata has expired.
    Expired { now_ms: u64, expires_at_ms: u64 },
    /// The release version is not strictly greater than the running version.
    Rollback { current: u64, candidate: u64 },
    /// The actual binary's digest does not match the signed `artifact_sha256`.
    ArtifactMismatch { signed: String, actual: String },
}

impl std::fmt::Display for RejectReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RejectReason::Malformed(e) => write!(f, "malformed release: {e}"),
            RejectReason::UnknownKey(k) => write!(f, "unknown signing key id {k:?}"),
            RejectReason::RevokedKey(k) => write!(f, "revoked signing key id {k:?}"),
            RejectReason::BadSignature => write!(f, "signature did not verify"),
            RejectReason::ChannelMismatch { pinned, got } => {
                write!(f, "channel {got:?} does not match pinned {pinned:?}")
            }
            RejectReason::Expired { now_ms, expires_at_ms } => {
                write!(f, "release metadata expired ({now_ms} > {expires_at_ms})")
            }
            RejectReason::Rollback { current, candidate } => {
                write!(f, "rollback rejected: candidate {candidate} not greater than current {current}")
            }
            RejectReason::ArtifactMismatch { signed, actual } => {
                write!(f, "artifact digest mismatch: signed {signed}, actual {actual}")
            }
        }
    }
}

impl std::error::Error for RejectReason {}

/// Verify a release's **metadata + signature + hardening** against `policy`
/// ([`REQ-UPD-2`], [`REQ-HAZARD-UPDATE-ROLLBACK`]). Returns the authenticated
/// [`ReleaseMetadata`] on success. Does **not** check the binary bytes — call
/// [`verify_artifact`] with the downloaded binary next (the signature binds it
/// by digest, but the digest must still be checked against the real bytes).
///
/// Check order is deliberate: identify + authenticate the signer *first* (an
/// unknown/revoked key or a bad signature is rejected before any field of the
/// payload is trusted for a policy decision), then enforce the channel / expiry /
/// monotonic-version policy on the now-authenticated metadata.
// [impl->REQ-UPD-2]
// [impl->REQ-HAZARD-UPDATE-ROLLBACK]
pub fn verify_metadata(
    signed: &SignedRelease,
    policy: &VerifyPolicy,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;

    // A revoked key's signatures are all void — reject before spending a verify.
    if policy.revoked_keys.contains(&meta.key_id) {
        return Err(RejectReason::RevokedKey(meta.key_id));
    }
    let key = policy
        .trusted_keys
        .get(&meta.key_id)
        .ok_or_else(|| RejectReason::UnknownKey(meta.key_id.clone()))?;
    check_signature(signed, key)?;

    // ── The metadata is now authentic; enforce the §D hardening policy. ──
    if meta.channel != policy.pinned_channel {
        return Err(RejectReason::ChannelMismatch {
            pinned: policy.pinned_channel.clone(),
            got: meta.channel,
        });
    }
    if policy.now_ms > meta.expires_at_ms {
        return Err(RejectReason::Expired { now_ms: policy.now_ms, expires_at_ms: meta.expires_at_ms });
    }
    if meta.version <= policy.current_version {
        return Err(RejectReason::Rollback { current: policy.current_version, candidate: meta.version });
    }
    Ok(meta)
}

/// Verify a release's **signature only** against an explicitly-supplied key,
/// returning the authenticated metadata. No trust-set lookup, no
/// channel/expiry/rollback policy — used for **adapter content signing** (C3),
/// where the trusted key is the per-adapter key declared in the adapter's
/// manifest `[update]` rather than spt-core's release-key set, and the adapter's
/// payload only needs authenticity + integrity (paired with [`verify_artifact`]).
// [impl->REQ-UPD-5]
pub fn verify_signature(
    signed: &SignedRelease,
    key: &VerifyingKey,
) -> Result<ReleaseMetadata, RejectReason> {
    let meta = parse_meta(signed)?;
    check_signature(signed, key)?;
    Ok(meta)
}

/// Parse the signed metadata bytes into a [`ReleaseMetadata`].
fn parse_meta(signed: &SignedRelease) -> Result<ReleaseMetadata, RejectReason> {
    serde_json::from_str(&signed.metadata_json).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Verify the Ed25519 signature over the exact signed metadata bytes with `key`.
fn check_signature(signed: &SignedRelease, key: &VerifyingKey) -> Result<(), RejectReason> {
    let sig_bytes = hex_decode(&signed.signature_hex).map_err(RejectReason::Malformed)?;
    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
        RejectReason::Malformed(format!("signature is {} bytes, want 64", sig_bytes.len()))
    })?;
    let signature = Signature::from_bytes(&sig_arr);
    key.verify(signed.metadata_json.as_bytes(), &signature)
        .map_err(|_| RejectReason::BadSignature)
}

/// Parse a hex-encoded Ed25519 public key (64 hex chars / 32 bytes) into a
/// [`VerifyingKey`] — the per-adapter key from a manifest `[update]` block (C3).
// [impl->REQ-UPD-5]
pub fn parse_verifying_key(hex: &str) -> Result<VerifyingKey, RejectReason> {
    let bytes = hex_decode(hex).map_err(RejectReason::Malformed)?;
    let arr: [u8; 32] = bytes
        .as_slice()
        .try_into()
        .map_err(|_| RejectReason::Malformed(format!("public key is {} bytes, want 32", bytes.len())))?;
    VerifyingKey::from_bytes(&arr).map_err(|e| RejectReason::Malformed(e.to_string()))
}

/// Confirm the downloaded binary's bytes match the signed `artifact_sha256`
/// ([`REQ-UPD-2`]). Run after [`verify_metadata`] so the signature has already
/// attested to the expected digest — this closes the loop to the real bytes.
// [impl->REQ-UPD-2]
pub fn verify_artifact(meta: &ReleaseMetadata, artifact: &[u8]) -> Result<(), RejectReason> {
    let actual = sha256_hex(artifact);
    if actual == meta.artifact_sha256.to_ascii_lowercase() {
        Ok(())
    } else {
        Err(RejectReason::ArtifactMismatch { signed: meta.artifact_sha256.clone(), actual })
    }
}

/// Lowercase-hex SHA-256 of `bytes` — the artifact-digest format used in
/// [`ReleaseMetadata::artifact_sha256`].
pub fn sha256_hex(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex_encode(&hasher.finalize())
}

/// Lowercase-hex encode (kept inline to avoid a `hex` crate edge for one helper).
fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push(HEX[(b >> 4) as usize] as char);
        s.push(HEX[(b & 0xf) as usize] as char);
    }
    s
}

/// Decode lowercase/uppercase hex. Errors (as a message) on odd length or a
/// non-hex digit.
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
    if !s.len().is_multiple_of(2) {
        return Err(format!("hex length {} is odd", s.len()));
    }
    let val = |c: u8| -> Result<u8, String> {
        match c {
            b'0'..=b'9' => Ok(c - b'0'),
            b'a'..=b'f' => Ok(c - b'a' + 10),
            b'A'..=b'F' => Ok(c - b'A' + 10),
            other => Err(format!("non-hex byte {other:#x}")),
        }
    };
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len() / 2);
    for pair in b.chunks_exact(2) {
        out.push((val(pair[0])? << 4) | val(pair[1])?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::update::BROKER_RESOURCE_ABI;
    use ed25519_dalek::{Signer, SigningKey};

    const KEY_ID: &str = "rel-2026";
    const CHANNEL: &str = "stable";

    /// A deterministic signing key (no RNG — test-stable). Real release tooling
    /// mints one from a CSPRNG; verification is identical either way.
    fn signing_key(seed: u8) -> SigningKey {
        SigningKey::from_bytes(&[seed; 32])
    }

    /// Build + sign a release for `artifact`, returning the wire form.
    fn sign_release(sk: &SigningKey, meta: &ReleaseMetadata) -> SignedRelease {
        let metadata_json = serde_json::to_string(meta).unwrap();
        let sig = sk.sign(metadata_json.as_bytes());
        SignedRelease { metadata_json, signature_hex: hex_encode(&sig.to_bytes()) }
    }

    fn meta_for(version: u64, artifact: &[u8]) -> ReleaseMetadata {
        ReleaseMetadata {
            version,
            channel: CHANNEL.to_string(),
            expires_at_ms: 10_000,
            key_id: KEY_ID.to_string(),
            artifact_sha256: sha256_hex(artifact),
            brain_ipc_version: 1,
            broker_resource_abi: BROKER_RESOURCE_ABI,
        }
    }

    // [unit->REQ-UPD-2] the production keys file loads: valid keys trusted,
    // bad hex skipped, channel pinned; a missing file = EMPTY trusted set
    // (fail-closed — an unprovisioned node accepts nothing).
    #[test]
    fn production_policy_loads_fail_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("release-keys.json");

        // Missing file: empty trusted set, default channel.
        let p = VerifyPolicy::load_from(&path, 3, 1_000);
        assert!(p.trusted_keys.is_empty(), "no file => trust nobody");
        assert_eq!(p.pinned_channel, DEFAULT_CHANNEL);
        assert_eq!((p.current_version, p.now_ms), (3, 1_000));

        // A real key + a malformed one + a revocation + a channel pin.
        let vk = signing_key(9).verifying_key();
        let json = serde_json::json!({
            "keys": { "good": hex_encode(vk.as_bytes()), "bad": "zz-not-hex" },
            "revoked": ["old-2024"],
            "channel": "beta",
        });
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let p = VerifyPolicy::load_from(&path, 5, 2_000);
        assert_eq!(p.trusted_keys.len(), 1, "bad hex skipped, good key kept");
        assert!(p.trusted_keys.contains_key("good"));
        assert!(p.revoked_keys.contains("old-2024"));
        assert_eq!(p.pinned_channel, "beta");

        // Malformed file degrades to fail-closed, never a panic.
        std::fs::write(&path, "not json {{{").unwrap();
        assert!(VerifyPolicy::load_from(&path, 0, 0).trusted_keys.is_empty());
    }

    fn policy(sk: &SigningKey) -> VerifyPolicy {
        let mut trusted = BTreeMap::new();
        trusted.insert(KEY_ID.to_string(), sk.verifying_key());
        VerifyPolicy {
            trusted_keys: trusted,
            revoked_keys: BTreeSet::new(),
            pinned_channel: CHANNEL.to_string(),
            current_version: 5,
            now_ms: 1_000, // before the 10_000 expiry
        }
    }

    // [unit->REQ-UPD-2] a correctly-signed, newer, in-channel, unexpired release
    // from a trusted key verifies, and its bytes match the signed digest.
    #[test]
    fn valid_release_verifies_and_artifact_matches() {
        let sk = signing_key(7);
        let artifact = b"the new spt binary bytes";
        let signed = sign_release(&sk, &meta_for(6, artifact));

        let meta = verify_metadata(&signed, &policy(&sk)).expect("valid release verifies");
        assert_eq!(meta.version, 6);
        verify_artifact(&meta, artifact).expect("artifact digest matches the signed value");
        assert_eq!(meta.spec().broker_resource_abi, BROKER_RESOURCE_ABI);
    }

    // [unit->REQ-UPD-2] tampering with the signed bytes after signing ⇒ the
    // signature no longer verifies (the metadata is not authentic).
    #[test]
    fn tampered_metadata_fails_signature() {
        let sk = signing_key(7);
        let mut signed = sign_release(&sk, &meta_for(6, b"bin"));
        // Flip the version in the JSON without re-signing.
        signed.metadata_json = signed.metadata_json.replace("\"version\":6", "\"version\":9");
        assert_eq!(verify_metadata(&signed, &policy(&sk)), Err(RejectReason::BadSignature));
    }

    // [unit->REQ-UPD-2] a signature from a key the node does not trust ⇒ UnknownKey.
    #[test]
    fn untrusted_key_is_unknown() {
        let real = signing_key(7);
        let attacker = signing_key(9);
        let signed = sign_release(&attacker, &meta_for(6, b"bin"));
        // Policy trusts only `real` under KEY_ID; the attacker signed under the
        // same key_id but with a different key → signature won't match the trusted
        // verifying key. (UnknownKey would fire if the id itself were untrusted.)
        assert_eq!(verify_metadata(&signed, &policy(&real)), Err(RejectReason::BadSignature));

        // A genuinely unknown key_id is UnknownKey.
        let mut meta = meta_for(6, b"bin");
        meta.key_id = "no-such-key".to_string();
        let signed2 = sign_release(&attacker, &meta);
        assert_eq!(
            verify_metadata(&signed2, &policy(&real)),
            Err(RejectReason::UnknownKey("no-such-key".to_string()))
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a revoked key is rejected even with an
    // otherwise-valid signature.
    #[test]
    fn revoked_key_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin"));
        let mut pol = policy(&sk);
        pol.revoked_keys.insert(KEY_ID.to_string());
        assert_eq!(verify_metadata(&signed, &pol), Err(RejectReason::RevokedKey(KEY_ID.to_string())));
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a version not strictly greater than the
    // running build is a rollback (both equal and lower are rejected).
    #[test]
    fn non_monotonic_version_is_rollback() {
        let sk = signing_key(7);
        let pol = policy(&sk); // current_version = 5
        for candidate in [5u64, 4, 0] {
            let signed = sign_release(&sk, &meta_for(candidate, b"bin"));
            assert_eq!(
                verify_metadata(&signed, &pol),
                Err(RejectReason::Rollback { current: 5, candidate }),
                "version {candidate} must be rejected as a rollback"
            );
        }
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] expired signed metadata is rejected even
    // when everything else is valid (replay of an old-but-signed release).
    #[test]
    fn expired_metadata_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"bin")); // expires_at_ms = 10_000
        let mut pol = policy(&sk);
        pol.now_ms = 20_000; // past expiry
        assert_eq!(
            verify_metadata(&signed, &pol),
            Err(RejectReason::Expired { now_ms: 20_000, expires_at_ms: 10_000 })
        );
    }

    // [unit->REQ-HAZARD-UPDATE-ROLLBACK] a release for another channel is rejected
    // (channel pinning: a beta build cannot land on a stable node).
    #[test]
    fn channel_mismatch_is_rejected() {
        let sk = signing_key(7);
        let mut meta = meta_for(6, b"bin");
        meta.channel = "beta".to_string();
        let signed = sign_release(&sk, &meta);
        assert_eq!(
            verify_metadata(&signed, &policy(&sk)),
            Err(RejectReason::ChannelMismatch { pinned: CHANNEL.to_string(), got: "beta".to_string() })
        );
    }

    // [unit->REQ-UPD-2] a binary whose bytes do not hash to the signed digest is
    // rejected — the signature attests to specific bytes, not just a manifest.
    #[test]
    fn artifact_digest_mismatch_is_rejected() {
        let sk = signing_key(7);
        let signed = sign_release(&sk, &meta_for(6, b"the signed bytes"));
        let meta = verify_metadata(&signed, &policy(&sk)).expect("metadata verifies");
        // A different binary than the one whose digest was signed.
        let tampered = b"DIFFERENT bytes than were signed";
        assert!(matches!(
            verify_artifact(&meta, tampered),
            Err(RejectReason::ArtifactMismatch { .. })
        ));
    }

    #[test]
    fn hex_roundtrips() {
        let bytes = [0u8, 1, 15, 16, 255, 128, 7];
        assert_eq!(hex_decode(&hex_encode(&bytes)).unwrap(), bytes);
        assert!(hex_decode("abc").is_err()); // odd length
        assert!(hex_decode("zz").is_err()); // non-hex
    }
}


crates\spt-daemon\src\applyhost.rs:196:    use crate::update::BROKER_RESOURCE_ABI;
crates\spt-daemon\src\applyhost.rs:197:    use ed25519_dalek::{Signer, SigningKey};
crates\spt-daemon\src\applyhost.rs:234:        let sk = SigningKey::from_bytes(&[9u8; 32]);
crates\spt-daemon\src\applyhost.rs:291:        let (cache, keys) = stage(dir.path(), 7, b"NEW BINARY BYTES", BROKER_RESOURCE_ABI);
crates\spt-daemon\src\applyhost.rs:324:        let (cache, keys) = stage(dir.path(), 8, b"X", BROKER_RESOURCE_ABI + 1);
crates\spt-daemon\src\applyhost.rs:334:        let (cache, keys) = stage(dir.path(), 9, b"GOOD BYTES", BROKER_RESOURCE_ABI);
crates\spt-daemon\src\applyhost.rs:341:        let (cache, _) = stage(dir.path(), 10, b"Y", BROKER_RESOURCE_ABI);
crates\spt-daemon\src\adapter_update.rs:168:    use ed25519_dalek::{Signer, SigningKey};
crates\spt-daemon\src\adapter_update.rs:170:    fn key_hex(sk: &SigningKey) -> String {
crates\spt-daemon\src\adapter_update.rs:174:    fn sign(sk: &SigningKey, artifact: &[u8]) -> FilePullPayload {
crates\spt-daemon\src\adapter_update.rs:220:        let sk = SigningKey::from_bytes(&[11u8; 32]);
crates\spt-daemon\src\adapter_update.rs:234:        let declared = SigningKey::from_bytes(&[11u8; 32]);
crates\spt-daemon\src\adapter_update.rs:235:        let attacker = SigningKey::from_bytes(&[22u8; 32]);
crates\spt-daemon\src\adapter_update.rs:248:        let sk = SigningKey::from_bytes(&[11u8; 32]);
crates\spt-daemon\src\adapter_update.rs:262:        let sk = SigningKey::from_bytes(&[11u8; 32]);
crates\spt-daemon\src\adapter_update.rs:296:        let sk = SigningKey::from_bytes(&[11u8; 32]);
crates\spt-daemon\src\lib.rs:208:    UpdateClass, UpdatePlan, BROKER_RESOURCE_ABI,
crates\spt-proto\src\identity.rs:15:use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
crates\spt-proto\src\identity.rs:55:    signing: SigningKey,
crates\spt-proto\src\identity.rs:61:    pub fn generate() -> Identity {
crates\spt-proto\src\identity.rs:63:            signing: SigningKey::generate(&mut OsRng),
crates\spt-proto\src\identity.rs:71:            signing: SigningKey::from_bytes(seed),
crates\spt-daemon\src\release.rs:355:    use crate::update::BROKER_RESOURCE_ABI;
crates\spt-daemon\src\release.rs:356:    use ed25519_dalek::{Signer, SigningKey};
crates\spt-daemon\src\release.rs:363:    fn signing_key(seed: u8) -> SigningKey {

[Showing results with pagination = limit: 25]


Grep

pub const BROKER_RESOURCE_ABI|brain_ipc_version: u32|pub const BRAIN_IPC|CURRENT_BRAIN_IPC|ipc_version


39:pub const BROKER_RESOURCE_ABI: u32 = 1;
48:    pub ipc_version: u32,
65:            ipc_version: IPC_PROTOCOL_VERSION,
78:    pub brain_ipc_version: u32,
116:    if release.brain_ipc_version >= running.min_compatible {
251:            brain_ipc_version: running.ipc_version,
257:            brain_ipc_version: running.ipc_version + 5,
273:        let running = BrokerAbi { ipc_version: 3, min_compatible: 3, resource_abi: BROKER_RESOURCE_ABI };
274:        let old_brain = ReleaseSpec { brain_ipc_version: 2, broker_resource_abi: BROKER_RESOURCE_ABI };
285:            brain_ipc_version: running.ipc_version, // IPC fine — irrelevant here

[Showing results with pagination = limit: 10]