Status: Reflects working tree · Updated: 2026-04-10 · 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 + 8 migrations
│ ├── springtale-scheduler/ # cron, fs-watch, job queue, heartbeat, backoff
│ ├── springtale-connector/ # native + WASM connector framework, capability checker
│ ├── springtale-ai/ # AiAdapter + Anthropic/Ollama/OpenAI-compat/Noop
│ ├── springtale-mcp/ # rmcp 1.x bridge (stdio)
│ ├── springtale-sentinel/ # behavioural monitor, toxic-pair detection
│ ├── springtale-runtime/ # shared init, dispatch, operations layer
│ ├── springtale-bot/ # runtime, router, cooperation, orchestrator, memory
│ └── libsqlite3-sys-mc/ # vendored sqlite shim
│
├── 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-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-store springtale-crypto (zero-dep peers)
| |
| +-----------+
| | |
springtale-scheduler springtale-transport
| |
+-------+---------+
|
springtale-connector ──── springtale-sentinel
|
springtale-ai springtale-mcp
\ /
\ /
springtale-runtime
|
springtale-bot
|
springtaled
|
springtale-cli
Fig. 2. Crate dependency graph.
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) boot/bot.rs
│ bot handle + per-connector shutdowns
│ (telegram, nostr, irc, discord, slack, signal)
├─[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 (40+ async methods).
SqliteBackend uses rusqlite 0.39 (WAL mode, 0o600 perms).
store/src/backend/
├── trait_.rs # StorageBackend trait
├── sqlite/
│ ├── mod.rs # SqliteBackend
│ ├── migrations.rs # apply_migrations()
│ └── {rules, connectors, events, jobs, sessions, memory,
│ aliases, audit, safety, formations, execution, wasm}.rs
├── memory/ # in-memory impl (tests)
└── 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 001_init.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
└── 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 14 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, migration 001). 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 |
| http | reqwest | webhook_received | GET/POST/PUT/DELETE |
| 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 |
| 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 |
crates/springtale-bot/src/runtime/event_loop.rs. Three-way
tokio::select!:
┌─────────────────────────────┐
│ Bot::event_loop() │
└──────────────┬──────────────┘
│
┌────────────────┼─────────────────┐
│ │ │
connector_rx rule_rx cadence_rx
(chat messages) (triggers) (CadenceBus ticks)
│ │ │
handle_incoming handle_trigger handle_cadence_tick
│ │ │
router dispatch engine evaluate for each formation:
│ │ • record_success
│ │ • persist momentum
│ │ • if Fever + orchestrator:
│ │ orchestrate_formation()
└────────┬───────┴─────────────────┘
▼
handler registry → connector action
Fig. 6. Bot three-way event-loop select.
Design constraint at event_loop.rs:8: never ? individual message
processing — a single bad message must never crash the bot.
crates/springtale-bot/src/cooperation/ — 20 files matching
docs/intended-arch/COOPERATION.md:
cooperation/
├── cadence.rs § 5 tokio broadcast Tick bus + IntentPattern
├── formation.rs § 6 peer group, FormationMember, DynamicRole
├── momentum.rs § 7 Cold/Warming/Hot/Fever + capability gates
├── awareness.rs § 8 LocalAwareness, NeighborSnapshot
├── attention.rs § 9 AttentionEconomy (zero-sum aggro)
├── environment.rs §10 SharedEnvironment, Surface, SurfaceType
├── consensus.rs §11 ConsensusVote, VoteResolution
├── commit.rs §12 CommitPhase enum (5-phase) ⚠ barrier stub
├── interference.rs §13 InterferenceEvent (4 types)
├── transformation.rs §14 RoleTransformation enum
├── rally.rs §15 RallyState, RallyResult ⚠ not invoked
├── capability.rs §16 DynamicCapabilitySet (4 layers)
├── recovery.rs §18 DistressSignal, RecoveryAction ⚠ not invoked
├── comms.rs §19 (not audited) ⚠ status TBD
├── handoff.rs §20 (not audited) ⚠ status TBD
├── mental_model.rs §21 SharedMentalModel, CooperationPattern
├── pacing.rs §22 (not audited) ⚠ status TBD
├── sacrifice.rs §24 SacrificeType, SacrificeCost ⚠ not invoked
├── action.rs SubTask, SubTaskResult
└── mod.rs declarations + re-exports
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: momentum.rs:16-134. 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 { // formation.rs:180
pub id: FormationId,
pub members: Vec<FormationMember>, // peers, no hierarchy
pub intent: IntentPattern, // Reconnoiter/Execute/...
pub constraints: FormationConstraints,
pub momentum: MomentumState,
pub environment: Arc<CooperativeBlackboard>, // dashmap workspace
pub fuel: FuelBudget, // shared budget
pub orchestrator: Option<Arc<dyn AiAdapter>>, // Fever-gated decomposer
}Orchestrator is called only when can_orchestrate() returns true (Fever
tier + orchestrator present), at which point it decomposes intent into
subtasks posted to the shared blackboard (orchestrate.rs:52-76).
Members pull from the blackboard (pull model, not push).
- Store: momentum persisted to
momentum:{id}config key after every successful tick (event_loop.rs:123-127). - Registry: bot owns
Arc<RwLock<ConnectorRegistry>>; orchestrator reads available actions per connector for decomposition context. - AI: two levels — per-formation orchestrator, per-member adapter.
Both behind the same
AiAdaptertrait, both Fever-gated.
See AUDIT-NOTES.md §3 for the list of cooperation
modules that are type-defined but not yet invoked from the hot path.
crates/springtale-ai/src/.
AiAdapter trait (adapter/trait_.rs:172)
│
├── complete(AiRequest) → Result<String, AiError>
├── stream(AiRequest) → Result<AiStream, AiError>
├── parse_rule(String) → Result<Rule, AiError>
└── is_available() -> bool
AiStream = Pin<Box<dyn futures_core::Stream<Item = Result<StreamChunk, AiError>> + Send>>
| Adapter | Streaming | Status |
|---|---|---|
AnthropicAdapter |
✓ (SSE, Claude Sonnet 4) | Full |
OllamaAdapter |
✓ (NDJSON) | Full |
OpenAiCompatAdapter |
✗ (stream() stubs error) |
Non-streaming only |
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 |
| Rules | `GET |
| Formations | `GET |
| Agents | GET /agents/states; `GET |
| Canvas | GET /canvas, /canvas/connections; POST /canvas/update; GET /canvas/stream (SSE) |
| 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 |
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
├── server start run daemon inline (dev)
├── panic emergency wipe
│
├── 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
│
├── travel
│ ├── prepare --backup-to <path>
│ └── restore --from <path>
│
├── memory
│ ├── audit
│ └── compact [--max-entries N]
│
├── data
│ ├── export [--output <path>] [--encrypt]
│ └── purge
│
└── agent set-autonomy <name> <level>
Command handlers live in apps/springtale-cli/src/commands/*.
Eight migrations under crates/springtale-store/src/migrations/:
| # | File | Tables / purpose |
|---|---|---|
| 001 | 001_init.sql |
_migrations, rules, connectors, events, jobs |
| 002 | 002_bot.sql |
bot_sessions, user_prefs, bot_memory (encrypted BLOB), bot_aliases |
| 003 | 003_sentinel.sql |
audit_trail (append-only, 3 indices) |
| 004 | 004_safety.sql |
safety_config (single row, disguise-first defaults) |
| 005 | 005_formations.sql |
formations, formation_members |
| 006 | 006_config.sql |
config_store (KV, UI-driven runtime config) |
| 007 | 007_execution_results.sql |
execution_results (capped at 100/connector) |
| 008 | 008_wasm_binaries.sql |
wasm_binaries (content-addressed, SHA-256 + Ed25519 sig) |
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).
tauri/ is workspace-excluded (pnpm + Tauri 2 + SolidJS 1.9 + Tailwind 4
- Vite 6).
tauri/
├── packages/
│ ├── types/ # TS types mirroring Rust schemas
│ └── ui/ # shared SolidJS components, DataProvider interface
│ └── src/dashboard/types.ts:48-135 # DataProvider (~60 methods)
│
└── apps/
├── desktop/
│ ├── src/ # SolidJS UI
│ └── src-tauri/src/commands/*.rs # IPC handlers → springtale-runtime
│
└── 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
Fig. 10. DataProvider abstraction. The frontend never calls the backend directly — always through the provider.
- ~60 async methods on
DataProvidercovering connectors, rules, events, formations, config, agents, canvas, memory, authors, data export. - 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.
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.