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.

PackageFilesRule
view/84pure Kotlin — no Android import at all. Contains no side effect: every decision the app makes, steps on a plain JVM
ui/36Compose only — draws what view/ decided
live/14the 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

ItemValue
module:app, settings.gradle (include ":app"), namespace + applicationId dev.staage.app
SDKcompileSdk 35, minSdk 26, targetSdk 35, Java/JVM 17
toolchainAGP 8.7.3, Kotlin 2.0.21, Compose plugin 2.0.21, Compose BOM 2024.10.01
enginestaage:core resolves to the pinned commit's extracted tree, through a composite build
versionversion.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

FeatureWhat it isview/ owner
The columnthe sidebar of threads, dragged and swiped, each row a live reading of the foldSidebar.kt, ColumnProjection.kt, Order.kt
Per-thread pagingthreads open as pages you swipe between, not as a stack of screensThreadPager.kt, ThreadProjection.kt, Swipe.kt
The stripthe chrome strip: state, usage, mode, the doorsStrip.kt, ChromeProjection.kt
Composerthe input, the outbox, and what happens to text typed offlineComposer.kt, Outbox.kt, OutboxProjection.kt
The mode — Absolute or Zena 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 rowMode.kt
Approval cardsthe 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
Pickerchoosing model / provider / target for a threadPickerMachine.kt, PickerProjection.kt
Searchfinding a thread or a sentence across the storeSearchMachine.kt, SearchProjection.kt
Renamerenaming a thread, with the same optimistic-then-honest shape as everything elseRename.kt
Plan and deskthe turn's spine and the workdesk page, rendered where the reader already looksPlan.kt, Notes.kt
Transcript blockstext, reasoning, tool runs and usage as blocks, windowedBlocks.kt, Transcript.kt, ToolRun.kt, Usage.kt, TurnLedger.kt
Self-updatethe 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 itUpdateFlow.kt, Update.kt, ReleaseManifest
Picturesone picture into the store, answered by the name it earned (digest)live/BlobDoor.kt, view/BlobDoor
Themeseleven, plus fonts: Ståge, Ayu, Catppuccin, Cyberdream, Desert Ex, Dracula, Kanagawa, One Dark, Oxocarbon, Rosé Pine, VagueTheme*.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

GateSubject
check-view.shview/ compiles and steps with no Android on the classpath
check-ui.shthe Compose layer's rules, plus the literal baseline (ui-literals.baseline)
check-live.shthe Android face
check-core-pin.shthe pin: the commit exists, the tree matches, the substitution group agrees
check-compile.shthe app actually compiles
check-icon.sh / check-version.sh / check-size.shthe icon, the one counter, the size report
view-rail.sh, fast-view.sh, column-bench, mutation-*.shthe fast rails and the mutations that prove the gates can fail