Architecture
Ståge is three programs and one contract. This page is the shape of the whole: the layers, the direction of every dependency, the wire, and the four rules that decide what may not change.
The layers
| Layer | Decision | Where |
|---|---|---|
| Client | Ours, from scratch: Kotlin + Compose, our reducers, our state machines, JVM harnesses off-device | staage-android |
| Wire (phone ↔ host) | Net-new and ours — shaped for a client, not a browser. NDJSON, one stream, per-lane seq | core/src/main/kotlin/dev/staage/wire/ |
| Engine | Ours, from scratch: store, bus, turn loop, surfaces. No dependency on the console app — nothing in :daemon reads its database, adapts its routes, or asks its engine anything | daemon/src/main/kotlin/ |
The consequence worth naming: the client never speaks to a third-party HTTP surface. It carries its own bearer to our surface (DaemonSurface), which is why an interactive-basic-auth hazard in the ancestor tooling stops constraining the design.
┌───────────────────────── :daemon ─────────────────────────┐
provider ───► │ TurnExecutor → TurnMachine → TurnTools → Toolset │
(Anthropic / │ │ │ │ │ │
OpenAI-compat) │ │ │ │ └─ rails │
│ ▼ ▼ ▼ (Bank, │
│ EventRecorder ApprovalGate Store(.kt) Librarian, │
│ │ │ (SQLite, one writer) Research)│
│ └────► LaneBus ─┴─────► DaemonSurface ◄── wire v7 │
└───────────────────────────┬──────────────────────────────┘
│ NDJSON: events, heartbeats, snapshots
┌────────────────────┴────────────────────┐
▼ ▼
:cli (same Reducer) staage-android :app
(view/ pure · ui/ Compose · live/ Android)
Dependency direction
:core ◄── :daemon ◄── (script/daemon-run/StaageDaemon.kt — the boot)
▲
└──── :cli :core has no dependency beyond the Kotlin stdlib
:daemon adds SQLite JDBC and nothing else of ours
staage-android does not read this repository's working tree. It pins a commit and includes the extracted copy as a Gradle composite build, substituting staage:core:
// staage-android/settings.gradle
includeBuild(pinTree) {
dependencySubstitution {
substitute module("${seamGroup}:core") using project(":core")
}
}
That is what makes the wire a contract rather than a coincidence: the phone is built against a named commit, and moving the pin is a deliberate act with a gate (script/check-core-pin.sh).
The wire, version 7
core/src/main/kotlin/dev/staage/wire/Wire.kt is the protocol as a typed artifact; WireCodec turns one message into exactly one canonical JSON line, and Json.kt is a hand-built canonical JSON model so the codec carries no dependency and no ambiguity.
const val VERSION: Int = 7
val USER_AGENT: String get() = "${Brand.ID}/$VERSION" // "staage/7"
Nine rules, and each one is load-bearing:
- One stream carries all lanes. One process-wide stream; a view is a client-side predicate.
- Framing is NDJSON, not SSE. SSE's
retry:andLast-Event-IDare browser affordances we cannot honour and do not need. - No replay — so sequence, and rehydrate. Per-lane monotonic
seq+epoch; the client detects that it must rehydrate by comparing the head at attach with what it holds. - Rehydrate on attach is mandatory. The host keeps a bounded per-lane journal (
dev.staage.loom) and answerssnapshot.request;truncated = truewhen it cannot reach far enough. - A ticket for the attached lane's duplex channel, scoped to one lane, short-lived,
ttlMs— a duration, so no two clocks must agree. - No clock, no randomness, no timestamps. Heartbeats carry a counter; every timestamp is injected.
- One stream, one journal, one store. The protocol owns
seq; the store owns durable order. A restart replayslane_eventinto a fresh bus, so a new epoch is rehydrated from disk. - Recency across threads is
activity, notseq—seqis per-lane and therefore incomparable. The fold is attention first (waiting → working → unread → settled), thenactivitydescending. - The roster is the only lane enumeration.
thread.rosteris written once per attach with the exact headsstream.opencarries; the reducer enrols a lane it has never seen, and a roster that drops a thread never deletes its transcript.
Message families: stream.open, thread.roster, snapshot.request / snapshot.page, prompt, event (blocks, tool calls, usage), approval.request / approval.answer, steer, abort, thread.create, thread.rename, thread.close, thread.archive, search, models, activity, heartbeat. The v3 additions live in wire/Roster.kt.
The engine's four mechanisms
The store — one writer. Db.kt holds the one connection and Store.kt the write paths; SQLite in WAL, keyset pagination on read, our schema and migrations from the first commit (Schema.kt). A restart replays into a fresh bus.
The bus. LaneBus.kt — in-process fan-out, no broker, no queue. Events are persisted then fanned out (EventRecorder.kt), in that order, so a subscriber can never see what the store does not hold.
The turn loop. TurnExecutor.kt drives TurnMachine.kt, a named state machine: every transition has a name, every refusal a class. Provider is the model seam (two real adapters: provider/Anthropic.kt, provider/OpenAiCompat.kt, both over provider/Sse.kt), Tool is the action seam, Approver is the consent seam. TurnTools.kt executes one assistant message's calls sequentially, in order, one result per call.
The surface. DaemonSurface.kt serves the wire; dev.staage.loom.LoomSurface is the same contract in :core for the client-side tests. Both refuse rather than improvise.
Determinism, the standing rule
No artifact reads a clock or a random number. Time arrives as a value (Clock.kt); ids are derived, not sampled; digests are one implementation (Digest.kt). script/check-determinism.sh proves the same input produces byte-identical output across two JVM processes, and greps the trees for a clock read so the rule is enforced, not remembered.
Where the rules live
| Rule | Enforced by |
|---|---|
| no clock, no randomness in artifacts | script/check-determinism.sh |
| the codec round-trips and refuses strictly | script/check-wire.sh |
| the bearer is never a command line | script/check-hygiene.sh (§ARGV) |
| no shipped file names internal tooling | script/check-hygiene.sh (§HYGIENE) |
| one explicit store path per role | script/check-store-roles.sh |
| the tool catalog and the implementations cannot drift | one object — Toolset |
These are gates, not conventions: each has a negative fixture, and a gate that cannot fail is not a gate. See Gates and proof.