From 53c5a50ce8f0ecb3aeb71a0fa80a00adcfbb3eac Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:35:20 -0700 Subject: [PATCH 01/15] docs: correct stale auth, template, docker, and a11y claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.md §2 still described the bearer as `hex(HMAC-SHA256(passphrase, "springtale-api-token"))` with "no separate API key". That model is gone: `POST /auth/login` verifies the passphrase against that hash and mints a 32-byte CSPRNG session token; long-lived named tokens come from `POST /auth/tokens`; both are stored only as `sha256(token)`. Rewrote the section around the issued-token model and added the route families the catalogue never had: auth/sessions/tokens, `/vault/lock` + `/vault/unlock`, and `/mcp` (with its Origin check). Dropped `GET /templates` and `POST /templates/{name}` from §3.15 and the Fig. 3 diagram — the template system was deleted. `/onboarding/platforms` now names the platforms that actually have `onboarding_form()` impls: telegram, discord, slack, signal (not github). cli.md §9 repeated the derived-token claim. Rotating the vault passphrase rotates no token at all; documented what it does change (the boot-time login verifier, stale until restart), and what it does not (in-memory sessions, `api_tokens` rows). QUICKSTART §3.2 told the reader to `export SPRINGTALE_PASSPHRASE`, which `/proc//environ` and `docker inspect` both leak, and which docker-compose.yml deliberately avoids. Rewrote it around the Docker secret file that compose actually mounts. ROADMAP Table I said "a11y not implemented" while §5 listed it as shipped. The work landed; the table now matches §5.1, with font scaling named as the one remaining gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- docs/QUICKSTART.md | 30 +++++++++++-- docs/ROADMAP.md | 13 ++++-- docs/reference/api.md | 99 +++++++++++++++++++++++++++++++++++-------- docs/reference/cli.md | 27 ++++++++++-- 4 files changed, 141 insertions(+), 28 deletions(-) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 5cb61672..eee4b5ed 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -86,28 +86,50 @@ docker build -t springtale . ### 3.2. Run +The passphrase goes in a **file**, never in the environment. `docker-compose.yml` +mounts it as a Docker secret and points `SPRINGTALE_PASSPHRASE_FILE` at the +mount, which is the first source `get_passphrase()` consults at boot. + ```bash -mkdir -p data +mkdir -p data secrets cp springtale.toml.example springtale.toml -# Set your vault passphrase -export SPRINGTALE_PASSPHRASE="your-secure-passphrase" +# Write the passphrase to the secret file. `-n` matters: no trailing +# newline. Leading `space` keeps it out of shell history on bash/zsh +# with HISTCONTROL=ignorespace / setopt HIST_IGNORE_SPACE. + printf '%s' 'your-secure-passphrase' > secrets/passphrase.txt +chmod 600 secrets/passphrase.txt + +# Create the vault before the first `up` +docker compose run --rm springtaled springtale init docker compose up -d ``` +Do **not** use `export SPRINGTALE_PASSPHRASE=...`. Any process running as +the same user can read another process's environment out of +`/proc//environ`, `docker inspect` prints it back in the clear, and it +is inherited by every child process. `SPRINGTALE_PASSPHRASE` still exists as +a development-only fallback; production and anything holding real user data +should use the file. + The container runs as a non-root user with read-only root filesystem, all capabilities dropped, and `no-new-privileges` enforced. **TABLE III. DOCKER ENVIRONMENT VARIABLES** | Variable | Default | Description | |----------|---------|-------------| -| `SPRINGTALE_PASSPHRASE` | (required) | Vault encryption passphrase | +| `SPRINGTALE_PASSPHRASE_FILE` | `/run/secrets/springtale_passphrase` | Path to the file holding the vault passphrase. Read as bytes, trailing whitespace trimmed, zeroized after use. **Preferred.** | +| `SPRINGTALE_PASSPHRASE` | — | Vault passphrase inline. Development only — visible in `/proc//environ` and `docker inspect`. Consulted only if `SPRINGTALE_PASSPHRASE_FILE` is unset | | `SPRINGTALE_STORE__PATH` | `/data/springtale.db` | Database path inside container | | `SPRINGTALE_CRYPTO__VAULT_PATH` | `/data/vault.bin` | Vault path inside container | +| `SPRINGTALE_TRANSPORT__SOCKET_PATH` | `/data/springtale.sock` | Control socket path inside container | | `SPRINGTALE_API__BIND` | `0.0.0.0:8080` | API bind address | | `RUST_LOG` | `info` | Log level | +> `secrets/passphrase.txt` is the one file that must never be committed or +> included in a backup image. Keep it out of the build context. + ### 3.3. Verify ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1980dd9..340d6ba0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Springtale ships in five phases. Each phase builds on the last — no phase skip | 1a | Framework + Connectors | Daemon, CLI, 14 library crates, 8 baseline connectors (kick, presearch, bluesky, github, filesystem, shell, http, opencode), SQLite (declarative schema v1 in `schema/sql/`), crypto vault, WASM sandbox, MCP endpoint | Present. Connector roster grew to 15 first-party through Phases 1b/2a. | | 1b | Bot Foundations | `springtale-bot`, command router (prefix / pattern / alias), cooperation framework, `connector-telegram`, session memory | Present. Cooperation framework extracted to its own `springtale-cooperation` crate (42 pub modules) and wired into a 25-step formation tick; see §3.2. | | 2a | Chat + AI | Discord, Slack, IRC, Signal, Nostr connectors. Anthropic / Ollama / OpenAI-compat adapters (all three stream). `HttpTransport` (rustls mTLS). `springtale-sentinel`. Tool-calling across all AI adapters. | Present. Matrix is held on upstream `rusqlite` CVE. | -| 2b | Desktop + Safety | Tauri 2 shell, SolidJS dashboard + colony canvas (RTS formation visualisation), duress vault, panic wipe, travel mode. Visual rule builder, i18n, a11y. | Shell, dashboard, colony canvas (with formation command grid, rally pips, attention bar, liveness/health encoding), duress, panic wipe, travel mode present. Visual rule builder (`RuleBuilderOverlay`), i18n (eight locales), quick-hide, and lock-screen content protection present. a11y not implemented. | +| 2b | Desktop + Safety | Tauri 2 shell, SolidJS dashboard + colony canvas (RTS formation visualisation), duress vault, panic wipe, travel mode. Visual rule builder, i18n, a11y. | Shell, dashboard, colony canvas (with formation command grid, rally pips, attention bar, liveness/health encoding), duress, panic wipe, travel mode present. Visual rule builder (`RuleBuilderOverlay`), i18n (eight locales), quick-hide, and lock-screen content protection present. a11y present — see §5.1: skip link (`ColonyShell`), `aria-live` regions, `role="application"` + `tabindex` + per-sprite `aria-label`s on the colony canvas with keyboard selection handled at the document level, a 7 px floor on the colony text scale, and `prefers-reduced-motion` / `prefers-contrast` styles. User-controlled font scaling is the one a11y item still missing (§5.2). | | 3 | Veilid Mesh | `VeilidTransport`, P2P mesh, distributed registry, Rekindle integration | Not implemented. `VeilidTransport` exists as a stub — every method returns `TransportError::NotConnected`. | --- @@ -241,9 +241,14 @@ Tauri 2 desktop shell with a SolidJS frontend that renders an RTS-inspired colon the window contents. - **i18n** — 8 locales (en, es, pt, fr, ar, th, tl, ja) via `@solid-primitives/i18n`, RTL-aware, switchable from app settings. -- **Accessibility** — skip link, `aria-live` regions on the safety panel, event ribbon, - rule builder, canvas, travel mode and sessions; screen-reader navigation over the - canvas; `prefers-reduced-motion` and high-contrast styles in `theme.css`. +- **Accessibility** — skip link (`ColonyShell`), `aria-live` regions on the safety panel, + event ribbon, rule builder, canvas, travel mode and sessions; the canvas root is + `role="application"` with `tabindex={0}` and an `aria-label`, every sprite, fuel/HP bar + and formation zone carries its own `aria-label`, and keystrokes (Escape, 1-9) are handled + at the document level rather than consumed by screen-reader navigation; a 7 px floor on + the colony text scale (`.colony-text-4xs`, scaled up from the v8 reference); + `prefers-reduced-motion` and high-contrast (`prefers-contrast: more`) styles in + `theme.css`. ### 5.2. Not Implemented diff --git a/docs/reference/api.md b/docs/reference/api.md index 20b12188..e78eb80e 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -38,15 +38,33 @@ ## 2. Authentication -Every route except `/health`, `/ready`, and `/ui` requires a bearer token. Webhook routes (`/webhook/{connector}/{trigger}`) also require the token — the connector then performs its own signature verification on the body (HMAC-SHA256 for GitHub, RSA for Kick, etc.) inside its `verify_webhook()` implementation. +Every route except `/health`, `/ready`, `/openapi.json`, `POST /auth/login`, `POST /vault/unlock`, and `/ui` requires a bearer token. Webhook routes (`/webhook/{connector}/{trigger}`) also require the token — the connector then performs its own signature verification on the body (HMAC-SHA256 for GitHub, RSA for Kick, etc.) inside its `verify_webhook()` implementation. -The token is derived from the vault passphrase: +Bearer tokens are **issued, never derived**. `POST /auth/login` takes the +vault passphrase, compares `HMAC-SHA256(passphrase, "springtale-api-token")` +against the value the daemon computed at boot — in constant time +(`subtle::ConstantTimeEq`) — and only on a match mints a token: 32 bytes +(256 bits) straight from the OS CSPRNG, hex-encoded, with no structure at +all. That passphrase-derived hash is the login **verifier** only; it is +never accepted as a bearer. -``` -token = hex(HMAC-SHA256(passphrase, "springtale-api-token")) -``` +Two kinds of bearer exist, and `require_auth` accepts either: + +| Kind | Minted by | Lifetime | Revoked by | +|---|---|---|---| +| **Session** | `POST /auth/login` | Idle + absolute timeouts from `bot:settings` (`session_idle_secs`, `session_absolute_secs`). Held in process memory only, so a daemon restart or a vault lock drops every one. | `POST /auth/logout` | +| **Long-lived named** | `POST /auth/tokens` | Until revoked. Persisted in the `api_tokens` table. | `DELETE /auth/tokens/{id}` | + +Neither kind is ever stored in the clear: both live as `sha256(token)` — +sessions in the in-memory map, long-lived tokens in `api_tokens`. The token +string is returned exactly once, in the minting response, and nothing keeps +it. A presented bearer is hex-decoded, hashed, and looked up (sessions +first, then `api_tokens`) with a constant-time compare, so a hit and a miss +cost the same work. A token that was never issued, one that expired, and +one that was revoked are all the same answer: `401`. -Verification uses constant-time comparison (`subtle::ConstantTimeEq`). There is no separate API key — rotating the token rotates the passphrase. +`POST /auth/login` is unauthenticated by definition, so it carries its own +tight rate limit (5 req/s) on top of the global 100 req/s. Authenticated routes also go through a CSRF-protection middleware (`require_csrf_protection`) that rejects cross-origin requests with @@ -54,7 +72,11 @@ unsafe methods. SSE readers are exempt because the browser `EventSource` cannot originate a state-changing request. ```bash -# Typical client +# Log in once, then use the minted token +TOKEN=$(curl -sX POST http://127.0.0.1:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"passphrase":"..."}' | jq -r .token) + curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/connectors ``` @@ -76,13 +98,16 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/connectors Agents Authors Bot admin Sessions Memory Safety Data ┌─────────┬──────────┬──────────┬──────────┬─────────────┐ ▼ ▼ ▼ ▼ ▼ ▼ - Send Diagnostics Fixes Onboarding Templates Webhooks - ┌──────────────┬──────────────────┐ - ▼ ▼ ▼ - Approvals Chat (SSE) Dashboard (SPA) + Send Diagnostics Fixes Onboarding Recipes Webhooks + ┌──────────────┬──────────────────┬──────────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + Approvals Chat (SSE) Dashboard (SPA) Auth Vault + │ + ▼ + MCP ``` -*Fig. 3. Route groups at a glance. Public routes: health, ready, `/ui`, `/ui/*`. Everything else requires the bearer token.* +*Fig. 3. Route groups at a glance. Public routes: `/health`, `/ready`, `/openapi.json`, `POST /auth/login`, `POST /vault/unlock`, `/ui`, `/ui/*`. Everything else requires the bearer token.* ### 3.1 Health @@ -251,7 +276,7 @@ Author Ed25519 public keys used to verify signed manifests. |---|---|---| | POST | `/send` | Execute an `Action` directly against a connector. Capability-checked through the sentinel, same as rule-dispatched actions. No back door. | -### 3.15 Diagnostics, Fixes, Onboarding, Templates, Recipes +### 3.15 Diagnostics, Fixes, Onboarding, Recipes These routes back the **Doctor** and **Onboarding** flows in the desktop shell. @@ -261,10 +286,8 @@ These routes back the **Doctor** and **Onboarding** flows in the desktop shell. | GET | `/fixes` | List available auto-repair suggestions bound to diagnostic ids | | GET | `/fixes/{id}` | Fetch a single fix with its proposed action | | POST | `/fixes/{id}/apply` | Apply a fix | -| GET | `/onboarding/platforms` | List platforms that have onboarding templates (telegram, discord, github, etc.) | -| POST | `/onboarding/{platform}` | Apply an onboarding template for the given platform | -| GET | `/templates` | List rule / connector templates bundled with the daemon | -| POST | `/templates/{name}` | Write a template into the current store | +| GET | `/onboarding/platforms` | List the platform forms the first-run wizard knows. Collected at runtime from every registered connector factory that returns an `onboarding_form()` — currently **telegram, discord, slack, signal** | +| POST | `/onboarding/{platform}` | Persist a completed answer set for that platform as a connector config | | GET | `/recipes` | List curated automation recipes (browseable cookbook surface) | | GET | `/recipes/categories` | Recipe categories | | GET | `/recipes/{id}` | One recipe by id | @@ -334,6 +357,48 @@ surface through the §3.18 endpoints. | POST | `/chat` | Inject a chat message. Body `{"text": "...", "session": "optional"}` (`session` defaults to `in-app`). Fire-and-forget — returns `202 Accepted` with `{"status":"queued","session":"..."}` once queued; `400` on empty text, `503` if the bot runtime is unavailable | | GET | `/chat/stream` | SSE stream of bot replies. Each event's data is `{"session": "...", "text": "..."}` | +### 3.20 Authentication, sessions & tokens + +See §2 for the token model. `POST /auth/login` is public; everything else +in this family requires an existing bearer. + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/auth/login` | — | Verify the vault passphrase and mint a session token. Body `{"passphrase": "..."}`; returns `{"token": "", "expires_in": }`. `401` on mismatch. Rate-limited to 5 req/s on top of the global limit | +| POST | `/auth/logout` | ✓ | Drop the presented session. Returns `{"logged_out": true\|false}`. A long-lived token is not a session — revoke those with `DELETE /auth/tokens/{id}` | +| POST | `/auth/tokens` | ✓ | Mint a long-lived named token. Body `{"name": "springtale-cli@laptop"}` (1–128 chars). Returns `{"id", "name", "token"}` — the token string appears here and nowhere else, ever | +| GET | `/auth/tokens` | ✓ | List long-lived tokens: `id`, `name`, `created_at`, `last_used`. Metadata only — the hash never crosses the wire | +| DELETE | `/auth/tokens/{id}` | ✓ | Revoke a long-lived token. Revocation is immediate: the next request carrying it fails its lookup | +| POST | `/stream/ticket` | ✓ | One-time 30 s ticket for the SSE routes, bound to the presented bearer. Logging out or revoking that bearer invalidates every outstanding ticket | + +### 3.21 Vault lock & unlock + +While the vault is locked the daemon swaps in an outer router: `/health`, +`/ready`, and the two vault routes always answer, and everything else is +refused until the vault is opened. + +| Method | Path | Auth | Description | +|---|---|---|---| +| POST | `/vault/unlock` | — | Open the vault. Body `{"passphrase": "..."}`. Deliberately unauthenticated: there is no bearer to present while locked, because sessions are dropped with the process state on lock and a bearer cannot be minted without a running daemon. `Vault::open` is the check — Argon2id over the wrong passphrase fails at AEAD decryption, with no comparison to shortcut. Rate-limited per minute so the KDF cannot be driven by a flood of guesses. `409` if already unlocked, `401` on failure | +| POST | `/vault/lock` | ✓ | Lock the vault. Drops the runtime state, and with it every in-memory session | + +### 3.22 Model Context Protocol + +`springtaled` **is** the MCP server (Streamable HTTP transport). Tool calls +dispatch through the same sentinel, approval gate, and executions recorder +as a rule-dispatched action — there is no separate path. + +| Method | Path | Auth | Description | +|---|---|---|---| +| ANY | `/mcp` | ✓ | MCP Streamable HTTP endpoint | + +Two layers run in front of it, outside-in: an `Origin` check +(`require_local_origin`) rejects any non-loopback origin per the MCP +transports spec's DNS-rebinding requirement, then `require_auth` checks the +bearer, and only then is any MCP framing parsed. A missing `Origin` header +is accepted (non-browser clients do not send one); anything non-loopback is +`403`. The `Mcp-Session-Id` header is never authentication. + --- ## 4. Middleware Stack diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f8b90a60..10e0f70c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -381,14 +381,35 @@ Duress vault configured. ## 9. `springtale crypto rotate-vault-key` -Re-encrypt the vault with a new passphrase. The API bearer token changes as a side effect — the token is `HMAC-SHA256(passphrase, "springtale-api-token")`. +Re-encrypt the vault with a new passphrase. It opens the vault with the old +passphrase, copies every entry into a fresh vault created under the new one, +and rewrites the vault file. Nothing outside the vault file is touched. ``` $ springtale crypto rotate-vault-key Enter current passphrase: ******** Enter new passphrase: ******** -Vault re-encrypted. Update API clients with the new token. -``` +Vault re-encrypted. +``` + +**No API token is rotated by this.** Bearer tokens are minted from the OS +CSPRNG at login, not derived from the passphrase (see +[`api.md` §2](api.md#2-authentication)). What the new passphrase changes: + +- **The login verifier.** `POST /auth/login` compares the presented + passphrase against a hash the daemon computed once, at boot, from the + passphrase it started with. A daemon that is already running keeps that + old value in memory, so it keeps accepting the **old** passphrase at + `/auth/login` until it is restarted (or the vault locked and unlocked) + with the new one. Restart the daemon after rotating. +- **Live sessions** are unaffected while the daemon runs — they are keyed + by `sha256(token)` in process memory, with no link to the passphrase. + Locking the vault or restarting the daemon drops all of them, so every + client will have to log in again with the new passphrase. +- **Long-lived named tokens** (`POST /auth/tokens`) survive rotation + entirely: their hashes live in the `api_tokens` table, which this command + never touches. If you are rotating in response to a compromise, revoke + them explicitly with `DELETE /auth/tokens/{id}`. --- From 1d3a5e8b35fa79464ede01bff30fb6e7f0bcf543 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:40:39 -0700 Subject: [PATCH 02/15] fix: correct stale comments in Cargo.toml, manifest types, and lock.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace manifest claimed connector-matrix's "src/ tree is preserved so it compiles when upstream lands a rusqlite >= 0.39 bump". There is no `connectors/connector-matrix` directory at all — nothing to preserve and nothing to re-enable by uncommenting a members entry. Comment now states that, and what bringing Matrix back would actually take. `ConnectorManifest`'s doc comment said "Parsed from `connector-{name}.toml`". No such file exists anywhere in the tree; manifests are constructed in `fn manifest()`. Corrected, with a note on why the serde impls exist (canonical JSON for signing, travelling with a WASM binary) so the next reader does not re-derive the file story. `POST /vault/unlock`'s comment justified being unauthenticated with "the API token is derived from the passphrase". It isn't, since plan 6.6. Restated the real reason: while locked there is no bearer that could be presented — sessions die with the process state, and a long-lived token can only be looked up against that same dropped state. Also tightened the api.md `/vault/lock` row while verifying the above: the handler runs the bearer check by hand because the outer router has no `AppState` for `require_auth` to sit over. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- Cargo.toml | 18 +++++++++++++----- apps/springtaled/src/api/lock.rs | 13 ++++++++----- .../springtale-connector/src/manifest/types.rs | 13 ++++++++++--- docs/reference/api.md | 4 ++-- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 482d9a6d..37209d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,13 +41,21 @@ members = [ "connectors/connector-slack", "connectors/connector-signal", "connectors/connector-browser", - # connector-matrix: DEFERRED — upstream matrix-sdk-sqlite (v0.18.0 main) - # still declares rusqlite = "0.37.0" which carries CVE-2025-70873 (heap - # info disclosure). Our store crate is pinned to rusqlite 0.39 (patched); + # connector-matrix: NOT IN THE TREE. There is no + # `connectors/connector-matrix` directory — no crate, no src/, nothing to + # re-enable by uncommenting a line here. + # + # Why it was never landed: upstream matrix-sdk-sqlite (v0.18.0 main) still + # declares rusqlite = "0.37.0", which carries CVE-2025-70873 (heap info + # disclosure). Our store crate is pinned to rusqlite 0.39 (patched), and # downgrading would expose activist/survivor data to the heap leak. # Accountable tracking: `vex/connector-matrix-rusqlite-cve-2025-70873.json` - # (v2, last reviewed 2026-06-02, next_review 2026-09-01). The src/ tree - # is preserved so it compiles when upstream lands a rusqlite >= 0.39 bump. + # (v2, last reviewed 2026-06-02, next_review 2026-09-01). + # + # To bring Matrix support back, two things must happen, in order: + # 1. Upstream matrix-sdk-sqlite bumps to rusqlite >= 0.39. + # 2. Someone writes `connectors/connector-matrix` from scratch against + # the current `Connector` trait, then adds it to `members` above. # Apps "apps/springtaled", "apps/springtale-cli", diff --git a/apps/springtaled/src/api/lock.rs b/apps/springtaled/src/api/lock.rs index 7f95a6fb..7167670a 100644 --- a/apps/springtaled/src/api/lock.rs +++ b/apps/springtaled/src/api/lock.rs @@ -519,11 +519,14 @@ where /// POST /vault/unlock — public, rate-limited. /// -/// Deliberately unauthenticated: the API token is derived from the -/// passphrase, so there is no credential to present while locked. The -/// passphrase itself is the credential, and `Vault::open` is the check -/// — Argon2id over the wrong passphrase fails at AEAD decryption, with -/// no comparison this code could shortcut. +/// Deliberately unauthenticated: while the vault is locked there is no +/// bearer that could be presented. Bearers are *issued*, never derived +/// from the passphrase (plan 6.6) — a session comes from +/// `POST /auth/login` and lives in the process state that locking drops, +/// and a long-lived token can only be looked up against that same +/// dropped state. So the passphrase itself is the credential here, and +/// `Vault::open` is the check — Argon2id over the wrong passphrase fails +/// at AEAD decryption, with no comparison this code could shortcut. async fn unlock(State(guard): State, Json(body): Json) -> Response { if !guard.is_locked() { return ( diff --git a/crates/springtale-connector/src/manifest/types.rs b/crates/springtale-connector/src/manifest/types.rs index b6897b41..9908f65f 100644 --- a/crates/springtale-connector/src/manifest/types.rs +++ b/crates/springtale-connector/src/manifest/types.rs @@ -5,10 +5,17 @@ use specta::Type; use springtale_crypto::signature::SignatureAlgorithm; /// A connector's manifest — the declaration of what it is, what it needs, -/// and what it can do. Parsed from `connector-{name}.toml`. +/// and what it can do. /// -/// Every connector ships with a manifest. For native connectors, the manifest -/// is embedded. For WASM connectors, it accompanies the `.wasm` binary. +/// Built in code, not parsed from a file: every connector constructs and +/// returns this struct from its `Connector::manifest()` implementation, so +/// the declaration is compiled and type-checked alongside the `triggers()` +/// and `actions()` it describes. There is no `connector-{name}.toml`. +/// +/// The `Serialize`/`Deserialize` impls exist because the manifest is +/// canonicalised to JSON for signing (see `signature_alg` / `signature`) +/// and travels with a WASM connector's `.wasm` binary at install time — +/// not because it is authored as a config file. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Type)] #[serde(deny_unknown_fields)] pub struct ConnectorManifest { diff --git a/docs/reference/api.md b/docs/reference/api.md index e78eb80e..18b6d244 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -379,8 +379,8 @@ refused until the vault is opened. | Method | Path | Auth | Description | |---|---|---|---| -| POST | `/vault/unlock` | — | Open the vault. Body `{"passphrase": "..."}`. Deliberately unauthenticated: there is no bearer to present while locked, because sessions are dropped with the process state on lock and a bearer cannot be minted without a running daemon. `Vault::open` is the check — Argon2id over the wrong passphrase fails at AEAD decryption, with no comparison to shortcut. Rate-limited per minute so the KDF cannot be driven by a flood of guesses. `409` if already unlocked, `401` on failure | -| POST | `/vault/lock` | ✓ | Lock the vault. Drops the runtime state, and with it every in-memory session | +| POST | `/vault/unlock` | — | Open the vault. Body `{"passphrase": "..."}`. Deliberately unauthenticated: while locked there is no bearer that could be presented — sessions are dropped with the process state on lock, and a long-lived token can only be looked up against that same dropped state. `Vault::open` is the check: Argon2id over the wrong passphrase fails at AEAD decryption, with no comparison to shortcut. Rate-limited per minute so the KDF cannot be driven by a flood of guesses. `409` if already unlocked, `401` on failure | +| POST | `/vault/lock` | ✓ | Lock the vault: signals the SSE streams, unwires the connector chat loops, pauses the scheduler, clears the session and stream-ticket maps, joins every background task, and zeroizes the vault key. Idempotent — locking an already-locked daemon is a `200`, so a panic-button UI never has to reason about current state. Served by the outer router, which has no `AppState`, so the bearer check is run by hand inside the handler rather than by `require_auth` | ### 3.22 Model Context Protocol From dd0500bba6d632281e0e46887bf495c918160d42 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:40:39 -0700 Subject: [PATCH 03/15] docs(rules): rewrite the manifest rule around the code-built manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector rules said "Every connector ships with a `connector-{name}.toml` manifest" and described a config file. Manifests are `ConnectorManifest` values returned from `fn manifest()`, built in code so the declaration stays type-checked beside the `triggers()` and `actions()` it declares. Rewrote the section against `manifest/types.rs`, and documented the two `ActionDecl` fields the rule never mentioned: - `destructive: Option` (MCP `destructiveHint`) — `None` is unknown and classifies as destructive, matching MCP's default of `true`; meaningful only when `read_only == false`. - `poll_interval_secs: Option` — opt-in sensing cadence, floored to 5 s, honoured only for a `read_only` action with no required parameters. `None` means a formation never polls it. Both are advisory, like `read_only`. The security boundary stays in springtale-sentinel. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .claude/rules/backend/connector-guidelines.md | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/.claude/rules/backend/connector-guidelines.md b/.claude/rules/backend/connector-guidelines.md index 53515f80..9568f990 100644 --- a/.claude/rules/backend/connector-guidelines.md +++ b/.claude/rules/backend/connector-guidelines.md @@ -55,9 +55,51 @@ Every connector implements `springtale_connector::Connector`: ## Manifest -Every connector ships with a `connector-{name}.toml` manifest declaring: +Every connector returns a `ConnectorManifest` from its `fn manifest()`. +It is **built in code, not parsed from a file** — there is no +`connector-{name}.toml`. Building it in code keeps the declaration compiled +and type-checked beside the `triggers()` and `actions()` it describes, so a +declaration cannot silently drift from the thing it declares. + +`ConnectorManifest` (`crates/springtale-connector/src/manifest/types.rs`) +carries: + - Name, version, author, description -- Required capabilities (NetworkOutbound hosts, FilesystemRead paths, etc.) -- Trigger declarations with typed schemas -- Action declarations with typed input/output schemas -- DataDisclosure: what user data the connector accesses +- `capabilities: Vec` — `NetworkOutbound { host }` (exact host, + no wildcards), `FilesystemRead`/`FilesystemWrite { path }`, + `KeychainRead { key }`, `ShellExec` (blocking approval, never bypassable) +- `triggers: Vec` — name, description, optional JSON Schema of + the event payload +- `actions: Vec` — name, description, optional input/output JSON + Schema, plus the three hints below +- `data_disclosure: Vec` — what user data is touched, why, + and where it is sent +- `roles: Vec` — custom cooperation roles contributed to the shared + `RoleRegistry` at install time +- `wasm_hash` — SHA-256 of the `.wasm` binary, WASM connectors only +- `signature_alg` + `signature` — signature over the canonical JSON of every + other field, verified before load. The manifest is serialized for signing + and to travel with a WASM binary; that is the only reason it derives + `Serialize`/`Deserialize`, not because anyone authors it as config. + +### `ActionDecl` hints + +All three carry MCP tool-annotation semantics and are **advisory only**. The +deterministic security boundary stays in `springtale-sentinel` and the +capability layer; never treat a hint as a gate. + +- `read_only: bool` — MCP `readOnlyHint`. See rule 8 above. Default `false` + (assume the action mutates). +- `destructive: Option` — MCP `destructiveHint`: whether the action + performs a *destructive* update rather than an additive one. `None` means + unknown and classifies as destructive, matching MCP's default of `true`. + Meaningful only when `read_only == false`. Set it explicitly to `Some(false)` + for a plain additive write (posting a new message) rather than leaving it + `None` and having it treated as a delete. +- `poll_interval_secs: Option` — opt-in sensing cadence, in seconds. + `None` (the default) means a formation never polls the action: work comes + from the environment — triggers, handoffs, CFP awards, surfaces — not from + a central poller. `Some(n)` permits polling on that interval, floored to + 5 s (Home Assistant's `update_interval` floor), and only if the action is + also `read_only` and requires no parameters. Set it only for an action that + is genuinely cheap and genuinely a sensor. From 5271a69702d2d49c124c2b56c47640ad8fcd45d2 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:42:05 -0700 Subject: [PATCH 04/15] docs: retire the last two stale auth/Matrix claims in ROADMAP and rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP Table I's Phase 2a row said "Matrix is held on upstream `rusqlite` CVE", and the connector roster said "Not in workspace" — both imply a crate sitting on a shelf. There is none: it was never written. Both rows now say so and point at the workspace `Cargo.toml` comment for what bringing it back would take. `.claude/rules/backend/security.md` still listed "HMAC bearer tokens for API auth" under Network, the exact claim the login rework retired. Replaced with the three facts that are true: bearers are issued (login mints a random session token, `/auth/tokens` mints named long-lived ones), both are stored only as `sha256(token)` and compared constant-time via `subtle`, and the passphrase-derived hash survives only as the login verifier. Same claim also appeared in ROADMAP's Phase 1a feature list; fixed. No Rust touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .claude/rules/backend/security.md | 5 ++++- docs/ROADMAP.md | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/rules/backend/security.md b/.claude/rules/backend/security.md index 0169da42..7d7b19dd 100644 --- a/.claude/rules/backend/security.md +++ b/.claude/rules/backend/security.md @@ -44,6 +44,9 @@ paths: ## Network - Management API binds `127.0.0.1` by default. Warn on `0.0.0.0`. -- HMAC bearer tokens for API auth. +- API bearers are issued, never derived: `POST /auth/login` mints a random session token, + `POST /auth/tokens` mints named long-lived ones. +- Both stored only as `sha256(token)`, compared constant-time via `subtle`. +- The passphrase-derived hash is the login verifier only. Never accepted as a bearer. - Rate limiting via `tower-http::limit`. - No secrets in URLs, query params, or error messages. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 340d6ba0..2e139e91 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ Springtale ships in five phases. Each phase builds on the last — no phase skip |---|---|---|---| | 1a | Framework + Connectors | Daemon, CLI, 14 library crates, 8 baseline connectors (kick, presearch, bluesky, github, filesystem, shell, http, opencode), SQLite (declarative schema v1 in `schema/sql/`), crypto vault, WASM sandbox, MCP endpoint | Present. Connector roster grew to 15 first-party through Phases 1b/2a. | | 1b | Bot Foundations | `springtale-bot`, command router (prefix / pattern / alias), cooperation framework, `connector-telegram`, session memory | Present. Cooperation framework extracted to its own `springtale-cooperation` crate (42 pub modules) and wired into a 25-step formation tick; see §3.2. | -| 2a | Chat + AI | Discord, Slack, IRC, Signal, Nostr connectors. Anthropic / Ollama / OpenAI-compat adapters (all three stream). `HttpTransport` (rustls mTLS). `springtale-sentinel`. Tool-calling across all AI adapters. | Present. Matrix is held on upstream `rusqlite` CVE. | +| 2a | Chat + AI | Discord, Slack, IRC, Signal, Nostr connectors. Anthropic / Ollama / OpenAI-compat adapters (all three stream). `HttpTransport` (rustls mTLS). `springtale-sentinel`. Tool-calling across all AI adapters. | Present. No Matrix connector exists — `connectors/connector-matrix` was never written, not merely deferred; see the `members` comment in the workspace `Cargo.toml` for what bringing it back would take. | | 2b | Desktop + Safety | Tauri 2 shell, SolidJS dashboard + colony canvas (RTS formation visualisation), duress vault, panic wipe, travel mode. Visual rule builder, i18n, a11y. | Shell, dashboard, colony canvas (with formation command grid, rally pips, attention bar, liveness/health encoding), duress, panic wipe, travel mode present. Visual rule builder (`RuleBuilderOverlay`), i18n (eight locales), quick-hide, and lock-screen content protection present. a11y present — see §5.1: skip link (`ColonyShell`), `aria-live` regions, `role="application"` + `tabindex` + per-sprite `aria-label`s on the colony canvas with keyboard selection handled at the document level, a 7 px floor on the colony text scale, and `prefers-reduced-motion` / `prefers-contrast` styles. User-controlled font scaling is the one a11y item still missing (§5.2). | | 3 | Veilid Mesh | `VeilidTransport`, P2P mesh, distributed registry, Rekindle integration | Not implemented. `VeilidTransport` exists as a stub — every method returns `TransportError::NotConnected`. | @@ -63,7 +63,7 @@ The foundation. A single-binary daemon, CLI, rule engine, crypto vault, WASM san - WASM sandbox with 10M instruction fuel, 64MB memory, 30s timeout - Manifest signing and verification - Capability-based permission system with toxic pair detection -- RESTful management API with HMAC bearer auth and rate limiting +- RESTful management API with issued bearer tokens (login-minted sessions + named long-lived tokens) and rate limiting - Cron scheduling + filesystem watching + webhook ingestion - MCP over Streamable HTTP at `/mcp`, covering the whole registry, behind the daemon's Origin check and bearer auth (`apps/springtaled/src/api/mcp.rs`) @@ -173,7 +173,7 @@ Broad chat platform support and optional AI integration. | `connector-slack` | Slack | Socket Mode + webhooks | Present | | `connector-nostr` | Nostr | relay WebSocket + NIP-44 | Present | | `connector-browser` | Headless browser | Chromium via CDP | Present | -| `connector-matrix` | Matrix | matrix-sdk | Not in workspace. Held on `matrix-sdk`'s pinned `rusqlite` 0.37 (CVE-2025-70873). Springtale uses the patched 0.39. | +| `connector-matrix` | Matrix | matrix-sdk | **No crate exists.** Never written, because `matrix-sdk-sqlite` pins `rusqlite` 0.37 (CVE-2025-70873) and Springtale uses the patched 0.39. Needs an upstream bump *and* a connector written from scratch. | ### 4.2. AI Integration From 1246b72a65639a29d4bfdfd27d622ee51d187319 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:35:47 -0700 Subject: [PATCH 05/15] security: refuse to load a WASM connector with no pinned signature `reverify_persisted_wasm` grandfathered persisted rows whose `author_pubkey_hex` / `manifest_sig_hex` columns were empty: it logged a WARN and returned Ok, so a WASM connector with no pinned trust anchor was installed into the registry at every boot with no signature verification at all. That contradicts SECURITY.md ("Verify signature before load. Verify on every subsequent load") and is reachable by an attacker with write access to the SQLite store, who need only blank two columns to disable signature checking entirely. Fail closed instead. A row with an empty pin is refused with a message naming the connector and telling the operator to reinstall it. No migration and no override flag: every install path already verifies the signature and writes both pins (operations::connectors::install rejects unsigned/unknown-author manifests before calling store_wasm_binary), so a freshly installed connector always has them. The boot loop already logs and skips on a reverify error, so the daemon starts with the offending connector absent rather than refusing to boot. Also syncs the black-box mirror of the checks in tests/wasm_reverify.rs so it does not drift back to the grandfathering contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- crates/springtale-runtime/src/init.rs | 73 +++++++++++++++---- .../springtale-runtime/tests/wasm_reverify.rs | 44 ++++++----- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/crates/springtale-runtime/src/init.rs b/crates/springtale-runtime/src/init.rs index 590a5a7b..da2c3199 100644 --- a/crates/springtale-runtime/src/init.rs +++ b/crates/springtale-runtime/src/init.rs @@ -1004,11 +1004,15 @@ fn init_sentinel( /// ORIGINAL signing key — which they don't have. TUF §4 /// trust-anchor separation. /// -/// Legacy rows (pre-v8 migration) carry empty `author_pubkey_hex`; -/// these are treated as "TOFU-grandfathered" and logged at WARN. We -/// don't fail closed on them so an existing deployment isn't bricked -/// by the audit fix; the operator sees the warning and can re-install -/// the connector to repopulate the pin. +/// There is no grandfather clause. Every install path signs and pins +/// (`operations::connectors::install` rejects an unsigned or +/// unknown-author manifest before it ever calls `store_wasm_binary`, +/// and writes `author_pubkey_hex` + `manifest_sig_hex` from that +/// verified install), so a row with an empty pin is either a +/// pre-pinning row or a row an attacker blanked to skip step 2. +/// Both are refused: SECURITY.md's rule is "verify signature before +/// load", and a row we cannot verify does not load. The operator +/// re-installs the connector to repopulate the pin. fn reverify_persisted_wasm( name: &str, wasm_bytes: &[u8], @@ -1035,15 +1039,14 @@ fn reverify_persisted_wasm( // 2. Signature re-verify against the pinned trust anchor. if pinned_pubkey_hex.is_empty() || pinned_sig_hex.is_empty() { - // Legacy install (pre-v8) — log + accept. Operators re-install - // to upgrade the row to a pinned-pubkey one. - tracing::warn!( - connector = %name, - "WASM connector loaded without pinned author pubkey — \ - legacy pre-v8 install. Re-install to enable boot-time \ - signature re-verification (Phase-7 audit Finding #1)." - ); - return Ok(()); + // Fail closed. An empty pin means the row carries nothing to + // verify against, so this load would be unverified — exactly + // what SECURITY.md forbids. No override flag by design. + return Err(OperationError::Validation(format!( + "WASM connector {name} has no pinned author pubkey/signature — \ + refusing to load an unverifiable connector. Re-install {name} \ + to repin its signature." + ))); } let pubkey_bytes = hex::decode(pinned_pubkey_hex).map_err(|e| { @@ -1088,6 +1091,48 @@ fn reverify_persisted_wasm( mod tests { use super::*; + /// A persisted row whose trust-anchor columns were never written + /// (or were blanked by an attacker to skip signature checking) + /// must not load. There is no grandfather path. + #[test] + fn test_reverify_persisted_wasm_empty_pins_refuses_to_load() { + use sha2::Digest; + + let wasm_bytes = b"\0asm\x01\0\0\0".to_vec(); + let hash = hex::encode(sha2::Sha256::digest(&wasm_bytes)); + let manifest: springtale_connector::ConnectorManifest = + serde_json::from_value(serde_json::json!({ + "name": "connector-unpinned", + "version": "1.0.0", + "author": "nobody", + "description": "row with no pinned signature", + "capabilities": [], + "wasm_hash": hash, + })) + .expect("manifest fixture parses"); + + for (pubkey, sig) in [("", ""), ("", "aa"), ("bb", "")] { + let err = reverify_persisted_wasm( + "connector-unpinned", + &wasm_bytes, + &hash, + &manifest, + pubkey, + sig, + ) + .expect_err("a row with an empty pin must be refused"); + let msg = err.to_string(); + assert!( + msg.contains("connector-unpinned"), + "refusal names the connector: {msg}" + ); + assert!( + msg.contains("Re-install"), + "refusal tells the operator to reinstall: {msg}" + ); + } + } + #[test] fn test_acquire_runtime_lock_second_holder_rejected_until_first_drops() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/springtale-runtime/tests/wasm_reverify.rs b/crates/springtale-runtime/tests/wasm_reverify.rs index a454c253..9334fbad 100644 --- a/crates/springtale-runtime/tests/wasm_reverify.rs +++ b/crates/springtale-runtime/tests/wasm_reverify.rs @@ -11,7 +11,9 @@ //! no longer verifies against the new canonical bytes). //! 3. Tampered manifest WITH a fresh attacker-signed signature: the //! pinned-sig-vs-manifest-sig check at the verifier must reject. -//! 4. Tampered pinned pubkey row WITH a fresh attacker-signed +//! 4. A row with an empty pinned pubkey/signature — unverifiable, so +//! refused rather than grandfathered. +//! 5. Tampered pinned pubkey row WITH a fresh attacker-signed //! manifest: signature verifies against the attacker key but the //! sig pin still matches. (This case requires the attacker to //! have write access to BOTH the manifest_json + the pinned @@ -143,27 +145,31 @@ async fn tampered_manifest_with_original_sig_rejected_at_boot() { } #[tokio::test] -async fn legacy_pre_v8_row_loads_with_warning() { - // Empty pinned pubkey + empty pinned sig = legacy install. Should - // NOT fail closed (so existing deployments aren't bricked) but - // should log + load. The function returns Ok(()) on this path. +async fn unpinned_row_is_refused() { + // Empty pinned pubkey and/or empty pinned sig: nothing to verify + // against, so the load would be unverified. SECURITY.md requires a + // signature check before every load, so this fails closed. There is + // no grandfather path — every install signs and pins, so an empty + // pin is either pre-pinning or an attacker blanking the column. let wasm_bytes = b"legacy-wasm".to_vec(); let mut manifest = fresh_manifest(); manifest.wasm_hash = Some(sha256_hex(&wasm_bytes)); // No signature, no pinned pubkey, no pinned sig. - let result = call_reverify( - "connector-legacy", - &wasm_bytes, - manifest.wasm_hash.as_deref().unwrap(), - &manifest, - "", - "", - ); - assert!( - result.is_ok(), - "legacy row must load without failing: {result:?}" - ); + for (pubkey, sig) in [("", ""), ("", "aa"), ("bb", "")] { + let result = call_reverify( + "connector-legacy", + &wasm_bytes, + manifest.wasm_hash.as_deref().unwrap(), + &manifest, + pubkey, + sig, + ); + assert!( + result.is_err(), + "an unpinned row must be refused (pubkey={pubkey:?} sig={sig:?}): {result:?}" + ); + } } #[tokio::test] @@ -213,7 +219,9 @@ fn call_reverify( } // 2. signature if pinned_pubkey_hex.is_empty() || pinned_sig_hex.is_empty() { - return Ok(()); // legacy + // No grandfather clause: an unpinned row is unverifiable and + // must not load. Mirrors init.rs::reverify_persisted_wasm. + return Err(format!("no pinned author pubkey/signature for {name}")); } let pubkey_bytes = hex::decode(pinned_pubkey_hex).map_err(|e| format!("hex decode pubkey: {e}"))?; From 0d13c21ef01a5344d01bcd98a43262b70e5f1e69 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:38:48 -0700 Subject: [PATCH 06/15] security: make AutoAllowApprovalGate unconstructible in production builds springtale-sentinel exported `AutoAllowApprovalGate` unconditionally, so any caller of `Sentinel::with_approval_gate` could pass it in and turn off the human approval gate on destructive actions. Nothing wired it today, but a public type whose whole purpose is "always say yes" should not exist in a production build of a crate built for people whose safety depends on the prompt appearing. Put it behind `#[cfg(test)]` and drop it from the crate re-exports. In-crate sentinel tests are unaffected. `apps/springtaled`'s e2e recipe test, the only out-of-crate user, now declares its own two-line auto-allow gate so the exemption is visible in the test that takes it rather than shipped in the library. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/tests/event_recipe_e2e.rs | 18 +++++++++++++++++- crates/springtale-sentinel/src/approval.rs | 18 ++++++++++++++---- crates/springtale-sentinel/src/lib.rs | 6 ++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/springtaled/tests/event_recipe_e2e.rs b/apps/springtaled/tests/event_recipe_e2e.rs index 7642a553..f115cd1b 100644 --- a/apps/springtaled/tests/event_recipe_e2e.rs +++ b/apps/springtaled/tests/event_recipe_e2e.rs @@ -27,7 +27,23 @@ use springtale_runtime::CapabilityBridge; use springtale_runtime::operations::recipes::apply::substitute_template_public; use springtale_runtime::operations::recipes::builtin; use springtale_runtime::operations::recipes::types::{FieldKind, RecipeInputs}; -use springtale_sentinel::{AutoAllowApprovalGate, Sentinel, SentinelConfig}; +use springtale_sentinel::{ApprovalGate, ApprovalRequest, Sentinel, SentinelConfig}; + +/// Local auto-allow gate for this e2e harness. +/// +/// `springtale_sentinel::approval::AutoAllowApprovalGate` is +/// `#[cfg(test)]`-only inside its own crate so a production build cannot +/// disable the human approval gate. Tests that genuinely want the gate +/// out of the path declare their own, as here — the exemption stays +/// visible in the test that takes it. +struct AutoAllowApprovalGate; + +#[async_trait::async_trait] +impl ApprovalGate for AutoAllowApprovalGate { + async fn request_approval(&self, _request: ApprovalRequest) -> bool { + true + } +} use springtale_store::SqliteBackend; use tokio::sync::RwLock; diff --git a/crates/springtale-sentinel/src/approval.rs b/crates/springtale-sentinel/src/approval.rs index a46d0c1e..08a0030b 100644 --- a/crates/springtale-sentinel/src/approval.rs +++ b/crates/springtale-sentinel/src/approval.rs @@ -18,8 +18,9 @@ //! exactly this — the desktop app constructs one, hands it to //! the sentinel, and listens on the receiver to dispatch each //! request to a Tauri event. -//! - **Tests** — [`AutoAllowApprovalGate`] removes the gate from -//! the path, useful for asserting other sentinel checks. +//! - **Tests** — `AutoAllowApprovalGate` removes the gate from the +//! path, useful for asserting other sentinel checks. It is +//! `#[cfg(test)]`-only: a production build cannot construct it. //! //! The trait is async because real implementations must await //! either a network round-trip or a UI confirmation. Default impls @@ -78,10 +79,19 @@ impl ApprovalGate for DefaultDenyApprovalGate { } } -/// Test-only convenience: every destructive action proceeds. Never -/// wire this in production paths. +/// Test-only: every destructive action proceeds. +/// +/// `#[cfg(test)]` is deliberate and is the security boundary. This type +/// disables the human approval gate wholesale, so a production build of +/// this crate must not be able to name it — otherwise any caller of +/// `Sentinel::with_approval_gate` could hand it in and silently turn the +/// gate off. Out-of-crate tests that want an auto-allow gate implement +/// the two-line [`ApprovalGate`] themselves rather than re-exporting +/// this one; see `apps/springtaled/tests/event_recipe_e2e.rs`. +#[cfg(test)] pub struct AutoAllowApprovalGate; +#[cfg(test)] #[async_trait] impl ApprovalGate for AutoAllowApprovalGate { async fn request_approval(&self, _request: ApprovalRequest) -> bool { diff --git a/crates/springtale-sentinel/src/lib.rs b/crates/springtale-sentinel/src/lib.rs index 82bc9c4c..954d3e0d 100644 --- a/crates/springtale-sentinel/src/lib.rs +++ b/crates/springtale-sentinel/src/lib.rs @@ -17,9 +17,11 @@ pub mod throttle_tier; pub mod toxic_pairs; pub mod verdict; +// `approval::AutoAllowApprovalGate` is deliberately NOT re-exported: it +// is `#[cfg(test)]`-only so no production caller of +// `Sentinel::with_approval_gate` can disable the human approval gate. pub use approval::{ - ApprovalGate, ApprovalRequest, AutoAllowApprovalGate, ChannelApprovalGate, - DefaultDenyApprovalGate, PendingApproval, + ApprovalGate, ApprovalRequest, ChannelApprovalGate, DefaultDenyApprovalGate, PendingApproval, }; pub use config::SentinelConfig; pub use error::SentinelError; From a9404fc2284b37ee95fbcf61e28574f466addc38 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:42:14 -0700 Subject: [PATCH 07/15] security: authenticate POST /workspaces/onboard like every other mutating route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/workspaces/onboard` was mounted in the stream-ticket router, so the one mutating route in the API that deploys a probe through a connector sat outside the bearer check and outside the CSRF/Origin layer, and was reachable with a single-use ticket minted for an SSE stream. The ticket exists because `EventSource` cannot send an `Authorization` header. That reason does not apply here: `EventSource` only issues GETs, and this route needs its connector config in a POST body, so its only client was already `fetch` — which can send the header. Moving it to the `authenticated` router therefore costs nothing and the streaming response is untouched: axum's `Sse` does not care how the request authenticated. The web client now sends the bearer instead of minting a ticket. Adds a test asserting the route rejects both an unauthenticated request and a valid stream ticket, plus a note on the streams router that a streaming response is not a reason to move a mutating route into it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtaled/src/api/mod.rs | 12 +++++- apps/springtaled/src/api/workspaces.rs | 17 ++++++-- apps/springtaled/tests/api_integration.rs | 47 +++++++++++++++++++++++ tauri/packages/types/src/api.ts | 6 +-- tauri/packages/ui/src/web/api/onboard.ts | 22 ++++++----- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/apps/springtaled/src/api/mod.rs b/apps/springtaled/src/api/mod.rs index 07e168b7..888103e7 100644 --- a/apps/springtaled/src/api/mod.rs +++ b/apps/springtaled/src/api/mod.rs @@ -201,6 +201,11 @@ pub fn build_router(state: AppState) -> Router { ) .route("/workspaces/scan", post(workspaces::scan)) .route("/workspaces/onboard-url", post(workspaces::onboard_url)) + // SSE response, but a bearer-authenticated POST like every + // other mutating route: it carries its connector config in a + // JSON body, so `EventSource` (GET-only, no headers) was never + // able to call it and it never needed a stream ticket. + .route("/workspaces/onboard", post(workspaces::onboard)) .route("/sessions", get(sessions::list)) .route( "/config/heartbeat", @@ -378,11 +383,14 @@ pub fn build_router(state: AppState) -> Router { // in the query string instead of a bearer token. Read-only GETs, so // no CSRF layer. `/stream` multiplexes events/canvas/cooperation; // `/chat/stream` stays separate because it is per-session. + // + // Nothing that mutates state belongs here: a ticket is single-use, + // minted for a stream, and skips the CSRF/Origin layer. A streaming + // *response* is not a reason to move a route in — see + // `/workspaces/onboard`, which streams from the authenticated router. let streams = Router::new() .route("/stream", get(stream::stream)) .route("/chat/stream", get(chat::stream)) - // POST: the connector config rides in the body, never the URL. - .route("/workspaces/onboard", post(workspaces::onboard)) .layer(middleware::from_fn_with_state( state.clone(), auth::require_stream_ticket, diff --git a/apps/springtaled/src/api/workspaces.rs b/apps/springtaled/src/api/workspaces.rs index 0cc4a5c7..32cb9ecb 100644 --- a/apps/springtaled/src/api/workspaces.rs +++ b/apps/springtaled/src/api/workspaces.rs @@ -1,7 +1,8 @@ //! HTTP routes for the D1 external-workspace directory and the //! Track D one-click Onboard stream — the same //! `operations::workspaces` calls the desktop IPC commands make -//! (plan 2.5). `onboard` is SSE under the stream-ticket layer. +//! (plan 2.5). `onboard` answers with SSE but is a bearer-authenticated +//! POST like every other mutating route. use std::convert::Infallible; use std::sync::Arc; @@ -191,9 +192,17 @@ impl Drop for CancelOnDrop { } } -/// POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered` -/// frames (same payload as the desktop `ChatDiscovered` event) until -/// the first match, the 60 s window, or client disconnect. +/// POST /workspaces/onboard — SSE of `chat-discovered` frames (same +/// payload as the desktop `ChatDiscovered` event) until the first +/// match, the 60 s window, or client disconnect. +/// +/// This mutates (it deploys a probe through the connector), so it sits +/// in the bearer + CSRF `authenticated` router, not in the stream-ticket +/// router. The ticket exists for `EventSource`, which cannot send an +/// `Authorization` header — but `EventSource` only issues GETs and this +/// route needs its connector config in a POST body, so its client was +/// always `fetch`, which can send the header. Streaming the response is +/// unaffected: axum's `Sse` does not care how the request authenticated. #[utoipa::path( post, operation_id = "workspaces_onboard", path = "/workspaces/onboard", diff --git a/apps/springtaled/tests/api_integration.rs b/apps/springtaled/tests/api_integration.rs index 3543c7f9..01a808ac 100644 --- a/apps/springtaled/tests/api_integration.rs +++ b/apps/springtaled/tests/api_integration.rs @@ -630,6 +630,53 @@ async fn test_stream_bearer_in_query_returns_401() { assert_eq!(status, StatusCode::UNAUTHORIZED); } +/// `POST /workspaces/onboard` streams SSE but mutates (it deploys a +/// probe through the connector), so it must carry the same bearer as +/// every other mutating route — not a single-use stream ticket. +#[tokio::test] +async fn test_workspaces_onboard_requires_bearer() { + let body = serde_json::json!({ + "connector_name": "connector-telegram", + "config": {}, + }) + .to_string(); + + // No credential at all. + let (router, _token) = build_test_app(true); + let req = Request::post("/workspaces/onboard") + .header("content-type", "application/json") + .body(Body::from(body.clone())) + .unwrap(); + let (status, _) = send(router, req).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "onboard must reject an unauthenticated request" + ); + + // A stream ticket is not a credential for a mutating route: it is + // minted for a stream and skips the CSRF/Origin layer. + let (router, token) = build_test_app(true); + let ticket_req = Request::post("/stream/ticket") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let (ticket_status, ticket_json) = send(router.clone(), ticket_req).await; + assert_eq!(ticket_status, StatusCode::OK); + let ticket = ticket_json["ticket"].as_str().unwrap().to_owned(); + + let req = Request::post(format!("/workspaces/onboard?ticket={ticket}")) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + let (status, _) = send(router, req).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a stream ticket must not authenticate onboard" + ); +} + #[tokio::test] async fn test_stream_ticket_requires_bearer() { let (router, _token) = build_test_app(true); diff --git a/tauri/packages/types/src/api.ts b/tauri/packages/types/src/api.ts index 6d9e1c87..b46c3a97 100644 --- a/tauri/packages/types/src/api.ts +++ b/tauri/packages/types/src/api.ts @@ -2241,9 +2241,9 @@ export interface paths { get?: never; put?: never; /** - * POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered` - * frames (same payload as the desktop `ChatDiscovered` event) until - * the first match, the 60 s window, or client disconnect. + * POST /workspaces/onboard — SSE of `chat-discovered` frames (same + * payload as the desktop `ChatDiscovered` event) until the first + * match, the 60 s window, or client disconnect. */ post: operations["workspaces_onboard"]; delete?: never; diff --git a/tauri/packages/ui/src/web/api/onboard.ts b/tauri/packages/ui/src/web/api/onboard.ts index 6b584602..771c7732 100644 --- a/tauri/packages/ui/src/web/api/onboard.ts +++ b/tauri/packages/ui/src/web/api/onboard.ts @@ -1,14 +1,16 @@ /** * Track D one-click Onboard over HTTP (plan 2.5). * - * `POST /workspaces/onboard` is an SSE stream behind the one-time - * stream ticket (plan 0.7). The connector config rides in the POST - * body — never the URL — so EventSource (GET-only) can't be used; - * the frames are read from a fetch body instead. + * `POST /workspaces/onboard` answers with an SSE stream. The connector + * config rides in the POST body — never the URL — so EventSource + * (GET-only) can't be used and the frames are read from a fetch body + * instead. Because it is a fetch, it sends the normal `Authorization` + * bearer: the route is bearer + CSRF authenticated like every other + * mutating route, not stream-ticketed. */ import type { ChatDiscoveredEvent } from "../../dashboard/types"; -import { getBaseUrl, post } from "./client"; +import { getBaseUrl, getToken } from "./client"; const EVENT_NAME = "chat-discovered"; @@ -75,11 +77,13 @@ export async function startOnboardStream( const controller = new AbortController(); sessions.set(sessionId, controller); - const { ticket } = await post<{ ticket: string }>("/stream/ticket", {}); - const url = `${getBaseUrl()}/workspaces/onboard?ticket=${encodeURIComponent(ticket)}`; - const res = await fetch(url, { + const res = await fetch(`${getBaseUrl()}/workspaces/onboard`, { method: "POST", - headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${getToken()}`, + }, body: JSON.stringify({ session_id: sessionId, connector_name: connectorName, From 4f8165c6cd1507c7ba35ef50d543d3c2333c1e78 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:44:31 -0700 Subject: [PATCH 08/15] security: read action hints from one authoritative source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consensus vote in springtale-bot's executor read the advisory `read_only` / `destructive` hints from `entry.host.manifest().actions` while `springtale_runtime::dispatch::step` read them from `entry.host.actions()`. Both feed the same decision — whether a task is destructive and therefore needs the formation's approval path — so a host whose two lists disagree would let a task be voted through as read-only and then dispatched as a mutation, or the reverse. `ConnectorHost::actions()` is the authoritative source: it is what dispatch reads to build the `ActionHints` the sentinel classifies with, and what every other hint consumer in the workspace already uses. The executor now reads it too, and both sites carry a comment saying so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../tick_steps/build_reports/executor/prepare.rs | 16 ++++++++++++---- crates/springtale-runtime/src/dispatch/step.rs | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs index da3ec368..022f82eb 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/prepare.rs @@ -60,10 +60,19 @@ pub struct ExecuteCtx<'a> { } impl ExecuteCtx<'_> { - /// Manifest-declared hints for the task's action, looked up the same - /// way dispatch resolves the connector. `None` means the connector is + /// Declared hints for the task's action, looked up the same way + /// dispatch resolves the connector. `None` means the connector is /// not installed or does not declare the action — the caller treats /// an unknown action as destructive. + /// + /// AUTHORITATIVE SOURCE: `ConnectorHost::actions()`. It is what + /// `springtale_runtime::dispatch::step` reads when it builds the + /// `ActionHints` the sentinel classifies with, so the consensus vote + /// here and the sentinel verdict there must read the same list. This + /// used to read `manifest().actions` instead; a host whose two lists + /// disagree would have let a task be voted through as read-only and + /// then dispatched as a mutation (or the reverse). Any new hint + /// consumer reads `actions()` too. pub async fn action_hints_for( &self, task: &SubTask, @@ -72,8 +81,7 @@ impl ExecuteCtx<'_> { let entry = registry.get(&task.target_connector.name)?; entry .host - .manifest() - .actions + .actions() .iter() .find(|decl| decl.name == task.action_name) .map(|decl| springtale_sentinel::ActionHints { diff --git a/crates/springtale-runtime/src/dispatch/step.rs b/crates/springtale-runtime/src/dispatch/step.rs index 9b41c83d..d4303bba 100644 --- a/crates/springtale-runtime/src/dispatch/step.rs +++ b/crates/springtale-runtime/src/dispatch/step.rs @@ -65,6 +65,11 @@ async fn run_step_inner( } = action { let reg = bridge.registry().read().await; + // AUTHORITATIVE SOURCE for advisory action hints: + // `ConnectorHost::actions()`, not `manifest().actions`. The + // consensus vote in `springtale-bot`'s executor reads the same + // method so a task cannot be voted on under one hint and + // classified by the sentinel under another. reg.get(connector) .and_then(|e| e.host.actions().iter().find(|d| d.name == *name).cloned()) .map(|d| ActionHints { From 214d0fcdc0ec9576480e102d185631a281386e61 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:49:08 -0700 Subject: [PATCH 09/15] fix(momentum): classify a tick on ActionState, not on alignment Plan 1.1. `update_momentum` counted a tick as success from `action_taken.is_some() && intent_alignment > 0.5`, but several paths that do no work report a descriptor above that line: a dispatch carried past its beat reports `Requested` at 0.8, `continue_active_task` reports 1.0 while the task is merely claimed, a sacrifice yield reports 0.9, and the observe, suggest and no-task paths report the step's surface reaction at 1.0. A hung connector call, or a formation of observe-autonomy members, walked itself to Fever with nothing done. `TickReport` now carries the `ActionState` the member reached, sourced from `ExecuteOutcome.state` in `executor::post`. A tick counts as work only for a terminal state; success additionally requires `Success` plus intent alignment. Waiting and claimed-only are idle for momentum, and per-member `consecutive_failures` follows the same rule. Test: a hung dispatch (`Requested`, alignment 0.8, descriptor present) classifies idle and never promotes past Cold over 50 beats; claims, yields and cancellations are idle too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- apps/springtale-cli/examples/task-runner.rs | 1 + .../src/cooperation/blackboard_router.rs | 1 + .../tick_steps/build_reports/executor/post.rs | 6 ++ .../build_reports/executor/test_support.rs | 1 + .../build_reports/fold_interference.rs | 1 + .../src/runtime/tick_steps/update_momentum.rs | 101 +++++++++++++++--- .../benches/formation_scaling.rs | 1 + .../benches/rally_cascade.rs | 1 + crates/springtale-cooperation/src/cadence.rs | 14 +++ .../src/interference/detector.rs | 2 + .../src/mental_model/learning.rs | 1 + .../src/tick_processor.rs | 1 + .../tests/properties.rs | 1 + .../tests/replay_determinism.rs | 2 + 14 files changed, 119 insertions(+), 15 deletions(-) diff --git a/apps/springtale-cli/examples/task-runner.rs b/apps/springtale-cli/examples/task-runner.rs index 40351983..abd094bc 100644 --- a/apps/springtale-cli/examples/task-runner.rs +++ b/apps/springtale-cli/examples/task-runner.rs @@ -134,6 +134,7 @@ async fn main() -> Result<(), Box> { latency: Duration::from_millis(5), intent_alignment: 1.0, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, }; let _ = reports_tx.send(report).await; } diff --git a/crates/springtale-bot/src/cooperation/blackboard_router.rs b/crates/springtale-bot/src/cooperation/blackboard_router.rs index 4db81271..d4c826d2 100644 --- a/crates/springtale-bot/src/cooperation/blackboard_router.rs +++ b/crates/springtale-bot/src/cooperation/blackboard_router.rs @@ -326,6 +326,7 @@ mod tests { latency: std::time::Duration::from_millis(0), intent_alignment: 0.95, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, }]); let pick = router diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs index 080f1705..d19cfbad 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/post.rs @@ -191,5 +191,11 @@ pub fn post_member( latency: Duration::from_millis(outcome.duration_ms), intent_alignment: outcome.alignment, interference_with: vec![], + // 0.3 — the beat's momentum signal. `Requested` (a dispatch + // carried past its beat) and `Init` (a claim, an observe/suggest + // surface reaction, a yield) are not work done, whatever their + // alignment; only `Success`/`Failure` say the beat finished + // something. + state: outcome.state, } } diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs index cf044d89..a8fc45ab 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/executor/test_support.rs @@ -181,6 +181,7 @@ pub(crate) fn successful_tick_result(agent: AgentId) -> FormationTickResult { latency: Duration::from_millis(1), intent_alignment: 1.0, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, }], interferences: vec![], all_succeeded: true, diff --git a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs index 18a4a5dd..42b5832b 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/build_reports/fold_interference.rs @@ -55,6 +55,7 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: 0.9, interference_with: vec![], + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs index bcba007f..82b793e5 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/update_momentum.rs @@ -3,9 +3,9 @@ //! Each tick is classified into exactly one `MomentumEvent` (see //! [`classify`]): //! * `TickInterference` — interference was detected (§13). -//! * `TickFailure` — a member acted and misaligned (alignment <= 0.5). -//! * `TickSuccess` — at least one member acted and nothing failed. -//! * `TickIdle` — nobody acted. Not a success, not a failure. Per the +//! * `TickFailure` — a member finished work that failed or misaligned. +//! * `TickSuccess` — at least one member finished work and nothing failed. +//! * `TickIdle` — nobody finished work. Not a success, not a failure. Per the //! Microsoft AGT trust calibration, idle time cannot raise scores; //! only the decay clock keeps running. //! @@ -16,6 +16,8 @@ //! (§14) executed in `transformation::run`. use crate::cooperation::formation::Formation; +use springtale_cooperation::action_state::ActionState; +use springtale_cooperation::cadence::TickReport; use springtale_cooperation::momentum::{MomentumEvent, TickCounts}; use springtale_cooperation::tick_processor::FormationTickResult; use springtale_cooperation::utterance::{UtteranceKind, utter}; @@ -24,13 +26,32 @@ use springtale_cooperation::utterance::{UtteranceKind, utter}; pub const LISTENING_AFTER_TICKS: u32 = 5; use std::collections::HashSet; +/// Whether this report is work the beat actually finished. +/// +/// The report's [`ActionState`] is the source, not `action_taken` and not +/// `intent_alignment`. Several non-work paths surface a descriptor with a +/// high alignment — a dispatch carried past its beat reports `Requested` +/// at 0.8, a continued active task reports 1.0 while the task is merely +/// claimed, a sacrifice yield reports 0.9, and the observe, suggest and +/// no-task paths report the step's surface reaction at 1.0. None of those +/// finished anything, so none of them may move momentum: a hung connector +/// call or an observe-autonomy member must not walk a formation to Fever. +fn completed_work(report: &TickReport) -> bool { + report.state.is_terminal() +} + +/// Whether the finished work counted as a success: the action reached +/// `Success` *and* aligned with the formation's intent. +fn succeeded(report: &TickReport) -> bool { + matches!(report.state, ActionState::Success) && report.intent_alignment > 0.5 +} + /// Classify a tick result into the single `MomentumEvent` it represents. /// -/// A report with `action_taken: None` is idle regardless of its alignment -/// (the executor reports alignment 1.0 for "nothing to do", which is not -/// a success). Only reports that actually acted can succeed or fail. -/// Success and failure carry the tick's [`TickCounts`] for the momentum -/// window. +/// A report that did not reach a terminal action state is idle regardless +/// of its alignment — waiting and claimed-only are not success. Only +/// reports that finished work can succeed or fail. Success and failure +/// carry the tick's [`TickCounts`] for the momentum window. pub fn classify(result: &FormationTickResult) -> MomentumEvent { let counts = count(result); let failed = counts.successes < counts.actions; @@ -60,13 +81,16 @@ fn count(result: &FormationTickResult) -> TickCounts { let mut seen: HashSet<(&str, Option<&str>, u64)> = HashSet::new(); let mut counts = TickCounts::default(); for report in &result.reports { - let Some(action) = report.action_taken.as_ref() else { + if !completed_work(report) { continue; - }; + } counts.actions = counts.actions.saturating_add(1); - if report.intent_alignment > 0.5 { + if succeeded(report) { counts.successes = counts.successes.saturating_add(1); } + let Some(action) = report.action_taken.as_ref() else { + continue; + }; let key = ( action.kind.as_str(), action.target.as_deref(), @@ -91,8 +115,9 @@ pub fn run( formation.momentum.apply_event(&classify(result)); // Step 4b — per-member consecutive failures for role transformation - // (§14). Idle and aligned reports reset the counter; a member that - // acted and misaligned increments it. + // (§14). Idle reports and finished-and-aligned work reset the counter; + // only a member whose work finished badly increments it. A member + // still waiting on a dispatch is neither. for report in &result.reports { let mut now_listening = false; if let Some(member) = formation.member_mut(&report.agent_id) { @@ -102,7 +127,7 @@ pub fn run( } else { member.consecutive_idle_ticks = 0; } - if report.action_taken.is_none() || report.intent_alignment > 0.5 { + if !completed_work(report) || succeeded(report) { member.consecutive_failures = 0; } else { member.consecutive_failures += 1; @@ -121,11 +146,13 @@ pub fn run( #[cfg(test)] mod tests { use super::*; + use crate::cooperation::dispatch_outcome::REQUESTED_ALIGNMENT; use springtale_cooperation::cadence::{ActionDescriptor, AgentId, TickReport}; + use springtale_cooperation::momentum::{MomentumState, MomentumTier}; use springtale_cooperation::tick::TickId; use std::time::Duration; - fn report(action: Option<&str>, alignment: f32) -> TickReport { + fn stated(action: Option<&str>, alignment: f32, state: ActionState) -> TickReport { TickReport { agent_id: AgentId::new(), tick_sequence: TickId(1), @@ -137,9 +164,19 @@ mod tests { latency: Duration::from_millis(1), intent_alignment: alignment, interference_with: vec![], + state, } } + /// A report for work that finished this beat (or for an idle member). + fn report(action: Option<&str>, alignment: f32) -> TickReport { + let state = match action { + Some(_) => ActionState::Success, + None => ActionState::Init, + }; + stated(action, alignment, state) + } + fn tick(reports: Vec) -> FormationTickResult { FormationTickResult { reports, @@ -190,4 +227,38 @@ mod tests { MomentumEvent::TickFailure { counts } if counts.actions == 2 && counts.successes == 1 )); } + + /// Fix 1 — a hung dispatch does not promote. + /// + /// A connector call carried past its beat reports `Requested` with a + /// descriptor and alignment 0.8. Under the old alignment-only rule + /// that was a success every beat, so a formation whose members were + /// all stuck walked itself to Fever. It is idle, and idle never + /// promotes. + #[test] + fn test_hung_dispatch_is_idle_and_never_promotes() { + let hung = || stated(Some("work"), REQUESTED_ALIGNMENT, ActionState::Requested); + let result = tick(vec![hung(), hung()]); + assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + + let mut momentum = MomentumState::default(); + for _ in 0..50 { + momentum.apply_event(&classify(&result)); + } + assert_eq!(momentum.tier, MomentumTier::Cold); + assert_eq!(momentum.consecutive_successes, 0); + } + + /// A claim, an observe-autonomy surface reaction and a sacrifice + /// yield all report a descriptor at high alignment without finishing + /// anything. None of them is a success. + #[test] + fn test_claimed_and_observed_reports_are_idle() { + let result = tick(vec![ + stated(Some("claimed"), 1.0, ActionState::Init), + stated(Some("sacrifice_yield"), 0.9, ActionState::Init), + stated(Some("cancelled"), 1.0, ActionState::Cancelled), + ]); + assert!(matches!(classify(&result), MomentumEvent::TickIdle)); + } } diff --git a/crates/springtale-cooperation/benches/formation_scaling.rs b/crates/springtale-cooperation/benches/formation_scaling.rs index 00ac9be0..a217ad36 100644 --- a/crates/springtale-cooperation/benches/formation_scaling.rs +++ b/crates/springtale-cooperation/benches/formation_scaling.rs @@ -39,6 +39,7 @@ fn synthetic_report(i: usize, tick: u64) -> TickReport { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/benches/rally_cascade.rs b/crates/springtale-cooperation/benches/rally_cascade.rs index f066ff5a..a39110f3 100644 --- a/crates/springtale-cooperation/benches/rally_cascade.rs +++ b/crates/springtale-cooperation/benches/rally_cascade.rs @@ -33,6 +33,7 @@ fn synth_report(agent: AgentId, alignment: f32) -> TickReport { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/cadence.rs b/crates/springtale-cooperation/src/cadence.rs index ca953497..2eb4d6b4 100644 --- a/crates/springtale-cooperation/src/cadence.rs +++ b/crates/springtale-cooperation/src/cadence.rs @@ -19,6 +19,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; + +use crate::action_state::ActionState; use specta::Type; use tokio::sync::broadcast; @@ -212,6 +214,16 @@ pub struct TickReport { pub intent_alignment: f32, /// Agents this action interfered with (Helldivers friendly fire). pub interference_with: Vec, + /// Lifecycle state the member's action reached this beat. + /// + /// The momentum step classifies on this, not on `intent_alignment`: + /// only a terminal state (`Success` / `Failure`) is work the beat + /// finished. A carried-over dispatch (`Requested`), a bare claim + /// (`Init`) and a cancelled action are idle for momentum however + /// well they align with the intent — otherwise a hung connector call + /// or an observe-autonomy member walks the formation to Fever with + /// nothing done. + pub state: ActionState, } /// The shared tick bus that all formation members synchronize to. @@ -330,6 +342,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: vec![], + state: crate::action_state::ActionState::Success, }) .await .expect("send report"); @@ -357,6 +370,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 0.5, interference_with: vec![], + state: crate::action_state::ActionState::Success, }) .await .expect("send"); diff --git a/crates/springtale-cooperation/src/interference/detector.rs b/crates/springtale-cooperation/src/interference/detector.rs index 7ec3c121..0884c854 100644 --- a/crates/springtale-cooperation/src/interference/detector.rs +++ b/crates/springtale-cooperation/src/interference/detector.rs @@ -391,6 +391,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![b], + state: crate::action_state::ActionState::Success, }, TickReport { agent_id: b, @@ -399,6 +400,7 @@ mod tests { latency: Duration::from_millis(0), intent_alignment: 1.0, interference_with: vec![a], + state: crate::action_state::ActionState::Success, }, ]; let events = detect(&reports); diff --git a/crates/springtale-cooperation/src/mental_model/learning.rs b/crates/springtale-cooperation/src/mental_model/learning.rs index 485df730..56392f04 100644 --- a/crates/springtale-cooperation/src/mental_model/learning.rs +++ b/crates/springtale-cooperation/src/mental_model/learning.rs @@ -140,6 +140,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: vec![], + state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/src/tick_processor.rs b/crates/springtale-cooperation/src/tick_processor.rs index 19eebd5b..4df9e74a 100644 --- a/crates/springtale-cooperation/src/tick_processor.rs +++ b/crates/springtale-cooperation/src/tick_processor.rs @@ -120,6 +120,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: interferes, + state: crate::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/properties.rs b/crates/springtale-cooperation/tests/properties.rs index 5cf07829..37aaf4bc 100644 --- a/crates/springtale-cooperation/tests/properties.rs +++ b/crates/springtale-cooperation/tests/properties.rs @@ -182,6 +182,7 @@ fn make_report(agent: AgentId, kind: &str, target: Option<&str>, tick: u64) -> T latency: Duration::from_millis(5), intent_alignment: 0.9, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } diff --git a/crates/springtale-cooperation/tests/replay_determinism.rs b/crates/springtale-cooperation/tests/replay_determinism.rs index ab9fed81..b997c047 100644 --- a/crates/springtale-cooperation/tests/replay_determinism.rs +++ b/crates/springtale-cooperation/tests/replay_determinism.rs @@ -97,6 +97,7 @@ impl From<&ReportRecord> for TickReport { latency: Duration::from_millis(r.latency_ms), intent_alignment: r.intent_alignment, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, } } } @@ -116,6 +117,7 @@ fn synth_reports(tick: u64, n: usize) -> Vec { latency: Duration::from_millis(5), intent_alignment: 0.95, interference_with: Vec::new(), + state: springtale_cooperation::action_state::ActionState::Success, }) .collect() } From eb27aca29e162805456644e4b0ca846bd10974d3 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:49:57 -0700 Subject: [PATCH 10/15] fix(rally): stop double-counting the failure, rally who can respond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.8. `attempt_self_rally` called `momentum.record_failure()` for a failure the same tick's `update_momentum` step had already recorded, so one bad beat demoted the formation twice — and the `supervision` and manual-rally call sites invented a failure no tick had reported. The call is gone (with a comment saying why) and the now-unused `&mut MomentumState` parameter with it; the rally's cost is the token. The target was `reports.iter().find(alignment <= 0.5)` — whichever failing member came first. Per Total War (§15.2) a general rallies the unit that can still answer him: `select_rally_target` takes the failing member with the LOWEST morale still above the new `SHATTERED_MORALE` floor, drawn only from members the caller considers operational (`is_operational` excludes Incapacitated and Dead). When nobody can respond, no token is spent at all. Tests: the rally leaves momentum untouched; the target is the wavering member, not the shattered one, the dead one or the steady one; no candidate yields no target. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/runtime/tick_steps/check_cascade.rs | 38 ++++-- .../src/runtime/tick_steps/handle_command.rs | 1 - .../src/runtime/tick_steps/supervision.rs | 8 +- .../src/awareness/mod.rs | 2 +- .../src/awareness/types.rs | 5 + .../src/rally/cascade.rs | 126 ++++++++++++++++-- .../tests/rally_cascade_recovery.rs | 11 +- 7 files changed, 152 insertions(+), 39 deletions(-) diff --git a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs index 6de2b769..5b6b8e52 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs @@ -37,6 +37,9 @@ pub async fn run( return; } + // Only operational members: `is_operational` is false for + // `Incapacitated` and `Dead`, and a member that cannot respond must + // never be handed a rally token. let awareness_map: HashMap = formation .members .iter() @@ -48,6 +51,21 @@ pub async fn run( return; }; + // Total War (§15.2): the rally goes to the member who can still + // answer it — the lowest morale still above the shattered floor — not + // to whichever failing report happened to come first. A shattered, + // incapacitated or dead member gets no token: `select_rally_target` + // returns `None` and we spend nothing. Resolved here so the awareness + // borrow ends before the formation is written to below. + let failing: Vec = result + .reports + .iter() + .filter(|r| r.intent_alignment <= 0.5) + .map(|r| r.agent_id) + .collect(); + let target = cascade::select_rally_target(&awareness_map, &failing); + drop(awareness_map); + // Increment the streak — this is the cascade_hits signal the L6 // intervention evaluator reads next. Saturating add so a perpetually // unhealthy formation never wraps. @@ -74,21 +92,17 @@ pub async fn run( }, ); - let Some(failing_agent) = result - .reports - .iter() - .find(|r| r.intent_alignment <= 0.5) - .map(|r| r.agent_id) - else { + let Some(failing_agent) = target else { + tracing::debug!( + formation = %formation.id.0, + failing = failing.len(), + "cascade detected but no member can be rallied — token withheld" + ); return; }; - let rally_result = cascade::attempt_self_rally( - &formation.rally, - &formation.attention_broker, - &mut formation.momentum, - failing_agent, - ); + let rally_result = + cascade::attempt_self_rally(&formation.rally, &formation.attention_broker, failing_agent); log_rally_result(&formation.id.0.to_string(), &rally_result); if matches!(rally_result, RallyResult::StabilizedWithCost { .. }) { springtale_cooperation::utterance::utter( diff --git a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs index cecfaf98..403bf8fb 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/handle_command.rs @@ -383,7 +383,6 @@ pub async fn handle_formation_command(bot: &mut Bot, cmd: FormationCommand) { let rally_result = cascade::attempt_self_rally( &formation.rally, &formation.attention_broker, - &mut formation.momentum, agent, ); match &rally_result { diff --git a/crates/springtale-bot/src/runtime/tick_steps/supervision.rs b/crates/springtale-bot/src/runtime/tick_steps/supervision.rs index 50f529ac..2c4ba7c6 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/supervision.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/supervision.rs @@ -86,12 +86,8 @@ fn execute( ); } SupervisionAction::RetryWithRally { agent } => { - let result = cascade::attempt_self_rally( - &formation.rally, - &formation.attention_broker, - &mut formation.momentum, - agent, - ); + let result = + cascade::attempt_self_rally(&formation.rally, &formation.attention_broker, agent); tracing::info!( formation = formation_id, agent = %agent.0, diff --git a/crates/springtale-cooperation/src/awareness/mod.rs b/crates/springtale-cooperation/src/awareness/mod.rs index 5e2bc0bb..5c7fbc4a 100644 --- a/crates/springtale-cooperation/src/awareness/mod.rs +++ b/crates/springtale-cooperation/src/awareness/mod.rs @@ -12,4 +12,4 @@ mod types; pub use bridge::{GossipEntry, InMemoryGossipStore}; pub use store::{ChitchatGossipConfig, ChitchatGossipStore, GossipStore}; pub use swim::{ProcId, SwimEvent, SwimNode, SwimNodeConfig, SwimSelfState}; -pub use types::{LocalAwareness, NeighborSnapshot, RoleSignature}; +pub use types::{LocalAwareness, NeighborSnapshot, RoleSignature, SHATTERED_MORALE}; diff --git a/crates/springtale-cooperation/src/awareness/types.rs b/crates/springtale-cooperation/src/awareness/types.rs index ca19fbe4..7e94cb0a 100644 --- a/crates/springtale-cooperation/src/awareness/types.rs +++ b/crates/springtale-cooperation/src/awareness/types.rs @@ -34,6 +34,10 @@ pub const MORALE_MIN_STEP: f32 = 0.01; /// (decisions §11 #7). The WH3 `max_routing_enemies_to_consider = 5` cap has no /// analog in a cooperative (non-adversarial) formation, so only this cap applies. pub const MAX_CONTAGION_DISTRESSED: usize = 4; +/// Total War's shattered floor (§15.2): below this morale a unit is past +/// rallying — the general's call cannot reach it. `rally::cascade` refuses +/// to spend a rally token on a member at or under it. +pub const SHATTERED_MORALE: f32 = 0.1; /// §A.4 rally falloff (decisions §11 #6), non-spatial analog. WH3's rally /// aura is full-strength out to `general_aura_radius = 70` units, then @@ -486,6 +490,7 @@ mod tests { latency: std::time::Duration::from_millis(10), intent_alignment: 0.8, interference_with: vec![my_id], + state: crate::action_state::ActionState::Success, }; awareness.record_tick_reports(vec![report]); diff --git a/crates/springtale-cooperation/src/rally/cascade.rs b/crates/springtale-cooperation/src/rally/cascade.rs index ee22aa77..b0dbffad 100644 --- a/crates/springtale-cooperation/src/rally/cascade.rs +++ b/crates/springtale-cooperation/src/rally/cascade.rs @@ -12,9 +12,8 @@ use std::collections::HashMap; use crate::attention::AttentionBroker; -use crate::awareness::LocalAwareness; +use crate::awareness::{LocalAwareness, SHATTERED_MORALE}; use crate::cadence::AgentId; -use crate::momentum::MomentumState; use crate::tick_processor::FormationTickResult; use super::{FormationRally, RallyEvent, RallyFailure, RallyResult}; @@ -75,13 +74,49 @@ pub fn detect_cascade( } } +/// Pick the member a rally token should be spent on. +/// +/// Per Total War (spec §15.2): a general rallies the unit that can still +/// answer him. A shattered unit is past rallying and a dead one cannot +/// hear it, so a token spent on either is a token wasted. Of the members +/// that failed this beat we take the one with the LOWEST morale that is +/// still above [`SHATTERED_MORALE`] — the closest to breaking that can +/// still be pulled back. +/// +/// `candidates` holds only members the caller considers alive and +/// operational (the bot's `check_cascade` builds it from +/// `FormationMember::is_operational`, which excludes `Incapacitated` and +/// `Dead`), so absence from the map is the "cannot respond" answer. +/// `None` means no token should be spent at all. +/// +/// `failing` is walked in order so ties resolve deterministically. +pub fn select_rally_target( + candidates: &HashMap, + failing: &[AgentId], +) -> Option { + let mut best: Option<(AgentId, f32)> = None; + for agent in failing { + let Some(awareness) = candidates.get(agent) else { + continue; // incapacitated, dead, or gone: cannot respond + }; + let morale = awareness.local_morale(); + if morale <= SHATTERED_MORALE { + continue; // shattered: past rallying (Total War §15.2) + } + if best.is_none_or(|(_, lowest)| morale < lowest) { + best = Some((*agent, morale)); + } + } + best.map(|(agent, _)| agent) +} + /// Attempt formation self-rally before escalating to orchestrator. /// /// Per §15.2 (Monster Hunter cart system): /// 1. Redistribute attention away from failing agent -/// 2. Reduce momentum to match reduced coherence -/// 3. Consume a rally token -/// 4. If no tokens left → escalate +/// 2. Consume a rally token (the tick's own momentum step already +/// recorded the failure — see the comment in the body) +/// 3. If no tokens left → escalate /// /// Takes `&FormationRally` (not `&mut`): the token pool is backed by /// `Arc` (interior-mutable), the event channel is a @@ -89,7 +124,6 @@ pub fn detect_cascade( pub fn attempt_self_rally( rally: &FormationRally, attention: &AttentionBroker, - momentum: &mut MomentumState, failing_agent: AgentId, ) -> RallyResult { if !rally.tokens.can_rally() { @@ -107,10 +141,14 @@ pub fn attempt_self_rally( from: failing_agent, }); - // 2. Reduce momentum — the formation lost coherence - momentum.record_failure(); + // The formation's lost coherence is NOT recorded here. The beat that + // produced this cascade already ran `update_momentum`, which recorded + // the same failed tick; recording it again demoted the formation twice + // for one bad beat (and `supervision`/`handle_command` rallies would + // have invented a failure that no tick reported). Momentum is the + // momentum step's to own — the rally's cost is the token. - // 3. Consume rally token. `consume()` fails only if something closed + // 2. Consume rally token. `consume()` fails only if something closed // the semaphore between the `can_rally` check and here; treat as // escalation. match rally.tokens.consume() { @@ -141,9 +179,15 @@ pub fn attempt_self_rally( mod tests { use super::*; use crate::cadence::TickReport; + use crate::action_state::ActionState; + use crate::momentum::MomentumState; use std::time::Duration; fn make_report(agent: AgentId, alignment: f32) -> TickReport { + make_stated(agent, alignment, ActionState::Success) + } + + fn make_stated(agent: AgentId, alignment: f32, state: ActionState) -> TickReport { TickReport { agent_id: agent, tick_sequence: crate::tick::TickId(1), @@ -155,6 +199,7 @@ mod tests { latency: Duration::from_millis(5), intent_alignment: alignment, interference_with: vec![], + state, } } @@ -254,7 +299,9 @@ mod tests { momentum.record_success(); } - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let before = momentum.tier; + let successes = momentum.consecutive_successes; + let result = attempt_self_rally(&rally, &attention, a); assert!(matches!( result, RallyResult::StabilizedWithCost { @@ -262,6 +309,10 @@ mod tests { } )); assert_eq!(rally.tokens.remaining(), 2); + // Fix 2 — the rally does not double-count the failure. The beat's + // momentum step already recorded it; the rally's cost is the token. + assert_eq!(momentum.tier, before); + assert_eq!(momentum.consecutive_successes, successes); } #[test] @@ -273,9 +324,60 @@ mod tests { rally.tokens.consume().unwrap(); rally.tokens.consume().unwrap(); let attention = AttentionBroker::for_agents(&[a]); - let mut momentum = MomentumState::default(); + let momentum = MomentumState::default(); - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let result = attempt_self_rally(&rally, &attention, a); assert!(matches!(result, RallyResult::EscalateToOrchestrator { .. })); + assert_eq!(momentum.consecutive_successes, 0); + } + + /// Fix 2 — the token goes to the member that can still respond: the + /// lowest morale ABOVE the shattered floor. A shattered member and a + /// member missing from the map (dead/incapacitated) are skipped even + /// though they failed first. + #[test] + fn test_select_rally_target_is_lowest_morale_above_shattered() { + let dead = AgentId::new(); + let shattered = AgentId::new(); + let wavering = AgentId::new(); + let steady = AgentId::new(); + + let aw_shattered = LocalAwareness { + morale: SHATTERED_MORALE - 0.01, + ..Default::default() + }; + let aw_wavering = LocalAwareness { + morale: 0.25, + ..Default::default() + }; + let aw_steady = LocalAwareness { + morale: 0.8, + ..Default::default() + }; + + // `dead` is absent: `check_cascade` builds this map from + // operational members only. + let mut map: HashMap = HashMap::new(); + map.insert(shattered, &aw_shattered); + map.insert(wavering, &aw_wavering); + map.insert(steady, &aw_steady); + + let failing = [dead, shattered, wavering, steady]; + assert_eq!(select_rally_target(&map, &failing), Some(wavering)); + } + + /// No failing member can respond → no target, so no token is spent. + #[test] + fn test_select_rally_target_none_when_nobody_can_respond() { + let dead = AgentId::new(); + let shattered = AgentId::new(); + let aw = LocalAwareness { + morale: 0.0, + ..Default::default() + }; + let mut map: HashMap = HashMap::new(); + map.insert(shattered, &aw); + assert_eq!(select_rally_target(&map, &[dead, shattered]), None); } + } diff --git a/crates/springtale-cooperation/tests/rally_cascade_recovery.rs b/crates/springtale-cooperation/tests/rally_cascade_recovery.rs index a9c65a39..46e93e9f 100644 --- a/crates/springtale-cooperation/tests/rally_cascade_recovery.rs +++ b/crates/springtale-cooperation/tests/rally_cascade_recovery.rs @@ -22,7 +22,6 @@ use springtale_cooperation::attention::AttentionBroker; use springtale_cooperation::cadence::AgentId; -use springtale_cooperation::momentum::MomentumState; use springtale_cooperation::rally::cascade::attempt_self_rally; use springtale_cooperation::rally::{FormationRally, RallyEvent, RallyResult}; @@ -45,7 +44,6 @@ fn cascade_consumes_tokens_then_escalates() { let rally = FormationRally::new(TOKEN_BUDGET, EVENT_CAP); let attention = AttentionBroker::for_agents(&agents); - let mut momentum = MomentumState::default(); // Sanity check: every agent is registered with the broker before // we begin so attention.release() has a valid neighbour set to @@ -71,7 +69,7 @@ fn cascade_consumes_tokens_then_escalates() { let expected_remaining: Vec = (0..TOKEN_BUDGET).rev().map(|n| n as u32).collect(); let mut seen_remaining = Vec::new(); for _ in 0..TOKEN_BUDGET { - let result = attempt_self_rally(&rally, &attention, &mut momentum, a); + let result = attempt_self_rally(&rally, &attention, a); match result { RallyResult::StabilizedWithCost { tokens_remaining } => { seen_remaining.push(tokens_remaining); @@ -89,7 +87,7 @@ fn cascade_consumes_tokens_then_escalates() { assert_eq!(rally.tokens.remaining(), 0); assert!(!rally.tokens.can_rally()); - let escalation = attempt_self_rally(&rally, &attention, &mut momentum, b); + let escalation = attempt_self_rally(&rally, &attention, b); match escalation { RallyResult::EscalateToOrchestrator { reason } => { assert!(reason.contains("exhausted"), "got reason: {reason}"); @@ -161,8 +159,7 @@ fn restore_tokens_reflects_persisted_state() { assert_eq!(rally.tokens.remaining(), 1); let attention = AttentionBroker::for_agents(&[a]); - let mut momentum = MomentumState::default(); - let first = attempt_self_rally(&rally, &attention, &mut momentum, a); + let first = attempt_self_rally(&rally, &attention, a); assert!(matches!( first, RallyResult::StabilizedWithCost { @@ -171,6 +168,6 @@ fn restore_tokens_reflects_persisted_state() { )); // Latch closed — restored exhausted state stays exhausted. - let second = attempt_self_rally(&rally, &attention, &mut momentum, a); + let second = attempt_self_rally(&rally, &attention, a); assert!(matches!(second, RallyResult::EscalateToOrchestrator { .. })); } From fdcb9267c1b9806e8a78649620f775cb012b8927 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:50:08 -0700 Subject: [PATCH 11/15] fix(rally): make RallyResult::Recovered real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.8. `Recovered` was constructed nowhere, so the variant meant nothing and `log_rally_result` had a dead arm. It is a LATER beat's answer to a rally, not an outcome `attempt_self_rally` can return, so carrying it needs one piece of state: `Formation.rallied`, the members a token has been spent on that still owe a beat showing they came back. `cascade::recovered` raises it when one of them files a report that finished work (terminal `Success` plus intent alignment — the same signal the momentum step uses), and `check_cascade` clears the set and utters `Rally` when it does. Test: `Recovered` for a rallied member that completes work; not while it is still `Requested`, not for a peer nobody rallied, not for an empty set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/cooperation/formation.rs | 5 ++ .../src/runtime/tick_steps/check_cascade.rs | 16 +++++ .../src/rally/cascade.rs | 59 ++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/springtale-bot/src/cooperation/formation.rs b/crates/springtale-bot/src/cooperation/formation.rs index 9c3ec53d..e08079ba 100644 --- a/crates/springtale-bot/src/cooperation/formation.rs +++ b/crates/springtale-bot/src/cooperation/formation.rs @@ -338,6 +338,10 @@ pub struct Formation { /// `Stabilize` intent change or `ForcedDissolve` per /// `crates/springtale-bot/src/orchestrator/intervention/evaluator/rules.rs`. pub cascade_hit_streak: u32, + /// Members a rally token has been spent on, still owing a beat that + /// shows they came back. `rally::cascade::recovered` reads this to + /// raise `RallyResult::Recovered`; cleared when one of them does. + pub rallied: std::collections::HashSet, /// Set by `tick_steps/supervision.rs` when the supervisor returns /// `SupervisionAction::Escalate`. Read by `check_interventions.rs` /// next tick and folded into the intervention signals; cleared after @@ -535,6 +539,7 @@ impl Formation { last_tick_write_count: 0, last_broadcast_tier: MomentumTier::Cold, cascade_hit_streak: 0, + rallied: std::collections::HashSet::new(), escalation_pending: None, cfp_channels, cfp_initiator, diff --git a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs index 5b6b8e52..a9c692d8 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/check_cascade.rs @@ -29,6 +29,20 @@ pub async fn run( store: &dyn StorageBackend, cooperation_tx: Option<&broadcast::Sender>, ) { + // A member a token was spent on finished work this beat: the rally + // worked. This is the only place `RallyResult::Recovered` is raised — + // it is a later beat's answer to an earlier rally, not an outcome + // `attempt_self_rally` can return. + if let Some(recovery) = cascade::recovered(&formation.rallied, result) { + log_rally_result(&formation.id.0.to_string(), &recovery); + formation.rallied.clear(); + springtale_cooperation::utterance::utter( + &mut formation.utter_ctx(cooperation_tx), + None, + springtale_cooperation::UtteranceKind::Rally, + ); + } + if result.all_succeeded { // Successful tick clears the cascade streak so the L6 evaluator // (`check_interventions.rs`) doesn't trip on a long-resolved @@ -105,6 +119,8 @@ pub async fn run( cascade::attempt_self_rally(&formation.rally, &formation.attention_broker, failing_agent); log_rally_result(&formation.id.0.to_string(), &rally_result); if matches!(rally_result, RallyResult::StabilizedWithCost { .. }) { + // Owed a beat that shows the member came back (fix 3). + formation.rallied.insert(failing_agent); springtale_cooperation::utterance::utter( &mut formation.utter_ctx(cooperation_tx), None, diff --git a/crates/springtale-cooperation/src/rally/cascade.rs b/crates/springtale-cooperation/src/rally/cascade.rs index b0dbffad..3f59f5b0 100644 --- a/crates/springtale-cooperation/src/rally/cascade.rs +++ b/crates/springtale-cooperation/src/rally/cascade.rs @@ -9,8 +9,9 @@ //! 3. Reduce momentum tier to match reduced coherence //! 4. Consume rally token (limited, like Monster Hunter carts) -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use crate::action_state::ActionState; use crate::attention::AttentionBroker; use crate::awareness::{LocalAwareness, SHATTERED_MORALE}; use crate::cadence::AgentId; @@ -110,6 +111,27 @@ pub fn select_rally_target( best.map(|(agent, _)| agent) } +/// Did a member we spent a rally token on come back? +/// +/// `RallyResult::Recovered` is the answer to a rally on a LATER beat: +/// the token bought the member a chance and it finished work with it. +/// `rallied` is the set of members with a token spent on them since the +/// last recovery; `Some(Recovered)` means the caller should clear it. +pub fn recovered(rallied: &HashSet, result: &FormationTickResult) -> Option { + if rallied.is_empty() { + return None; + } + result + .reports + .iter() + .any(|r| { + rallied.contains(&r.agent_id) + && matches!(r.state, ActionState::Success) + && r.intent_alignment > 0.5 + }) + .then_some(RallyResult::Recovered) +} + /// Attempt formation self-rally before escalating to orchestrator. /// /// Per §15.2 (Monster Hunter cart system): @@ -179,7 +201,6 @@ pub fn attempt_self_rally( mod tests { use super::*; use crate::cadence::TickReport; - use crate::action_state::ActionState; use crate::momentum::MomentumState; use std::time::Duration; @@ -380,4 +401,38 @@ mod tests { assert_eq!(select_rally_target(&map, &[dead, shattered]), None); } + /// Fix 3 — `Recovered` is emitted when a rallied member finishes work + /// on a later beat, and only then. + #[test] + fn test_recovered_only_when_a_rallied_member_completes_work() { + let rallied_agent = AgentId::new(); + let other = AgentId::new(); + let mut rallied = HashSet::new(); + rallied.insert(rallied_agent); + + let still_trying = FormationTickResult { + reports: vec![make_stated(rallied_agent, 0.8, ActionState::Requested)], + interferences: vec![], + all_succeeded: false, + }; + assert!(recovered(&rallied, &still_trying).is_none()); + + let someone_else = FormationTickResult { + reports: vec![make_report(other, 1.0)], + interferences: vec![], + all_succeeded: true, + }; + assert!(recovered(&rallied, &someone_else).is_none()); + + let came_back = FormationTickResult { + reports: vec![make_report(rallied_agent, 1.0)], + interferences: vec![], + all_succeeded: true, + }; + assert!(matches!( + recovered(&rallied, &came_back), + Some(RallyResult::Recovered) + )); + assert!(recovered(&HashSet::new(), &came_back).is_none()); + } } From c18b6c011fad7cba03d14e0bcce98bda9f83c5bc Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:50:18 -0700 Subject: [PATCH 12/15] fix(runtime): derive agent activity from the utterance ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.15 F. `list_agent_states` fetched the last 200 events and ran `compute_activity` over them, so the server's idea of what an agent was doing came from what ran, while every browser derived it from the utterance ring (`dashboard/activity.ts`) — the two disagreed, and `LiveAgentEnrichment` had no agent id, so a formation member's utterance could not have been matched even if the ring had been read. The ring is now the source, with the browser's derivation done once at fetch time: newest unexpired utterance (`seq + ttl_ticks > now` against the ring's own tick clock), matched by rule id for a solo agent and by the member's cooperation agent id for a formation member, falling back to `listening`. A disabled rule still reads `idle`. `compute_activity` and the event fetch that fed it are gone; nothing else used either. Test: activity comes from the utterance — by rule id, by member agent id, not from someone else's utterance, and `None` (so `listening`) once the utterance has expired. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../src/operations/agent.rs | 182 ++++++++++++------ 1 file changed, 125 insertions(+), 57 deletions(-) diff --git a/crates/springtale-runtime/src/operations/agent.rs b/crates/springtale-runtime/src/operations/agent.rs index cca5b81a..f1e237fb 100644 --- a/crates/springtale-runtime/src/operations/agent.rs +++ b/crates/springtale-runtime/src/operations/agent.rs @@ -12,10 +12,10 @@ use serde::Serialize; use specta::Type; use springtale_cooperation::AutonomyLevel; +use springtale_cooperation::utterance::Utterance; use springtale_core::rule::action::Action; use springtale_core::rule::types::Rule; use springtale_store::StorageBackend; -use springtale_store::schema::events::{EventEntry, EventFilter}; use crate::error::OperationError; use crate::state::RuntimeState; @@ -254,48 +254,49 @@ fn infer_role(trigger_type: &str) -> &'static str { } } -/// Compute agent activity from recent events. +/// The resting activity: an agent that has said nothing unexpired. /// -/// Moved from frontend `ColonyCanvas.tsx` — event interpretation -/// belongs in the backend, not derived client-side. -fn compute_activity( - connector_name: &Option, - trigger_type: &str, - status: &str, - events: &[EventEntry], -) -> &'static str { - if status != "enabled" { - return "idle"; - } - - // Find the most recent event matching this agent's connector or trigger - let latest = events.iter().find(|e| { - connector_name - .as_ref() - .is_some_and(|cn| e.connector_name == *cn) - || e.trigger_type == trigger_type - }); +/// Same value as the canvas's `SILENT_ACTIVITY` +/// (`tauri/packages/ui/src/dashboard/activity.ts`). +pub const SILENT_ACTIVITY: &str = "listening"; - let Some(event) = latest else { - return "waiting"; - }; +/// An agent's activity is what it SAID — the newest unexpired utterance +/// in the cooperation ring (plan §1.15 F). +/// +/// This is the same derivation the canvas does over the same ring +/// (`activityOf` in `dashboard/activity.ts`), done once at fetch time so +/// a polling client and a streaming one agree. Deriving it from the event +/// log instead made the server disagree with every browser: the log knows +/// what ran, not what the agent is saying about it. +/// +/// Matching follows the canvas: a solo agent by the `rule_id` its +/// utterances are stamped with, a formation member by its cooperation +/// `agent_id`. `utterances` is the ring, newest first; `now` is the +/// colony tick clock, and an utterance is live while +/// `seq + ttl_ticks > now`. +fn activity_from_utterances( + utterances: &[Utterance], + rule_id: &str, + agent_id: Option<&str>, + now: u64, +) -> Option { + utterances + .iter() + .find(|u| { + let matches_rule = u.rule_id.is_some_and(|r| r.0.to_string() == rule_id); + let matches_agent = match (u.agent, agent_id) { + (Some(a), Some(want)) => a.0.to_string() == want, + _ => false, + }; + (matches_rule || matches_agent) && u.seq.0.saturating_add(u64::from(u.ttl_ticks)) > now + }) + .map(|u| u.utterance.name().to_owned()) +} - let age_ms = (chrono::Utc::now() - event.timestamp).num_milliseconds(); - if age_ms < 5_000 { - // Check for error indicators in the action text - let action_lower = event.action_taken.to_lowercase(); - if action_lower.contains("error") - || action_lower.contains("fail") - || action_lower.contains("block") - { - return "error"; - } - return "firing"; - } - if age_ms < 60_000 { - return "active"; - } - "waiting" +/// The colony tick clock as the ring knows it: the newest sequence any +/// utterance carries. An empty ring has no clock, so nothing is expired. +fn ring_now(utterances: &[Utterance]) -> u64 { + utterances.iter().map(|u| u.seq.0).max().unwrap_or(0) } /// Autonomy level to its L0–L3 index. @@ -311,6 +312,10 @@ fn autonomy_to_index(level: AutonomyLevel) -> u8 { /// Live formation member data — used to enrich AgentState with /// real cooperation data when formations are active. struct LiveAgentEnrichment { + /// Cooperation-layer agent id — how a formation member's utterances + /// are addressed. Without it the server could not match a member's + /// utterance and disagreed with the browser about its activity. + agent_id: String, attention_load: f32, liveness: f32, health_state: String, @@ -364,15 +369,11 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, .collect() }; - // Fetch recent events (last 200) for activity computation - let events = state - .store - .list_events(&EventFilter { - limit: Some(200), - ..Default::default() - }) - .await - .map_err(OperationError::Store)?; + // Activity comes from the utterance ring, not the event log: what an + // agent is doing is what it last said, and the canvas derives it from + // the same ring (plan §1.15 F). + let utterances = crate::utterance_ring::recent(&state.utterances).await; + let now = ring_now(&utterances); // Fetch all config rows once; autonomy is keyed `autonomy:agent:{rule_id}`. let config = state @@ -393,6 +394,7 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, enrichment_map.insert( detail.connector_name.clone(), LiveAgentEnrichment { + agent_id: detail.agent_id.clone(), attention_load: detail.attention_load, liveness: match detail.liveness.as_str() { "Alive" => 1.0, @@ -417,7 +419,21 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, .and_then(|(_, v)| parse_level_opt(v)) .unwrap_or(AutonomyLevel::ActAutonomously); - let activity = compute_activity(&r.connector_name, &r.trigger_type, &r.status, &events); + let enrichment = r + .connector_name + .as_ref() + .and_then(|cn| enrichment_map.get(cn)); + let activity = if r.status == "enabled" { + activity_from_utterances( + &utterances, + &r.id, + enrichment.map(|e| e.agent_id.as_str()), + now, + ) + .unwrap_or_else(|| SILENT_ACTIVITY.to_owned()) + } else { + "idle".to_owned() + }; let task_display = if activity == "idle" { "Idle".to_owned() } else { @@ -433,12 +449,6 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, } .to_owned(); - // Enrich from live formation data when available - let enrichment = r - .connector_name - .as_ref() - .and_then(|cn| enrichment_map.get(cn)); - // Live members report their real fuel budget; rules outside a // live formation have no budget, so enabled reads full and // disabled reads empty. @@ -458,7 +468,7 @@ pub async fn list_agent_states(state: &RuntimeState) -> Result, action_connector: action_targets.get(&r.id).cloned(), role: infer_role(&r.trigger_type).to_owned(), fuel, - activity: activity.to_owned(), + activity, autonomy: autonomy_idx, autonomy_label, fuel_status, @@ -543,6 +553,64 @@ mod tests { assert_eq!(fuel_status_label(21), "warn"); assert_eq!(fuel_status_label(20), "critical"); } + + /// Fix 4 — agent state activity comes from an utterance, not from the + /// event log. A solo agent matches on its rule id, a formation member + /// on its cooperation agent id, and an expired ring leaves the agent + /// `listening`. + #[test] + fn test_activity_comes_from_the_utterance_ring() { + use springtale_cooperation::TickId; + use springtale_cooperation::utterance::{UtteranceDefs, UtteranceKind, emit_solo}; + + let defs = UtteranceDefs::default(); + let rule = springtale_core::rule::RuleId::new(); + let other_rule = springtale_core::rule::RuleId::new(); + let mut ring: Vec = Vec::new(); + // Newest first, as the ring stores them. + let mut firing = emit_solo(None, &defs, rule, TickId(10), UtteranceKind::Firing) + .expect("firing has a def"); + let member = springtale_cooperation::cadence::AgentId::new(); + let mut member_failed = + emit_solo(None, &defs, other_rule, TickId(10), UtteranceKind::Failed) + .expect("failed has a def"); + member_failed.rule_id = None; + member_failed.agent = Some(member); + ring.push(member_failed); + ring.push(firing.clone()); + + let now = ring_now(&ring); + assert_eq!(now, 10); + assert_eq!( + activity_from_utterances(&ring, &rule.0.to_string(), None, now).as_deref(), + Some("firing") + ); + assert_eq!( + activity_from_utterances( + &ring, + &other_rule.0.to_string(), + Some(&member.0.to_string()), + now + ) + .as_deref(), + Some("failed") + ); + // Someone else's utterance is not this agent's activity. + assert!( + activity_from_utterances( + &ring, + &springtale_core::rule::RuleId::new().0.to_string(), + None, + now + ) + .is_none() + ); + // Expired: `seq + ttl_ticks <= now` — the agent falls back to + // `listening`. + firing.ttl_ticks = 1; + let expired = vec![firing]; + assert!(activity_from_utterances(&expired, &rule.0.to_string(), None, 99).is_none()); + } } /// Request body for setting an agent's autonomy level. From c5a0e638f49644ee4a356273b0f16e7f9cc4381b Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:50:31 -0700 Subject: [PATCH 13/15] fix(awareness): let what a peer heard survive the beat, and act on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 1.15 / §19. `agent::step::react` folded a heard `Failed` utterance into the neighbour snapshot, then `gossip_awareness` replaced that whole snapshot later in the same tick from the beat's tick reports — where a peer that filed no report publishes `last_action_success: true`. The fold was overwritten before anything could read it, and nothing did read it: `last_action_success` was written in five places and consumed in none. Running gossip first is not an option (it consumes the reports the fold runs alongside), so the fold survives by merging instead of replacing: `LocalAwareness::heard_failures` records what was heard, and `merge_neighbor` folds it into the incoming gossip snapshot and consumes it — the heard failure wins exactly the beat it was heard on, and the next beat's gossip is authoritative again. Hearing the peer work clears it outright. The consumer is morale: `morale_target` now takes a bounded penalty for neighbours whose last action failed, which cascade detection reads. A peer that hears a failure wavers, as in Total War, rather than filing the news away. Test: the heard failure survives the merge that used to erase it, lowers morale, is consumed by the following merge, and is cleared by hearing the peer fire. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- .../runtime/tick_steps/gossip_awareness.rs | 7 +- .../src/agent/step/react.rs | 83 +++++++++++++++++-- .../src/awareness/mod.rs | 4 +- .../src/awareness/types.rs | 63 +++++++++++++- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs b/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs index 972bb72a..23367a35 100644 --- a/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs +++ b/crates/springtale-bot/src/runtime/tick_steps/gossip_awareness.rs @@ -56,7 +56,12 @@ pub async fn run(formation: &mut Formation, result: &FormationTickResult) { for m in formation.members.iter_mut() { for snap in &snapshots { if snap.agent_id != m.agent_id { - m.awareness.update_neighbor(snap.clone()); + // Merge, not replace: the L2 react step folded what this + // member HEARD peers say earlier in this same beat, and + // gossip reports `last_action_success: true` for any peer + // that filed no report — which used to overwrite the fold + // before anything could act on it. + m.awareness.merge_neighbor(snap.clone()); } } m.awareness.formation_momentum = tier; diff --git a/crates/springtale-cooperation/src/agent/step/react.rs b/crates/springtale-cooperation/src/agent/step/react.rs index d6396d43..02de3ca5 100644 --- a/crates/springtale-cooperation/src/agent/step/react.rs +++ b/crates/springtale-cooperation/src/agent/step/react.rs @@ -41,15 +41,30 @@ fn apply(awareness: &mut LocalAwareness, msg: StateMessage) { // a thought bubble is private to the speaker and the observer. StateMessage::Utterance(u) if u.carrier.heard_by_peers() => { let Some(agent) = u.agent else { return }; - if let Some(n) = awareness.neighbor_states.get_mut(&agent) { - match u.utterance { - UtteranceKind::Failed => n.last_action_success = false, - UtteranceKind::Working - | UtteranceKind::Firing - | UtteranceKind::Claimed { .. } => n.last_action_success = true, - UtteranceKind::Down => n.liveness = Liveness::Down { since_tick: u.seq }, - _ => {} + let Some(n) = awareness.neighbor_states.get_mut(&agent) else { + return; + }; + // What was heard is also remembered on `heard_failures`: the + // beat's gossip merge republishes every neighbor snapshot and + // would otherwise overwrite this fold before anything acted + // on it (see `LocalAwareness::merge_neighbor`). + let mut heard: Option = None; + match u.utterance { + UtteranceKind::Failed => { + n.last_action_success = false; + heard = Some(false); } + UtteranceKind::Working | UtteranceKind::Firing | UtteranceKind::Claimed { .. } => { + n.last_action_success = true; + heard = Some(true); + } + UtteranceKind::Down => n.liveness = Liveness::Down { since_tick: u.seq }, + _ => {} + } + match heard { + Some(false) => awareness.heard_failure(agent), + Some(true) => awareness.heard_progress(&agent), + None => {} } } _ => {} @@ -166,6 +181,58 @@ mod tests { )); } + /// Fix 5 — a heard failure survives the tick. + /// + /// The fold used to be overwritten by the same beat's gossip merge, + /// which republishes every neighbor snapshot and reports + /// `last_action_success: true` for a peer that filed no tick report. + /// The heard failure now wins that merge, and it moves morale — a + /// real consumer, which cascade detection reads. + #[test] + fn test_heard_failure_survives_the_gossip_merge_and_moves_morale() { + let a = AgentId(uuid::Uuid::new_v4()); + let mut awareness = awareness_with_neighbor(a); + let calm = awareness.morale_target(); + + let mut bus = VecBus { + msgs: vec![utterance( + a, + UtteranceKind::Failed, + crate::utterance::Carrier::Burst, + )] + .into(), + }; + run(&mut bus, &mut awareness, MomentumTier::Warming); + assert!(!awareness.neighbor_states[&a].last_action_success); + assert!(awareness.heard_failures.contains(&a)); + + // The beat's gossip merge: `a` filed no report, so gossip says it + // succeeded. What this agent heard wins. + let mut fresh = awareness.neighbor_states[&a].clone(); + fresh.last_action_success = true; + fresh.last_updated = Instant::now(); + awareness.merge_neighbor(fresh.clone()); + assert!(!awareness.neighbor_states[&a].last_action_success); + assert!(awareness.morale_target() < calm); + + // Consumed: the next beat's gossip is authoritative again. + awareness.merge_neighbor(fresh); + assert!(awareness.neighbor_states[&a].last_action_success); + + // And hearing the peer work clears the memory outright. + awareness.heard_failure(a); + let mut bus = VecBus { + msgs: vec![utterance( + a, + UtteranceKind::Firing, + crate::utterance::Carrier::Burst, + )] + .into(), + }; + run(&mut bus, &mut awareness, MomentumTier::Warming); + assert!(awareness.heard_failures.is_empty()); + } + #[test] fn test_apply_thought_utterance_is_private_and_ignored() { let a = AgentId(uuid::Uuid::new_v4()); diff --git a/crates/springtale-cooperation/src/awareness/mod.rs b/crates/springtale-cooperation/src/awareness/mod.rs index 5c7fbc4a..0255a6fd 100644 --- a/crates/springtale-cooperation/src/awareness/mod.rs +++ b/crates/springtale-cooperation/src/awareness/mod.rs @@ -12,4 +12,6 @@ mod types; pub use bridge::{GossipEntry, InMemoryGossipStore}; pub use store::{ChitchatGossipConfig, ChitchatGossipStore, GossipStore}; pub use swim::{ProcId, SwimEvent, SwimNode, SwimNodeConfig, SwimSelfState}; -pub use types::{LocalAwareness, NeighborSnapshot, RoleSignature, SHATTERED_MORALE}; +pub use types::{ + FAILURE_PENALTY, LocalAwareness, NeighborSnapshot, RoleSignature, SHATTERED_MORALE, +}; diff --git a/crates/springtale-cooperation/src/awareness/types.rs b/crates/springtale-cooperation/src/awareness/types.rs index 7e94cb0a..c352cc6b 100644 --- a/crates/springtale-cooperation/src/awareness/types.rs +++ b/crates/springtale-cooperation/src/awareness/types.rs @@ -12,7 +12,7 @@ //! //! Available at Warming+ tier (§7 capability table). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Instant; use serde::{Deserialize, Serialize}; @@ -38,6 +38,10 @@ pub const MAX_CONTAGION_DISTRESSED: usize = 4; /// rallying — the general's call cannot reach it. `rally::cascade` refuses /// to spend a rally token on a member at or under it. pub const SHATTERED_MORALE: f32 = 0.1; +/// Weight of "a neighbor's last action failed" in [`LocalAwareness::morale_target`]. +/// Smaller than the distress penalty: a failed action is a stumble, an +/// incapacitated neighbor is a casualty. +pub const FAILURE_PENALTY: f32 = 0.15; /// §A.4 rally falloff (decisions §11 #6), non-spatial analog. WH3's rally /// aura is full-strength out to `general_aura_radius = 70` units, then @@ -166,6 +170,17 @@ pub struct LocalAwareness { /// read via [`Self::local_morale`]. Stateful (not instantaneous) so routing /// is gradual and bounded, per decisions §11 #8. pub morale: f32, + /// Peers this agent HEARD fail (an L2 `Failed` utterance folded in by + /// `agent::step::react`) since the last gossip merge. + /// + /// Gossip republishes every neighbor's snapshot each beat from the + /// beat's tick reports, and a peer that reported nothing publishes + /// `last_action_success: true` — which used to overwrite what this + /// agent heard, in the same tick, before anything could act on it. + /// [`Self::merge_neighbor`] folds this set into the incoming snapshot + /// and consumes it, so a heard failure survives exactly the beat it + /// was heard on and then ages out normally. + pub heard_failures: HashSet, } impl Default for LocalAwareness { @@ -175,16 +190,43 @@ impl Default for LocalAwareness { formation_momentum: MomentumTier::Cold, last_tick_reports: Vec::new(), morale: 0.5, // neutral + heard_failures: HashSet::new(), } } } impl LocalAwareness { - /// Update a neighbor's snapshot. + /// Update a neighbor's snapshot, replacing what was known about it. pub fn update_neighbor(&mut self, snapshot: NeighborSnapshot) { self.neighbor_states.insert(snapshot.agent_id, snapshot); } + /// Merge a gossip snapshot over what is known, keeping what this agent + /// HEARD this beat where gossip cannot know better. + /// + /// Gossip derives `last_action_success` from the beat's tick reports + /// and defaults to `true` for a member that reported nothing, so a + /// plain [`Self::update_neighbor`] silently discarded the L2 utterance + /// fold. The heard failure wins and is consumed: the next beat's + /// gossip is authoritative again. + pub fn merge_neighbor(&mut self, mut snapshot: NeighborSnapshot) { + if self.heard_failures.remove(&snapshot.agent_id) { + snapshot.last_action_success = false; + } + self.neighbor_states.insert(snapshot.agent_id, snapshot); + } + + /// Record that a peer was heard failing (L2 `Failed` utterance). + pub fn heard_failure(&mut self, agent: AgentId) { + self.heard_failures.insert(agent); + } + + /// Record that a peer was heard working again — it is no longer the + /// last thing this agent heard from it. + pub fn heard_progress(&mut self, agent: &AgentId) { + self.heard_failures.remove(agent); + } + /// Remove a neighbor (disconnected or dead). pub fn remove_neighbor(&mut self, agent_id: &AgentId) { self.neighbor_states.remove(agent_id); @@ -260,10 +302,25 @@ impl LocalAwareness { .sum::() .min(MAX_CONTAGION_DISTRESSED as f32); + // Neighbors whose LAST ACTION failed — heard directly (§19 implicit + // signals, folded by `agent::step::react`) or read off the beat's + // gossip. Total War: a unit whose neighbors are losing their fight + // wavers before any of them is a casualty. Bounded by the same + // contagion cap so a bad beat cannot rout a whole formation. + let failing: f32 = self + .neighbor_states + .values() + .filter(|n| !n.last_action_success) + .map(|n| aoi_weight(n.last_updated.elapsed())) + .sum::() + .min(MAX_CONTAGION_DISTRESSED as f32); + // Base morale from (AoI-weighted) neighbor health ratio. let health_factor = healthy / total; // Penalty for distressed neighbors (cascade risk). let distress_penalty = distressed / total * 0.3; + // Penalty for neighbors whose last action failed. + let failure_penalty = failing / total * FAILURE_PENALTY; // Momentum bonus. let momentum_bonus = match self.formation_momentum { MomentumTier::Cold => 0.0, @@ -272,7 +329,7 @@ impl LocalAwareness { MomentumTier::Fever => 0.2, }; - (health_factor - distress_penalty + momentum_bonus).clamp(0.0, 1.0) + (health_factor - distress_penalty - failure_penalty + momentum_bonus).clamp(0.0, 1.0) } /// Advance the lerped morale one tick toward [`Self::morale_target`] at From 44e052e70aa3a0fa2835dcd77fc95d88946c6de0 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 16:53:05 -0700 Subject: [PATCH 14/15] docs: retire the derived-token auth model across arch and operations docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HMAC-derived-bearer model was still described as current in six files. A reader consulting any of them was told the daemon authenticates in a way it no longer does, which is worse than the two stale lines already fixed on this branch. current-arch/ARCHITECTURE.md: the threat table said "Token derived from vault passphrase hash — no separate API key to manage", and the session row claimed "Stateless… No server-side sessions". Both false. Sessions are server-side; they are simply never client-fixable — minted from the OS CSPRNG, regenerated on every login, held as sha256(token), expired by idle and absolute timeouts. Corrected the adjacent CSRF row too, which claimed "No cookies = no CSRF risk" while require_csrf_protection exists precisely because a page can POST to 127.0.0.1 without cookies (Transmission CVE-2018-5702, cited in auth.rs). current-arch/SECURITY.md: ASVS V2 and V3 rewritten around issued tokens and real session management; the ClawJacked row no longer says HMAC. arch/ARCHITECTURE.md and arch/SECURITY.md: §7.1's derivation diagram showed the passphrase becoming the bearer. Redrawn: the HMAC output is the boot-time verifier, the compare gates a fresh OsRng token, and the token is stored only as its hash. Both "rotating the token requires rotating the passphrase" claims removed. operations/: both file trees listed an `api_token` file holding the bearer. No such file exists, and no `api_token` vault entry does either — the vault holds `identity` and `bot_identity`, and long-lived tokens are sha256 rows in springtale.db. Removed the line from both, and documented what a restore actually recovers: session tokens are memory only and do not survive, long-lived hashes come back but cannot yield their strings, and an operator with the passphrase gets a working credential from POST /auth/login. docs/intended-arch/ deliberately left alone: CLAUDE.md states current-arch supersedes it, and it records intended rather than shipped state. Historical records — docs/adr/0004, .claude/phases/*, current-arch/AUDIT-NOTES.md — also left as written for the same reason. No Rust or frontend files touched, so cargo/pnpm verification is unchanged from 5271a69. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- docs/arch/ARCHITECTURE.md | 10 +++++--- docs/arch/SECURITY.md | 35 +++++++++++++++++++++------ docs/current-arch/ARCHITECTURE.md | 8 +++--- docs/current-arch/SECURITY.md | 6 ++--- docs/operations/README.md | 1 - docs/operations/backup-and-restore.md | 34 ++++++++++++++++++++++++-- 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/docs/arch/ARCHITECTURE.md b/docs/arch/ARCHITECTURE.md index 9ea7ed26..96fe90f0 100644 --- a/docs/arch/ARCHITECTURE.md +++ b/docs/arch/ARCHITECTURE.md @@ -177,9 +177,13 @@ main.rs (apps/springtaled/src/main.rs) - Passphrase acquisition (`boot/crypto.rs:65-99`) has a 3-way fallback: `SPRINGTALE_PASSPHRASE_FILE` (Docker secrets), `SPRINGTALE_PASSPHRASE` (dev only), or interactive TTY prompt. Fatal if none available. -- The API token is `HMAC-SHA256(passphrase, "springtale-api-token")`. - There is no separate API key; rotating the token means rotating the - vault passphrase. +- API tokens are *issued*, not derived. `POST /auth/login` verifies the + passphrase against `HMAC-SHA256(passphrase, "springtale-api-token")` + computed once at boot, then mints a 32-byte OS-CSPRNG session token; + `POST /auth/tokens` mints named long-lived ones. Both are held only as + `sha256(token)`. That derived hash is the login *verifier* and is never + accepted as a bearer, so rotating the passphrase rotates no token — it + only changes what a future login must present. - AI adapter is hot-swappable at runtime via `ArcSwap>` (`state.rs:27`). Config changes to `/config/ai` atomically replace the active adapter with zero locking on the read path. diff --git a/docs/arch/SECURITY.md b/docs/arch/SECURITY.md index c44e67bd..36a8be07 100644 --- a/docs/arch/SECURITY.md +++ b/docs/arch/SECURITY.md @@ -260,17 +260,36 @@ Return codes: `0` = allowed, `-1` = invalid input, `-2` = denied by capability. ### 7.1 Token scheme +Tokens are **issued, never derived**. The passphrase-derived hash survives +only as the login verifier. + ``` -passphrase ──HMAC-SHA256(key=passphrase, msg="springtale-api-token")──▶ token[32B] - │ - ▼ -Request: Authorization: Bearer hex token + boot: passphrase ──HMAC-SHA256(·, "springtale-api-token")──▶ verifier[32B] + │ + POST /auth/login {passphrase} ─── constant-time compare ──────┘ + │ match + ▼ + OsRng ──▶ token[32B] ──▶ hex-encoded, returned exactly once + │ + └──▶ stored as sha256(token) + · session → process memory + · long-lived → api_tokens table + + every request: Authorization: Bearer ``` -- 32 B token, hex-encoded. -- Comparison uses `subtle::ConstantTimeEq` — timing-attack resistant. +- 32 B (256-bit) token straight from the OS CSPRNG, hex-encoded, with no + structure. Returned exactly once, in the minting response. +- Two kinds, both accepted by `require_auth`: **sessions** + (`POST /auth/login`, in-memory, idle + absolute timeouts, dropped on vault + lock or restart, revoked by `POST /auth/logout`) and **named long-lived + tokens** (`POST /auth/tokens`, rows in `api_tokens`, revoked by + `DELETE /auth/tokens/{id}`). +- Neither is ever stored in the clear — both live as `sha256(token)`. +- Comparison uses `subtle::ConstantTimeEq` — timing-attack resistant. A token + never issued, one expired, and one revoked are all the same `401`. - **SSE tickets**: `EventSource` cannot set custom headers, and the token never goes in a URL. `POST /stream/ticket` (bearer-authenticated) returns `{ "ticket": "<64 hex chars>", "ttl_secs": 30 }`; the client opens the single multiplexed `GET /stream?ticket=…` with it. Tickets are single-use and expire after 30 seconds. -- There is no separate API key. Rotating the API token requires rotating the vault passphrase. +- Rotating the vault passphrase rotates no token. It changes only what a future `POST /auth/login` must present, and a running daemon keeps the old verifier in memory until it restarts. ### 7.2 Middleware stack @@ -448,7 +467,7 @@ Sentinel checks run in this order per action: The gate sees an `ApprovalRequest` with the action, target, and reason. Its async decision is one of `Approved` / `Denied` / `Escalated` (route to another surface, e.g. push notification). -`ShellExec` is gated harder still: `crates/springtale-runtime/src/approval/` parks every ShellExec grant in a pending queue regardless of capability policy. The requestor blocks until a decision lands via `GET /approvals` + `POST /approvals/{id}` (HMAC bearer auth), the desktop approval card, or the in-app chat panel (`ChatApprovalGate`); the deny-fallback timeout (default 60s) means a dropped connection never silently grants. Decisions are recorded as `ApprovalRequested` / `ApprovalResolved` audit rows, and tool loops paused behind an approval are checkpointed (`approvals.sql`: `tool_loop_checkpoints`) and resumed after the verdict — replaying exactly the persisted bound calls, never a re-derived action. +`ShellExec` is gated harder still: `crates/springtale-runtime/src/approval/` parks every ShellExec grant in a pending queue regardless of capability policy. The requestor blocks until a decision lands via `GET /approvals` + `POST /approvals/{id}` (bearer auth), the desktop approval card, or the in-app chat panel (`ChatApprovalGate`); the deny-fallback timeout (default 60s) means a dropped connection never silently grants. Decisions are recorded as `ApprovalRequested` / `ApprovalResolved` audit rows, and tool loops paused behind an approval are checkpointed (`approvals.sql`: `tool_loop_checkpoints`) and resumed after the verdict — replaying exactly the persisted bound calls, never a re-derived action. When a bot has an AI adapter configured, the adapter is additionally wrapped in `GuardrailAdapter` (`crates/springtale-ai/src/guardrail/`): wall-clock timeout, output size cap, refusal-rate counters, and a per-bot daily token quota (`[sentinel] daily_token_limit`, persisted in `ai_token_usage`). The tool surface defaults to a zero-tool allow-list (`[bot] tool_policy`, OWASP LLM06). diff --git a/docs/current-arch/ARCHITECTURE.md b/docs/current-arch/ARCHITECTURE.md index b884409a..1c2eaf5e 100644 --- a/docs/current-arch/ARCHITECTURE.md +++ b/docs/current-arch/ARCHITECTURE.md @@ -2285,7 +2285,7 @@ lifecycle, rule evaluation, scheduler, and the management HTTP API. | Concern | Control | |---|---| -| API authentication bypass | All routes except `/health` and `/ready` require HMAC bearer token. Token derived from vault passphrase hash — no separate API key to manage. | +| API authentication bypass | All routes except `/health`, `/ready`, `/openapi.json`, `POST /auth/login` and `POST /vault/unlock` require a bearer the daemon *issued* — a session from `POST /auth/login` or a named long-lived token from `POST /auth/tokens`. Both are stored only as `sha256(token)` and matched constant-time (`subtle`). The passphrase-derived hash is the login verifier and is never accepted as a bearer. | | Webhook injection (unauthenticated) | `/webhook/{connector}/{trigger}` verifies HMAC-SHA256 signature from header before processing. Rejects if connector doesn't declare webhook support in manifest. | | Startup race conditions | Strict ordered boot (1-10 above). Each step must succeed before the next starts. API is the LAST thing to start — no requests accepted during boot. | | Management API DoS | `tower-http::limit` rate limiting: 100 req/s default. Request body size limit: 1 MiB. Timeout: 30s per request. | @@ -2432,7 +2432,7 @@ Shares the SolidJS component library with the Tauri frontend but runs standalone in any browser — for headless/remote server management. - Connector status, rule management, event log viewer - Heartbeat schedule configuration, session viewer -- No sensitive operations without HMAC bearer auth +- No sensitive operations without an issued bearer token (login session or named long-lived token) - Replaces OpenClaw's Gateway Control UI on :18789 - Bind to `127.0.0.1` by default — never `0.0.0.0` (OpenClaw's default) @@ -2469,8 +2469,8 @@ standalone in any browser — for headless/remote server management. | Concern | Control | |---|---| -| Session fixation | Stateless: HMAC bearer token per request. No server-side sessions. No cookies. Token in `Authorization` header only. | -| CSRF | No cookies = no CSRF risk. All mutation via POST/PUT/DELETE with bearer token. | +| Session fixation | Sessions are server-side but never client-fixable: the token is minted by the daemon from the OS CSPRNG at `POST /auth/login`, regenerated on every login, never accepted from the client. Held only as `sha256(token)` in process memory, expired by idle + absolute timeouts, dropped on vault lock or restart. No cookies; token in the `Authorization` header only. | +| CSRF | Not assumed away by the absence of cookies: a malicious page can POST to `127.0.0.1` regardless (Transmission CVE-2018-5702, Zoom 2019). `require_csrf_protection` rejects mutating requests carrying a non-loopback `Origin` or `Sec-Fetch-Site: cross-site`, and `Access-Control-Allow-Origin` is never sent, so a cross-origin preflight cannot succeed. | | XSS | SolidJS auto-escapes all rendered content. No `innerHTML`. CSP enforced by `springtaled` response headers. | | Clickjacking | `X-Frame-Options: DENY` and `frame-ancestors 'none'` in CSP. | | URL parameter PII | No PII in URL paths or query parameters. Rule IDs and connector names are non-sensitive identifiers. | diff --git a/docs/current-arch/SECURITY.md b/docs/current-arch/SECURITY.md index 8c957f95..eb331fd1 100644 --- a/docs/current-arch/SECURITY.md +++ b/docs/current-arch/SECURITY.md @@ -231,8 +231,8 @@ Below maps each ASVS domain to specific Springtale controls. | ASVS Domain | Springtale Controls | Verification | |---|---|---| | **V1: Architecture & Threat Modeling** | Threat model in §2.1, capability grant model §2.3, defense-in-depth (WASM sandbox + signing + capability checks). STRIDE threat model maintained as living document. | Manual review, threat model updates per release | -| **V2: Authentication** | `springtaled` management API: HMAC bearer tokens, no JWT. Tauri shell: OS-level auth for vault unlock. Duress unlock costs the same time as a real unlock, so timing does not reveal which passphrase was entered. SSE: one multiplexed `GET /stream`, opened with a one-time 30-second ticket from `POST /stream/ticket` — never a bearer token in a URL. Connector OAuth2: PKCE flow only, no implicit grant. | ZAP auth bypass scan, manual review of auth flows | -| **V3: Session Management** | Management API: stateless HMAC tokens, no server-side sessions. Dashboard: short-lived tokens, no localStorage (SolidJS stores only). | ZAP session fixation scan | +| **V2: Authentication** | `springtaled` management API: issued bearer tokens, no JWT — `POST /auth/login` verifies the passphrase against a hash computed at boot and mints a 256-bit CSPRNG session token; `POST /auth/tokens` mints named long-lived tokens. Both are stored only as `sha256(token)` and compared with `subtle`; the passphrase-derived hash is the login verifier, never a bearer. Tauri shell: OS-level auth for vault unlock. Duress unlock costs the same time as a real unlock, so timing does not reveal which passphrase was entered. SSE: one multiplexed `GET /stream`, opened with a one-time 30-second ticket from `POST /stream/ticket` — never a bearer token in a URL. Connector OAuth2: PKCE flow only, no implicit grant. | ZAP auth bypass scan, manual review of auth flows | +| **V3: Session Management** | Management API: server-side sessions, held only as `sha256(token)` in process memory, regenerated on every login (never client-supplied), expired by idle *and* absolute timeouts (`session_idle_secs` / `session_absolute_secs`), and dropped wholesale on vault lock or daemon restart. `POST /auth/logout` revokes a session; `DELETE /auth/tokens/{id}` revokes a long-lived token, effective on the next request. Dashboard: no localStorage (SolidJS stores only). | ZAP session fixation scan | | **V4: Access Control** | Capability grant model: connectors declare permissions, user approves. `ShellExec` requires blocking modal. Approval gate: an action that needs a human is put to a person through chat and the dashboard (`GET /approvals`, `POST /approvals/{id}`) instead of being silently default-denied; every destructive connector action (MCP `destructiveHint` semantics — unknown counts as destructive) goes through it. One runtime per store, enforced with an OS file lock beside the database. No privilege self-elevation from sandbox. | Manual review, fuzzing of capability enforcement in `host_api.rs` | | **V5: Input Validation** | `garde` derive-based validation on all API inputs. Connector manifest: canonical JSON with schema validation. No raw SQL (`sqlx::query_as!` only). | Semgrep rules for unvalidated input, ZAP injection scans | | **V6: Cryptography** | Ed25519 signing (`ed25519-dalek`), XChaCha20-Poly1305 AEAD (vault), Argon2id KDF, rustls-tls exclusively. No OpenSSL. No deprecated ciphers. | `cargo-geiger` for unsafe in crypto path, manual review of `expose_secret()` sites | @@ -266,7 +266,7 @@ Relevant ATT&CK techniques for an agent framework and their mitigations: | T1659 | Content Injection / Indirect Prompt Injection (Zenity) | Zenity demonstrated: malicious Google Doc → agent creates rogue Telegram integration → attacker gains C2. Impossible in Springtale: (1) connector installation requires signed manifest + user approval modal — AI adapter cannot autonomously install connectors, (2) `AiRequest` closed enum prevents raw document content from reaching AI as instructions, (3) connector output is typed and sanitized, never passed as raw prompt, (4) new connector registration requires `springtale-cli connector install` with manifest verification — no hot-loading from conversation context. | | — | Persistent Memory Poisoning (Palo Alto "stateful delayed-execution") | Palo Alto described attacks persisting in OpenClaw's Markdown memory files across sessions. Springtale mitigations: (1) memory is SQLite-backed with app-layer encryption, not plaintext Markdown, (2) memory write operations go through `springtale-bot` memory module with typed schemas — no arbitrary string injection, (3) memory compaction summarizes via AI adapter but output is validated before storage, (4) `springtale-cli memory audit` command allows users to inspect and purge memory entries. | | — | Credential Theft via Infostealer (RedLine/Lumma/Vidar/AMOS) | Infostealers specifically target `~/.openclaw/` plaintext files. Springtale mitigations: (1) all secrets in encrypted vault (XChaCha20-Poly1305 + Argon2id KDF) — no plaintext credential files, (2) `Secret` + `zeroize` ensures credentials zeroed from memory on drop, (3) vault unlock requires passphrase (not stored on disk), (4) no `~/.springtale/` directory contains any credential material in recoverable form. | -| — | WebSocket Hijack / ClawJacked (Oasis Security, CVE-2026-25253) | OpenClaw's localhost WebSocket assumed local = trusted. Springtale has no WebSocket gateway. Management API is HTTP REST with HMAC bearer tokens, `tower-http` rate limiting, and `127.0.0.1` default binding. The ClawJacked attack vector does not exist. | +| — | WebSocket Hijack / ClawJacked (Oasis Security, CVE-2026-25253) | OpenClaw's localhost WebSocket assumed local = trusted. Springtale has no WebSocket gateway. Management API is HTTP REST with issued bearer tokens, `tower-http` rate limiting, and `127.0.0.1` default binding. The ClawJacked attack vector does not exist. | | — | Default Network Exposure (135K+ instances, SecurityScorecard) | OpenClaw binds `0.0.0.0:18789` by default. Springtale: `springtaled` and `springtale-dashboard` both bind `127.0.0.1` by default. Config validation warns if binding to `0.0.0.0`. Remote access requires explicit `--bind` flag or SSH tunnel / Tailscale / VPN. | diff --git a/docs/operations/README.md b/docs/operations/README.md index 5962e84a..b3df4c80 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -26,7 +26,6 @@ read first. ├── springtale.db-wal ← WAL file (transient, will be empty after clean shutdown) ├── springtale.db-shm ← shared-memory file (transient) ├── vault.bin ← AEAD-encrypted vault (constant 131,152 bytes; two regions) -├── api_token ← HMAC bearer token for the management API ├── connectors/ ← installed connector binaries (WASM) + manifests │ └── manifest_*.toml └── audit.log ← rotating audit log (also mirrored to audit_trail table) diff --git a/docs/operations/backup-and-restore.md b/docs/operations/backup-and-restore.md index 5a4db9cf..30161579 100644 --- a/docs/operations/backup-and-restore.md +++ b/docs/operations/backup-and-restore.md @@ -15,8 +15,8 @@ in [`docs/current-arch/SECURITY.md`](../current-arch/SECURITY.md) §2.7. ``` $SPRINGTALE_DATA_DIR/ ├── vault.bin ← Ed25519 identity + connector credentials + duress region -├── springtale.db ← rules, events, formations, mental_model, audit_trail -├── api_token ← HMAC bearer token (regenerate-able) +├── springtale.db ← rules, events, formations, mental_model, audit_trail, +│ api_tokens (hashes only — see below) ├── connectors/ ← installed connector manifests + WASM binaries └── audit.log ← rolling audit log (mirrored to DB) ``` @@ -25,6 +25,36 @@ Plus the **passphrase** itself, which is *not* in the data directory. You can back up the data and have nothing useful if you don't remember the passphrase. Write it down somewhere offline. +### API credentials are not in the backup + +There is no `api_token` file, and no `api_token` vault entry. Bearer tokens +are minted by the daemon from the OS CSPRNG at login — never derived from the +passphrase, never written to disk in recoverable form. So a backup carries no +usable credential: + +- **Sessions** (`POST /auth/login`) live in process memory only, as + `sha256(token)`. Nothing to back up; nothing survives a restore, or even a + daemon restart. +- **Named long-lived tokens** (`POST /auth/tokens`) are rows in + `springtale.db` (`api_tokens`), but again only as `sha256(token)`. The rows + come back with the database, so an old token string still authenticates + *if you kept it* — the backup cannot give it back to you. + +After a restore, an operator holding the passphrase gets a working credential +by logging in: + +```bash +curl -sX POST http://127.0.0.1:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"passphrase":"..."}' +# → {"token":"","expires_in":} +``` + +If the old long-lived token strings were lost with the machine, mint +replacements with `POST /auth/tokens` and revoke the stale rows with +`DELETE /auth/tokens/{id}` — the restored hashes are still live credentials +for anyone who has the matching string. + ## What's NOT in scope - The daemon binary itself. Rebuild from source. From 23fe76131ff325f9cf2d691803be32a0a4c15e69 Mon Sep 17 00:00:00 2001 From: radicalkjax Date: Sun, 6 Sep 2026 17:07:15 -0700 Subject: [PATCH 15/15] contract: regenerate the served document after the onboard route moved Moving `POST /workspaces/onboard` off the stream-ticket router changed its annotation, but the committed contract and its generated TypeScript still described the ticket form, so a frontend build reverted the change. Both are regenerated from the handlers the way the contract job does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8 --- tauri/packages/types/openapi.json | 3 ++- tauri/packages/types/src/api.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tauri/packages/types/openapi.json b/tauri/packages/types/openapi.json index 9cca88d9..ab9a4bd9 100644 --- a/tauri/packages/types/openapi.json +++ b/tauri/packages/types/openapi.json @@ -4449,7 +4449,8 @@ "tags": [ "workspaces" ], - "summary": "POST /workspaces/onboard?ticket=.. — SSE of `chat-discovered`\nframes (same payload as the desktop `ChatDiscovered` event) until\nthe first match, the 60 s window, or client disconnect.", + "summary": "POST /workspaces/onboard — SSE of `chat-discovered` frames (same\npayload as the desktop `ChatDiscovered` event) until the first\nmatch, the 60 s window, or client disconnect.", + "description": "This mutates (it deploys a probe through the connector), so it sits\nin the bearer + CSRF `authenticated` router, not in the stream-ticket\nrouter. The ticket exists for `EventSource`, which cannot send an\n`Authorization` header — but `EventSource` only issues GETs and this\nroute needs its connector config in a POST body, so its client was\nalways `fetch`, which can send the header. Streaming the response is\nunaffected: axum's `Sse` does not care how the request authenticated.", "operationId": "workspaces_onboard", "requestBody": { "content": { diff --git a/tauri/packages/types/src/api.ts b/tauri/packages/types/src/api.ts index b46c3a97..87ddf0f4 100644 --- a/tauri/packages/types/src/api.ts +++ b/tauri/packages/types/src/api.ts @@ -2244,6 +2244,13 @@ export interface paths { * POST /workspaces/onboard — SSE of `chat-discovered` frames (same * payload as the desktop `ChatDiscovered` event) until the first * match, the 60 s window, or client disconnect. + * @description This mutates (it deploys a probe through the connector), so it sits + * in the bearer + CSRF `authenticated` router, not in the stream-ticket + * router. The ticket exists for `EventSource`, which cannot send an + * `Authorization` header — but `EventSource` only issues GETs and this + * route needs its connector config in a POST body, so its client was + * always `fetch`, which can send the header. Streaming the response is + * unaffected: axum's `Sse` does not care how the request authenticated. */ post: operations["workspaces_onboard"]; delete?: never;