The client — staage-android
The phone. One Gradle module (:app), 134 Kotlin files in three packages that are not allowed to leak into each other.
| Package | Files | Rule |
|---|---|---|
view/ | 84 | pure Kotlin — no Android import at all. Contains no side effect: every decision the app makes, steps on a plain JVM |
ui/ | 36 | Compose only — draws what view/ decided |
live/ | 14 | the Android face: the socket, the filesystem, the cache, PackageInstaller |
The law is enforced, not documented: script/check-view.sh fails if view/ imports android.*. That is why the app's hard problems — attach, rehydrate, reorder, rename, the update flow — are steppable on a desk with no emulator.
Build facts
| Item | Value |
|---|---|
| module | :app, settings.gradle (include ":app"), namespace + applicationId dev.staage.app |
| SDK | compileSdk 35, minSdk 26, targetSdk 35, Java/JVM 17 |
| toolchain | AGP 8.7.3, Kotlin 2.0.21, Compose plugin 2.0.21, Compose BOM 2024.10.01 |
| engine | staage:core resolves to the pinned commit's extracted tree, through a composite build |
| version | version.properties — versionStep + versionDescriptor, one writer (bump.sh) |
The pin is the contract: :app never reads the engine's working tree. The pinned tree is extracted into .core-pin/<sha>/ by script/core-pin-tree.sh (gitignored, reused), and settings.gradle fails loudly if the pinned tree's rootProject.name no longer matches the substitution group — renaming the contract's root project breaks resolution, and the failure names that hazard instead of producing a mystery.
Features, as a person sees them
| Feature | What it is | view/ owner |
|---|---|---|
| The column | the sidebar of threads, dragged and swiped, each row a live reading of the fold | Sidebar.kt, ColumnProjection.kt, Order.kt |
| Per-thread paging | threads open as pages you swipe between, not as a stack of screens | ThreadPager.kt, ThreadProjection.kt, Swipe.kt |
| The strip | the chrome strip: state, usage, mode, the doors | Strip.kt, ChromeProjection.kt |
| Composer | the input, the outbox, and what happens to text typed offline | Composer.kt, Outbox.kt, OutboxProjection.kt |
| The mode — Absolute or Zen | a mode changes what asks a human; it never changes what is allowed. Per lane, default Absolute (the conservative one). Zen answers some approval cards itself; the daemon still blocks on the same approval row | Mode.kt |
| Approval cards | the effect class comes from the daemon's pinned descriptor, carried in the approval frame — so the card can say irreversible, not just approve? | Mode.kt, Projection.kt |
| Picker | choosing model / provider / target for a thread | PickerMachine.kt, PickerProjection.kt |
| Search | finding a thread or a sentence across the store | SearchMachine.kt, SearchProjection.kt |
| Rename | renaming a thread, with the same optimistic-then-honest shape as everything else | Rename.kt |
| Plan and desk | the turn's spine and the workdesk page, rendered where the reader already looks | Plan.kt, Notes.kt |
| Transcript blocks | text, reasoning, tool runs and usage as blocks, windowed | Blocks.kt, Transcript.kt, ToolRun.kt, Usage.kt, TurnLedger.kt |
| Self-update | the release shelf fetched (GET /v1/release, /v1/release/apk), verified by size + sha256, installed through PackageInstaller. Every decision is in view/UpdateFlow.kt; live/ only performs it | UpdateFlow.kt, Update.kt, ReleaseManifest |
| Pictures | one picture into the store, answered by the name it earned (digest) | live/BlobDoor.kt, view/BlobDoor |
| Themes | eleven, plus fonts: Ståge, Ayu, Catppuccin, Cyberdream, Desert Ex, Dracula, Kanagawa, One Dark, Oxocarbon, Rosé Pine, Vague | Theme*.kt, Fonts.kt, live/ThemePreferences.kt |
The app's machine is thin, on purpose
view/AppMachine.kt is a composition, not a second state machine: every decision about lanes — including the roster — stays in :core's Reducer. The app steps Intent/AppCommand in, gets AppStep out, and the laws that gate its own acts live beside it (Refusals.kt, Truth.kt, Register.kt). A lane the client has never seen is enrolled from thread.roster; the v2 workaround that enrolled unknown stream.open.heads by hand is deleted.
Everything stateful is the same shape: a *Projection (pure reading), a *Machine or *Seq (the transitions), and a *Preferences in live/ that only stores the bytes.
Versioning and release
./bump.sh <descriptor> # writes version.properties; checks every ref first
./script/check-version.sh # the one counter, one writer
./script/shelf-guard.sh # the shelf is append-only
The rendered name is 0.SSSS--descriptor, zero-padded to four places — step 99 with descriptor attach-in-field is 0.0099--attach-in-field. The step must be past every ref, not just this working copy's, because on 2026-10-19 two lanes bumped to 80 within the hour and the second publish replaced the first. A release is cut from a clean main on the Mac; the shelf never shrinks.
The client's own gates
| Gate | Subject |
|---|---|
check-view.sh | view/ compiles and steps with no Android on the classpath |
check-ui.sh | the Compose layer's rules, plus the literal baseline (ui-literals.baseline) |
check-live.sh | the Android face |
check-core-pin.sh | the pin: the commit exists, the tree matches, the substitution group agrees |
check-compile.sh | the app actually compiles |
check-icon.sh / check-version.sh / check-size.sh | the icon, the one counter, the size report |
view-rail.sh, fast-view.sh, column-bench, mutation-*.sh | the fast rails and the mutations that prove the gates can fail |