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

LayerDecisionWhere
ClientOurs, from scratch: Kotlin + Compose, our reducers, our state machines, JVM harnesses off-devicestaage-android
Wire (phone ↔ host)Net-new and ours — shaped for a client, not a browser. NDJSON, one stream, per-lane seqcore/src/main/kotlin/dev/staage/wire/
EngineOurs, 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 anythingdaemon/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:

  1. One stream carries all lanes. One process-wide stream; a view is a client-side predicate.
  2. Framing is NDJSON, not SSE. SSE's retry: and Last-Event-ID are browser affordances we cannot honour and do not need.
  3. 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.
  4. Rehydrate on attach is mandatory. The host keeps a bounded per-lane journal (dev.staage.loom) and answers snapshot.request; truncated = true when it cannot reach far enough.
  5. A ticket for the attached lane's duplex channel, scoped to one lane, short-lived, ttlMs — a duration, so no two clocks must agree.
  6. No clock, no randomness, no timestamps. Heartbeats carry a counter; every timestamp is injected.
  7. One stream, one journal, one store. The protocol owns seq; the store owns durable order. A restart replays lane_event into a fresh bus, so a new epoch is rehydrated from disk.
  8. Recency across threads is activity, not seq — seq is per-lane and therefore incomparable. The fold is attention first (waiting → working → unread → settled), then activity descending.
  9. The roster is the only lane enumeration. thread.roster is written once per attach with the exact heads stream.open carries; 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

RuleEnforced by
no clock, no randomness in artifactsscript/check-determinism.sh
the codec round-trips and refuses strictlyscript/check-wire.sh
the bearer is never a command linescript/check-hygiene.sh (§ARGV)
no shipped file names internal toolingscript/check-hygiene.sh (§HYGIENE)
one explicit store path per rolescript/check-store-roles.sh
the tool catalog and the implementations cannot driftone 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.