Skip to content

what the verification pass found: momentum truth, load-time signing, utterances that reach their listeners, and docs that match - #95

Merged
radicalkjax merged 17 commits into
mainfrom
cooperation/momentum-counts-only-work
Sep 7, 2026
Merged

what the verification pass found: momentum truth, load-time signing, utterances that reach their listeners, and docs that match#95
radicalkjax merged 17 commits into
mainfrom
cooperation/momentum-counts-only-work

Conversation

@radicalkjax

Copy link
Copy Markdown
Member

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/onboard was 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 warnings clean, cargo fmt --check clean, 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

radicalkjax and others added 17 commits September 6, 2026 16:35
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
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
@radicalkjax
radicalkjax merged commit 9a03dbe into main Sep 7, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant