what the verification pass found: momentum truth, load-time signing, utterances that reach their listeners, and docs that match - #95
Merged
Conversation
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/<pid>/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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<bool>` (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<u64>` — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…ilds 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…ting route `/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…docs 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…ion/momentum-counts-only-work
…m-counts-only-work
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I read the alignment plan end to end and had every phase re-read against the source, with no keyword search: files located by listing, then read whole. Most of the plan holds. This branch fixes what did not, and corrects the record where the register was wrong.
Momentum counted things that were not work. Idle ticks were handled, but a carried-over dispatch reported alignment 0.8, a merely-claimed task 1.0, a sacrifice yield 0.9, and the observe, suggest and no-task paths returned 1.0 whenever they carried a descriptor. One hung connector call could walk a formation to Fever while also keeping the member marked alive. The action state the plan specified for the tick report was never added; it is now, sourced from the execution outcome, and a tick counts only when a member finished work. A test drives a hung dispatch for fifty beats and asserts the formation stays Cold.
Signing was required at install but not at load. Stored WASM rows whose signature columns were empty were grandfathered at every boot with a warning. They are now refused, naming the connector and telling the operator to reinstall it, with no migration and no override, because the project's own rule is that signatures are verified before load. A test in the tree asserted the old grandfathering; it is inverted rather than deleted.
Two more ways the gate could be bypassed. The auto-allow approval gate was a production export that any caller could hand to the sentinel, silently disabling the human gate; it is now test-only.
POST /workspaces/onboardwas mounted in the stream-ticket router, so a mutating route was authenticated by a single-use ticket outside the cross-site protection; it now takes the same bearer as every other mutating route, keeping its streaming response, and its client sends the bearer instead of minting a ticket. The premise that streaming forced it there did not hold: tickets exist for the browser event source, which is read-only, and this route always took a body.Utterances did not reach their listeners. Server-side agent activity still came from the last two hundred events rather than the utterance ring, so the daemon and the browser could disagree about what an agent was doing. And what a peer heard was overwritten by the gossip step later in the same tick, so nothing acted on it. Both are fixed, and a heard failure now has a real consumer: it weighs into the morale that cascade detection reads.
Rally. It double-counted a failure the momentum step had already recorded, it rallied the first failing member rather than one who can still respond, and its recovered outcome was constructed nowhere. All three are fixed, with a shattered-morale floor so a token is never spent on a member who cannot answer.
Documents that no longer matched. The API and CLI references still described the retired passphrase-derived token, as did the architecture and security documents, the operations pages, and the project's own security rule. The API reference still listed the deleted template routes and an onboarding platform list that was never true. The quickstart taught an environment-variable passphrase while the compose file deliberately uses a secret file. The workspace manifest claimed a Matrix source tree that is not in the repository. The connector guideline still described a per-connector TOML manifest file, and did not mention the two action fields added since. All corrected against the code, and the intended-architecture directory left alone deliberately, since the current-architecture documents supersede it.
Verification: workspace clippy
-D warningsclean,cargo fmt --checkclean, 1013 cooperation, bot and runtime tests plus the daemon suite green, frontend typecheck, lint and build clean, and the served contract regenerated from the handlers so it matches the moved route.🤖 Generated with Claude Code
https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8