Skip to content

finish the plan: the remaining phase 1, 2, 5 and 6 items, the SDK world, and the deferred tests - #96

Merged
radicalkjax merged 28 commits into
mainfrom
cooperation/finish-phase-one
Sep 7, 2026
Merged

finish the plan: the remaining phase 1, 2, 5 and 6 items, the SDK world, and the deferred tests#96
radicalkjax merged 28 commits into
mainfrom
cooperation/finish-phase-one

Conversation

@radicalkjax

Copy link
Copy Markdown
Member

Everything the verification pass left open, finished rather than listed. Twenty-seven commits, grouped below by what they close.

Phase 1, the four pieces its own sections specified and never got. The agent loop can now report a surface reaction and a claimed task from the same beat: sense and inbox are no longer mutually exclusive, a scan no longer overwrites the surface descriptor, and sacrifice always runs. Momentum thresholds became a per-formation constraint, which is what the section asked for; the derive mismatch that stalled it is resolved rather than worked around. Pacing weights and timings became configuration with the same defaults, for the reason the section gives: the game this is modelled on ships every director number as a tunable. Handoff success is measured, so the rate in the momentum window means something instead of always reading zero.

The contract now covers every route. The protocol endpoint, the vault unlock route and the document itself were served but unannotated, so they were missing from the contract and its generated types; all three are described now. The surface check reads a real machine-readable dump of the command tree and the route each verb calls, instead of grepping path literals out of source, and it still fails loudly on an empty list. Six formation handlers took untyped bodies and hand-plucked fields; they take typed requests with no silent defaults. Regenerating the contract exposed a live bug: the browser posted a vote in a shape the route did not read, so every ballot cast from the dashboard was rejected. Fixed on both sides.

No surface writes behind the daemon's back any more. Pairing goes through a route. The two commands that stay offline do so deliberately and are written down with their reasons: an emergency unpair has to work from a recovered terminal when the daemon is the thing that was taken, and registering a signing identity happens before a daemon exists. The author registry no longer keeps a parallel implementation; it goes through the same operation the API uses. The gap ledger is empty.

Chat is a real surface. The tool loop learned the platform verbs, so a model can steer a formation through the same registry the command line and the check script read, with read-only verbs running unattended and everything else crossing the approval gate. Sentence templates exist per locale and are matched in the speaker's language. The chat path reads the live model handle instead of a snapshot taken when the bot was built, so changing the model no longer waits for a lock and unlock.

Connectors are no longer known by name in the daemon. The webhook route dispatches through the trait rather than special-casing one connector's callback answer, and any installed connector can be configured from the file, not the fourteen that were hard-coded. The protocol server tells clients when the tool list changes. Kick's replay window survives a restart instead of reopening. The desktop notices its daemon dying rather than talking to nothing.

The SDK has an interface description. The connector world is written, the example is a real component built against it, and the sandbox test now loads that component and calls it, closing the "proves linkage, not execution" gap recorded earlier.

The deferred tests are written. Travel and panic wipe, the desktop safety logic, the mutual-TLS transport including the post-quantum group, the command line's output shapes, and CI jobs for the Python wheel and the WebAssembly example.

One real defect found while writing them. Travel mode copied the database file without folding in the write-ahead log, so a backup taken before wiping the originals silently predated the traveller's last work. It check points first now. The test writes through the live handle, backs up, restores, and asserts the row survived; it fails without the fix.

Also: the Python bindings crate had five module files that were never declared, duplicating everything inlined in its root, so they compiled into nothing and were free to drift. The modules are now the code.

Verification: workspace build, cargo clippy --workspace --all-targets -D warnings clean, cargo fmt --check clean, the full workspace suite green, the surface check passing at 127 routes, and frontend typecheck, lint and build clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8

radicalkjax and others added 27 commits September 6, 2026 17:57
The crate carried both a 281-line `lib.rs` with every pyclass inline and
five module files defining the same types. Because `lib.rs` never declared
the modules, those five files compiled into nothing: dead duplicates that
would drift from the definitions actually in use.

`lib.rs` becomes the table of contents the crate-structure rule requires,
the module files are what compiles, and the Python module entry point moves
into its own file beside them. No behaviour change: the same four classes
and the same version string are registered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Plan 1.9 says the agent loop runs sense, inbox, react, scan, respond_cfp,
sacrifice without early return between the steps. `decide` still had sense
and inbox as `else if`, so a primed surface starved the inbox; the scan hit
overwrote `tick_action` with its own descriptor; and sacrifice only ran when
the scan had run, so an inbox hit skipped the B9 final consideration.

`Decision` now carries `surface` next to `tick_action`, sense fills only
that, inbox always runs, the scan runs when no task was claimed, and
sacrifice always runs. `TickReport` gains `surface_reaction`, attached by
the gather phase, so one report carries both descriptors instead of one
erasing the other.

Test: a primed surface and an open task in the same beat produce one report
with the surface reaction and the claimed task's action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Plan 1.3 says `FormationConstraints` carries `MomentumConfig` "so it is per
formation like every other constraint". It stalled because the constraint
struct is `Serialize + Type` while the config derived only `Deserialize`.

`MomentumConfig` and `TierThreshold` now derive `Serialize`, `Deserialize`,
`Type` and `PartialEq`, which is what the constraint struct needs, and
`FormationConstraints` carries the table. `Formation::new` builds its
`MomentumState` with `with_config` from its own constraints, so two
formations deployed with different thresholds each promote on their own
numbers.

No generated TypeScript changed: `FormationConstraints` is not part of the
exported command or event surface (tauri-specta `.export()` is disabled in
`tauri/apps/desktop/src-tauri/src/lib.rs` pending specta-rs/specta#455), and
nothing under `tauri/packages/types/src/generated` references it.

Test: two formations, same two clean actions, different Cold rows — the
eager one reaches Warming, the patient one stays Cold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Plan 1.5 specifies `[cooperation.pacing]` with the peak threshold, sustain
and relax seconds, decay rate and the four stress weights, for the reason
Left 4 Dead ships every Director number as a cvar or `DirectorOptions`
field: tuning happens after play, not before. They were `const`s.

New `pacing/config.rs` holds `PacingConfig` with those eight fields, the
plan's defaults kept as named consts and documented against Booth's deck.
`PacingManager` carries one and reads every number from it;
`FormationConstraints` carries one too, so it reaches the manager the same
way the momentum thresholds do.

Test: a formation tuned to peak at 0.1 with a 60-second sustain peaks on one
failure and does not fade after the default four seconds, while a default
manager given the same sample stays in BuildUp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Plan 1.3 gives the momentum window `handoffs` / `handoffs_ok` and a
`handoff_rate`, and the 1.15 work noted the gap: "handoff has no completion
event to hook", so the rate was permanently zero and promotion could not
see the point COOPERATION.pdf §20 calls the place most cooperative failures
occur.

New `handoff/completion.rs`: `HandoffCompletion` (pattern, from, to,
success), `HandoffType::pattern/from/to`, `HandoffResult::succeeded` (false
only for `Failed`), and a `HandoffLog` the formation shares behind an `Arc`
because `dispatch_handoff` takes `&self`. Every dispatch records one
completion. The momentum step drains the log each tick, emits a
`CooperationEvent::HandoffCompleted` per record, and counts them into the
tick's `TickCounts`, so `RunWindow::handoff_rate()` now reflects real
handoffs. A poisoned lock drops the statistic, never the work.

Also carries `cargo fmt` over the two preceding commits.

Tests: record/drain round trip and outcome mapping; a Direct dispatch leaves
exactly one successful completion on the formation's log; three completions,
two ok, put a 2/3 handoff rate in the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`collect_tools` enumerated connector actions only, so a model could
send a Telegram message but could not see, let alone steer, a
formation. Every verb in the runtime registry is now a tool under a
`platform` pseudo-connector:

- `platform__formation_pause` etc., published from `platform_verbs()`
  with the registry's own description and `input_schema`. `.` is not
  legal in a tool name, so the dotted verb name maps through
  `PlatformVerb::tool_segment` and back through
  `find_verb_by_tool_segment`.
- Name resolution in `execute_tool_call` routes the `platform`
  namespace to `operations::platform::run_platform_verb` — the runtime,
  not the connector registry. That function is new and delegates every
  branch to the runtime operation the chat command already calls.
- Read-only verbs (list, get, status) run straight through; every other
  verb blocks on the same `ApprovalGate` a connector write blocks on,
  and is refused outright when no gate is wired.
- Platform tools are emitted before connector tools so they survive
  `MAX_TOOLS_HARD_CAP` truncation, and only when the bot has a
  `RuntimeState` to run them against.

Tests assert `platform__formation_pause` is published, that it needs
`writes_with_approval` exactly as a connector write does, and that no
published tool name contains an assign — the drum rule, enforced where
a model can reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…anguage (plan 5.4)

The catalogue was pinned to `const CHAT_LOCALE = "en"` and seven of the
eight locale files were empty stubs, so chat understood one language
while the interface spoke eight.

- `es`, `fr`, `pt`, `tl` and `ar` now carry real sentence templates for
  all twenty platform verbs, with the same slots the English file uses
  (`{formation}`, `{connector}`, `{intent}`, `{id}`, `{key}`,
  `{value}`, `{adapter}`) — filled from live state at match time, never
  hard-coded. Tagalog is written in the Taglish register technical
  commands are actually typed in.
- `ja` and `th` stay stubs, and their files now say why: the NLU
  tokenizer splits on non-alphanumeric characters, and neither language
  puts spaces between words, so a template would collapse to one token
  and could never match. Populating them is blocked on word
  segmentation (lindera / ICU), not on translation. Both fall back to
  English, which at least matches loan words and the formation name.
- `build_catalog` takes the speaker: their `language` preference picks
  the sentence file, region tags narrow to the base language, and
  anything uncovered falls back to English.

Tests: every translated locale covers every verb (no half-translated
file), and no translated phrase invents or drops a slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Changing the model swapped `RuntimeState::ai_adapter` (an `ArcSwap`),
which rule dispatch reads through the capability bridge — but the chat
fallback held `Bot::ai_adapter`, the snapshot taken when the bot was
built. So a model change landed on rules immediately and on chat only
after a lock and unlock rebuilt the bot.

`ai_fallback` now loads the same swappable handle when a runtime is
wired, falling back to the built-with adapter for headless and test
bots that have none. The availability check and the tool runner both
see the current adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Finding 83 / ALIGNMENT-PLAN 2.7: `crates/springtale-wit` shipped the
cooperation world, but `sdk/connector-sdk` had no `.wit` at all, so the
sandbox work rested on hand-written WebAssembly text and the SDK's
documented ABI was the only statement of the contract.

`sdk/connector-sdk/wit/connector.wit` states it. Two worlds, because
host imports are gated by the manifest and not by the language:
`connector` (exports `guest`, imports nothing) for a connector that
declares no capabilities, and `networked-connector` which adds the
`host` interface. `host` carries exactly one function, mirroring the
one `register_http_request` actually provides, with the -1/-2 return
values named as `access-error` variants. WASI imports are documented
rather than declared: the host links the `wasi:http/proxy` set plus
`wasi:cli/{exit,environment,terminal-*}` and `wasi:filesystem`, all
against a `WasiCtx` that grants nothing, and never `wasi:sockets`.
Declaring that world here would need the `wasi:*` packages vendored and
would imply a grant the host does not make.

`connector-hello-wasm` is now a `wasm32-wasip2` component built against
that world with `wit-bindgen`, so the actions it exports and the
`read_only` bits in its manifest are checked by the type system rather
than by comment. Both example and SDK gain an empty `[workspace]` table
so they resolve their own graph from any checkout path.

The 2.7 gap is closed: the tier cache's positive test no longer proves
linkage against hand-written WAT. It `include_bytes!`s the built
component from `prebuilt/`, links it against the WASI p2 linker,
instantiates it, and calls `execute` — lifting the result into a
host-side mirror of the WIT record, so a renamed field fails the typed
lookup. `greet` returns its greeting and an unknown action comes back
through `action-result` instead of trapping. The artefact is checked in
so the test needs no wasm target, and no Python or JavaScript
toolchain, at test time; `prebuilt/README.md` says how to regenerate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
ALIGNMENT-PLAN 5.5, row 2: `autolock.rs`, `commands/safety.rs`,
`quick_hide.rs` and `tray.rs` had no tests, because every decision was
tangled with a Tauri or OS call. The decisions now live in
`src/policy/`, the call sites are thin, and the decisions have tests.

- `policy::autolock::AutoLockTimer` is the timer state machine:
  `Disabled` when the timeout is zero, otherwise `Counting` until idle
  reaches the threshold and then `Locked`. Activity resets the
  countdown; a backwards clock reads as zero idle rather than
  underflowing.
- `policy::disguise` picks the window title and the tray disguise from
  the safety config, with the real icon id and tooltip as named
  constants.
- `policy::shortcut::quick_hide_candidates` builds the accelerator
  ladder, fallbacks included.

20 unit tests over the three modules. Behaviour is unchanged: the
exact-zero-disables and exact-threshold-fires boundaries and the
fallback order are preserved.

The tap-count threshold named in the plan row is not extracted. It is
not Rust — it lives in `tauri/apps/desktop/src/Colony.tsx` (the 1500 ms
window and the `panic_tap_count` comparison), and no Rust code receives
those clicks, so a Rust implementation would be uncalled code and fake
coverage. The daemon-side bound on the configured value stays in
`springtale-runtime::operations::safety`. Testing the frontend counter
needs a vitest task against `Colony.tsx`.

Drive-by: `sidecar.rs` had `login` appended after its test module,
which fails `clippy::items_after_test_module`; the test module moved to
the end of the file, no logic touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
ALIGNMENT-PLAN 5.5, row 5: neither the Python bindings nor the WASM SDK
example was built anywhere, so both could rot silently.

`wasm-sdk` adds the `wasm32-wasip2` target, builds
`sdk/examples/connector-hello-wasm` against the SDK's WIT world, checks
that the fresh build and the checked-in `prebuilt/` artefact both carry
the component preamble rather than a core-module one, and runs the
sandbox tests that load the component. The wasip2 target emits a
component directly, so the job needs no `wasm-tools`, no Node and no
Python.

`python-bindings` builds the maturin wheel and smoke-imports it. The
check is not a bare `import springtale`: it asserts `__version__` and
every class the `#[pymodule]` declares, so a binding dropped from the
module fails the job instead of passing silently. It uses the runner's
preinstalled Python in a venv rather than adding `actions/setup-python`
— no new third-party action enters the trust set, and every action in
both jobs is one this file already pins by digest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`/mcp`, `POST /vault/unlock` and `/openapi.json` were served but carried
no annotation, so the document the daemon publishes did not describe
every route the daemon answers, and the generated TypeScript could not
see them either.

`/mcp` is documented for what it is — a Streamable HTTP MCP endpoint
carrying JSON-RPC 2.0 — and deliberately not by restating MCP's own
schema, which the specification versions, not this daemon. The
annotation sits on the router constructor because the endpoint is a
nested service rather than a handler.

`/vault/unlock` takes a typed body whose passphrase is a `SecretString`
described to the wire as a password string. It gains a command-line
surface at the same time: `springtale vault unlock`, because a locked
daemon serves three routes and nothing else, so the dashboard SPA cannot
load to unlock it and a headless instance had no way back after an
auto-lock short of a restart. The passphrase is read from the terminal,
never from a flag.

`/openapi.json` and the browser half of `/mcp` and `/vault/unlock` are
recorded in the not-surfaced list with their reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Three commands opened the store directly behind a running daemon.

`bot pair-init` now calls `POST /bot/pair-init`. The pairing rows live in
the daemon's store, so the daemon is the writer and the command line is
a client of it, like every other daemon-backed verb (plan 2.2).

`bot panic-unpair` stays offline, deliberately. It is reached when the
paired device or account is in the wrong hands, from whatever terminal
the user has recovered — precisely when springtaled may be dead, wedged,
or itself the thing that was taken. It deletes rows a running daemon
then stops finding, so there is nothing for it to have been told. Same
reasoning as `springtale panic`.

`author` stays offline too: `author add --self` registers this
instance's connector-signing identity on first run, before
`springtale server start` has ever been typed. What was not acceptable
was a second implementation of the registry, so it is gone — every read,
write and validation now goes through
`springtale_runtime::operations::authors`, the same functions and the
same `trusted-author:` rows the `/authors` routes use. One registry,
one implementation, reached from a socket or from a terminal.

Both decisions are written down in the not-surfaced list. The exemption
ledger — the open product decision this closes — is now empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`update_intent`, `propose_intent`, `cast_vote`, `add_member`,
`remove_member` and `run_command` took `Json<serde_json::Value>` and
hand-plucked fields out of it. The bodies are now `IntentBody`,
`CastVoteBody`, `MemberBody` and `RunCommandBody` — required fields with
no silent defaults, `ToSchema` for the contract, and a missing field is
a rejection at deserialization rather than a value the handler invents.
`params` on `run-command` stays optional because a command with no
parameters is a real case; nothing else is.

Regenerating the document off the annotations found a caller that had
never worked: the web provider's `castFormationVote` posted `{ choice }`
to a route that reads `voter` and `approve`, so every browser ballot was
a 400. The `DataProvider` signature now carries the two fields the route
takes, which is the point of generating the contract rather than
describing it twice.

`tauri/packages/types/openapi.json` and `src/api.ts` are regenerated the
way the contract job does, covering this and the two commits before
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`scripts/cli-routes.sh` grepped path string literals out of the command
sources, which answered a weaker question than the surface check asks.
A path in a comment counted as a verb. A verb that built its path from a
constant or a `match` did not count at all. The list was a guess about
code rather than a statement by the program.

`springtale dump-commands` is that statement: it walks its own clap tree
at runtime and prints every verb with the routes it calls. The tree half
cannot drift — it is the parser. The route half is declared beside the
verb in `surface.rs` and held to the tree by a unit test, so a new
subcommand cannot be added without saying what it talks to and a deleted
one cannot leave a ghost behind. A verb that declares no routes is
saying it runs offline, which is itself checkable.

The script reads the dump, asks cargo for the binary rather than
trusting whatever is in target/, and still fails loudly on an empty
list — and on a verb with no declaration, the new way for the extractor
to be broken. The drum rule is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
ALIGNMENT-PLAN 5.5, row 4: the command line had one test, so any
rename, drop, or type change in a `--json` body was a silent breaking
change for every script parsing it.

`output::render_json` is split out of `print_json` — the exact function
`--json` prints through — so tests assert the real path rather than a
parallel one. The inline `serde_json::json!` bodies and the inline
table closures scattered through the command modules are now named
functions, which is what lets a test tie the JSON key contract to the
code the CLI actually runs.

114 tests over every subcommand family with a `--json` mode: connector,
rule, events, formation, approval, agent, auth, session, memory,
execution, recipe, workspace, canvas, onboarding, chat, send, safety,
data, doctor, fix, login, bot pairing, panic, travel, vault duress,
crypto rotate, healthcheck, server, cooperation, and the redaction in
`config ai get`. Each asserts the top-level key set and the type of
each field, and per-item keys for list output — a renamed field fails.
The pretty-print pass-through family is covered by one test that a
daemon document survives `render_json` unchanged.

Not covered, with reason: `init` ignores `--json` by design, `mcp serve`
is a stdio JSON-RPC bridge with no `--json` path, and `recipe
export`/`render` emit TOML.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
ALIGNMENT-PLAN 5.5, row 3: the rustls mTLS transport — the component
that carries inter-node traffic for people whose safety depends on it —
had no tests at all.

`crates/springtale-transport/tests/http_transport.rs` drives the real
`HttpTransport`, not a reimplementation:

- two nodes with an in-test CA round-trip a message in both directions,
  asserting sender node id, message id and payload;
- a send to an unknown peer is rejected as `ConnectionFailed` naming
  the node;
- a client that trusts the wrong authority is refused — the real
  `send()` fails, the server inbox stays empty, and a raw rustls probe
  against the same live server pins the reason to
  `InvalidCertificate(UnknownIssuer)` rather than any error at all;
- a client presenting a certificate from the wrong authority is
  likewise refused, pinned to a fatal certificate alert (TLS 1.3
  surfaces the server's client-cert rejection after the handshake, so
  it is asserted on the application-data exchange);
- the hybrid post-quantum group is asserted from handshake state:
  `negotiated_key_exchange_group()` must be `X25519MLKEM768`.

Both negative tests were mutation-checked: pointed at a matching CA,
they fail, so they are not passing on an incidental error.

`rcgen` was already a hardcoded dev-dependency of this crate; it moves
to a workspace pin like every other dependency, `default-features =
false` with `crypto,pem,ring` so it stays on the workspace's ring
backend — no aws-lc-rs, no OpenSSL, and `Cargo.lock` is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
…anges (plan 6.5)

The server advertised the listChanged capability and never sent the
notification, so a client cached a stale tool list until it reconnected.

The runtime owns a protocol-free `ToolCatalogNotifier` (a broadcast
sender on `RuntimeState`, the shape `canvas_tx` already uses) that
every operation mutating the LIVE registry publishes to — setup,
install-wasm, reload, enable, disable, remove and remove-cascade — each
fired right after the registry changes rather than after fallible
follow-up work. `install_connector` deliberately does not: it registers
a manifest row and leaves `tools/list` unchanged.

`ServerHandler::on_initialized` spawns one forwarder per connected
client holding that client's peer and its own subscription, filters by
the server's connector scope, and calls `notify_tool_list_changed`.
It exits when the transport closes or the runtime drops the channel, so
dead peers prune themselves; a failed send on a live session does not.
The loop is generic over a small sink trait so it is tested without a
transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
… 6.4)

The route hard-coded Telegram: on a `callback_query_received` trigger it
reached into the body and called `answer_callback_query` itself.

`WebhookIngest` gains `acks` — `WebhookAck { action, input }` values a
connector asks the ingress to run back on it. Telegram's
`ingest_update` emits its own callback answer (mirroring what the
polling chat source already did), and the route just loops over the
acks through the same capability-checked `registry.execute`.
Verification, responses, event log and trigger dispatch are unchanged
and in the same order. A test scans the route module's own source for
connector names so the coupling cannot come back.

Two things ride along in this commit because they interleave with the
above inside the same two files and hunks cannot be staged separately
here: the Kick replay store check in the route (see the replay commit),
and a real bug — the old code read the callback id at the body's top
level, which a genuine Telegram Update never has, so webhook-mode
button presses were never acknowledged. The connector now reads
`callback_query.id` and still accepts the old shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`extract_connector_configs` matched fourteen literal keys, so a
connector added later could not be configured from the file at all —
the daemon silently ignored its table.

It now enumerates the factory registry (`factory::config_keys()` over
the `inventory` entries), so whatever is installed is configurable, and
accepts `[connectors.<name>]` as well as the historical bare
`[<name>]`. No alias map was needed: all fourteen old keys are exact
factory config keys, and a test asserts that and says to add an alias if
one ever diverges. A second test extracts a key outside the old
fourteen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
The replay cache was a process-memory `Mutex<ReplayCache>`, so a daemon
reload or a vault unlock reopened the window an attacker could replay a
captured webhook in.

It moves to the store, reusing the existing `dedupe_seen` table through
`StorageBackend::dedupe_check` (atomic insert-or-ignore with LRU
eviction) rather than adding a table. Keys are blake3-hashed, per that
table's documented privacy invariant. Connectors declare the key with a
new optional `webhook_replay_key(headers)` on the connector and host
traits — WASM keeps the `None` default — so the check stays generic;
Kick returns `Kick-Event-Message-Id` and keeps only its timestamp
check. The route runs the check after signature verification and before
the event log, and fails closed on a store error.

No connector gained a `springtale-store` dependency: the check lives in
`springtale-connector`, which already depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
The shell kept the sidecar's child handle and nothing watched it. If
springtaled died after signalling ready, the window held a stale port
and token, with no restart, no health path, and no sign anything was
wrong.

`Daemon` now carries the event receiver out of `start`, and
`supervise()` drains it: on `Terminated` or stream close it clears the
stored `DaemonHandle` and emits `DaemonStopped { code }`. A deliberate
stop (lock_vault) finds the slot already taken and stays silent, so
locking does not raise an alarm. The frontend listens through a small
IPC module and renders a notice while a session is on screen; strings
are in all eight locales.

Also kills the child when `login` fails, which previously orphaned a
daemon holding an unlocked vault. No restart supervisor — detection and
an honest signal only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
ALIGNMENT-PLAN 5.5, row 1: `operations/travel.rs` and
`operations/safety.rs` had zero tests. These are the operations a user
runs when crossing a border or when someone is at the door, so an
untested regression here is the worst kind this project can ship.

`travel.rs` gains five tests: a prepared backup reopens under the
vault; the prepared tree leaves no plaintext behind; prepare→restore
round-trips vault, database and config; a missing backup returns
not-found; and a restore with the wrong passphrase writes nothing
rather than half-restoring.

`panic_wipe` gets its own integration binary. It resolves paths
internally through `springtale_store::paths`, so the only way to test
it is to redirect `XDG_DATA_HOME`, and `#![forbid(unsafe_code)]` on the
library rules out `unsafe env::set_var` inside a `#[cfg(test)]` module.
The test asserts the resolved data directory is inside the temporary
root before wiping anything, so it can never reach a real vault, then
asserts the vault, database, `-wal`, `-shm` and config are all
unreadable afterwards.

All three are mutation-checked: disabling the config wipe in `prepare`
fails two tests, disabling it in `panic_wipe` fails that one.

The round-trip test exposed something worth recording: `travel::prepare`
copies only the `.db` file, so anything still in the SQLite write-ahead
log at departure is not in the backup. The test closes the writer handle
to force a checkpoint before preparing. A `prepare` on a live,
uncheckpointed store would back up stale data — a separate fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
`travel prepare` copied the `.db` file and wiped the originals. A SQLite
database in write-ahead-log mode keeps recent commits in a sidecar file, so
everything written since the last automatic checkpoint was left behind: the
traveller carried a backup that looked complete and silently predated their
last work, and the wipe then destroyed the only copy.

The store gains a checkpoint method, a no-op for backends with no such log
and a truncating checkpoint for SQLite, and `prepare` calls it first. The
test writes a row through the live handle, backs up, restores, and asserts
the row is there; it fails without the checkpoint.

Found while writing the tests this plan deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
Comment thread .github/workflows/ci.yml
permissions:
contents: read
steps:
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2
Comment thread .github/workflows/ci.yml
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
targets: wasm32-wasip2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
Comment thread .github/workflows/ci.yml
permissions:
contents: read
steps:
- uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2
Comment thread .github/workflows/ci.yml
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
…e the document

Two things the new CI jobs caught, which is what they are for.

The wheel declares a Python source directory that was never created, so
`maturin build` failed outright: the crate promised curated type stubs and
shipped none. The package now exists, with stubs for the four classes the
extension registers and the marker that makes them count.

The webhook route's description changed when it stopped knowing connectors
by name, and the committed contract still carried the old text. Regenerated
from the handlers, along with its TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M415coMKAv5V2xJQsaSf8
@radicalkjax
radicalkjax merged commit 726f993 into main Sep 7, 2026
32 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.

2 participants