Status: Reflects working tree · Updated: 2026-05-11 · Source of truth: the code
This document describes what actually exists in the repository today. For design intent (threat-model philosophy, Veilid transport, accessibility roadmap), see
docs/current-arch/ARCHITECTURE.md— that document is the locked design reference. When this file andcurrent-arch/disagree, this file is reality andcurrent-arch/is intent.
- Workspace Layout
- Dependency Graph
- Boot Sequence
- Foundational Crates
- Connector Framework
- Bot Runtime & Cooperation
- AI Adapters
- MCP Bridge
- HTTP API Surface
- CLI Surface
- Storage Schema
- Frontend
- Data Flow
Companion: SECURITY.md, AUDIT-NOTES.md.
Springtale/
├── crates/ # Pure Rust library crates
│ ├── springtale-core/ # rules, pipelines, router, transforms, canvas types
│ ├── springtale-crypto/ # vault (KDF, AEAD, duress), signatures
│ ├── springtale-transport/ # Transport trait + Local/Http/Veilid impls
│ ├── springtale-store/ # SQLite backend + declarative schema (`user_version` v1)
│ ├── springtale-scheduler/ # cron, fs-watch, job queue, heartbeat, backoff
│ ├── springtale-connector/ # native + WASM connector framework, capability checker,
│ │ # subscription lifecycle
│ ├── springtale-ai/ # AiAdapter + Anthropic/Ollama/OpenAI-compat/Noop + tool-calling
│ ├── springtale-mcp/ # rmcp 1.x bridge (stdio), split handler modules
│ ├── springtale-sentinel/ # behavioural monitor, toxic-pair detection
│ ├── springtale-cooperation/ # cooperation framework (40 pub modules, zero internal deps)
│ ├── springtale-runtime/ # shared init, dispatch, operations, approval gate,
│ │ # token quota, trigger lifecycle, extraction, embedded
│ │ # runtime, LiveFormationReader
│ ├── springtale-bot/ # runtime, router, conversation engine, colony commander,
│ │ # cooperation glue, orchestrator, handler, identity,
│ │ # memory, tool_runner, 14-step formation tick
│ ├── springtale-wit/ # WIT world for WASM Component Model embedding (G3)
│ ├── springtale-py/ # pyo3 Python bindings — cdylib + rlib (G3)
│ └── libsqlite3-sys-mc/ # vendored sqlite shim (SQLite3MultipleCiphers)
│
├── connectors/ # First-party connectors (all native Rust today)
│ ├── connector-bluesky connector-browser connector-discord
│ ├── connector-filesystem connector-github connector-http
│ ├── connector-irc connector-kick connector-matrix (deferred)
│ ├── connector-nostr connector-opencode connector-presearch
│ ├── connector-shell connector-signal connector-slack
│ ├── connector-telegram
│
├── apps/
│ ├── springtaled/ # Daemon — axum HTTP API, boot, scheduler wiring
│ └── springtale-cli/ # clap-based CLI
│
├── tauri/ # Excluded from workspace (own dep tree)
│ ├── packages/types/ # shared TS types
│ ├── packages/ui/ # shared SolidJS components + DataProvider interface
│ ├── apps/desktop/ # Tauri 2 desktop shell
│ └── apps/dashboard/ # SPA served by springtaled
│
├── sdk/connector-sdk/ # Excluded — wasm32-unknown-unknown target
└── docs/
├── current-arch/ # Locked design intent (do not edit)
├── intended-arch/ # Includes COOPERATION.md spec
└── arch/ # ← this folder (as-built)
Fig. 1. Workspace tree.
Workspace members are declared in Cargo.toml:1-40. matrix-sdk is
held due to CVE-2025-70873 in its pinned rusqlite 0.37; Springtale uses
rusqlite 0.39.
springtale-core springtale-cooperation
/ | \ (zero-dep peer)
/ | \ │
springtale-store springtale-crypto │
| | │
| +-----------+ │
| | | │
springtale-scheduler springtale-transport │
| | │
+-------+---------+ │
| │
springtale-connector ──── springtale-sentinel │
| │
springtale-ai springtale-mcp │
\ / │
\ / │
springtale-runtime ───────────────────┤
| │
springtale-bot ──────────────────────┘
|
springtaled
|
springtale-cli
Fig. 2. Crate dependency graph. springtale-cooperation is a zero-dep
peer consumed by runtime, bot, and (for schema) store.
Dependencies flow downward only. No circular edges. Rule enforced by
.claude/rules/backend/crate-structure.md.
springtaled boot is a 9-step ordered pipeline split between the daemon
and the shared runtime crate.
main.rs (apps/springtaled/src/main.rs)
│
├─[1] Install rustls::crypto::ring provider main.rs:13
├─[2] tracing_subscriber init (EnvFilter) main.rs:16
├─[3] config::load_config() → LoadedConfig main.rs:23
└─[4] runtime::boot(config, connector_configs) main.rs:32
│
│ apps/springtaled/src/runtime/boot/mod.rs:20-167
│
├─[1] Log + 0.0.0.0 bind warning boot/mod.rs:25
├─[2] init_crypto() boot/crypto.rs:8
│ vault OR ephemeral, keypair,
│ api_token_hash, db_key_hex
├─[3] springtale_runtime::init(&RuntimeConfig) init.rs:28-78
│ store, RuleEngine, WasmEngine (+ epoch
│ ticker), ConnectorRegistry (inventory),
│ AiAdapter (ArcSwap), Sentinel, Canvas bus
├─[4] init_transport(keypair) boot/transport.rs
├─[5] init_schedulers() boot/schedulers.rs
│ CronExecutor, FsWatcher, HeartbeatMonitor
├─[6] init_job_queue() boot/queue.rs
├─[7] init_bot(wiring, msg channels, cooperation) boot/bot.rs
│ bot handle + per-connector shutdowns
│ (telegram, nostr, irc, discord, slack, signal)
│ FormationCommand channel installed; runtime
│ receives a LiveFormationReader impl backed by
│ the bot's formation set
├─[7c] data retention purge task (if configured) boot/mod.rs
│ spawned when [store] retention_days is set
├─[8] api::build_router(state) + bind boot/mod.rs
└─[9] mark `ready=true`, spawn API server
+ event_loop(trigger_rx, engine) boot/mod.rs:138
wait on shutdown_signal()
Fig. 3. 9-step boot pipeline.
Design notes
boot/crypto.rs(daemon-only) was split off from the deletedspringtale-runtime/src/boot.rs. Shared init now lives inspringtale-runtime/src/init.rs; daemon-specific crypto and bot wiring stays withspringtaled.- 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. - AI adapter is hot-swappable at runtime via
ArcSwap<Arc<dyn AiAdapter>>(state.rs:27). Config changes to/config/aiatomically replace the active adapter with zero locking on the read path.
core/src/
├── lib.rs # module declarations only
├── canvas/types.rs # CanvasState, CanvasBlock, CanvasUpdate
├── pipeline/
│ ├── stage.rs # Stage trait (async call → PipelineContext)
│ ├── context.rs # trace_id, input, output, errors, fuel_remaining
│ ├── compose.rs # compose_pipeline()
│ └── error.rs
├── router/dispatch.rs # dispatch_event(engine, event) → Vec<RuleMatch>
├── rule/
│ ├── engine.rs # RuleEngine (regex pre-compile at add_rule)
│ ├── action.rs # Action enum
│ ├── trigger.rs # Trigger enum
│ ├── condition.rs # Condition enum
│ ├── evaluate.rs # evaluate_condition()
│ ├── template.rs # ${trigger.field} interpolation
│ ├── parse.rs # TOML + NL parsing
│ └── types.rs # Rule, RuleId, RuleStatus, RuleVersion
└── transform/
├── extract.rs # field extraction
├── filter.rs # data filtering
└── format.rs # resolve_template()
Key types
| Type | File:Line | Notes |
|---|---|---|
Stage trait |
pipeline/stage.rs:12 |
async fn call(PipelineContext) -> Result<PipelineContext, PipelineError> |
RuleEngine |
rule/engine.rs:54 |
Pre-compiles regex at add_rule(); evaluation is pure |
Action |
rule/action.rs:21 |
RunConnector, SendMessage, WriteFile, RunShell, Notify, Chain, Transform |
Trigger |
rule/trigger.rs:7 |
Cron, FileWatch, Webhook, ConnectorEvent, SystemEvent |
CanvasState |
canvas/types.rs:57 |
Broadcast to SolidJS via SSE |
SQLite-only backend behind a StorageBackend trait. SqliteBackend
uses rusqlite 0.39 via SQLite3MultipleCiphers (ChaCha20-Poly1305
encryption at rest, WAL mode, 0o600 perms).
store/src/backend/
├── trait_.rs # StorageBackend trait
├── sqlite/
│ ├── mod.rs # SqliteBackend
│ ├── cooperation.rs # momentum / rally / mental_model queries
│ └── {rules, connectors, events, jobs, sessions, memory,
│ aliases, audit, safety, formations, execution, wasm}.rs
├── memory/ # in-memory impl (tests)
│ └── cooperation.rs # in-memory cooperation tables
└── wipe.rs # panic wipe hooks
See §11 Storage Schema for tables.
scheduler/src/
├── cron/executor.rs # CronExecutor (tokio-cron-scheduler)
├── watcher/fs_watcher.rs # FsWatcher (notify-rs)
├── heartbeat/monitor.rs # HeartbeatMonitor (liveness + TTL)
├── queue/
│ ├── producer.rs # JobProducer, Job, JobStatus
│ └── consumer.rs # JobConsumer (dequeue + retry)
└── retry/backoff.rs # BackoffConfig (exponential)
Job queue is in-memory today. JobProducer is an mpsc sender; the
schema for SQLite-backed jobs exists in schema/sql/jobs.sql but
StorageBackend::enqueue_job() is not yet wired. Producer comments note
the API is stable and only the backing changes. See AUDIT-NOTES §1.
transport/src/
├── transport/trait_.rs # Transport trait (send/recv/node_id/name)
├── local/unix_socket.rs # LocalTransport — present, Unix socket
├── http/server.rs # HttpTransport — present, rustls mTLS
├── crypto_provider.rs # installs rustls-post-quantum as process default
├── safe_http.rs # typed reqwest wrapper (rustls-only, no raw Client::new)
└── veilid/stub.rs # VeilidTransport — stub, returns NotConnected
Wire envelope is length-delimited JSON (WireMessage { sender: [u8;32], message: Message }). Message::MAX_MESSAGE_SIZE = 16 MiB. recv() is
cancel-safe for tokio::select!. VeilidTransport is a stub: every
method returns TransportError::NotConnected. The struct has a private
constructor and is currently a compile-time placeholder only.
crates/springtale-connector/src/connector/trait_.rs:27-82
#[async_trait]
pub trait Connector: Send + Sync {
fn triggers(&self) -> &[TriggerDecl];
fn actions(&self) -> &[ActionDecl];
async fn execute(&self, action: &str, input: Value) -> Result<ActionResult>;
async fn on_event(&self, trigger: &str, handler: Box<dyn EventHandler>);
fn manifest(&self) -> &ConnectorManifest;
async fn verify_webhook(&self, headers: &Headers, body: &[u8]) -> Result<()>;
// default: reject all
}Invariant (line 31): the capability layer wraps this trait; every
execute() is preceded by check_action_capabilities() in the dispatch
path. Connectors cannot skip it.
Manifest declares User policy Runtime check
───────────────── ─────────── ─────────────
[[capabilities]] CapabilityPolicy: On each execute():
type = NetworkOutbound • AllowAll 1. Try per-action
host = "api.kick.com" • DenyAll inference:
• AllowList(set) input["host"] → NetworkOutbound
[[capabilities]] • Interactive (default) input["path"] → Fs{Read,Write}
type = ShellExec (ShellExec always input["command"] → ShellExec
holds pending 2. Fallback: check ALL declared
user approval) 3. Error → dispatch aborts
Fig. 4. Capability declaration → verification → runtime check.
File refs: manifest/types.rs:10-70, manifest/verify.rs:11-71,
capability/grant.rs:60-143, native/capability.rs:20-42.
Wildcard NetworkOutbound hosts are rejected at manifest validation
(verify.rs:56-61). Signatures are Ed25519 over canonical JSON (all
manifest fields except signature).
Arc<dyn ConnectorHost>
┌─────────┴──────────┐
│ │
NativeConnectorHost WasmConnectorHost
(in-process) (Wasmtime sandbox)
no sandbox 64 MB mem, 10 M fuel,
trait object epoch timeout,
#[forbid(unsafe)] per-invocation Store
Fig. 5. Two execution models behind one ConnectorHost trait.
Native path (native/runtime.rs:16-97): wraps Box<dyn Connector>,
runs execute_checked() which calls check_action_capabilities() then
delegates. All 15 first-party connectors ride this path.
WASM path (wasm/connector.rs:45-301): per-invocation Store with
fresh fuel budget and epoch deadline. ABI: guest exports
execute(action_ptr, action_len, input_ptr, input_len) -> i32 with
length-prefixed JSON in linear memory at offset 1024. Host functions
(currently only http_request) gate through CapabilityChecker::check()
in wasm/host_api.rs. SHA-256 of the wasm bytes is verified against
manifest.wasm_hash before module load (wasm/connector.rs:70).
Engine config (wasm/runtime.rs:23-56):
| Setting | Value |
|---|---|
consume_fuel |
true |
epoch_interruption |
true |
cranelift_opt_level |
Speed |
| memory limit | limits.memory_bytes (64 MB default) |
| instance cap | 10 |
| table cap | 10 |
| memory cap | 2 |
No WASM connector exists today. All 15 first-party connectors ride
the native path. The Wasmtime host, capability gate, SHA-256 integrity
check, SDK, and per-invocation limits are all built and tested — but
no first-party or community connector actually rides the sandbox. The
SDK under sdk/connector-sdk/ is usable for authors targeting
wasm32-unknown-unknown.
registry/store.rs:25-137. In-memory HashMap<String, ConnectorEntry>.
Persistence lives at the application layer via springtale-store
(connectors table, schema/sql/connectors.sql). get_for_execute() (line 96-112)
returns a cloned Arc<dyn ConnectorHost> + cloned capability checker so
network calls don't hold the registry RwLock.
All 15 are native Rust. Matrix is workspace-excluded (deferred).
| Name | Transport | Notable triggers | Notable actions |
|---|---|---|---|
| bluesky | ATProto + Jetstream | note_received, repost, like | create_post, reply, like, repost |
| browser | Chromium (WASM) | dom_element_found, nav_complete | click, fill, navigate, screenshot |
| discord | twilight gateway | interaction_received | send_message, send_embed |
| filesystem | inotify | file_{created,modified,deleted} | read_file, write_file, list_dir |
| github | REST v3 + webhooks | push, pr_opened, issue_opened | create_issue, post_comment, create_branch, commit_file, create_pr |
| http | reqwest | — | GET, POST |
| irc | native IRC | channel_message, user_joined | send_message, join, part |
| kick | OAuth 2.1 + REST | stream_live, chat_message | send_message, start_raid, ban |
| nostr | NIP-44 relays | note_received, dm_received | send_note, send_dm |
| opencode | HTTP to local opencode serve |
— (action-only) | run_task, continue_session |
| presearch | REST | query_results_received | search, get_trending |
| shell | OS exec | command_exit_received | execute_command/script |
| signal | signal-cli bridge | message_received | send_message, group_invite |
| slack | Socket Mode + webhooks | slash_command, app_mention | send_message, send_blocks |
| telegram | Bot API poll/webhook | message_received, callback_query | send_message, send_photo |
The cooperation architecture has two parts:
crates/springtale-cooperation/— the crate with 40 pub modules, zero internal Springtale deps. Types, traits, and algorithms.crates/springtale-bot/src/cooperation/— the glue. Holds the liveFormationstruct (mutable runtime fields likeactive_task,fuel,liveness) and the blackboard.
crates/springtale-bot/src/runtime/event_loop.rs. Four-way
tokio::select!:
┌─────────────────────────────────────┐
│ Bot::run_event_loop() │
└──────────────┬──────────────────────┘
│
┌────────────────┼────────────────┬──────────────────┐
│ │ │ │
connector_rx rule_rx cadence_rx formation_cmd_rx
(chat messages) (triggers) (Tick broadcast) (FormationCommand)
│ │ │ │
handle_incoming handle_trigger handle_cadence_tick handle_formation_
│ │ │ │ command
router dispatch engine evaluate 14-step tick deploy/pause/
│ │ pipeline resume/dissolve/
└──────┬─────────┴─────────────┬──┘ rally/intent/
▼ ▼ members
registry.execute() per-formation work
Fig. 6. Bot four-way event-loop select.
Design constraint at event_loop.rs: never ? individual message
processing — a single bad message must never crash the bot. The
formation_cmd_rx branch is the only code path that materialises
live Formation structs from DB rows.
springtale-cooperation/src/ ── zero internal Springtale deps
├── lib.rs re-exports the public API
├── cadence.rs §5 Tick bus, AgentId, TickReport
├── momentum.rs / momentum/ §7 Cold/Warming/Hot/Fever gate
├── awareness/ §8 LocalAwareness, gossip substrate
│ ├── store/ (InMemory + chitchat)
│ └── swim/ (SWIM liveness)
├── attention/ §9 AttentionEconomy (zero-sum)
├── state/ §10 Workspace + SharedEnvironment
├── consensus.rs §11 Ballot, weighted voting
├── commit.rs §12 CommitPhase barrier
├── interference/ §13 4 kinds of conflict detection
├── transformation/ §14 Role change on capability loss
├── rally/ §15 Self-healing + supervise drain
├── capability/ §16 DynamicCapabilitySet (4 layers)
├── recovery/ §18 DistressSignal → helper
├── comms/ §19 Bus, dispatcher, state broadcast
├── handoff/ §20 Direct / flex-chain / sequential
├── mental_model/ §21 Learned domain knowledge
├── pacing/ §22 Work/rest (GCRA, L4D Director)
├── sacrifice/ §24 Deliberate self-cost
├── contract_net/ CNP (CFP / bid / award)
├── routing/ L1/L3 task routing
├── stigmergy/ L0 ambient surfaces
├── supervision/ Erlang OTP + K8s probes
├── role/ DynamicRoleTrait
├── authority/ momentum × layer perm matrix
├── agent/ per-agent 5-step loop
├── action.rs, action_state.rs SubTask + ActiveTask state
├── tick_processor.rs per-tick aggregation
├── dissemination/ L2 state dissemination
├── peer.rs PeerMsg protocol
├── replan/ CBBA global re-plan
├── utility/ utility AI scoring
├── layer.rs 7-layer routing abstraction
├── context.rs FormationContext (read-only share)
├── command.rs FormationCommand (runtime → bot)
├── types.rs FormationId, AgentHealth, etc.
└── error/ typed errors per concern
springtale-bot::runtime::event_loop::handle_cadence_tick:
1. per-agent loop (sense / scan / react / respond_cfp / inbox)
1b. drain async tick reports from cadence reports channel
2. tick_processor (action records, interference)
2b. rally::supervise::drain (member outcomes → rally events)
3. momentum.check_decay (inactivity)
4. momentum update (success / interference / failure)
4a. record_activity (only when real actions happened)
4b. consecutive_failures per member
4c. liveness (Alive / Suspect / Down)
4d. supervisor.check_member → SupervisionAction
4e. per-member fuel consumption
4f. publish ImplicitSignal (Overcooked — peers watch bus)
4g. broadcast_state on health threshold (L4D "I'm hurt")
4h. signal_cohesion on momentum tier change (Rock-and-Stone)
5. persist momentum row → SQLite
6. formation.broadcast_context (FormationContext watchers)
7. update_member_awareness (gossip publish + snapshot)
7b. log interference events
8. pacing.evaluate_transition (phase change)
9. cascade::detect_cascade + attempt_self_rally
9b. recovery::evaluate_recovery per distress signal
10. role transformation for failing members
11. consensus.check_deadlines
12. expire completed / timed-out commit barriers
13. mental_model::learning::update_model
14. orchestrate_formation (Fever tier + can_orchestrate())
Step 14 decomposes intent into sub-tasks, posts them to the blackboard
under task:* keys; members pull via step 1's scan phase. Then
remove_dead_members() reclaims slots and formations.retain(is_viable)
prunes exhausted formations.
The core differentiator: momentum tiers gate runtime capabilities.
| Tier | Successes | env read | neighbours | env write | commit | consensus | AI | recruit |
|---|---|---|---|---|---|---|---|---|
| Cold | 0 | ✓ | — | — | — | — | — | — |
| Warming | ≥3 | ✓ | ✓ | — | — | — | — | — |
| Hot | ≥8, 0 interference | ✓ | ✓ | ✓ | ✓ | — | — | — |
| Fever | ≥15, 0 interference | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Fig. 7. Momentum tiers gate runtime capabilities.
Source: springtale-cooperation/src/momentum.rs. Gates are enforced via
methods like can_write_environment(), can_use_ai(),
can_consensus() — called before the corresponding action. Cold
formations physically cannot reach the AI adapter because the dispatch
predicate fails.
pub struct Formation { // bot/cooperation/formation.rs
pub id: FormationId,
pub members: Vec<FormationMember>, // peers, no hierarchy
pub intent: IntentPattern,
pub momentum: MomentumState,
pub blackboard: CooperativeBlackboard,
pub shared_env: SharedEnvironment,
pub rally: FormationRally,
pub attention_broker: AttentionBroker,
pub gossip_store: Arc<dyn GossipStore>, // InMemory or Chitchat
pub mental_model: SharedMentalModel,
pub consensus: ConsensusEngine,
pub pacing: PacingManager,
pub supervisor: FormationSupervisor,
pub bus: CommsBus,
pub fuel: FuelBudget,
pub paused: bool,
// orchestrator is looked up from bot.registry at tick time
}springtale-runtime::LiveFormationReader is how the Tauri IPC commands
and the dashboard HTTP API read enriched formation state (momentum,
rally tokens, attention load, guard status, member health, liveness)
from the running bot. The bot installs an impl backed by its formation
set during step 7 of boot; the runtime holds the reader through an
Arc<dyn LiveFormationReader> so UI code paths never touch the bot's
internal types.
Two domain SQL files back the cooperation framework:
schema/sql/formations.sql—formations,formation_members,formation_momentum,formation_rallyschema/sql/cooperation.sql—coop_writes,coop_deposits,mental_model_{domain, capability, pattern, vocabulary, convention}
Momentum and rally are persisted every tick. Mental model is persisted on formation dissolve so later formations with the same id benefit from what prior instances learned.
A separate subsystem under crates/springtale-runtime/src/operations/recipes/
that wraps the cooperation/connector/rule/AI primitives in click-and-play
"recipes" — blueprints the UI deploys without TOML editing.
recipes/
├── types.rs Recipe, RecipeCategory, FieldVisibility, FieldKind,
│ RecipeSource (Builtin / User / Community), RecipeBlueprint
├── builtin.rs Built-in recipe catalogue — shipped in the binary
├── builtin/ One file per recipe
├── library.rs Server-side list / filter / sort. Built-ins + user merge
├── apply.rs apply_recipe() — substitute ${input_id} placeholders,
│ call existing connector/rule/AI ops, return ApplyReport
├── authoring.rs W2.B field-classification helpers (UI-side authoring)
├── pieces.rs Modular recipe pieces (sub-blueprints composed at apply)
└── mod.rs
Architecture invariant — backend owns the decisions. Every "is this
required vs optional vs advanced vs baked" decision lives in the
Recipe value the backend returns. Frontends render what they're told;
they never invent categories or classify fields. Same shape feeds
desktop IPC, dashboard HTTP, and any future surface. See
feedback_thin_frontend_modular_backend and
feedback_zero_frontend_logic.
Wire: 16 endpoints under /recipes/* (see §9 — Recipes group).
Storage status:
- Built-in recipes compile into the binary; no table needed.
recipes_userSQLite table is planned, not yet shipped —library::load_user_recipes()returnsOk(Vec::new())until the schema lands. User-saved recipes (W2.B) are wire-shaped via the/recipes/user,/recipes/user/{id},/recipes/importendpoints but returnOperationError::NotSupporteduntil storage ships.- Community recipes (
RecipeSource::Community { author, signature }) are wire-shape only; sentinel signature verification + trusted-author flow (W3.A) lights up when the marketplace lands.
W-series milestones in flight:
| Milestone | Concern | Status |
|---|---|---|
| W1.C | Progressive-disclosure deploy form (Required → Optional → Advanced) | Shipped |
| W1.D | Preflight + Deploy-button gating | Shipped |
| W1.F | Approval-gate UX dispatcher (Tauri side) | Shipped — see §10.2 below |
| W2.B | User-recipe authoring + storage | Partial (UI shipped, table pending) |
| W3.A | Community signature verification + trust badges | Wire-shape only |
User-facing: docs/guide/recipes.md. Format:
docs/reference/recipes-format.md.
Per-chain-fire observability that the legacy tables couldn't answer.
crates/springtale-runtime/src/operations/executions/:
recorder.rs—ExecutionRecordertrait +StoreRecorder+NoopRecorder. Dispatcher callsrecord_start()/record_step()/record_finish().query.rs— list / steps / vacuum.drift.rs—recipe_drift()/rule_drift()→DriftReport { latency, rate, classification }.DriftClass: Stable | Improving | Degrading | Volatile.
Persistence: two new tables in schema/sql/executions.sql:
executions— ULID PK, per-fire row withagent_id,formation_id,momentum_tier,mode(Normal / DryRun),status(running / success / error / empty / suppressed),error_kind(enum tag),summary_bytes.execution_steps— per-step row withinput_bytes,output_bytes,error_kind.
Privacy posture: sizes-only by default. No payload content. error_kind is an enum tag. Default 14-day retention swept hourly. Content retention is Phase C, opt-in, separate.
Cooperation envelope: springtale-cooperation::execution::ExecutionContext carries execution_id (ULID), agent_id, formation_id, momentum_tier, rule_id, mode. Threaded through every dispatch so the executions log scopes per (formation, agent, tier).
User-facing: docs/guide/executions-and-drift.md.
The formation's mental-model extension that stores discovered chat destinations. Per-formation, gossip-replicated within a formation.
- Schema:
schema/sql/mental_model_workspaces.sql. Row keyed(formation_id, workspace_key). Storesdisplay_name,kind,metadata_json,first_seen_at,last_seen_at. No message bodies, no member rosters — names only. - Workspace key URIs:
telegram://chat/12345,discord://guild/G/channel/C, etc. Parsed incrates/springtale-connector/src/workspace_key.rs. Each connector owns its scheme. - Harvester:
springtale-runtime::operations::workspaces::harvester::harvest_eventruns on every dispatched event, calls the connector'sMentionExtractor::extract_destinations(), upserts into the table. MentionExtractortrait lives incrates/springtale-connector/src/mention.rs. Each messaging connector implements it. Pure function — no async, no I/O.
Within-formation gossip replicates new rows across members via the
chitchat substrate. Conflict resolution in
crates/springtale-cooperation/src/mental_model/external_workspaces.rs::merge_gossip_delta.
User-facing: docs/guide/external-workspaces.md.
Two Action variants in springtale-core::rule::action that
together make polling recipes practical.
Action::Extract { source, kind }— parse bytes into structured data.ExtractKindvariants:Readability(article body),Css { schema }(selectors),JsonPath { schema }(RFC 9535),Feed(RSS/Atom/JSON Feed),Ical { window_days },LlmSchema { schema }(structured AI output),Passthrough.Action::Dedupe { key, bucket, history }— short-circuit the chain when the key has been seen. Plaintext key never persisted; blake3 hex digest stored indedupe_seentable. Bounded tohistoryentries per bucket (default 10,000) with LRU prune.
Scoping per Phase 0.4 cooperation alignment: dedupe_seen is keyed by (formation_id, rule_id, bucket, key_hash). formation_id NULL = global rule; NOT NULL = formation-scoped. Two formations running the same rule see independent dedupe state. Schema: schema/sql/dedupe.sql.
Hit path: chain returns ChainError::Suppressed, execution row gets status = "empty" (not failed). Drift detector distinguishes empty from error.
User-facing: docs/guide/dedupe-and-extract.md.
Authoring-time helpers under crates/springtale-runtime/src/operations/:
preflight/(W1.D) — checks the recipe's preconditions before Deploy.PreflightReport { items, deployable }. Checks include required inputs, input format, connector loaded/capable, AI config, structured outputs support, host allow-list, cron sanity.preview.rs(W2.C) — fresh in-memoryRuleEngine, fires synthetic trigger, returnsPreviewReport { steps }. No side effects.test_step.rs(W2.C / Phase C) — runs chain inExecutionMode::DryRunup to a single target step, returns recordedStepOutput. Read-only arms run for real; side-effecting arms stubbed. Persisted to executions log withmode = "dry_run".
UI: PreflightChecklist, PreviewPanel, TestStepButton,
SelectorPickerOverlay. The selector picker opens a Tauri webview at
the recipe's target URL, injects picker.js, returns the chosen CSS
selector (authoring-time only, not a headless-browser feature).
User-facing: docs/guide/recipe-authoring-tools.md.
crates/springtale-ai/src/.
AiAdapter trait (adapter/trait_.rs)
│
├── complete(AiRequest) → Result<String, AiError>
├── complete_with_tools(...) → tool-calling loop entry (ToolCall / ToolResult)
├── stream(AiRequest) → Result<AiStream, AiError> (SSE / NDJSON deltas)
├── parse_rule(String) → Result<Rule, AiError>
├── is_available() -> bool
└── structured_extractor() → Option<&dyn StructuredExtractor> (schema-constrained JSON)
AiStream = Pin<Box<dyn futures_core::Stream<Item = Result<StreamChunk, AiError>> + Send>>
Adapters are wrapped in GuardrailAdapter (src/guardrail/) at boot:
wall-clock timeout fence, output size cap, refusal-rate counters, and a
per-bot daily token quota behind the TokenQuota trait (SQLite-backed
impl in springtale-runtime::quota, persisted in ai_token_usage).
| Adapter | Streaming | Status |
|---|---|---|
AnthropicAdapter |
✓ (SSE, Claude Sonnet 4) | Full |
OllamaAdapter |
✓ (NDJSON) | Full |
OpenAiCompatAdapter |
✓ (SSE) | Streaming for text deltas; tool calling via complete_with_tools() |
NoopAdapter |
— | AiError::Disabled for everything |
Factory at factory.rs:16-38 — selection priority Anthropic → OpenAI →
Ollama → Noop. Hot-swapped at runtime via ArcSwap<Arc<dyn AiAdapter>>
on the RuntimeState (state.rs). POST /config/ai replaces the active
adapter atomically.
Two layers before every request:
- Compile-time:
AiRequestis a closed enum with concreteStringfields. Secrets can't be passed through the type system. - Runtime:
Sanitizer(sanitize/sanitizer.rs) scans for PII (SSN/CC/phone/email), credentials (sk-/pk-prefixes, bearer tokens), prompt injection patterns from the OWASP LLM Cheat Sheet, excessive length (>10k chars), and suspicious base64 blobs. Policy modes:Warn(default),Redact,Block.
Called from all three production adapters before sending (e.g.
OllamaAdapter:34, OpenAiCompatAdapter:57, AnthropicAdapter:67).
crates/springtale-mcp/. Built on rmcp 1.x.
ConnectorMcpServer (server/builder.rs:34)
├── connector: Arc<dyn Connector>
├── capability_checker
└── tools: cached at construction from connector.actions()
list_tools(): if ALL capabilities approved → return tools
else → return [] (defense-in-depth discovery filter)
call_tool(name, args):
1. JSON Schema validate args against action.input_schema (jsonschema crate)
2. check_action_capabilities() (re-check, not just trust list_tools)
3. connector.execute(name, args)
4. Return ActionResult → MCP tool result
Fig. 8. MCP bridge. Capabilities are re-checked at both list_tools and call_tool — MCP does not bypass the sandbox.
Transport: stdio only. start_stdio_server() reads JSON-RPC from
stdin, writes to stdout. No HTTP or SSE MCP transport is wired. Exit on
stdin EOF.
Defined in apps/springtaled/src/api/. Router built at api/mod.rs:93.
All authenticated routes require Authorization: Bearer <token> or
?token= query param (SSE fallback). Middleware stack: rate limit
(default 100 req/s, tower::limit::RateLimitLayer), 1 MiB body cap,
30 s timeout, CSP headers, X-Frame-Options: DENY.
Public routes
| Method | Path | Handler |
|---|---|---|
| GET | /health |
health::health |
| GET | /ready |
health::ready |
| GET | /ui, /ui/*path |
dashboard::serve_* (embedded SPA) |
Authenticated routes (grouped). Webhook routes live here — they require the same bearer token as every other authenticated endpoint. Each connector performs its own signature check on the body via Connector::verify_webhook().
| Group | Routes |
|---|---|
| Connectors | GET /connectors, /connectors/schemas, /connectors/available; POST /connectors/setup, /connectors/install; DELETE /{name}, /{name}/cascade; GET /{name}/config, /{name}/outputs; POST /{name}/enable, /{name}/disable, /{name}/test, /{name}/upsert-config, /{name}/reload (G4 hot-reload) |
| Rules | `GET |
| Formations | `GET |
| Cooperation | GET /cooperation/events (SSE — formation lifecycle, momentum, rally, interference events) |
| Agents | GET /agents/states; `GET |
| Canvas | GET /canvas, /canvas/connections; GET /canvas/stream (SSE). Canvas is read-only over HTTP — layout writes go through the Tauri IPC layer, not the daemon API. |
| Events | GET /events; GET /events/stream (SSE) |
| Config | `GET |
| Authors | GET /authors; `POST |
| Bot admin | `GET /bot/{status |
| Sessions | GET /sessions |
| Memory | POST /memory/audit, /memory/compact |
| Safety | `GET |
| Data | POST /data/export. Import is CLI-only (springtale-cli data import) — runs offline against the local SQLite backend. |
| Send | POST /send (capability-gated direct action dispatch) |
| Diagnostics | GET /diagnostics (Doctor flow) |
| Fixes | GET /fixes, GET /fixes/{id}, POST /fixes/{id}/apply |
| Onboarding | GET /onboarding/platforms; POST /onboarding/{platform} |
| Templates | GET /templates; POST /templates/{name} |
| Recipes | GET /recipes, /recipes/categories, /recipes/{id}, /recipes/{id}/pieces, /recipes/{id}/export; `POST /recipes/{id}/{favorite |
| Webhooks | POST /webhook/{connector}/{trigger} |
CSRF protection middleware (require_csrf_protection) sits in front of
all authenticated state-changing methods.
Full auth + middleware + response header details in SECURITY.md §7.
apps/springtale-cli/src/cli.rs. Global --json flag switches
human-readable tables to JSON.
springtale
├── init create ~/.local/share/springtale
├── new <template> scaffold from starter template
├── server start run daemon inline (dev)
├── doctor diagnostic checks (same as /diagnostics)
├── fix <error-id> apply auto-repair (same as /fixes/apply)
├── trace [--connector --rule] real-time execution trace
├── panic emergency wipe (no confirm)
│
├── connector
│ ├── list
│ ├── enable <name>
│ ├── disable <name>
│ ├── remove <name>
│ └── install <path> verify signature + manifest
│
├── rule
│ ├── list
│ ├── toggle <id>
│ ├── add <file>
│ ├── run <id> dry-run against synthetic event
│ ├── update <id> <file>
│ └── delete <id>
│
├── events [--limit N] [--connector NAME]
│
├── vault duress-setup
├── crypto rotate-vault-key
├── bot
│ ├── pair-init generate pairing code
│ └── panic-unpair revoke all paired users
│
├── travel
│ ├── prepare --backup-to <path>
│ └── restore --from <path>
│
├── memory
│ ├── audit
│ └── compact [--max-entries N]
│
├── data
│ ├── export [--output <path>] [--encrypt]
│ ├── import --input <path>
│ └── purge
│
└── agent set-autonomy <name> <level>
Command handlers live in apps/springtale-cli/src/commands/*.
The schema is declarative, not migration-driven. One canonical
DDL file per domain lives under
crates/springtale-store/src/schema/sql/, applied as a single
transaction at backend open. PRAGMA user_version fingerprints the
schema; old dev DBs carrying the pre-launch _migrations marker are
auto-wiped and rebuilt.
| Domain file | Tables / purpose |
|---|---|
connectors.sql |
connectors (installed connectors + manifest JSON) |
rules.sql |
rules (incl. activation_error for /diagnostics) |
events.sql |
events (timeline metadata) |
jobs.sql |
jobs (queue schema; in-memory mpsc today) |
bot.sql |
bot_sessions, user_prefs, bot_memory (encrypted BLOB), bot_aliases |
audit.sql |
audit_trail (append-only, 3 indices) |
safety.sql |
safety_config (single row, disguise + panic_tap defaults) |
formations.sql |
formations, formation_members, formation_momentum, formation_rally |
runtime_config.sql |
config_store (KV, UI-driven runtime config, seeded defaults) |
execution.sql |
execution_results (capped at 100/connector; legacy) |
executions.sql |
executions, execution_steps (Phase B per-chain-fire observability) |
wasm.sql |
wasm_binaries (content-addressed, SHA-256 + Ed25519 sig) |
cooperation.sql |
coop_writes, coop_deposits, mental_model_{domain, capability, pattern, vocabulary, convention} |
mental_model_workspaces.sql |
mental_model_workspaces (D1 discovered chat destinations) |
dedupe.sql |
dedupe_seen (Action::Dedupe blake3 key digests) |
approvals.sql |
pending_approvals, tool_loop_checkpoints (ShellExec approval gate + resumable tool loops) |
ai_token_usage.sql |
ai_token_usage (per-bot daily token counters) |
Schema-apply ordering and the SCHEMA_VERSION constant live in
schema/apply.rs; bump the constant when DDL shape changes.
Notes
bot_memoryis encrypted at rest (content_encrypted BLOB,nonce BLOB) but not compressed.safety_configis forced single-row viaCHECK (id = 1).execution_resultsauto-prunes on insert (oldest dropped once a connector exceeds 100 rows).jobsschema is ready for persistent queues; the currentJobProducerstill uses in-memory mpsc (see AUDIT-NOTES §1).formation_momentumis upserted every tick;formation_rallyis upserted on every token consumption.mental_model_*tables are written on formation dissolve so later formations with the same id recover accumulated conventions, patterns, and vocabulary.- The whole DB file is encrypted at rest via SQLite3MultipleCiphers (ChaCha20-Poly1305), key derived from the vault passphrase.
tauri/ is workspace-excluded (pnpm + Tauri 2 + SolidJS 1.9 + Tailwind 4
- Vite 6).
tauri/
├── packages/
│ ├── types/ # TS types mirroring Rust schemas + ts-rs generated
│ │ # (FormationDelta, FormationOutcome, FormationStatus,
│ │ # FormationView under types/src/generated/)
│ └── ui/ # shared SolidJS components, DataProvider interface
│ └── src/
│ ├── Canvas.tsx # theme/provider-aware top-level wrapper
│ └── colony/ # ColonyShell, ColonyCanvas, Viewport,
│ # BottomPanel, TopBar, TeamBuilder,
│ # ConnectorConfigPanel, AiConfigPanel,
│ # AppSettingsPanel; geometry + mappers;
│ # colony.css + sprites.css.
│ # Plus the G-series + W-series overlays:
│ # ApprovalCard (W1.F),
│ # EventRibbon (G6 cross-formation),
│ # MemberPickerOverlay, ModeSelectOverlay,
│ # PreflightChecklist (W1.D),
│ # PreviewPanel (W2.C),
│ # ProofOfLifePanel, RuleBuilderOverlay,
│ # SafetyPanel (disguise + quick-hide + panic-tap),
│ # RecipeAuthorPanel, RecipeCard,
│ # RecipeDeployPanel, RecipeLibraryOverlay,
│ # RecipeQuickView,
│ # AiSchemaEditor (Phase B structured AI),
│ # CronFrequencyChip, DeploySummaryModal,
│ # DriftBadge (Phase B drift trend),
│ # ExecutionsPanel (Phase B log viewer),
│ # SelectorPickerOverlay (web-recipe authoring),
│ # TestStepButton (W2.C single-step DryRun),
│ # WorkspaceTargetPicker (D1 external workspaces).
│ └── dashboard/ # context, model, query, types (DataProvider ~60 methods)
│
└── apps/
├── desktop/
│ ├── src/ # SolidJS UI
│ └── src-tauri/src/commands/ # 32 command modules:
│ # agent, approval (W1.F gate dispatcher),
│ # authors, bot, canvas, config, connectors,
│ # cooperation (G6 IPC + SSE),
│ # data, diagnostics, drift (Phase B trend),
│ # events, executions (Phase B log),
│ # fixes, formations, heartbeat, memory,
│ # onboarding, panic, quick_hide (G5g),
│ # recipes, rules, safety, selector_picker
│ # (recipe-authoring overlay), send,
│ # sessions, templates, test_step (W2.C
│ # single-step DryRun), travel,
│ # tray (G5f), vault, workspaces (D1);
│ # plus runtime_guard
│
└── dashboard/
└── src/provider.ts # HTTP + SSE DataProvider
Fig. 9. Frontend workspace layout.
DashboardState (SolidJS store)
▲
│
DataProvider (interface)
┌───────┴───────┐
│ │
DesktopProvider WebProvider
│ │
Tauri invoke() HTTP + SSE
│ │
src-tauri commands springtaled /api
│ │
└───────┬───────┘
▼
springtale-runtime (LiveFormationReader, operations)
Fig. 10. DataProvider abstraction. The frontend never calls the backend directly — always through the provider.
- ~60 async methods on
DataProvidercovering connectors, rules, events, formations (including rich live state viaLiveFormationReader), config, agents, canvas, memory, authors, data export, diagnostics, fixes, onboarding, templates, send. - Subscribe methods return
() => voidunsubscribe fns. - Desktop:
createDesktopProvider()wrapsinvoke(); real-time via Taurilisten("event-fired")andlisten("canvas-update"). - Web:
createWebProvider()wraps fetch +EventSourceon/events/stream?token=...and/canvas/stream?token=.... Token lives in the query param becauseEventSourcecannot set custom headers. - Rule from
.claude/rules/frontend/solidjs-conventions.md: components never callinvoke()directly — always through the provider.
The primary UI surface is an RTS-style ecosystem view:
- Connectors → pixel nodes
- Rules / agents → springtails (sprites near their node)
- Formations → zones (dashed ellipses)
- Pipelines → mycelium (SVG paths)
Live state flows through two channels:
/canvas/streamSSE →CanvasStatedelta updatesLiveFormationReader→ rich formation state (momentum tier, rally tokens, attention load, guard status, aggregate operational / load / fuel, member health + liveness)
Formation command grid (StarCraft-style 3×3): DEPLOY / PAUSE /
RESUME / RALLY / INTENT / GUARD / ADD MBR / RM MBR / REMOVE. Each
button maps to a /formations/* endpoint, which pushes a
FormationCommand onto the bot's command channel.
Formation detail card shows rally pips (Monster Hunter carts), attention distribution bar (Army of Two aggro meter), per-member health
- liveness icons (K8s-style probe states encoded via opacity).
Two CSS-only themes:
- Colony (forest) — original, Silkscreen font, soil palette
- Chiral diorama — Death-Stranding-inspired, default in Tauri desktop since April 2026
Switching themes changes zero backend behaviour. Theme selection lives
in AppSettingsPanel.tsx.
See ../guide/colony-canvas.md for a
user-facing tour.
Connector trigger (webhook / poll / fs-event / cron)
│
▼
TriggerEvent { trigger_type, connector, payload }
│
▼
RuleEngine::evaluate() → Vec<RuleMatch>
│ (pre-compiled regex, pure fn)
▼
router::dispatch_event()
│
▼
JobProducer::enqueue(Job { payload, max_attempts })
│ (mpsc today; SQLite-backed planned)
▼
JobConsumer::dequeue() → exponential backoff on failure
│
▼
dispatch_action(action, registry, sentinel) runtime/dispatch.rs
│
▼
sentinel.evaluate(action, connector) → Go | Throttle | Pause | Quarantine
│ (every action, no bypass)
▼
per-action branch
│ ├─ RunConnector → registry.execute(name, action, input)
│ │ → CapabilityChecker.check()
│ │ → NativeConnectorHost / WasmConnectorHost
│ ├─ WriteFile → filesystem with path capability
│ ├─ RunShell → shell connector (blocking approval)
│ ├─ SendMessage → chat connector
│ ├─ AiComplete → AiAdapter.complete() (sanitized)
│ ├─ Transform → template resolver
│ ├─ Chain → recursive dispatch_with_depth (max 15)
│ └─ Notify → canvas bus + events table
│
▼
sentinel.report(action, outcome) success | failure rows
▼
StorageBackend::log_event()
StorageBackend::complete_job()
│
▼
canvas_tx.send(CanvasUpdate) → SSE → DataProvider → SolidJS signal
Fig. 11. Event → action happy path. Sentinel evaluates every action before the per-action branch.
Connector chat message (Telegram/Discord/IRC/...)
│
▼
bot.connector_rx (mpsc into event_loop)
│
▼
handle_incoming_message()
│
▼
Router (router/{prefix,pattern,alias,fallback}.rs)
│
┌────┴────┐
▼ ▼
Prefix hit No match
(/search) (fallback)
│ │
▼ ▼
Handler Fallback AI
dispatch (Fever-gated; else "unknown command")
│ │
▼ ▼
ActionResult → response template → connector.execute(send_message)
Fig. 12. Chat message routing in the bot runtime.
CadenceBus::tick() (broadcast, generous window)
│
▼
bot.cadence_rx (per-bot receiver)
│
▼
handle_cadence_tick()
│
├─ for each Formation:
│ record_success() → momentum.try_promote()
│ persist momentum → config_store[momentum:{id}]
│ if can_orchestrate(): (Fever + orchestrator present)
│ AiAdapter.complete(intent_prompt)
│ parse subtasks → post to CooperativeBlackboard
│
└─ members pull subtasks from blackboard (pull, not push)
Fig. 13. Cadence tick → orchestrator → blackboard → members. Orchestrator is Fever-gated; Cold/Warming/Hot formations never reach the AI adapter.
End of ARCHITECTURE.md. See SECURITY.md for the
security posture audit and AUDIT-NOTES.md for known
drift, gaps, and in-flight work.