Introduction
What a Kit is
A Kit is a focused piece of SlashBuilder’s platform that owns one job and says plainly what it will not do. The App Context Kit owns an app’s identity, its folders for files and settings, test and scratch modes, and a presence entry other apps can read. The Venue (a resource broker) owns machine time on a shared computer. A storage Kit owns where bytes live; an identity Kit owns who is allowed to do what. Each Kit lives in its own repository with its own tests and documentation, and an app is built by composing the ones it needs.
What this repository is
This repository is the book, not a Kit. It is the starting point for using the Kits: it holds this documentation, a set of demo apps that compose Kits, and a Makefile that prepares your computer, runs every test and builds the apps. It never appears in the Kit registry or the catalog, and nothing depends on it.
The Kits themselves are not copied in. make setup downloads each one, at the commit named in kits.lock, into a folder called .kits/. That keeps the Kits’ own repositories as the single place their code changes, and it keeps this book honest: the demos build against exactly the versions the book describes.
How to use this book
- Read Start here first. It takes you from an empty folder to a running app in four commands.
- Then read the guides in
20-guides/. The demo apps guide shows the Kits composed into real apps, with measurements of what each Kit saved. The guide on fitting the App Context Kit to other Kits shows where its design meets its neighbours. - Reference pages live in
90-reference/: every make target and a glossary. - The Kit catalog lives in
30-kits/: a status card for every Kit in the registry, including the ones that are not public yet. See Kits. A Kit’s own reference pages are pulled into the book from the Kit, so you never need its repository to read them; today that is the App Context Kit’s reference, and the others are still to come.
What is written and what is not
| Section | State |
|---|---|
| Introduction, start here | Written |
| Demo apps guide, fitting the App Context Kit to other Kits | Written; moved here from the App Context Kit’s own documentation and reworded for a wider audience |
| Make targets, glossary | Written |
| Kit catalog | Written, and generated from the registry |
| Per-Kit reference | Pulled from the Kits themselves (see Make targets). The App Context Kit’s is in; no other Kit has reference pages yet |
| A demo app with a Rust core | Written: notes_rust, the seventh demo app. The other six are Flutter on a Dart core |
How much to trust it
Every number in the guides says what it was measured on. A claim that was reasoned but not tested says so. Where something is not known, the page says that instead of guessing. If you find a page that does not do this, that is a bug; see CONTRIBUTING.
Start here
From an empty folder to a running app. You need make (it comes with the Xcode command line tools on macOS and with build-essential on Debian and Ubuntu) and a way to get this repository onto your computer: either git clone, or the “Download ZIP” button on GitHub. After that, no git is needed: the Kits are downloaded as plain archives.
1. Check your computer
make doctor
This prints a table of the tools the demos need (Flutter and Dart, Rust, a C toolchain, and a few optional ones such as Docker), what it found, and the exact install command for your system where something is missing. It installs nothing, and it fails only when a required tool is missing. Run the commands it prints yourself, then run make doctor again until the last line says everything required is installed.
If you would rather have the tool run the install commands for the required tools, SETUP_INSTALL=1 make setup prints them and asks “y/N” before running anything.
2. Fetch the Kits and the packages
make setup
This runs the doctor, downloads each Kit listed in kits.lock into .kits/ at its pinned commit (printing one line per Kit: fetched, up to date, or skipped and why), then fetches the Dart, Flutter and Rust packages every project needs. It is safe to run again.
While some Kits are private, fetch them too. This needs a GitHub login: install the GitHub CLI and run gh auth login, or set a GITHUB_TOKEN environment variable that can read the repositories.
make setup KITS=all
Without a login, private Kits are skipped with a one-line explanation. A demo app that needs a Kit that was skipped is skipped too, with a line that says which (for example skipped apps/notes_rust: needs storage-kit (private; fetch with KITS=all)); everything else still sets up and tests. The demo apps all need the App Context Kit, so while that is private they cannot be built or tested without a login.
3. Run the tests
make test-fast
This runs every fetched Kit’s own tests, the components in labs/, and the tests of all seven demo apps (the Rust core of notes_rust is tested with cargo test), then prints a table: suite, passed, failed, skipped and seconds. It finishes with OK or FAILED. make test additionally runs the suites that need Docker, Redis or a running daemon, and skips them with a message when the tool is absent.
4. Build and run an app
make app APP=calculator # a debug build for this computer
make run APP=calculator # run it (tried on macOS; on Linux the apps build and launch headless in a container, a real desktop has not been tried)
make apps # build all seven
The apps are Flutter desktop apps. On macOS this needs the full Xcode app, not only the command line tools; on Linux it needs a few packages (make doctor lists them); the demo apps guide says what the Linux test did and did not show. Windows is not covered.
To see two copies of an app find each other, follow run two copies and watch them find each other.
When something goes wrong
| You see | What it means | What to do |
|---|---|---|
make doctor says a tool is MISSING or TOO OLD | The tool is not installed or is older than the apps need | Run the command listed under “What to do”, then make doctor again |
skipped (private Kit; run: make setup KITS=all) | That Kit is not public yet | Log in with gh auth login, then make setup KITS=all |
skipped (no GitHub login ...) | You asked for private Kits without a login | Same as above, or set GITHUB_TOKEN |
skipped apps/<app>: needs <kit> (...) | That app needs a Kit that is not fetched (it lists it in its kits.need). Skipping is not a failure | Nothing, or make setup KITS=all if the Kit is private and you have access |
app-context-kit is not fetched | The demo apps need the App Context Kit | make setup KITS=all |
cargo ... skipped: could not resolve | The Rust crate needs a Kit that is not fetched | make setup KITS=all |
| A test fails | Something real, or a changed Kit pin | Read the log named under the table (build/test-logs/); see Make targets |
Where to go next
Read the demo apps guide to see what each app asks of the Kits, then the make targets for everything else the Makefile can do.
Demo apps
Seven small Flutter desktop apps in apps/ that use the App Context Kit (the resource broker the Kit talks to is Venue). They were written to measure the Kit: what it removes from an app, what it still leaves for the app to write, and where it falls short.
One rule shaped them: an app does not edit the Kit. If an app needs something the Kit lacks, it writes it under lib/kit_gaps/ with a // KIT GAP: comment. The line count of those directories is the measurement of what the Kit is missing. When the Kit absorbs a gap, the app deletes its copy.
| App | Manifest id | Exercises | Kits in app.json | Tests | main.dart lines | Gap lines left | lib/coordination lines |
|---|---|---|---|---|---|---|---|
| calculator | com.slashbuilder.demo.calc | The least an app can ask of the Kit | none | 32 | 5 | 0 | none |
| minesweeper | com.slashbuilder.demo.minesweeper | Saved game state; handing a game to another copy | peers | 86 | 5 | 0 | 329 |
| notepad | com.slashbuilder.demo.notepad | Files on disk, autosave, damaged files; syncing notes between copies | peers | 63 | 5 | 79 | 619 |
| stopwatch | com.slashbuilder.demo.stopwatch | State that survives restart; a shared countdown and a signal between copies | venue, peers | 49 | 10 | 35 | 332 |
| render-queue | com.slashbuilder.demo.render-queue | A session with a resource broker: holds, refusals, waiting | venue | 38 passed, 5 skipped | 11 | 0 | none |
| launcher | com.slashbuilder.demo.launcher | The roster: who is running on this machine | venue | 24 | 9 | 50 | none |
| notes_rust | com.slashbuilder.demo.notes-rust | A Flutter UI over a Rust core that uses the Storage Kit and shares one App Context Kit context with the UI; sync with the blocks and checks in Rust | peers | 29 cargo test + 19 + 10 integration | 35 | 81 | 416 |
How the numbers were taken for the first six apps, on 2026-10-24 with Flutter 3.41.9 and Dart 3.11.5 on macOS (Apple silicon): tests are the result of flutter test in each app directory; main.dart and gap lines are wc -l (raw lines, comments and blanks included) of lib/main.dart and lib/kit_gaps/*.dart; coordination lines are wc -l of lib/coordination/*.dart. The five skipped render-queue tests need a running resource-broker daemon and skip themselves when no token is set. The venue entry in kits is a label: only peers is checked against the kits list today (see the App Context Kit’s architecture guide, section 1).
Contents: running and testing any app · the seven apps · how much code the Kit saved · run two copies and watch them find each other
Running and testing any app
Each app is a standard Flutter project. Desktop runners exist for macOS and Linux; the apps were run on macOS, and on Linux they were built and launched headless in a container (see Linux smoke test). Every command below assumes you are in the repository root, and that you have run make setup once (add KITS=all while the App Context Kit is private) so the Kits the apps depend on are in .kits/.
The Makefile wraps the common cases: make test-fast runs every app’s tests, make app APP=stopwatch builds one app for this computer and make run APP=stopwatch runs it. The plain Flutter commands work too:
cd apps/stopwatch # or any app
flutter pub get
flutter run -d macos # run it
flutter test # run its tests
An app reads its settings from environment variables before it opens. These are the ones that matter. PREFIX is the app’s envPrefix, or the last segment of its id, upper-cased: CALC, MINES, NOTEPAD, STOPWATCH, RENDER_QUEUE, LAUNCHER.
| Variable | Effect |
|---|---|
APP_CONTEXT_SCRATCH=/some/dir | Puts the app’s data, config, state, cache and runtime directories, and the roster, under /some/dir. Use it to run without touching your real settings |
PREFIX_MODE=mock (or APP_CONTEXT_MODE=mock) | Mock mode: the resource-broker session is an in-memory fake, and peers use the fake network |
APP_CONTEXT_DEVICE=laptop | The name of this copy for peers. Give each copy its own |
APP_CONTEXT_ACCOUNT=home | The account half of the peer scope key. Default local |
APP_CONTEXT_PEERS_DIR=/some/dir | Where copies on one machine announce themselves. Must be the same for every copy |
APP_CONTEXT_ROSTER_DIR=/some/dir | Where the app writes its presence file. Beats APP_CONTEXT_SCRATCH |
PREFIX_DATA_DIR, PREFIX_CONFIG_DIR, … | Override one directory exactly |
Details are in the App Context Kit’s specification (spec/APP-CONTEXT.md in that Kit), sections 2 and 6.3.
flutter run -d macos starts the app as a child of the Flutter tool, which passes your shell’s variables on (this is from reading the tool’s source; no window was opened for this page). An app started from Finder or the Dock does not see your shell’s variables.
The macOS sandbox rule. Three apps have the macOS App Sandbox on: calculator, minesweeper and notepad. The other three (launcher, render-queue, stopwatch) have it off. A sandboxed app can only write inside its own container, ~/Library/Containers/<bundle id>/Data/, and that container also has its own HOME and TMPDIR. Two things follow:
- A path outside the container, such as
/tmp/..., inAPP_CONTEXT_SCRATCHorAPP_CONTEXT_PEERS_DIRdoes not work for a sandboxed app. WithAPP_CONTEXT_SCRATCHthe app cannot create its directories: the companion shows its startup error screen (the calculator’s first version, before the companion package, crashed with an unhandledPathAccessException). WithAPP_CONTEXT_PEERS_DIRthe process stays up and works alone, and the footer says “Peers: off (peers unavailable: …)” (asserted in a widget test, not seen). Use a path under the container instead, for example~/Library/Containers/com.slashbuilder.demo.notepad/Data/tmp/ak-scratch. The bundle ids arecom.slashbuilder.demo.calc,com.slashbuilder.demo.minesweeperandcom.slashbuilder.demo.notepad. - The default roster and peers directories are inside the container too. Two copies of one sandboxed app share the container and find each other with no variables set. Two different sandboxed apps have different containers, so they are not expected to share a roster (reasoned, not tested).
The sandboxed apps that use peers, notepad and minesweeper, also need the com.apple.security.network.client and com.apple.security.network.server entitlements; both projects have them in Debug and Release. With only the server entitlement, two copies listen but never connect, and nothing is logged. That observation was recorded in the apps’ development notes (not published) and was not repeated for this page.
Everything about the sandbox is macOS-specific, and the entitlement files are native build files that a running Dart app cannot change. The App Context Kit’s platforms guide has the full table of what each capability needs.
Linux smoke test
make linux-smoke-image and then make linux-smoke build every app with flutter build linux --release in an Ubuntu 24.04 container (Flutter 3.41.9, the same version as the macOS runs, and Rust 1.98.1 for notes_rust) and launch each one under a virtual display (Xvfb, 1280x800x24). Run on 2026-10-24, all seven apps built and started; the table is the summary (the full output, every warning line and the screenshots are what the command writes to build/linux-smoke/; the target is described in make targets).
| What was checked | Result |
|---|---|
Release build, linux/arm64 | All seven built in 8 to 10 s each (54 s for notes_rust, which also compiles its Rust core); bundles 49 to 54 MB |
| Launch | All seven were still running at 10 s and drew a real window with content (screenshots read by eye: calculator, stopwatch, minesweeper, notepad, notes_rust, render-queue, launcher, and two copies side by side) |
| SIGTERM | Exit code 143 for every app, in 0.1 s; the roster entry (<manifest id>@<instance>.json) appeared and was withdrawn, for render-queue too, so the manifest id com.slashbuilder.demo.render-queue against the Linux application id com.slashbuilder.demo.render_queue does not stop it from launching |
| Input | xdotool clicks reached the Flutter window (the calculator showed 42 after 7 x 6 =) and typing reached the notepad |
| State survives a relaunch | stopwatch (state.json; the second launch showed the running stopwatch at 13.9 s), minesweeper (game.json; the second launch showed a half-played board with the timer at 012), notepad (a note typed in the first launch was listed in the second), notes_rust (the three seeded notes and heads-<partition>.json in the second, “Rust core: 3 notes, 6 blocks”) |
Two copies, one APP_CONTEXT_PEERS_DIR | stopwatch and notepad, real mode, devices smoke-a and smoke-b: each announced, the two processes held a TCP connection to each other on 127.0.0.1, each footer read Peers: 1 with the other copy’s name, and a note written into copy A’s folder arrived in copy B’s folder. Both announcements were withdrawn on exit |
| Warnings and exceptions | No uncaught exception in any app. The only Gtk/GLib lines are the same four per app: dbind-WARNING ... AT-SPI: Error retrieving accessibility bus address and Atk-CRITICAL ... atk_socket_embed: assertion 'plug_id != NULL' failed, which come from the container having no accessibility bus, plus libEGL warning: DRI3 error from the virtual display having no GPU |
What this is not. It is headless, under a virtual display, in a container, on linux/arm64 only (Docker on Apple silicon). There was no window manager and no compositor, no real GPU (software OpenGL), no click on a window’s close button (only SIGTERM), no notification daemon (the stopwatch’s desktop notification was not exercised), and no other distribution, Wayland or x86_64. It is independent evidence that the Linux runners build and the apps start, draw, take input and keep their state; it does not replace a session on a real Linux desktop.
One thing it found outside the apps: with a clean ~/.cargo, notes_rust does not build, because the Storage Kit’s manifest names a private cargo registry that only the Kit’s own .cargo/config.toml defines, and cargo reads that file only when run inside the Kit. The smoke test copies it into the container’s cargo configuration, as the Kit’s own README says to; the fix belongs in the Kit (for example by not naming the registry in a dev-dependency).
calculator
Folder: apps/calculator.
Exercises. The least an app can ask of the Kit. A four-function calculator with no stored state, written to find the minimum useful adoption.
Run and test.
cd apps/calculator
CALC_MODE=mock flutter run -d macos # shows a "mock" chip in the app bar
flutter test # 32 tests
What the Kit gave it. AppContextKit.run loads app.json from the asset bundle and closes the app once on window close, quit, SIGINT or SIGTERM. AppContextScope supplies the app name for the title. ModeBadge shows the mock and scratch chip. pumpKitApp in the tests runs the app in mock mode under a scratch directory. The Kit also writes a roster entry that nothing in this app reads.
What it still writes itself. Nothing: lib/kit_gaps/ does not exist. The whole of main.dart is:
import 'package:app_context_kit_flutter/app_context_kit_flutter.dart';
import 'ui/calculator_app.dart';
Future<void> main() => AppContextKit.run(manifestAsset: 'app.json', app: (_) => const CalculatorApp());
Did it need the Kit? No. It is the floor: the Kit is nearly free here, and what it gives back is the app name, test isolation and a roster entry nobody reads.
minesweeper
Folder: apps/minesweeper.
Exercises. A game saved to disk (the game in progress, high scores, two settings), and, through the peer layer, handing a game to another copy or playing one board together. It is the case where a state change has to happen on two sides.
Run and test.
cd apps/minesweeper
flutter run -d macos
flutter test # 86 tests
This app is sandboxed. To run two copies that find each other, see the walkthrough and use paths under the container. To exercise the handoff without a window, run the probe (below).
What the Kit gave it. The data directory and AppJsonFile for the saved game and scores, SessionSettings for the two settings, ctx.startPeers to find and connect to other copies, PeerJson for the message bodies, PeersIndicator for “This copy / Peers: N”, NotesBanner and ModeBadge.
What it still writes itself. No gap files. lib/coordination/ (329 lines) is the game protocol: the offer, the person’s yes or no, the pull of the board, the confirmation, and the checks that refuse anything inconsistent. That is the app’s meaning and stays the app’s.
A probe, not a window. tool/peer_probe.dart runs the app’s own coordination code without a window, over the real loopback provider. Two probes on one machine, one offering a game and one accepting:
cd apps/minesweeper
dart run tool/peer_probe.dart --scratch /tmp/ak-ms/laptop --peers /tmp/ak-ms/peers --device laptop --for 9 --accept &
sleep 3
dart run tool/peer_probe.dart --scratch /tmp/ak-ms/desktop --peers /tmp/ak-ms/peers --device desktop --for 6 --game --offer 1:handoff
A run of exactly this on 2026-10-24 printed, trimmed to the lines about the handoff (the first number is epoch milliseconds):
laptop: 1792851222914 HOOK message="desktop #c694 wants to send you a game." incoming=1 sharing=null
laptop: 1792851222924 ADOPT status=playing sum=649a874f5e98919e
laptop: 1792851222925 ACCEPTED -> Took over the game from desktop #c694.
desktop: 1792851221880 GAME in progress sum=649a874f5e98919e
desktop: 1792851222907 COMMAND offer handoff
desktop: 1792851222925 RELEASE to laptop #f142
The offer arrived 7 ms after it was sent, the board was applied 10 ms later and the sender released its game when the receiver confirmed. Both boards have the same checksum. This is two probes, not two windows, so it shows the protocol and not what a person sees.
Did it need the Kit? For handing a game to another copy it needs a way to find and reach that copy, and the Kit supplies it; the protocol that makes a handoff safe, and what happens when a message is lost, is the app’s 329 lines.
notepad
Folder: apps/notepad.
Exercises. One JSON file per note in the data directory, autosave 600 ms after the last keystroke, damaged-file handling, and the close hook that flushes unsaved typing on quit. With peers: syncing notes between copies, including conflicts and notes too large for one message. It is the heaviest user of the hook.
Run and test.
cd apps/notepad
flutter run -d macos
flutter test # 63 tests
This app is sandboxed; see the sandbox rule above. Two copies need different APP_CONTEXT_DEVICE values: notepad refuses a peer with its own device id, because two copies with one device id share one notes folder and are one replica.
What the Kit gave it. The data directory, ctx.dataJson('sync.json') and AppJsonFile for atomic JSON files, SessionSettings, ctx.onClose to save the last keystrokes, ctx.startPeers, PeerJson, PeersIndicator, NotesBanner, ModeBadge.
What it still writes itself. One gap file: lib/kit_gaps/blob_pull.dart (79 lines), a chunked transfer for notes larger than one message, pulled part by part over request and reply. It stays because only one app wants it. lib/coordination/ (619 lines) is the sync engine: version vectors, conflict copies, the digest exchange. The hook itself, note_sync_hook.dart, is 29 lines.
A probe, not a window. Two probes over the real loopback provider; the second creates a 300-character note after the first is ready:
cd apps/notepad
dart run tool/peer_probe.dart --scratch /tmp/ak-np/laptop --peers /tmp/ak-np/peers --device laptop --for 10 &
sleep 3
dart run tool/peer_probe.dart --scratch /tmp/ak-np/desktop --peers /tmp/ak-np/peers --device desktop --for 7 --create 1:Shopping:300
A run on 2026-10-24 (trimmed):
desktop: 1792851200930 COMMAND created hniggyzhs0ggau "Shopping" body=300
laptop: 1792851200960 NOTE hniggyzhs0ggau "Shopping" body=300 chars
The note was a file in the other copy’s notes folder 30 ms after it was created. Afterwards find /tmp/ak-np -name '*.json' lists the same note file under both scratch directories, plus each side’s sync.json. A person typing would add the 600 ms autosave pause to that number; that was not measured.
Did it need the Kit? The Kit supplied find, connect and route; replicated notes still cost about 620 lines of the app’s own code (versions, conflicts, chunked transfer) that the Kit does not provide.
stopwatch
Folder: apps/stopwatch.
Exercises. A stopwatch with laps and a countdown. Its state is a point in time plus banked time, saved on every change, so it tests the state directory, settings, and a clean quit and relaunch. With peers: one countdown shared between copies, and a “timer finished” signal announced by one copy.
Run and test.
cd apps/stopwatch
flutter run -d macos
flutter test # 49 tests
This app is not sandboxed, so /tmp paths work.
What the Kit gave it. The state directory and settings file, ctx.onClose to save, the exit path, test isolation (the “start, lap, relaunch” test is two opens on one scratch directory), ctx.startPeers, PeerJson, PeersIndicator, NotesBanner, ModeBadge.
What it still writes itself. One gap file: lib/kit_gaps/notifier.dart (35 lines), which shows a desktop notification through osascript or notify-send. It is not something the App Context Kit should own. lib/coordination/ (332 lines) is the shared-countdown rule.
Did it need the Kit? It needed the state directory, settings, the exit path and, for coordination, discovery and routing; it never needed the resource broker.
render-queue
Folder: apps/render-queue.
Exercises. A session with a resource broker. Each job takes a hold (ctx.venue.withHoldWaiting), runs, and releases it. It tests what the app does when the broker refuses, waits for capacity, or loses the app (a killed app keeps its hold until the lease runs out).
Run and test.
cd apps/render-queue
RENDER_QUEUE_MODE=mock flutter run -d macos # an in-memory broker that grants 2 holds at once
flutter run -d macos # real mode
flutter test # 38 passed, 5 skipped
In real mode, with no broker token configured, the session is a local one with no limit, and the app says so. Connecting to a real broker daemon needs a token and the daemon’s address; the variables are in the App Context Kit’s specification (spec/APP-CONTEXT.md in that Kit), section 3. The daemon is not part of this repository, which is why the real-daemon tests skip. The app’s behaviour when refused is a setting in config.json: policy (wait or failFast), maxWaitSeconds, retrySeconds and leaseSeconds.
What the Kit gave it. The session that hides fake, local and real, withHoldWaiting (retry a refusal for up to maxWait, with cancel), typed refusals, the settings file, ctx.onClose to cancel running jobs and wait for their holds to be released before the session closes, and the roster scan it uses to tell whether another copy of the app is running.
What it still writes itself. No gap files. The app keeps the policy (wait or fail fast) and the words the person reads.
Did it need the Kit? Yes, and this is the clearest case: the one thing it needed from the broker session that the Kit lacked, waiting for capacity, is now in the Kit.
launcher
Folder: apps/launcher.
Exercises. The roster. The launcher lists the apps that are running on this machine, which tests whether a file-based presence list is enough.
Run and test.
cd apps/launcher
LAUNCHER_APPS_DIR="$PWD/.." flutter run -d macos # also lists the manifests found in apps/*/app.json
flutter test # 24 tests
The launcher is not sandboxed. It sees an app only if that app writes its presence file to the same roster directory. The three sandboxed apps write inside their own containers, so to see apps in the launcher run the unsandboxed ones (stopwatch, render-queue) with the same APP_CONTEXT_ROSTER_DIR as the launcher.
A probe, not a window. tool/rows_probe.dart prints the rows the launcher would show for a roster directory. A hand-made roster directory with one live entry and one damaged file:
mkdir -p /tmp/ak-roster
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "{\"id\":\"com.slashbuilder.demo.stopwatch\",\"instance\":\"a0000001\",\"name\":\"Stopwatch\",\"version\":\"0.1.0\",\"pid\":$$,\"startedAt\":\"$NOW\",\"heartbeatAt\":\"$NOW\"}" > /tmp/ak-roster/com.slashbuilder.demo.stopwatch@a0000001.json
echo '{broken' > /tmp/ak-roster/junk.json
cd apps/launcher && dart run tool/rows_probe.dart /tmp/ak-roster
It printed, on 2026-10-24 (the pid is your shell’s):
running Stopwatch com.slashbuilder.demo.stopwatch copy=a0000001 pid=70742
unreadable: junk.json
A file whose last heartbeat is more than 15 seconds old shows as stale, and a file the launcher cannot parse is listed as unreadable instead of being hidden.
What the Kit gave it. Roster.scan() and RosterRecord (status live, stale or unreadable), the instance id in the file name so two copies of one app are two rows, ctx.venue.nodes() for the node strip, settings, and scratch isolation for the tests.
What it still writes itself. Two gap files, 50 lines: manifest_scan.dart (36), which walks a folder for */app.json because nothing lists installed apps, and pid_alive.dart (14), which asks the operating system whether a pid is alive (kill -0), because scan() reports live for up to 15 seconds after a crash. Each has one user. A pid check does not work inside the macOS sandbox, and a reused pid reads as alive.
Did it need the Kit? For the roster, yes: Roster.scan() and instance ids replaced a 70-line shim (roster_scan.dart) and fixed two copies erasing each other’s entry; the pid check, the app index and the memory of “gone” are still the launcher’s own, and they are most of what makes it a launcher.
notes_rust
Folder: apps/notes_rust. The only demo app with a Rust core, and the only one that uses a Kit written in Rust.
Exercises. The intended shape of an app: a Flutter interface over a Rust core, joined by flutter_rust_bridge. The core (notes_core) stores notes as content-addressed blocks on the Storage Kit and works inside the App Context Kit context that the Flutter side opened. The question it answers is how two halves share one context. The Dart side opens the context once and hands the Rust side a snapshot of it; the Rust side adopts it (an adopt API added to the App Context Kit for this) and so announces nothing, writes no settings, and creates files only under data and state. Notes have a version history (restoring a version writes one small manifest and copies no body). Two copies sync over the App Context Kit’s peer layer: Dart carries the bytes, Rust holds the blocks, checks every hash before storing anything, and reports conflicts with both versions.
Run and test.
make app APP=notes_rust # needs make setup KITS=all, flutter_rust_bridge_codegen 2.12.0 and, on macOS, CocoaPods
make run APP=notes_rust
make test-fast # cargo test (29) and flutter test (19, in-memory stand-in for the core)
make test-integration # flutter test integration_test -d macos (10, the real library inside the app)
sh apps/notes_rust/tool/smoke.sh # launch the built macOS app for 10 s, check what it wrote, SIGTERM it
What the Kits gave it. From the App Context Kit: the directories and instance id (shared with Rust through the snapshot), AppContextKit.run, the config file the Dart side owns and the Rust side only reads, ctx.startPeers and the peer hub. From the Storage Kit: PartitionStore, per-partition de-duplication, hash-verified reads and the fragment trees.
What it still writes itself. lib/kit_gaps/blob_pull.dart (81 lines, copied from the notepad) for moves bigger than one message; in Rust, the head index (the Storage Kit has no mutable store implementation), a verified insert (its put files tampered bytes under their own hash), the child list of a list node, and the flush of the pack before a head moves. lib/coordination/ is 416 lines of Dart; the Rust core is about 1,800 lines (plus 5,130 generated bridge lines).
What it showed. See the app’s README for the measurements, what the bridge made awkward and how the shared context behaved in practice.
Did it need the Kits? The Storage Kit supplied what a notes store is made of; the App Context Kit’s adopt API is what made one app with two runtimes possible without announcing twice. Neither supplied the sync protocol, the conflict policy or the head index.
What the demo apps do not cover
- One Rust core, not a family. Only
notes_rusthas a Rust crate and aflutter_rust_bridgedependency; the other six are Flutter on a Dart core. It was built and run on macOS; on Linux it was built and launched headless in a container (see Linux smoke test), not on a real desktop. The bridge was not tried with a Rust-to-Dart stream, hot reload, or a second Rust core in the same app. - Two Kits used from Rust, the rest not.
notes_rustuses the App Context Kit’s Rust crate and the Storage Kit. Identity, messaging and transport Kits were probed in separate Rust packages in a private repository (the public-safe summary is Fitting the App Context Kit to other Kits), and the other apps’ own sync, conflict handling and large-note transfer are written in Dart. Even innotes_rustthe transport is Dart: the peer layer has no Rust half. notes_rustneeds a private Kit. The Storage Kit is not public yet, so the app cannot be built from a public clone alone: it needsmake setup KITS=alland a GitHub login with access. Without it,make setup,make testandmake appsskip the app with one line (skipped apps/notes_rust: needs storage-kit (private; fetch with KITS=all)) and carry on with the other six; the Kit it needs is named in itskits.needfile. The App Context Kit’s Rust crate, which the app also uses, is part of the pinned release candidate.- No real network. Peers find each other through announcement files and loopback sockets on one machine. That holds for
notes_rusttoo: nothing in it was run on a second machine.
How much code the Kit saved
The measurement is the size of lib/kit_gaps/ across the six apps (raw wc -l), and the length of each main.dart.
| Stage | Gap lines, six apps | main.dart lines |
|---|---|---|
| Before the Kit had a Flutter companion (first round) | 727 | 13 to 19 |
| After migrating to the Kit and its companion | 330 | 5 to 11 |
| After adding coordination to three apps | 777 | 5 to 11 |
| After moving the repeated parts into the Kit | 164 | 5 to 11 |
| App | Gap lines: before | after migration | after coordination | now | main.dart: before | now |
|---|---|---|---|---|---|---|
| calculator | 28 | 0 | 0 | 0 | 13 | 5 |
| minesweeper | 149 | 78 | 200 | 0 | 13 | 5 |
| notepad | 149 | 78 | 281 | 79 | 13 | 5 |
| stopwatch | 91 | 35 | 157 | 35 | 19 | 10 |
| render-queue | 148 | 89 | 89 | 0 | 18 | 11 |
| launcher | 162 | 50 | 50 | 50 | 18 | 9 |
| total | 727 | 330 | 777 | 164 |
These were counted from the App Context Kit repository’s history on 2026-10-24: the first column at the Kit’s first round, the next three at the commits that migrated the apps, added coordination, and moved the repeated parts into the Kit. The “now” column matches the apps in this repository.
What the numbers say. Migrating to the Kit removed about 400 lines of shims. Then adding coordination added about 450 new ones, taking the total to 777, higher than before the Kit. For a while the Kit had made main.dart tiny and the apps’ total gap code worse. Moving the repeated parts into the Kit (ctx.startPeers, PeerJson, PeersIndicator, AppJsonFile, SessionSettings, VenueSession.withHoldWaiting) brought the total to 164, 77% below the start and 79% below the peak. Calculator, minesweeper and render-queue have no gap code left. What remains is four helpers that one app each uses: notifier, blob_pull, manifest_scan, pid_alive.
The lesson: build a Kit piece when the second app copies it, not before. Each piece that moved into the Kit had been written independently by several apps and was identical across them: the exit handling in all six, the JSON file store and settings wrapper in two, and the three peers helpers in three. The four that stayed out have one user each. One exception to be honest about: the wait-for-capacity loop (withHoldWaiting) had one app writing it, and moved because the resource-broker session was incomplete without it. These counts measure how much the Kit absorbed, not how long the work took. Lines are a proxy.
Run two copies and watch them find each other
This uses the stopwatch because it is not sandboxed, so /tmp works. The first part is a probe: no window, but the app’s real coordination code over real loopback TCP and a real announcement directory. The second part opens windows and was not run for this page.
Part 1: two probes (run on 2026-10-24)
In one terminal:
cd apps/stopwatch
flutter pub get
dart run tool/peer_probe.dart --scratch /tmp/ak-demo/laptop --peers /tmp/ak-demo/peers --device laptop --for 12
Within a few seconds, in a second terminal:
cd apps/stopwatch
dart run tool/peer_probe.dart --scratch /tmp/ak-demo/desktop --peers /tmp/ak-demo/peers --device desktop --for 10 --start-after-ready 1
What the variables are: --scratch is APP_CONTEXT_SCRATCH (this copy’s own data, config and state), --peers is APP_CONTEXT_PEERS_DIR (shared by both copies), --device is APP_CONTEXT_DEVICE. Each probe prints a line per event, starting with the epoch milliseconds. After the second probe is ready it plays the person on the second copy: it resets the countdown to 6 seconds and starts it. This is what the two probes printed, with the once-a-second TICK lines and a few STATE lines removed:
desktop: 1792850582509 START desktop@efe50421 stamp=0
desktop: 1792850582511 PEER found laptop@a8def94c hello=null
desktop: 1792850582524 PEER ready laptop@a8def94c hello=null
desktop: 1792850583526 COMMAND reset 6s + start (this is the person on the probe copy)
desktop: 1792850583532 STATE duration=6000 running=true remaining=5999 phase=running stamp=2/desktop@efe50421
desktop: 1792850589539 NOTIFY Timer finished: 00:06 countdown is done
laptop: 1792850582523 PEER found desktop@efe50421 hello=null
laptop: 1792850582524 PEER ready desktop@efe50421 hello=null
laptop: 1792850583538 STATE duration=6000 running=true remaining=5998 phase=running stamp=2/desktop@efe50421
laptop: 1792850589540 NOTIFY Timer finished: 00:06 countdown finished on desktop #efe5
Read it like this. The desktop probe started and saw the laptop as found 2 ms later and ready 15 ms after it started. The countdown the desktop started was running on the laptop 12 ms after the desktop’s command. When it ended, the desktop, as the writer of the current state, announced; the laptop said where it finished. While both ran, ls /tmp/ak-demo/peers/local/com.slashbuilder.demo.stopwatch/ showed one announcement file for each copy, named DEVICE@INSTANCE.json: the directory is <peers dir>/<account>/<app id>/, and the account is local because nothing set one.
See a crash
Start the two probes as above, but give the first --for 20, the second --for 30, and leave out --start-after-ready. Find its process id in its announcement file and kill it hard:
kill -9 "$(python3 -c "import json,glob; print(json.load(open(glob.glob('/tmp/ak-demo/peers/local/com.slashbuilder.demo.stopwatch/desktop@*.json')[0]))['pid'])")"
The surviving probe (the laptop) printed this on 2026-10-24, TICK lines removed:
1792851139352 PEER found desktop@9bb7531b hello=null
1792851139361 PEER ready desktop@9bb7531b hello=null
1792851143134 PEER lost desktop@9bb7531b hello=null
1792851143134 PEER found desktop@9bb7531b hello=null
1792851146452 PEER lost desktop@9bb7531b hello=null
One death is three events. The operating system closed the dead copy’s socket, so lost arrives at once. The dead copy’s announcement file was still there with a recent beat, so it was found again. About 3.3 seconds later the beat was old enough, the provider withdrew the candidate, and the final lost came. The dead copy’s file stays on disk until a live copy reaps it after 60 seconds. The numbers behind the rule are in the App Context Kit’s coordination guide, section 6 (providers).
Part 2: two windows (not run for this page)
Build once, then run the binary twice, rather than running flutter run twice from one directory.
cd apps/stopwatch
flutter build macos --debug
BIN="$PWD/build/macos/Build/Products/Debug/stopwatch.app/Contents/MacOS/stopwatch"
APP_CONTEXT_DEVICE=laptop APP_CONTEXT_SCRATCH=/tmp/ak-demo/win-laptop APP_CONTEXT_PEERS_DIR=/tmp/ak-demo/win-peers "$BIN" &
APP_CONTEXT_DEVICE=desktop APP_CONTEXT_SCRATCH=/tmp/ak-demo/win-desktop APP_CONTEXT_PEERS_DIR=/tmp/ak-demo/win-peers "$BIN" &
Each window’s footer should read “This copy: laptop #xxxx” and “Peers: 1” with one chip for the other copy. Start the countdown in one window and the other follows. Give each copy its own APP_CONTEXT_SCRATCH as well as its own APP_CONTEXT_DEVICE: copies that share a config directory share a generated device id. The peers directory is the one thing they share, which is why it is set with APP_CONTEXT_PEERS_DIR. If APP_CONTEXT_SCRATCH were the only variable, each copy would announce in <its scratch>/roster-peers and never see the other.
For the sandboxed apps (notepad, minesweeper), the same recipe works with every path under ~/Library/Containers/<bundle id>/Data/tmp/, for example APP_CONTEXT_SCRATCH=~/Library/Containers/com.slashbuilder.demo.notepad/Data/tmp/ak/laptop and APP_CONTEXT_PEERS_DIR=~/Library/Containers/com.slashbuilder.demo.notepad/Data/tmp/ak/peers, with the binary at build/macos/Build/Products/Debug/notepad.app/Contents/MacOS/notepad. A copy that cannot reach the peers directory keeps working alone and its footer says why. That route was recorded in the apps’ development notes (not published) and was not repeated for this page.
See also: the App Context Kit’s own getting-started, coordination, platforms and design-decisions guides, which ship with the Kit (after make setup they are under .kits/app-context-kit/docs/), and Fitting the App Context Kit to other Kits for how it meets its neighbours.
Fitting the App Context Kit to other Kits
The App Context Kit is the root namespace for one running app: identity, directories, settings, a session with a resource broker (Venue), and a way for copies of the same app to find each other. It is not the only Kit an app touches. During its first round of work, six neighbours were probed to find out where the App Context Kit’s design meets theirs.
This page records, for each one: the question, what was built, what it showed, what the App Context Kit does about it, and what is still open and with whom.
How to read the evidence:
- The probes run in a private repository; the public-safe summary is here. Each probe was a standalone Rust package that exercised a neighbouring Kit directly. You cannot rebuild them from this repository, and this page does not point at them. The numbers below were measured when the page was written.
- None of this is inside a demo app. No Flutter app calls the Rust App Context Kit crate or any of these Kits through a bridge, so “works in a probe” is not “works in an app”. See what the demo apps do not cover.
- The one piece that lives in this repository is
labs/interface_candidates, the proposed shared UI components. Nothing in the Kit’s own packages depends on it. - Numbers come from debug builds on one machine, usually single runs. Latencies are to show magnitude, not to rank anything.
- Where a page below says “tested”, it was tested while this page was written. Anything else is from the project’s own notes and says so.
Summary
| Kit | What we tried | Result | Status |
|---|---|---|---|
| Hearth (transport) | Carry traffic between copies of one app, on one account, through a real local Hearth hub. | Works on the stock hub with the app id carried in the message type. App-scoped topics need a change to the hub’s topic rules. Usable payload is 64 KiB minus the header. | Adapter prototype (private). No Hearth provider in the Kit. Waiting on a protocol ruling. |
| Message Kit | Map the Kit’s coordination envelope onto Message Kit, locally and across a remote-provider seam. | The envelope fits without bending either side. The local transport needs Linux. Same-machine peers on macOS and Windows use the Kit’s loopback provider. | Prototype (private). The Kit ships a fake and a loopback provider only. |
| Storage Kit | Keep a note body in a per-account, per-app partition and sync it between two copies. | A 37-byte insert into a 10 MiB note transferred 0.47% of the body. Tamper rejection works. Several Storage Kit gaps. | Prototype (private). Not part of the Kit. |
| Identity Kit | Decide with Identity Kit capabilities whether a peer may join an app’s coordination. | The admit / read-only / reject shape maps cleanly. | Prototype with a test double. Waiting on Identity Kit issuance and signing. |
| Interface Kit (candidates) | Derive shared UI components from what six demo apps and a larger app each wrote by hand. | Nine entries built (158 tests). Four are marked thin: one real user in the demo apps, or none. | Candidates only, in labs/interface_candidates. Nothing promoted to the real Interface Kit. |
| Venue | A Venue session in the app context. | Works against a real daemon in Dart and Rust. Typed refusals, lease-loss reporting and a wait helper (Dart) exist. Registering an app is manual. | Shipped in the Kit. |
Hearth (transport)
The question. Hearth is SlashBuilder’s messaging protocol, with a hub that fans messages out to devices on an account. Can the App Context Kit use it to carry traffic between copies of one app, using topics scoped to the app?
What was built. A prototype adapter, HearthTransport: join(account, app, device), then broadcast, send(device, bytes), recv, leave. It validates ids before anything reaches the wire. It came with 53 tests in eight files (topics, delivery, isolation, presence, request/reply, payload, catch-up, adapter), run against a real local hub and Redis on loopback ports, plus a patched copy of the hub whose topic module changed by about 60 lines. Nothing touched a production service.
Re-run when this page was written: 53 passed, 0 failed (with the 90-second idle-timeout test skipped).
What it showed.
- The stock hub rejects app-scoped topics.
account/<id>/app/<app>/...getsTOPIC_INVALIDon subscribe and publish. The hub’s topic grammar is a closed list of four shapes. The patch adds two. Until the protocol’s topic naming is amended, the adapter carries app traffic on the existing broadcast and device topics, marks each messagepayload_type = ak.app/<app>, and filters at the receiver. - The usable payload is 64 KiB minus the Kit’s header. Re-run: a 65,536-byte payload was delivered intact; 65,537 bytes was refused with
ENVELOPE_TOO_LARGEand the connection survived. The 1 MiB figure is the cap on the whole envelope; above it the hub closes the connection without an error. - The hub does things the Kit must handle itself. It echoes a broadcast back to the sender. Its message id is not a safe correlation key: 1 of 40 replies was already buffered before the requester’s own publish receipt. Subscribing and then catching up can deliver the same message live and in the catch-up page, so de-duplicate on message id. A copy that goes silent with its socket open is noticed only after the hub’s 90-second idle timeout; a killed copy is reported offline in under a millisecond.
- Rate limits matter for liveness. On one connection, 10 of 25 rapid publishes were accepted (limit 10 per second). Hourly caps of 600 per connection and 6,000 per account were read in the hub’s code, not measured. By that arithmetic a heartbeat every 30 s uses 20% of one connection’s hourly cap, and every 5 s exceeds it.
- Ordering holds per sender only. 200 messages from one sender arrived 200 of 200, in order. Four concurrent senders kept their own order, but arrival order across senders disagreed with message-id order.
- Loopback latency is small. Publish to peer receipt: p50 about 1.4 ms, p95 about 2 ms (60 samples, debug build, one machine).
- Catch-up paging can skip messages. Paging by a millisecond timestamp with a strict comparison skips messages that share a millisecond across a page boundary. With five synchronized senders and one message per page, a run recovered 9 of 40 messages; an earlier run recovered 10 of 40. The count depends on timing.
- A retention field means two things. The hub reads field 5 of the publish message as a “do not retain” flag. The published schema and the client SDK call it a retention class. An explicit “history” class is silently not stored.
What the App Context Kit does because of it.
- The peer layer has a provider interface (discover, connect, send, inbound stream). The Kit ships a fake provider and a loopback provider. There is no Hearth provider yet.
- Payload limits differ by provider, so a provider has to declare its own maximum and
sendhas to fail before the wire when it is exceeded. The App Context Kit’s specification says so (section 6.2). Not built: today the app passes the limit toPeerJson(for Hearth: 64 KiB minus the header). - The Kit’s own envelope carries a Kit-owned correlation id and
from.deviceplusfrom.instance, which is how echo is suppressed. What a Hearth provider must still add: request deadlines, de-duplication across live delivery and catch-up, ano_retainheartbeat no more often than every 30 s, and reconnect.
What is still open, and who owns it.
| Open | Owner |
|---|---|
| Amend the protocol’s topic naming for app-scoped topics (or decide to stay on the shared topics). | The protocol owners |
| The retention field mismatch, the catch-up paging cursor, and the missing unsubscribe in the hub. | The protocol owners |
| The Fabric Kit SDK exposes no catch-up, presence or unsubscribe, and does not reconnect. The prototype used a raw wire client for those. | The Fabric Kit’s maintainers |
| Not tested: a remote hub, TLS, paired-device connections, and the Dart peer code against a real hub (only the Rust adapter ran). | The App Context Kit’s maintainers |
Message Kit
The question. The Kit’s coordination envelope is {account, app, channel, from, seq, kind} plus an opaque, app-owned payload. Can Message Kit carry it between processes on one machine, and across its remote-provider seam to another machine?
What was built. A mapping of the envelope inside a Message Kit frame (19 mapping tests), a small peer process for local exchange tests (6 tests), a TCP provider of under 200 lines through the remote seam (4 tests), and a test of an oversize message (1 test).
What it showed.
-
The envelope fits without bending either side. The whole Kit message goes inside one Message Kit payload. One header works for Message Kit and for Hearth, with two mappings. They differ in who acknowledges a publish, whether a peer can send a directed message, size limits, rates, retention, and echo.
Message Kit, local Hearth Largest message about 200 KiB with default socket buffers (204,800 bytes sent; 212,992 failed) 64 KiB including the Kit header Round trip p50 about 70 microseconds (1,000 requests, two processes, container, debug build) about 2 ms, hub on loopback Echo to the sender none yes, suppress by from -
The local transport is Linux-only. It uses
AF_UNIXwithSOCK_SEQPACKET, which the macOS kernel does not implement. Checked here: creating such a socket on macOS fails with “Protocol not supported” (Errno 43). Windows is also out: the local bus code is compiled for Linux and Android only (read in Message Kit’s source; not run on Windows). So same-machine peers on macOS and Windows use the Kit’s loopback provider, which is plain TCP on127.0.0.1. -
On macOS the integration tests pass without testing anything. Re-run on macOS: 19 mapping tests passed, and the 11 integration tests print
SKIP ... local bus unavailableand report “passed”. Read the SKIP lines, not the totals. In a Linux container the same 30 tests ran with none skipped and all passed. -
What Message Kit cannot express today: an application answer inside a reply message; local fan-out to several receivers; a caller-chosen socket path; a configurable request timeout; and anything on macOS or iOS.
-
The remote seam has gaps. Several were found and reported to the Kit’s maintainers.
What the App Context Kit does because of it.
- It ships the loopback provider for same-machine peers, so macOS and Windows do not wait on Message Kit.
- The envelope header needs additions, tracked in the specification: an
in_reply_tocorrelation field, atofield for directed messages, a per-message retain hint, and a provider-declared maximum payload checked before the wire. The Dart core already has a request id (rid, kindkit.reply) for correlation. - A Message Kit provider is not built.
What is still open, and who owns it.
| Open | Owner |
|---|---|
Whether seq is per link, restarting at 1 (the Dart core), or one counter per sender (the Rust prototype). | The App Context Kit’s maintainers (a specification decision) |
Whether kind is a closed enum (Rust prototype) or kit.-prefixed strings (Dart core). | The App Context Kit’s maintainers (a specification decision) |
| The remote-provider seam gaps, an error code for an app-level refusal, an unreachable-endpoint code, and a caller-chosen socket path. | The protocol owners |
| Not tested: a real Hearth hub behind the provider, real LAN discovery, signed or packaged release builds. | The App Context Kit’s maintainers |
Storage Kit
The question. Storage Kit is content-addressed storage. The App Context Kit names an app by (account, app id). Can that name also name the app’s slice of storage, and is syncing a note between two copies cheap when only a little of it changed?
What was built. A mapping from (account, app id) to a storage partition, so copies of one app on one account share a partition and different accounts never do; a notes model on that partition; and copy-to-copy sync. The “wire” was an in-process call, so this measured bytes and blocks, not network time. Conflict handling used manifest ancestry. The Kit never merges: the app’s resolver chooses between keep-local, take-remote, merged, or defer.
What it showed. Re-run when this page was written, 10 MiB pseudo-random body, two contexts, debug build:
| Step | Blocks sent | Bytes | Share of the body |
|---|---|---|---|
| Cold sync to the second copy | 544 | 10,507,722 | 100.21% |
| Insert 37 bytes in the middle | 3 | 49,249 | 0.47% |
The 3 blocks are one manifest, the rewritten list node (about 25 KiB), and the one chunk that changed. 541 blocks were already present. The list node is the floor for any edit to a large body.
Also tested: identical bodies under two names store one set of chunks; each of the 10 blocks of one version, corrupted in turn, was rejected and the old body still read back.
What the App Context Kit does because of it. Nothing yet in the Kit. The findings point at three changes to the Kit’s directory model, none made:
- Reserve names under
datafor the block store (a convention, not a sixth directory kind). - Add an account dimension. The specification’s section 6 uses
(account, app id)as the scope, but the Kit’s directories are per operating-system user, not per account. - Quotas cannot be enforced until Storage Kit can delete.
Gaps in Storage Kit (for its maintainers): no insert that verifies a hash before filing the bytes, so a receiver must hash first; no way to list a list node’s children from a half-synced tree; no mutable head; no delete, garbage collection or quota; compression hides the payload from a sync; the async API has no chunking; heavy dependencies are always built (about 327 packages).
What is still open, and who owns it.
| Open | Owner |
|---|---|
| The gaps above. | The Storage Kit’s maintainers |
| Account dimension and reserved directory names in the Kit. | The App Context Kit’s maintainers |
| Not tested: a real network, two processes on one partition, crash safety (reasoned only), the S3 and HTTP backends. | The App Context Kit’s maintainers |
Identity Kit
The question. Identity Kit has service principals with scopes. Can the App Context Kit’s admit(peer, hello) hook use them to decide whether a peer may join an app’s coordination: accept, accept read-only, or reject?
What was built. An admit module with scopes app:<id>:run, app:<id>:coordinate and app:<id>:storage. coordinate is accept, run alone is read-only, storage alone is refused. An in-memory fake issuer stood in for issuance, which Identity Kit does not have yet. The fake is a test double, not a design for the real one.
What it showed.
- The shape works. A program that only types an app id is refused. One app’s principal cannot join another app. A principal from another account is refused. A household rule (a child account may not run a given app) can be checked at each join, so changing a role takes effect without reissuing anything. Identity Kit keeps role out of the principal on purpose, so that rule can only live in whoever verifies.
- Gaps in Identity Kit: issuance, signing and verification are not built; the principal carries no account; scopes are opaque strings with no constructor or parser; the family graph is not built, so the role rule cannot read real roles.
What the App Context Kit does because of it. The Kit’s only authenticators are an open one that accepts everyone and a shared-secret one marked as a test fake, and the code says plainly that they are for one machine and tests, never a network. The loopback provider has no encryption or authentication; do not use it across a trust boundary. A real authenticator waits on Identity Kit issuance and signing. Until then the App Context Kit scopes copies by account and app id as a naming convention and does not authenticate them: do not treat peers as trusted.
What is still open, and who owns it.
| Open | Owner |
|---|---|
| Issuance, signing and verification, and the lifecycle of a capability after it is issued. | The Identity Kit’s maintainers |
Where a principal travels in the coordination envelope. admit(peer, hello) has no slot for one today. | The protocol owners and the Identity Kit’s maintainers |
| Account in the principal; the family graph. | The Identity Kit’s maintainers |
Interface Kit (candidates)
The question. Six demo apps and a larger production app each wrote the same UI pieces by hand. Which pieces are shared enough to belong in the Interface Kit?
What was built. labs/interface_candidates: small, plain-Material components derived from what the apps duplicate. This is not the Interface Kit. The real Kit is untouched and nothing here is published. Every colour and text style comes from the ambient Theme, so the components can later be mapped onto the real design system. The package’s README lists the accessibility contract that the tests enforce.
Re-run when this page was written: flutter test in that package, 158 passed (make test-fast runs it).
What it showed. The catalog’s table has nine entries. Use in the demo apps, counted from the apps’ source:
| Component | Demo apps using it | Marked THIN? |
|---|---|---|
PageScaffold | 5 of 6 | no |
EmptyState | 3 | no |
StatusBanner | 2 | no |
AsyncStateView (with LoadingView, ErrorView) | 1 | no (strong support from the larger app) |
confirmDialog and promptDialog | 1 | yes |
SaveStatusChip | 1 | yes |
StatusDotRow | 0 (tried in one app and reverted) | yes |
showToast | 0 (four apps wanted one; none built one) | no (strong support from the larger app) |
relativeTime | 0 | yes |
The catalog marks four entries THIN: fewer than three real users, so a candidate rather than a recommendation. The rule for promotion is that at least three real screens duplicate the piece and it needs nothing from the app.
Two attempts were reverted, per the project’s notes (not re-run): EmptyState in the minesweeper demo (three one-line rows grew from 20 to 72 dp each) and StatusDotRow in the launcher (it worked, but turned a scannable table into a list; a design call).
No screen reader was run. The accessibility checks are semantics-tree and guideline tests.
What the App Context Kit does because of it. The core package stays free of Flutter. The Flutter companion carries ModeBadge, NotesBanner and the exit coordinator. The candidates stay in their own package. The Kit’s notes carry a severity (info, warning, error) so a banner can pick its style.
What is still open, and who owns it.
| Open | Owner |
|---|---|
| Which candidates, if any, are promoted, and mapping them onto the design system’s tokens. | The Interface Kit’s maintainers and the design system’s owners |
| The THIN components need a second or third real user before promotion. | The Interface Kit’s maintainers |
| Design calls such as dot rows versus tables, and tabs in the page scaffold. | The design owners |
Venue
The question. Can an app hold a reservation on a shared machine the same way whether Venue is real, absent or faked?
What was built. A session type in both languages (Dart and Rust) and the render-queue demo app, which uses it harder than the others.
What it showed. The session hides the three cases. It also showed what the Kit’s first version got wrong: the daemon’s refusal for a live-hold limit was mistyped as “unavailable”; a failed lease renewal was swallowed; apps had no way to find holds left by a crash; and every app that could be refused wrote the same wait-and-retry loop. All four are addressed in the Dart Kit (the wait helper is not in the Rust Kit). What remains needs changes in Venue itself.
What the App Context Kit does because of it. Typed refusals, holds(), lease-loss reporting and withHoldWaiting (Dart). Registering an app with a daemon is still four manual steps that need an operator with authority. The render-queue demo app and the demo apps guide show the session in use; the full story, with code and measurements, is in the App Context Kit’s Venue guide.
What is still open, and who owns it.
| Open | Owner |
|---|---|
| A machine-readable reason for the live-hold limit, a wait queue or push, and a small typed client library. | The Venue Kit’s maintainers |
| A provisioning step for new apps, and a manifest declaration of contract classes. | Undecided; the App Context Kit’s maintainers |
A Rust with_hold_waiting. | The App Context Kit’s maintainers |
Cross-cutting
Three themes recur across the probes.
- Most of what makes the Kit hard lives in the other Kits. The Kit’s own part (find, connect, route) held on the first try in three very different apps. The gaps were payload limits, topic grammar, identity and signing, and storage lifecycle.
- App identity is a naming convention until the Identity Kit issues and verifies capabilities. Hearth, Storage Kit and Identity Kit each showed a different face of the same fact.
- Build a shared piece when the second app copies it, not before. The same rule decided what moved into the Kit (a wait helper, JSON helpers, a peers handle) and what stayed out (single-user helpers).
Kits
This is the catalog of every Kit in the SlashBuilder Kit registry: public or private, working or banked or archived. Each Kit has a status card. A Kit that is not public yet is listed anyway, because a catalog that hid its unfinished parts would not be honest about the platform.
The catalog is generated from the registry, not written by hand, so it cannot drift from it. The pages are committed to this repository so you can read them without access to the registry. A link to a repository appears only when that repository is public today.
Generated from registry commit 389c4a5.
19 Kits: 2 pre-release, 11 alpha, 4 design, 1 banked, 1 legacy. 1 of them has a public repository today.
How to read the cards
- Status says how real the Kit is, judged by evidence (code and tests), not by intent. See the status vocabulary.
- Has real code today is Yes for the statuses alpha, pre-release and stable. It says nothing about whether you can read the code: that depends on the Source line.
- Kind is the registry’s own label for the Kit (cross-platform, hybrid, substrate, infrastructure or core). It is not defined further yet.
- Some cards are brief because the registry entry behind them is brief. They say so.
- A Kit that was merged into another keeps its card and says which. Things that are not registered Kits, and this book itself, are on Not in the registry.
pre-release
Published, and depended on by products. The API is settling but not frozen.
| Kit | Language | Kind | What it is | Has real code today | Repository |
|---|---|---|---|---|---|
| Fabric Kit | Rust | cross-platform | The Hearth SDK: pub/sub, pairing, and the family-graph client. | Yes | not public yet |
| Message Kit | Rust | hybrid | The Hearth wire envelope: T/R/D grammar and RemoteTransport. | Yes | not public yet |
alpha
Real code and tests exist, but the API is not stable. Expect it to change.
| Kit | Language | Kind | What it is | Has real code today | Repository |
|---|---|---|---|---|---|
| Inference Kit | Rust | hybrid | Local-first inference orchestration for Hearth households. | Yes | slash-builder/inference-kit |
| Manifest Kit | Rust | infrastructure | Generate operating systems from a single manifest. | Yes | not public yet |
| Spec Kit | Rust | cross-platform | Conformance and spec primitives shared by every Kit. | Yes | not public yet |
| Identity Kit | Rust | cross-platform | OIDC client and account/family identity types for the household. | Yes | not public yet |
| Service Kit | Rust | cross-platform | axum/tower service foundation: metrics, structured errors, audit trail. | Yes | not public yet |
| Storage Kit | Rust | hybrid | Content-addressed storage core: BLAKE3, dedup, per-household partitions. | Yes | not public yet |
| Substrate Kit | Rust | substrate | The Linux+dockerd container/process substrate for the household box. | Yes | not public yet |
| Interface Kit | Rust + Dart | cross-platform | Interface Kit provides shared composed UI/interaction patterns for household-computing surfaces (service tiles, install/status flows, navigation shells, pairing/onboarding chrome): not raw design tokens and not a full app. | Yes | not public yet |
| Venue Kit | Rust | cross-platform | One registry and planner for every place work can run. | Yes | not public yet |
| Mold Kit | Rust | cross-platform | No public tagline yet. Merged into Interface Kit. | Yes | not public yet |
| Stash Kit | Rust | core | Content-addressed working-memory substrate with per-principal keys and real deletion. | Yes (not published) | Repository not created yet |
design
A document. There is no code to speak of yet.
| Kit | Language | Kind | What it is | Has real code today | Repository |
|---|---|---|---|---|---|
| Platform Kit | Rust | substrate | No public tagline yet. Merged into Thunderhead Kit. | No | not public yet |
| Device Kit | Rust | substrate | Hardware peripheral access, DRM, audio, input, on the reference boards. Merged into Thunderhead Kit. | No | not public yet |
| Score Kit | Rust | cross-platform | Capability grants, rate limiting, and the household’s conductor/notification/sonic logic. Merged into Thunderhead Kit. | No | not public yet |
| App Store Kit | Rust | hybrid | Manifest, capability, and signing core for installable household services. Merged into Thunderhead Kit. | No | not public yet |
banked
Deliberately paused, together with the program it belonged to. Not abandoned, but not being worked on.
| Kit | Language | Kind | What it is | Has real code today | Repository |
|---|---|---|---|---|---|
| Thunderhead Kit | Rust | substrate | The Thunderhead device stack: board bring-up, peripherals, the conductor and the app runtime. Banked with the Thunderhead program. | No | not public yet |
legacy
Archived or retired. Kept in the catalog for the record; do not build new things on it.
| Kit | Language | Kind | What it is | Has real code today | Repository |
|---|---|---|---|---|---|
| Design Kit | Dart | cross-platform | Design Kit shipped Flutter/Dart design tokens. | No | not public yet |
Regenerating
Maintainers regenerate this folder with make docs-catalog; see Make targets.
Status vocabulary
Every Kit card carries one status. The test for a status is evidence, not intent: what the repository holds today (code, tests, a published version), not what is planned.
stable
The API is frozen, with a versioning promise.
No Kit has this status today.
pre-release
Published, and depended on by products. The API is settling but not frozen.
Kits with this status: Fabric Kit, Message Kit.
alpha
Real code and tests exist, but the API is not stable. Expect it to change.
Kits with this status: Inference Kit, Manifest Kit, Spec Kit, Identity Kit, Service Kit, Storage Kit, Substrate Kit, Interface Kit, Venue Kit, Mold Kit, Stash Kit.
scaffold
A skeleton of code that nothing depends on yet.
No Kit has this status today.
design
A document. There is no code to speak of yet.
Kits with this status: Platform Kit, Device Kit, Score Kit, App Store Kit.
banked
Deliberately paused, together with the program it belonged to. Not abandoned, but not being worked on.
Kits with this status: Thunderhead Kit.
legacy
Archived or retired. Kept in the catalog for the record; do not build new things on it.
Kits with this status: Design Kit.
Not in the registry
The catalog lists the Kits in the registry. Some things this book talks about are not registered Kits, so they have no card. They are listed here so that nobody has to wonder why.
App Context Kit
- Registered Kit: no, so it is not in the catalog
- Source: not public yet
The App Context Kit is the root namespace for one running app: its identity, its folders for files and settings, test and scratch modes, a roster entry other apps can read, a session with a resource broker, and an opt-in way to find other copies of the app. The seven demo apps in this book are built on it (six are Flutter on a Dart core; notes_rust is a Flutter UI over a Rust core that shares the UI’s context through the Kit’s adopt API), and the demo apps guide measures what it saved and what it still leaves for an app to write. Its name is final, and it is a candidate for the registry rather than a member of it.
Its own reference documentation is pulled into this book from the Kit, at the commit this book pins (make docs-pull), so you can read it without access to the Kit’s repository. It is the only Kit pulled so far. Start at the reference index.
- Reference index
- Overview
- Getting started
- Context guide
- Venue guide
- Coordination guide: copies of one app talking to each other
- Testing
- Architecture
- Platforms
- Design decisions
- The app context spec
- Example: dart_cli
- Example: rust_cli
- Example: flutter_hello
This book
This repository, kit-book, is the book, not a Kit. It has no card, it is not in the registry, and nothing depends on it.
Fabric Kit
The Hearth SDK: pub/sub, pairing, and the family-graph client.
- Status: pre-release (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
What it owns
Fabric Kit is the Hearth protocol SDK: pub/sub messaging, device pairing, and family-graph primitives.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Message Kit
The Hearth wire envelope: T/R/D grammar and RemoteTransport.
- Status: pre-release (what this means)
- Language: Rust
- Kind: hybrid
- Has real code today: Yes
- Source: not public yet
What it owns
Message Kit’s core is the envelope proto, the T/R/D grammar, and the RemoteTransport trait; its substrate binding is AF_UNIX SOCK_SEQPACKET (in-crate, not a separate repo). Inference Kit’s optional async inference fallback rides this kit via its message-kit-binding module.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Inference Kit
Local-first inference orchestration for Hearth households.
- Status: alpha (what this means)
- Language: Rust
- Kind: hybrid
- Has real code today: Yes
- Source: slash-builder/inference-kit
What it owns
Inference Kit is a cross-platform inference service for Hearth household clusters. Apps declare model dependencies (semantic versions); Inference Kit resolves, loads, and caches model weights via Storage Kit (shared across apps, mmap’d RO). Models are OS-shipped, signed at build time, and verified immutably at boot. Capability gating via Score Kit.
What it will not do
- Training or fine-tuning (inference-only; no backprop, no graph construction)
- Model serving infrastructure (multi-model load balancing, LLM-specific optimizations like LoRA/flash-attention deferred to app logic)
- Runtime model distribution (models ship with OS; no OTA, no remote fetch)
- Prompt filtering or jailbreak detection (application-layer concern)
- Output safety gates (application-layer concern)
- Model-specific tuning (quantization strategy is app config, not Inference Kit)
- Cloud hub relay in MVP (future feature gate; local-only MVP)
- TPM attestation (optional future enhancement; not MVP)
Related Kits
Storage Kit, Message Kit, Score Kit, Service Kit, Spec Kit, Device Kit, Platform Kit
Reference docs
This Kit’s own reference documentation has not been pulled into the book yet. make docs-pull pulls it once the Kit has a section in tools/docs-pull.conf; see Make targets.
Manifest Kit
Generate operating systems from a single manifest.
- Status: alpha (what this means)
- Language: Rust
- Kind: infrastructure
- Has real code today: Yes
- Source: not public yet
What it owns
Build-time infrastructure kit: each OS or app is one manifest over a common component catalog. Also owns the shared cross-platform target axis schema that any kit with more than one build target selects against, instead of inventing its own per-kit target list.
What it will not do
- Board bring-up or device drivers (Platform Kit / Device Kit concern)
Related Kits
Platform Kit, Device Kit, Substrate Kit
Reference docs
No reference docs yet.
Spec Kit
Conformance and spec primitives shared by every Kit.
- Status: alpha (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
What it owns
Spec Kit provides the conformance and spec primitives imported into every other Kit’s test suite: the invariants list pattern, the CHANGELOG-freeze convention, and the conformance::{Case, run_suite, ...} harness that Storage Kit and Inference Kit already reference.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Identity Kit
OIDC client and account/family identity types for the household.
- Status: alpha (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
What it owns
Identity Kit provides the OIDC client, capability primitives, and account/family types used across the fabric.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Substrate Kit, Score Kit, Service Kit
Reference docs
No reference docs yet.
Service Kit
axum/tower service foundation: metrics, structured errors, audit trail.
- Status: alpha (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
What it owns
Service Kit is the axum/tower service foundation used across SlashBuilder’s Rust services: no dockerd dependency. It provides the observability primitives (latency metrics, structured error codes, logging, audit trail) that other kits emit to; Inference Kit’s service-kit-bridge module is a live consumer.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Storage Kit
Content-addressed storage core: BLAKE3, dedup, per-household partitions.
- Status: alpha (what this means)
- Language: Rust
- Kind: hybrid
- Has real code today: Yes
- Source: not public yet
What it owns
The registry has no public-safe description for this Kit yet.
Related Kits
Reference docs
No reference docs yet.
Substrate Kit
The Linux+dockerd container/process substrate for the household box.
- Status: alpha (what this means)
- Language: Rust
- Kind: substrate
- Has real code today: Yes
- Source: not public yet
What it owns
Substrate Kit covers container/process lifecycle, cgroups, mount/volume, and service-principal embodiment (embodies identity-kit’s ServicePrincipal verbatim, never redefines it).
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Interface Kit
- Status: alpha (what this means)
- Language: Rust + Dart
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
What it owns
Interface Kit provides shared composed UI/interaction patterns for household-computing surfaces (service tiles, install/status flows, navigation shells, pairing/onboarding chrome): not raw design tokens and not a full app.
The registry entry for this Kit is still a short summary, so this card is brief.
It is made up of: Interface Kit (module patterns), Mold Kit (module mold).
Related Kits
Reference docs
No reference docs yet.
Venue Kit
One registry and planner for every place work can run.
- Status: alpha (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
- Name: provisional; it may change
What it owns
Venue Kit describes and plans over the places work can run: a machine, a VM, an API provider, a cloud pool you can rent from, a peripheral behind a host. What each is, how to reach it, what it has, what it can do, what it will accept, what it costs, and who may use it. It is a Kubernetes Resource Model vocabulary rather than a new format: Node, Endpoint, NodePool, ContractClass, ContractClaim, Placement and Budget in the API group node.slashbuilder.com/v1alpha1, with PriorityClass and Lease accepted verbatim from Kubernetes. Kubernetes (and DRA, Karpenter) is treated as the standards body: its names, shapes and semantics are followed unless a divergence is recorded with a reason.
Every answer is Yes, No or Unknown, and an Unknown says what would settle it. Nothing is trusted because a document says so: each manager writes its own observation, the effective view is computed on read and keeps disagreements visible, and work that carries data classes needs both a use grant and the data owner’s egress grant or the answer is Unknown.
What it will not do
- Running the work, or provisioning anything: Venue plans and records, something else provisions
- Inference, storage, messaging: those are their own Kits, which sit on top of or beside this one
- Containers and other workloads (many, short-lived; their names and images describe the estate): another layer’s job
- Changing a machine: accounts, keys, sudo, packages, policies.
Related Kits
Identity Kit, Storage Kit, Inference Kit, Interface Kit, Manifest Kit, Score Kit
Reference docs
No reference docs yet.
Mold Kit
- Status: alpha (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: Yes
- Source: not public yet
- Registry entry: merged into Interface Kit; this Kit’s repository and crate names are unchanged
What it owns
The registry has no public-safe description for this Kit yet.
What it will not do
- Mold rendering / UI widgets
Related Kits
Reference docs
No reference docs yet.
Stash Kit
Content-addressed working-memory substrate with per-principal keys and real deletion.
- Status: alpha (what this means)
- Language: Rust
- Kind: core
- Has real code today: Yes (not published)
- Source: Repository not created yet
- Registry entry: pending; not yet a published Kit
What it owns
The registry has no public-safe description for this Kit yet.
What it will not do
- Inference of any kind: that is Inference Kit
- Retrieval ranking, graph retrieval, consolidation, decay curves
- Bytes, chunking, packfiles, dedup: Storage Kit
Related Kits
Storage Kit, Inference Kit, Message Kit, Score Kit
Reference docs
No reference docs yet.
Platform Kit
- Status: design (what this means)
- Language: Rust
- Kind: substrate
- Has real code today: No
- Source: not public yet
- Registry entry: merged into Thunderhead Kit; this Kit’s repository and crate names are unchanged
What it owns
The registry has no public-safe description for this Kit yet.
Related Kits
Reference docs
No reference docs yet.
Device Kit
Hardware peripheral access, DRM, audio, input, on the reference boards.
- Status: design (what this means)
- Language: Rust
- Kind: substrate
- Has real code today: No
- Source: not public yet
- Registry entry: merged into Thunderhead Kit; this Kit’s repository and crate names are unchanged
What it owns
Device Kit covers hardware peripherals (DRM display, PipeWire audio, evdev input) on the Thunderhead reference boards. Score Kit’s conductor/sonic logic routes hardware audio output through this kit. Attributed to the parked Thunderhead hardware program alongside Platform Kit.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Score Kit
Capability grants, rate limiting, and the household’s conductor/notification/sonic logic.
- Status: design (what this means)
- Language: Rust
- Kind: cross-platform
- Has real code today: No
- Source: not public yet
- Registry entry: merged into Thunderhead Kit; this Kit’s repository and crate names are unchanged
What it owns
Score Kit covers capability grants, rate limiting, SLA tracking, and conductor/notification/sonic logic; hardware audio output routes through Device Kit.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
App Store Kit
Manifest, capability, and signing core for installable household services.
- Status: design (what this means)
- Language: Rust
- Kind: hybrid
- Has real code today: No
- Source: not public yet
- Registry entry: merged into Thunderhead Kit; this Kit’s repository and crate names are unchanged
What it owns
App Store Kit’s core is the manifest/capability/signing/catalog logic for installable household services; its runtime binding is currently Podman/crun and must move onto the Linux+dockerd substrate (via Substrate Kit) per the single-dockerd-substrate commitment.
The registry entry for this Kit is still a short summary, so this card is brief.
Related Kits
Reference docs
No reference docs yet.
Thunderhead Kit
The Thunderhead device stack: board bring-up, peripherals, the conductor and the app runtime. Banked with the Thunderhead program.
- Status: banked (what this means)
- Language: Rust
- Kind: substrate
- Has real code today: No
- Source: not public yet
- Name: provisional; it may change
What it owns
The registry has no public-safe description for this Kit yet.
It is made up of: Platform Kit (module platform), Device Kit (module device), Score Kit (module conductor), App Store Kit (module appstore).
Related Kits
Substrate Kit, Identity Kit, Venue Kit, Inference Kit
Reference docs
No reference docs yet.
Design Kit
- Status: legacy (what this means)
- Language: Dart
- Kind: cross-platform
- Has real code today: No
- Source: not public yet
What it owns
Design Kit shipped Flutter/Dart design tokens.
The registry entry for this Kit is still a short summary, so this card is brief.
Reference docs
No reference docs yet.
App Context Kit documentation
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
Overview · Getting started · Context guide · Venue guide · Coordination · Architecture · Platforms · Testing · Design decisions · Spec · Contributing · Repository home
The App Context Kit is the root namespace for one running app. It gives an app an identity, a place for its files and settings, test and scratch modes, a presence entry other apps can see, a session with a resource broker, and an opt-in way to find and talk to other copies of itself. It does not decide what any of that means for your app.
It is a piece of a larger platform, it is not published to a package registry yet, and the question behind it is: does the BeOS idea of an Application Kit still make sense when you build several small apps instead of one big one? These pages say what was built, how it was checked, and what is not yet known.
Start here
| I want to… | Read |
|---|---|
| understand what this is in five minutes | Overview |
| build a Flutter app on it | Getting started, then the context guide |
| use it from plain Dart or from Rust | Getting started: plain Dart, Rust and examples/dart_cli, examples/rust_cli |
| have two copies of my app find and talk to each other | Coordination guide |
| take machine time from a resource broker | Venue guide |
| write tests that never touch real files | Testing |
| ship on macOS, Linux or Windows | Platforms |
| judge whether the idea holds | Design decisions, then the results at the foot of this page |
| see how the pieces fit | Architecture |
| read the contract itself | Spec and its fixtures |
Every page
Learn
| Page | What you get |
|---|---|
| Overview | The problem every app re-solves, the idea, the pieces in one table, the rules, what it does not do |
| Getting started | Numbered steps from nothing to a running app, plus pure-Dart and Rust versions |
| Architecture | Six diagrams: the layers, start-up and shutdown, the peer handshake, the roster versus peer announcements |
| Design decisions | Why it is shaped this way, what we chose not to build, and the open questions |
Build
| Page | What you get |
|---|---|
| Context guide | Manifest fields, directories per platform, every environment variable, settings, lifecycle, the roster, the Flutter companion |
| Venue guide | The broker session: modes, holds and leases, typed refusals, orphan holds, pinned TLS, what registering an app costs |
| Coordination guide | The five-method hook, the hub, providers, three worked patterns, and what the hook does not give you |
| Testing | Scratch and mock modes, fakes, the shared fixtures, real-daemon runs, Linux and TLS scripts |
Evidence
| Page | What you get |
|---|---|
| Platforms | What runs where and how it was verified, with the macOS sandbox entitlement table and the crashed-copy numbers |
Runnable examples
Every code sample in these pages is copied from one of these, or from a passing test or one of the demo apps in The Kit Book. Run them rather than trusting the text.
| Example | What it shows | Run |
|---|---|---|
examples/dart_cli | The Kit from plain Dart: open a context, a setting, a counter, the Venue-off session | dart test / dart run bin/main.dart |
examples/rust_cli | The same app in Rust; prints the same lines | cargo run |
examples/flutter_hello | A five-line main.dart, app.json and a widget test | flutter test |
examples/coordination | A two-copy “ping” app on the fake network, plus behaviour tests (scope, ordering, limits) | dart test |
The short version of the results
- One contract, two implementations. Dart and Rust pass the same 61 fixtures. The second implementation found about a dozen behaviours the first had decided silently.
- Less code per app. Helper code the six apps wrote themselves went from 727 lines to 330, up to 777 after coordination was added, and down to 164 once the repeated parts moved into the Kit. Every
main.dartis 5 to 11 lines. Build a Kit piece when the second app copies it, not before. - The hook is the right shape, and not enough on its own. It carried a shared timer and a game handoff cleanly, and live play only just. For sync it was a small shell around a lot of app code, because the Kit gives no acknowledgement, large transfer, replay or liveness tick.
- Same-machine coordination works; across machines is not built. Two copies found each other in under half a second on one machine; a crashed copy disappeared in about 3.5 s.
What is not verified
A real Linux desktop session (the cores are tested in Linux containers, and the demo apps were built in release mode and launched headless in a Linux container), window close on a real window, signed or packaged release builds, Windows beyond the pure path rules, and the peer code against a real remote hub. Each page marks its own unverified claims. “Verified” in these pages means a test, a command or a measurement in the App Context Kit repository; anything else is labelled as reasoned or reported.
The demo apps
Several pages quote six small demo apps (a calculator, minesweeper, a notepad, a stopwatch, a render queue and a launcher) and the helper code each one had to write. The apps are not in the App Context Kit repository: they live in The Kit Book (the Kit Book repository), which documents the Kit and holds the demo apps. Paths such as lib/kit_gaps/ or lib/coordination/ in these pages are paths inside those apps.
Conventions
- Page names are stable; sections are linked by their headings.
- Code blocks are checked against the App Context Kit repository. Where a block is illustrative, it says so.
- Some findings about other systems are deliberately not described here: where a review is pending, the page says that and nothing more.
- Numbers come with what they were measured on (macOS debug builds, loopback, a throwaway local broker). They are not benchmarks.
Repository home · Spec · Contributing · Licence (Apache-2.0)
Pages in this book
Every page pulled from the Kit, in reading order.
| Page | What it covers |
|---|---|
| Overview | The App Context Kit is a small library that gives one running application its root namespace: who it is, where its files live, where its settings are, whether it is running for real or under test… |
| Getting started | This guide takes you from an empty folder to a Flutter desktop app that runs on the App Context Kit, then shows the same thing in plain Dart and in Rust. |
| Context guide | Reference for the AppContext and everything it is built from. |
| Venue guide | How an app built on the App Context Kit asks Venue for a share of a machine’s resources, and what happens when Venue says no, goes away, or the app dies. |
| Coordination guide: copies of one app talking to each other | This guide is for a developer who wants two running copies of the same app to find each other, agree on who is there, and exchange messages. |
| Testing | Two things are covered here: how to test an app built on the Kit, and how to run the App Context Kit repository’s own test suites. |
| Architecture | This page explains how the App Context Kit is put together: the layers, what happens when an app starts and stops, how two copies of an app connect, and how the spec, the shared test fixtures and the… |
| Platforms | What runs where, how it was checked, and what was not. |
| Design decisions | Why the App Context Kit is shaped the way it is, what we tried instead, and what is still undecided. |
| The app context spec | This is the contract both implementations (Rust in rust/, Dart in dart/app_context_kit/) must satisfy. |
| Example: dart_cli | A command-line program on the App Context Kit core (pure Dart, no Flutter). |
| Example: rust_cli | The same program as ../dart_cli, using the Rust crate in rust/: same app.json, same directories, same settings file. |
| Example: flutter_hello | A small Flutter desktop app on the App Context Kit: a button that counts taps, saves the count as a setting and writes it to a file under the data directory. |
Overview
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
The App Context Kit is a small library that gives one running application its root namespace: who it is, where its files live, where its settings are, whether it is running for real or under test, who else is running, and how it shuts down. You describe the app in a short app.json, open an AppContext, and the rest of your code asks that object instead of working these things out itself.
It is a work in progress. App Context Kit borrows its idea from the BeOS Application Kit; it is not a reimplementation of it and is not affiliated with Haiku. Nothing here is published to a package registry. See Status.
The problem
Every desktop or mobile app starts main() by answering the same questions, and most answer them differently:
- Identity. What is this app called, what version is it, and what is its reverse-DNS id? The answer ends up in four places that can disagree: the
pubspec.yaml, the macOS bundle, the Linux runner and a string in the code. - Where files live. Data, settings, cache and runtime files each have a conventional home on macOS, Linux and Windows. Apps hard-code one of them, or copy a path helper from the last project.
- Settings. Reading and writing a small JSON file safely: atomically, and without overwriting a file you could not read.
- Test modes. A way to run the app against fakes, in a throwaway folder, without touching the developer’s real data. Each app grows its own
--mockswitch. - Who else is running. Is another copy of this app already open? Which apps are running at all?
- Quitting cleanly. Window close, Quit and Ctrl-C are different events. Cleanup that runs on one of them is often skipped on the others.
None of this is hard. It is the same code, written again for each app, and it is where small bugs live: a settings file that is silently replaced after one bad byte, a lock file left behind after a crash, a test that wrote into the real home directory.
The idea
Put all of it in one place, under one object, driven by one file.
app.json -> AppManifest -> resolve (pure function) -> AppContext.open -> your app
Resolution (which directory, which mode) is a pure function of the manifest, the platform, the home directory and the environment. It does no I/O, so it can be specified exactly and tested with fixtures. Opening a context then creates the directories, reads the settings, opens a Venue session and announces the app in the roster. Closing it undoes that, in a fixed order, once.
Where it comes from
The idea and the shape come from the BeOS Application Kit. In BeOS every program had a BApplication object that was the program’s root, and a BRoster that knew which programs were running. This Kit keeps those two ideas: one root object per running app, and a roster of running apps.
It differs on purpose in three ways:
- No looper or handler model. BeOS built the Kit around message loops (
BLooper,BHandler). This Kit does not own your event loop or your threads. Flutter already has one. The Kit gives you a context object, and the only messaging it provides is the opt-in peers piece, between copies of the same app. - Cross-language. The contract is a written spec plus shared test fixtures, not one implementation. A Dart implementation and a Rust implementation both pass every fixture.
- Flutter on top of a Rust-or-Dart core. The intended shape is a Flutter UI over a core that may be written in either language. Today six of the demo apps (in The Kit Book) use the Dart core, and a seventh puts a Rust core under a Flutter UI. The Rust crate implements the same spec and passes the same fixtures, and the two sides share one context: the Dart side opens it and the Rust side adopts it (see Sharing one context with a Rust core).
The pieces
| Piece | What your app gets | In the Dart core | In the Rust crate |
|---|---|---|---|
| Manifest and app id | app.json parsed and validated; a reverse-DNS id, name, version | AppManifest | Manifest |
| Directories | data, config, state, cache and runtime, resolved per platform, created for you | ctx.dirs | ctx.resolved.dirs |
| Settings | A JSON settings file with atomic writes that is never overwritten when damaged | ctx.config (ConfigStore), SessionSettings, AppJsonFile | ctx.config (ConfigStore) |
| Modes | real or mock, and a scratch root that puts every directory in one throwaway folder | ctx.mode, ctx.isMock, ctx.scratch | ctx.mode(), ctx.is_mock(), ctx.is_scratch() |
| Roster | A presence file per running copy, refreshed every 5 seconds; a way to list who is running | ctx.roster | ctx.roster |
| Venue session | One object for “ask the resource broker for something”, the same whether Venue is real, absent or faked | ctx.venue | ctx.venue (local and fake sessions only) |
| Peers (opt-in) | Find, connect to and exchange messages with other running copies of the same app | ctx.startPeers, ctx.peerHub | not implemented |
Venue is a resource broker. An app asks it for a lease on something, such as a slot to run a job, holds it while it works and releases it. The Kit does not implement Venue; it provides the client side and hides whether a Venue is configured. With no Venue the session is local and says so, and in mock mode it is an in-memory fake. The client for a real daemon exists in the Dart core only: the Rust crate has local and fake sessions and no remote Venue session.
Peers are off unless the manifest lists "peers" in its kits. The Kit ships two providers: an in-memory fake network for mock mode and tests, and a loopback provider for copies on one machine. Peers between machines are not part of the Dart core.
A Flutter companion package, app_context_kit_flutter, adds what the pure-Dart core cannot do: load app.json from the asset bundle, close the context exactly once however the app ends, and a few small widgets (a mode badge, a notes banner, a peers indicator, a startup error screen). It also provides widget-test helpers.
Design rules
- The spec is the contract.
spec/APP-CONTEXT.mdand the fixtures inspec/conformance/define behaviour. Both implementations must pass every fixture. If the prose and a fixture disagree, the fixture wins and the prose is corrected. - Apps compose the Kit and never fork it. An app that needs something the Kit lacks writes it in the app (the demo apps keep these under
lib/kit_gaps/) and marks it// KIT GAP:. That code is the record of what is missing. - The Kit never interprets an app’s payload. It finds peers, connects to them and carries bytes. What a message means is the app’s decision.
- The core stays pure Dart.
app_context_kithas no Flutter import. Anything that needs Flutter goes inapp_context_kit_flutter. - Build a piece when the second app copies it. Code only one app needs stays in that app. A helper moves into the Kit once a second app has written the same thing.
What it does not do
- No UI toolkit. The companion package has a handful of plain Material widgets for the Kit’s own state. It has no design system, theming, routing or state management.
- No sync engine. Peers gives you discovery, a connection and best-effort ordered delivery. Reconciling data between copies is your code. Large transfers are not built.
- No installer or updater. The Kit does not install, update or launch apps. The roster reports which apps are running, not which are installed.
- No authentication or accounts. The peers scope key is
(account, app id), and the account defaults to the stringlocal. The default authenticator,OpenAuthenticator, accepts every peer, so an account or app id is a claim, not a proof. The only other authenticator in the code is a test fake. The Kit does not issue accounts or credentials. - No window title, bundle id or version sync. Those live in native build files that a running app cannot change. A script,
dart/app_context_kit_flutter/tool/check_identity.dart, checks that they agree withapp.json.
Repository map
| Path | What it is |
|---|---|
spec/APP-CONTEXT.md | The contract: manifest, resolution, lifecycle, roster, peers |
spec/conformance/ | Shared fixtures, generated by tools/gen_fixtures.py; both languages must pass them |
dart/app_context_kit/ | The Dart core (pure Dart) |
dart/app_context_kit_flutter/ | The Flutter companion package |
rust/ | The Rust crate (slashbuilder-app-context-kit); builds from the App Context Kit repository alone |
tools/ | gen_fixtures.py, linux-test.sh |
docs/ | This documentation. docs/examples/ holds code that was compiled and run |
Documentation:
- Getting started: a running app, step by step
- Context guide: reference for fields, directories, environment variables and lifecycle
- Testing: testing an app built on the Kit, and running the App Context Kit repository’s suites
- Contributing: building and changing the Kit itself
Status
- The Dart packages are marked
publish_to: none; none is on pub.dev. The Rust crateslashbuilder-app-context-kitis not published to crates.io yet. - The spec is version 0.1. Behaviour may change, including in breaking ways.
- Verified for this documentation: the Dart core and companion test suites, the Rust test suite and the conformance fixtures in both languages, run on macOS; the Linux runs of the cores in Docker; and the demo apps built in release mode and launched headless in a Linux container (see Platforms). No Windows test run is recorded in the App Context Kit repository, although the fixtures cover Windows path resolution.
Getting started
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
This guide takes you from an empty folder to a Flutter desktop app that runs on the App Context Kit, then shows the same thing in plain Dart and in Rust. For what the Kit is, read the overview. For every field and rule, see the context guide.
The app you build is a window with a button. Each tap is counted, saved as a setting and written to a file. The finished project is in docs/examples/flutter_hello/. Every code block below is a file from that directory, or a file from docs/examples/dart_cli/ or docs/examples/rust_cli/.
You need Flutter 3.24 or later with desktop support enabled. The steps were walked through with Flutter 3.41.9 and Dart 3.11.5 on macOS. The demo apps built on the Kit were also built in release mode and launched on Linux, headless in a container (see Platforms). Where a step differs on Linux or Windows and was not run there, the text says so.
1. Create the project
The app id you choose in app.json should match the bundle id Flutter generates, so create the project with a package name and organisation that join up. Package hello with organisation dev.example gives the id dev.example.hello:
flutter create --platforms=macos,linux,windows --org dev.example hello
cd hello
rm test/widget_test.dart # the template's counter test; it does not fit this app
2. Add the dependencies
The two Kit packages are not published. They are publish_to: none, so you point at them by path or by git.
By path (what the demo apps use). Check out the App Context Kit repository somewhere and point at it. Add to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
app_context_kit:
path: ../app-context-kit/dart/app_context_kit
app_context_kit_flutter:
path: ../app-context-kit/dart/app_context_kit_flutter
Adjust the relative path to wherever your checkout is. List both packages: app_context_kit_flutter depends on app_context_kit, and your code imports both.
By git, once the App Context Kit repository is reachable by URL. Pin both entries to the same full 40-character commit hash in ref:
app_context_kit:
git:
url: <repository-url>
path: dart/app_context_kit
ref: <full-commit-sha>
app_context_kit_flutter:
git:
url: <repository-url>
path: dart/app_context_kit_flutter
ref: <full-commit-sha>
This form was tested against a local clone of the App Context Kit repository. With the default branch instead of a pinned hash, or with an abbreviated hash, pub get failed with a version-solving error, because the companion’s own dependency on the core resolved to a different reference than yours. I have not tested it against a public URL, because there is none yet.
Then declare the manifest as an asset, and make the pubspec.yaml version match the manifest:
version: 0.1.0+1
flutter:
uses-material-design: true
assets:
- app.json
The template’s version is 1.0.0+1. The Kit does not read the pubspec.yaml, but app.json and the pubspec.yaml should say the same thing, and the check in step 9 fails if they do not.
3. Write app.json
Create app.json next to pubspec.yaml. This is docs/examples/flutter_hello/app.json:
{
"id": "dev.example.hello",
"name": "Hello",
"version": "0.1.0",
"kits": [],
"envPrefix": "HELLO",
"venue": { "mode": "off" }
}
| Field | Required | What it is | Default |
|---|---|---|---|
id | yes | Reverse-DNS id under a domain you control (the examples use com.slashbuilder.demo.*), at least two dot-separated segments. Each segment starts with a lower-case letter and then has lower-case letters, digits or -. It names your directories and your roster entry. | none |
name | yes | Display name. Must not be blank. | none |
version | yes | MAJOR.MINOR.PATCH, digits only. A pre-release suffix such as -beta is refused. | none |
kits | no | List of lower-case kit names, no duplicates. Today only "peers" has an effect: it allows ctx.startPeers and ctx.peerHub. Other names are accepted and ignored. | [] |
envPrefix | no | UPPER_SNAKE_CASE prefix for this app’s environment variables (HELLO_MODE, HELLO_DATA_DIR). | the last segment of id, upper-cased, with anything outside A-Z0-9 replaced by _ (render-queue becomes RENDER_QUEUE) |
venue.mode | no | auto, on or off. See Venue. | auto |
venue.consumer | no | The name this app presents to Venue. Lower-case letters, digits and -, starting with a letter. | the last segment of id |
If the manifest is wrong, the app shows an error screen with a code such as bad-id. The context guide lists every code. The rules are in spec section 1.
4. Write main.dart
Replace lib/main.dart with this. It is docs/examples/flutter_hello/lib/main.dart, and apart from the import it is the same line as the calculator demo app’s lib/main.dart (the demo apps are in The Kit Book):
import 'package:app_context_kit_flutter/app_context_kit_flutter.dart';
import 'hello_app.dart';
Future<void> main() => AppContextKit.run(manifestAsset: 'app.json', app: (_) => const HelloApp());
AppContextKit.run does the work a hand-written main() would: it initialises Flutter’s binding, loads and validates app.json from the asset bundle, opens the AppContext, mounts an AppContextScope above your widget, and makes window close, Quit, SIGINT and SIGTERM all close the context exactly once before the process ends. If it cannot start, it shows an error screen instead of crashing.
5. Read the context, save a setting, write a file
Create lib/hello_app.dart. This is docs/examples/flutter_hello/lib/hello_app.dart:
import 'package:app_context_kit/app_context_kit.dart';
import 'package:app_context_kit_flutter/app_context_kit_flutter.dart';
import 'package:flutter/material.dart';
class HelloApp extends StatefulWidget {
const HelloApp({super.key});
@override
State<HelloApp> createState() => _HelloAppState();
}
class _HelloAppState extends State<HelloApp> {
late final AppContext ctx;
late final SessionSettings settings;
late int taps;
var _started = false;
// An InheritedWidget cannot be read in initState, so read the context on the first didChangeDependencies.
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_started) return;
_started = true;
ctx = AppContextScope.of(context);
settings = SessionSettings(ctx.config);
taps = settings.get<int>('taps', 0);
}
void _tap() {
setState(() => taps++);
settings.set('taps', taps); // a setting, in <config>/config.json
ctx.dataFile('taps.log').writeAsStringSync('$taps\n'); // a file, under <data>
}
@override
Widget build(BuildContext context) => MaterialApp(
title: ctx.manifest.name,
home: Scaffold(
appBar: AppBar(title: Text(ctx.manifest.name), actions: [ModeBadge.forContext(ctx)]),
body: Column(children: [
NotesBanner.forContext(ctx),
Text('Taps: $taps'),
FilledButton(onPressed: _tap, child: const Text('Tap')),
]),
),
);
}
What this uses:
AppContextScope.of(context)returns theAppContextthatAppContextKit.runmounted. It throws aFlutterErrorthat says how to fix it if there is no scope above the widget. There is alsomaybeOf, which returnsnull.ctx.manifestis the parsedapp.json.- A setting.
ctx.configis aConfigStoreover<config>/config.json.ctx.config.set('taps', 3)saves at once, atomically. Ifconfig.jsonis damaged,setthrowsDamagedConfigErrorrather than overwrite a file it could not read. This example wraps the store inSessionSettings, whosesetnever throws: while the file is damaged it keeps the change in memory for the session, andsettings.readOnlytells you so. See settings. - A file under data.
ctx.dataFile('taps.log')is aFileunderctx.dirs.data. The directory already exists. For a JSON file with an atomic write and a read that tells “missing” from “damaged”, usectx.dataJson('name.json'). ModeBadge.forContext(ctx)shows nothing in a normal run and a chip readingmock,scratchormock + scratchotherwise.NotesBanner.forContext(ctx)shows warnings and errors the Kit recorded inctx.notes, such as a damaged settings file. It hides information-level notes.
ctx.notes is a plain list that notifies nobody. If your app adds a note, rebuild the banner yourself.
6. Run it
flutter run -d macos # or -d linux, -d windows
On macOS the app wrote its folders here (checked by running a fresh project built as above):
| Kind | Where on macOS |
|---|---|
| data | ~/Library/Application Support/dev.example.hello/data |
| config | ~/Library/Preferences/dev.example.hello |
| state | ~/Library/Application Support/dev.example.hello/state |
| cache | ~/Library/Caches/dev.example.hello |
flutter create turns the macOS App Sandbox on, so the ~ above is the app’s container, ~/Library/Containers/dev.example.hello/Data. See the macOS sandbox. The context guide has the table for Linux and Windows.
Scratch and mock
Two environment variables let you run the app without touching real data:
APP_CONTEXT_SCRATCH=<folder>puts every directory, and the roster, under that folder.APP_CONTEXT_MODE=mockswaps Venue for an in-memory fake.HELLO_MODE=mockdoes the same for this app only.
flutter run passes your shell’s environment to the app (checked with APP_CONTEXT_SCRATCH on macOS). On Linux:
APP_CONTEXT_SCRATCH="$(mktemp -d)" APP_CONTEXT_MODE=mock flutter run -d linux
That exact command was not run on Linux (the demo apps were built there with flutter build linux --release and launched headless in a container; see Platforms). With both variables set, the app bar shows the chip mock + scratch. On macOS the scratch folder has to be inside the sandbox container, so the command is different there; see the macOS sandbox.
7. Venue, and the default
Venue is a resource broker. ctx.venue is the one object an app uses to ask it for something. The manifest’s venue.mode decides how the Kit opens it:
venue.mode | Behaviour |
|---|---|
off | Never looks for a token or a daemon. The session is local, with the description Venue: local (Venue is off for this app). |
auto (the default) | Looks for a token. With none, the session is local: Venue: local (Venue not configured). With a token, it connects if the daemon answers; if not, it degrades to local and records a warning. |
on | The app needs Venue. With no token, or no answer, opening the context fails, and the Flutter companion shows the error screen with the code venue-required. |
So an app does not need a Venue to run, and does not need "mode": "off" for that: auto already degrades to a local session when nothing is configured. This example sets off to say plainly that it does not use Venue. In a normal run the Kit records one information-level note carrying the Venue description. NotesBanner hides it unless you pass showInfo: true.
In mock mode the session is always the in-memory FakeVenue, whatever venue.mode says. The environment variables that configure a real Venue are in the context guide.
8. Write the first widget test
Create test/hello_test.dart. This is docs/examples/flutter_hello/test/hello_test.dart:
import 'dart:io';
import 'package:app_context_kit_flutter/testing.dart';
import 'package:hello/hello_app.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('taps are saved and survive a quit and relaunch', (tester) async {
final kit = await pumpKitApp(
tester,
manifest: File('app.json').readAsStringSync(),
app: (ctx) => const HelloApp(),
);
expect(find.text('Taps: 0'), findsOneWidget);
expect(find.text('mock + scratch'), findsOneWidget);
await tester.tap(find.text('Tap'));
await tester.pump();
expect(find.text('Taps: 1'), findsOneWidget);
expect(kit.ctx.dataFile('taps.log').readAsStringSync(), '1\n');
await kit.relaunch(); // close, open again on the same scratch directory, mount again
expect(find.text('Taps: 1'), findsOneWidget);
});
}
flutter test
Expected: All tests passed!.
pumpKitApp makes a temporary scratch folder, sets APP_CONTEXT_MODE=mock, opens the context without a roster entry, mounts your widget under an AppContextScope, and deletes the folder when the test ends. It never reads the environment of the machine running the tests. kit.relaunch() closes the context, so your onClose hooks run, opens a new one on the same files and mounts the app again. Testing has the rest.
9. Check that the identity agrees
An app’s identity lives in app.json, in pubspec.yaml, in the macOS runner and in the Linux runner. The Kit cannot change native build files, so a script compares them. From a checkout of the App Context Kit repository:
cd dart/app_context_kit_flutter
dart tool/check_identity.dart /path/to/hello
For the project above it printed:
== /path/to/hello
note name: app.json says "Hello" but the macOS PRODUCT_NAME is "hello" (same name, different letter case)
note name: app.json says "Hello" but the Linux window title is "hello" (same name, different letter case)
ok: id, name and version agree everywhere they were checked
With the template’s version: 1.0.0+1 it reported MISMATCH version and exited 1. It does not check Windows, Android or iOS, and it never edits a file.
macOS sandbox
The macOS runner that flutter create generates has the App Sandbox on. Three things follow, each checked on macOS with a project built as above.
Files go in the app’s container. Inside the sandbox, HOME and TMPDIR point into ~/Library/Containers/<bundle id>/Data, so the default directories and the roster all land there. You do not need to do anything.
A scratch folder must be inside the container too. The sandbox refuses writes elsewhere. With APP_CONTEXT_SCRATCH=/tmp/somewhere, the app created nothing and showed its start-up error screen: “This app could not start. The app’s folders could not be created or read: Creation failed (/tmp/somewhere)”, with Error code: open-failed. A path under the container works:
APP_CONTEXT_SCRATCH="$HOME/Library/Containers/dev.example.hello/Data/tmp/scratch" flutter run -d macos
The container folder only exists after the app has run once.
Network access needs an entitlement. flutter create writes these files in macos/Runner/: DebugProfile.entitlements (sandbox, JIT and network.server) and Release.entitlements (sandbox only). A Venue daemon is reached over HTTP, and peers connect to each other, so an app that does either is likely to need outgoing connections allowed. Add this to both files:
<key>com.apple.security.network.client</key>
<true/>
For reference, in release builds the calculator demo app (Venue off, no peers) declares the sandbox and nothing else, and the notepad demo app (peers on) declares network.server and network.client. What happens to an app that uses peers or Venue without these entitlements was not tested. Two of the demo apps, launcher and render-queue, turn the sandbox off.
Plain Dart
The core package has no Flutter dependency, so a command-line tool or server can use it. The program is docs/examples/dart_cli/. It reads the same kind of app.json:
{
"id": "dev.example.hello",
"name": "Hello",
"version": "0.1.0",
"envPrefix": "HELLO",
"venue": { "mode": "off" }
}
pubspec.yaml depends on the core only:
name: dart_cli
description: A command-line program built on the App Context Kit core. Example from docs/GETTING-STARTED.md.
publish_to: none
environment:
sdk: ^3.0.0
dependencies:
app_context_kit:
path: ../../../dart/app_context_kit
dev_dependencies:
test: ^1.24.0
bin/main.dart:
import 'dart:io';
import 'package:app_context_kit/app_context_kit.dart';
Future<void> main() async {
final manifest = AppManifest.parse(File('app.json').readAsStringSync());
await AppContext.run(manifest, (ctx) async {
// A setting: kept in <config>/config.json, written atomically.
final launches = ctx.config.get<int>('launches', 0) + 1;
ctx.config.set('launches', launches);
// A file of the app's own: under <data>.
ctx.dataFile('hello.txt').writeAsStringSync('launch $launches\n');
print('app ${ctx.manifest.id} ${ctx.manifest.version}');
print('mode ${ctx.mode.name}${ctx.scratch ? ' (scratch)' : ''}');
print('data ${ctx.dirs.data}');
print('config ${ctx.dirs.config}');
print('launches $launches');
print('venue ${ctx.venue.description}');
});
}
cd docs/examples/dart_cli
dart pub get
APP_CONTEXT_SCRATCH=/tmp/app-context-demo dart run bin/main.dart
app dev.example.hello 0.1.0
mode real (scratch)
data /tmp/app-context-demo/dev.example.hello/data
config /tmp/app-context-demo/dev.example.hello/config
launches 1
venue Venue: local (Venue is off for this app)
Run it again and launches is 2. AppContext.run opens the context, runs your function and closes the context afterwards, also when your function throws. A bad manifest throws ManifestError with the code; a bad HELLO_MODE throws ResolveError(bad-mode).
AppContext.run closes as soon as your function returns. That is right for a command-line tool. It is wrong for a Flutter app, where runApp returns at once, which is why Flutter apps use AppContextKit.run.
Rust
The crate in rust/ implements the same manifest, the same directory rules and the same settings file. It does not have peers, SessionSettings, AppJsonFile or a remote Venue session. The program is docs/examples/rust_cli/, with the same app.json.
Cargo.toml:
[package]
name = "rust_cli"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
app-context-kit = { package = "slashbuilder-app-context-kit", path = "../../../rust" }
serde_json = "1"
[workspace]
src/main.rs:
use app_context_kit::{AppContext, Manifest, OpenOptions};
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest = Manifest::parse(&std::fs::read_to_string("app.json")?)?;
AppContext::run(manifest, OpenOptions::default(), |ctx| {
// A setting: kept in <config>/config.json, written atomically.
let launches = ctx.config.get_or::<i64>("launches", 0) + 1;
ctx.config.set("launches", json!(launches)).expect("settings are writable");
// A file of the app's own: under <data>.
std::fs::write(ctx.data_file("hello.txt"), format!("launch {launches}\n")).expect("data dir is writable");
println!("app {} {}", ctx.manifest.id, ctx.manifest.version);
println!("mode {}{}", ctx.mode().as_str(), if ctx.is_scratch() { " (scratch)" } else { "" });
println!("data {}", ctx.resolved.dirs.data);
println!("config {}", ctx.resolved.dirs.config);
println!("launches {launches}");
println!("venue {}", ctx.venue.description());
})?;
Ok(())
}
cd docs/examples/rust_cli
APP_CONTEXT_SCRATCH=/tmp/app-context-demo-rs cargo run
It prints the same six lines as the Dart program, with /tmp/app-context-demo-rs in the paths.
The Kit’s Rust crate has no remote Venue session. Its Venue sessions are local and fake only, so the last line above always reports a local session. A token that is found cannot connect to a daemon: with venue.mode auto the session is local and says so, and with on the context fails to open. The crate builds from the App Context Kit repository alone; its dependencies (serde, serde_json and chrono) come from crates.io.
Next
- Context guide: every manifest code, directory rule, environment variable and lifecycle detail
- Testing: scratch and mock modes, the fakes, the conformance fixtures and every test suite in the App Context Kit repository
- The demo apps in The Kit Book run from a calculator (the smallest app) to a notepad (the largest: settings, files, peers and tests).
Context guide
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
Reference for the AppContext and everything it is built from. It describes the Dart core (app_context_kit) and the Flutter companion (app_context_kit_flutter), and says where the Rust crate differs. The contract these rules come from is spec/APP-CONTEXT.md; this guide links to its sections instead of repeating them, and adds what the code does that the spec leaves open. If the guide and a fixture in spec/conformance/ disagree, the fixture is right.
To build something, start with Getting started.
Contents: the manifest · directories · environment variables · modes · the config store · AppJsonFile and ctx.dataJson · SessionSettings · notes · the Venue session · lifecycle · what the Flutter companion adds · the roster · peers · errors · the Rust crate
The manifest
app.json is parsed by AppManifest.parse(text) (JSON text) or AppManifest.fromJson(value) (an already-decoded value). Both throw ManifestError, whose code is the first rule that failed, checked in the order below. A field that is present but null is present and invalid. Every pattern must match the whole string, so a trailing newline fails. Spec: section 1.
| Field | Type | Rule | Default | Error code |
|---|---|---|---|---|
| (the source) | text | valid JSON (parse only) | not-json | |
| (the value) | object | a JSON object | not-object | |
id | string, required | ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$ | bad-id | |
name | string, required | not empty after removing space, tab, CR and LF. Other characters, including U+FEFF, count as content | bad-name | |
version | string, required | ^\d+\.\d+\.\d+$ (ASCII digits; no pre-release suffix) | bad-version | |
kits | list of strings | each name matches ^[a-z][a-z0-9-]*$ | [] | bad-kits |
kits | no name twice, checked only once every name is valid | duplicate-kit | ||
envPrefix | string | ^[A-Z][A-Z0-9_]*$ | derived from id, see environment variables | bad-prefix |
venue | object | an object | {} | bad-venue |
venue.mode | string | auto, on or off | auto | bad-venue |
venue.consumer | string | ^[a-z][a-z0-9-]*$ | the last segment of id | bad-venue |
AppManifest exposes id, name, version, kits (unmodifiable), envPrefix (nullable: only what the file says) and venue (VenueSettings: mode, consumer). Unknown fields are ignored.
kits is checked in one place today: ctx.peerHub and ctx.startPeers throw KitNotDeclared unless it contains "peers". A Venue session is opened whether or not kits lists "venue".
Directories
resolve(manifest:, platform:, home:, env:) is a pure function: it reads no files and touches no disk. AppContext.open calls it and then creates every directory with createSync(recursive: true). Spec: section 2.
ID is the manifest id. Paths are joined with / on Linux and macOS and with \ on Windows, whatever the host is.
| Kind | Linux | macOS | Windows |
|---|---|---|---|
data | $XDG_DATA_HOME/ID, else ~/.local/share/ID | ~/Library/Application Support/ID/data | %LOCALAPPDATA%\ID\data |
config | $XDG_CONFIG_HOME/ID, else ~/.config/ID | ~/Library/Preferences/ID | %APPDATA%\ID |
state | $XDG_STATE_HOME/ID, else ~/.local/state/ID | ~/Library/Application Support/ID/state | %LOCALAPPDATA%\ID\state |
cache | $XDG_CACHE_HOME/ID, else ~/.cache/ID | ~/Library/Caches/ID | %LOCALAPPDATA%\ID\cache |
runtime | $XDG_RUNTIME_DIR/ID; if that is unset, the default state directory plus /run | $TMPDIR/ID, else /tmp/ID | %TEMP%\ID |
roster | $XDG_RUNTIME_DIR/app-context-roster, else /tmp/app-context-roster | $TMPDIR/app-context-roster, else /tmp/app-context-roster | %TEMP%\app-context-roster |
The roster is one directory shared by every app for the user, so it has no ID in its path.
A trailing separator on a base such as TMPDIR=/var/T/ is dropped before joining, using the target platform’s separators only. A base that is only a separator (/) is not reduced to nothing: / and ID join to /ID.
On Windows, a missing LOCALAPPDATA, APPDATA or TEMP is the error missing-env, but only if a default that needs it is actually used. A directory fixed by an override or by scratch needs none.
Overrides, highest priority first, for each of data, config, state, cache and runtime:
<PREFIX>_<KIND>_DIR(for exampleHELLO_DATA_DIR): used exactly as given. No id is appended and nothing is trimmed.- Scratch: if
APP_CONTEXT_SCRATCH=Sis set, the directory isS/ID/<kind>and the roster isS/roster. Scratch always joins with/, on every platform. - The platform default.
APP_CONTEXT_ROSTER_DIR overrides the roster directory and beats scratch.
AppContext.hostPlatform() returns macos on macOS, windows on Windows, and linux on every other host, including Android and iOS. The home directory is HOME, else USERPROFILE, else /.
Environment variables
Every variable the Kit reads. An empty value counts as unset for all of them except HOME and USERPROFILE in the Dart core, where an empty HOME is used as it is (the Rust crate skips an empty one). <PREFIX> is envPrefix, or the last segment of the id upper-cased with each character outside A-Z0-9 replaced by _ (com.slashbuilder.demo.render-queue gives RENDER_QUEUE).
By default the Kit reads the process environment. AppContext.open(manifest, env: {...}) replaces it with a map you supply, and then nothing from the real environment is read. That is how the tests in the App Context Kit repository stay isolated.
| Name | Meaning | Default | Example |
|---|---|---|---|
APP_CONTEXT_SCRATCH | Root of a throwaway tree. Every directory becomes <S>/<id>/<kind> and the roster <S>/roster | unset | /tmp/app-context-demo |
APP_CONTEXT_MODE | Mode for every app: real or mock | real | mock |
<PREFIX>_MODE | Mode for one app. Wins over APP_CONTEXT_MODE | unset | CALC_MODE=mock |
<PREFIX>_DATA_DIR, _CONFIG_DIR, _STATE_DIR, _CACHE_DIR, _RUNTIME_DIR | Exact directory for one kind. Wins over scratch | unset | HELLO_DATA_DIR=/srv/hello |
APP_CONTEXT_ROSTER_DIR | Exact roster directory. Wins over scratch | unset | /tmp/roster |
HOME, USERPROFILE | The home directory (AppContext.open only; resolve takes home as a parameter) | / | /home/ada |
XDG_DATA_HOME, XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME | Base directories on Linux | ~/.local/share, ~/.config, ~/.local/state, ~/.cache | /x/data |
XDG_RUNTIME_DIR | Runtime and roster base on Linux | see directories | /run/user/1000 |
TMPDIR | Runtime and roster base on macOS | /tmp | /var/folders/ab/T/ |
LOCALAPPDATA, APPDATA, TEMP | Windows bases. Needed only if a default that uses them is used | none (error missing-env) | C:\Users\ada\AppData\Local |
VENUE_DAEMON | URL of the Venue daemon | http://127.0.0.1:7878 | https://venue.example:7878 |
VENUE_TOKEN | The Venue token itself | unset | |
VENUE_TOKEN_FILE | A file holding the token. If set, a missing, unreadable or empty file is an error and the lookup does not go on to the next source | unset | /run/secrets/venue-token |
VENUE_DAEMON_FINGERPRINT | Pin the daemon’s TLS certificate by SHA-256. Accepts sha256: or not, with or without colons, any case | unset (system trust) | sha256:ab12... (64 hex digits) |
APP_CONTEXT_ACCOUNT | Account for peers | config account, else local | household-1 |
APP_CONTEXT_DEVICE | Device name for peers | config device, else generated once | laptop |
APP_CONTEXT_PEERS_DIR | Where the loopback peers provider announces copies | the roster directory plus -peers | /tmp/peers |
The fake Venue’s capacity is not an environment variable. It is the fakeCapacity parameter of AppContext.open (default 1). The Rust tests confirm that <PREFIX>_FAKE_CAPACITY, APP_CONTEXT_FAKE_CAPACITY and VENUE_FAKE_CAPACITY have no effect.
Token lookup, in order: VENUE_TOKEN; the file named by VENUE_TOKEN_FILE; <config>/venue-token. See spec section 5, item 2.
Modes
| Mode | Set by | Venue session | Notes |
|---|---|---|---|
real | default | local, remote or none, from venue.mode and the token | |
mock | <PREFIX>_MODE=mock or APP_CONTEXT_MODE=mock | always FakeVenue, whatever venue.mode says | peers use an in-memory fake network |
Any other value is ResolveError('bad-mode'). Only the variable that wins is checked: a garbage APP_CONTEXT_MODE is ignored when <PREFIX>_MODE is set. bad-mode is reported before missing-env.
Scratch is separate from mode. APP_CONTEXT_SCRATCH moves the files; mock swaps the services. They are independent, and tests normally set both. ctx.mode, ctx.isMock and ctx.scratch report what was resolved; ModeBadge shows it.
The config store
ctx.config is a ConfigStore over <config>/config.json. It is a flat JSON object that you read and write by key.
| Member | Behaviour |
|---|---|
get<T>(key, fallback) | The value if it is a T, otherwise fallback. A whole JSON number reads as double (3 is 3.0). Decoded lists are List<dynamic>, so get<List<int>> always returns the fallback; ask for List<dynamic> |
ctx.config[key] | The raw value or null |
all | An unmodifiable copy of every key |
set(key, value) | Sets the key and saves at once. null removes the key. Throws DamagedConfigError first, changing nothing, if the file is damaged |
save() | Writes the whole file. Throws DamagedConfigError if damaged |
problem, isDamaged | Why the file could not be read, if it could not |
setAsideCorrupt() | See below |
Writes are atomic: the content goes to config.json.tmp, which is then renamed over config.json. The file is pretty-printed JSON.
A missing file is an empty store, not a problem. A damaged file is one that is not valid UTF-8, not valid JSON, not a JSON object, or cannot be read. AppContext.open still succeeds, problem says why, a warning note is added, and every write is refused until you decide what to do. The file is never replaced silently (spec section 5, item 3).
setAsideCorrupt() is that decision. It renames the file to config.json.corrupt (replacing an older .corrupt copy), empties the store, clears problem and returns the new File. It returns null when nothing was damaged. The damaged content is kept, never deleted. If you want the person to choose, show them problem and call it from a button.
Each copy of an app keeps its own in-memory values and rewrites the whole file on set. Two copies running at once can overwrite each other’s keys, and the Kit takes no lock. This follows from reading the code; there is no test for it.
AppJsonFile and ctx.dataJson
ConfigStore is for the one settings file. AppJsonFile does the same careful handling for any JSON file your app owns, usually under ctx.dirs.data. ctx.dataJson('notes.json') returns the AppJsonFile for that name under the data directory, and AppJsonFile(file) wraps any File.
| Member | Behaviour |
|---|---|
read() | Returns an AppJsonRead; never throws for a missing or damaged file |
write(json) | Creates the directory, writes <name>.tmp with a flush, renames it over the target. Throws FileSystemException if the disk refuses |
quarantine() | Renames a damaged file to <name>.damaged-<UTC milliseconds> and returns the new File. Never deletes |
delete() | Removes the file; a missing file is fine |
AppJsonRead has exists, value, problem and ok (true when problem is null, which includes the file not existing). The three outcomes:
| Outcome | exists | ok | value | problem |
|---|---|---|---|---|
| no such file | false | true | null | null |
| read fine | true | true | the decoded JSON | null |
| damaged or unreadable | true | false | null | not valid JSON: ... or could not be read: ... |
A file that contains the JSON literal null is exists: true, ok: true, value: null; check exists to tell it from a missing file.
SessionSettings
SessionSettings(ctx.config) wraps a ConfigStore for UI code that should not throw when the file is damaged.
- While the file is healthy it is the store:
setsaves,getreads the file’s values. - While the file is damaged,
setkeeps the value in memory for this session only and never touches the file.getreturns that session value, then the file’s value, then the fallback.readOnlyistrue, so you can tell the person their change will not be kept. - After
setAsideCorrupt()on the store,readOnlyisfalseand writes reach the file again. Values set during the damage are not written then.
SessionSettings.set never throws DamagedConfigError. It can still throw FileSystemException if the disk refuses a write.
Notes
ctx.notes is a List<AppNote>; each has a severity (NoteSeverity.info, warning or error) and text. ctx.noteTexts gives the texts only. Notes are plain data: adding one notifies nobody.
AppContext.open adds these:
| When | Severity | Text |
|---|---|---|
config.json is damaged | warning | Settings: <problem>. They will not be saved until the file is set aside. |
Venue, in mock mode | info | Venue: fake (mock mode) |
Venue, venue.mode is off | info | Venue: local (Venue is off for this app) |
| Venue, no token anywhere | info | Venue: local (Venue not configured) |
Venue, VENUE_TOKEN_FILE set but unusable | warning | Venue: local (VENUE_TOKEN_FILE is set to <path> but <reason>) |
Venue, token found but the daemon did not answer /healthz within 2 seconds | warning | Venue: local (Venue did not answer at <url>) |
| Venue connected | info | Venue: connected to <url> as <consumer> |
AppContext.run adds an error note if closing fails after your function threw (see lifecycle). Your app can add its own; the notepad demo app adds a warning when a note file is damaged.
The Venue session
ctx.venue is a VenueSession. Its kind is local, fake or venue; isConnected is true only for venue; description is one sentence. The same calls work for all three, so app code does not branch. The wire details (the daemon’s HTTP API) belong to Venue, not to this Kit; spec section 3 and section 5, items 2, 6 and 7.
| Call | Meaning |
|---|---|
hold(need, {leaseSeconds: 300}) | Ask for something. A VenueNeed has name, contractClass and parameters. Returns a VenueHold |
start(hold), renew(hold), release(hold) | The hold’s life cycle |
holds() | This consumer’s own live holds, for finding orphans after a crash |
nodes() | The nodes Venue knows about |
withHold(need, body, {leaseSeconds, onLeaseLost, renewEvery}) | Hold, start, renew in the background while body runs, then release, also on error. renewEvery defaults to a third of the lease. A renewal failure sets hold.leaseLost and calls onLeaseLost once; the body keeps running. A failing release never replaces the body’s result: it is recorded in hold.releaseError |
withHoldWaiting(need, body, {required maxWait, retryEvery, cancel, onWaiting, ...}) | Like withHold, but retries a refusal until maxWait has been spent in naps of retryEvery. maxWait: Duration.zero is withHold. Completing cancel ends the wait with HoldWaitCancelled |
Refusals are typed. VenueRefused carries a kind: doesNotFit (HTTP 409; retrying later can succeed) or forbidden (HTTP 403). VenueRefused.retryable is true only for doesNotFit, and plainReason unwraps the daemon’s JSON error message. VenueUnavailable is everything else: a transport failure, a timeout, 401, 5xx. It carries the HTTP status when there was an answer.
withHoldWaiting retries forbidden only while holds() shows this consumer still has live holds, because the daemon uses the same status for “over your live-hold limit” and for “no grant”. This is a heuristic and the spec records its known failure: a consumer that holds something and lacks a grant waits the full maxWait for nothing. See spec section 7.
The three sessions:
LocalVenue(why): no Venue. Work still runs. Holds exist in this process only, so two apps can both “hold” the same thing, and a second hold with the same name in one process is refused asdoesNotFit.FakeVenue({capacity: 1}): in memory, one node. It refuses a hold whencapacityholds are live, keeps alogof calls (hold <name>,start <name>,renew <name>,release <name>), andrefuseNext(n, {kind})makes the nextnholds refuse. Use it in tests.RemoteVenue(Dart only): the daemon over HTTP with a bearer token.IoTransportdoes the HTTP; withVENUE_DAEMON_FINGERPRINTit trusts exactly one certificate and no authority. A fingerprint that is not 32 bytes of hex, or a certificate that does not match, is reported asVenueUnavailablewith an explanation. Pass your ownDaemonTransporttoAppContext.open(transport: ...)to supply replies without a socket.
Lifecycle
Open
AppContext.open(manifest, {env, platform, home, transport, announce, heartbeat, fakeCapacity}) does these steps in this order:
- Resolve (may throw
ResolveError). - Create the five directories (may throw
FileSystemException). - Open the settings; add a warning note if damaged.
- Open the Venue session; add its note. In
venue.mode: on, this step throwsVenueUnavailableif Venue cannot be used. Nothing has been announced yet, so nothing is left behind. - Draw this copy’s instance id: eight random lower-case hex digits.
- Write the roster entry and start the heartbeat, unless
announce: false. If that fails, Venue is closed and the error is rethrown.
announce: false is for tests and tools that should not appear in the roster. heartbeat (default 5 seconds) is how often the roster entry is refreshed.
Run
AppContext.run(manifest, body, {...}) is open, then body(ctx), then close, also when body throws. If body throws, that error is what you get; a failure while closing is recorded as an error note instead of replacing it. If body returns and closing fails, run throws the AppCloseError.
run closes the moment body returns. That suits a command-line tool. It does not suit a Flutter app, where runApp returns at once; use AppContextKit.run there.
Close
ctx.close() does these steps in this order and always does all of them:
- Stop the heartbeat and delete this copy’s own roster entry (only that file).
- Run every
ctx.onClosehook, last added first. A hook may be async; each is awaited. Peer hubs made byctx.peerHuborctx.startPeersare closed by a hook of their own, so they follow the same order. - Close Venue, last, so a hook can still use it.
If any step threw, the errors are raised together afterwards as one AppCloseError (its errors and stackTraces lists hold every failure). A failing hook never stops a later hook or the Venue close.
close() is idempotent. A second call, even a concurrent one, runs nothing again and returns when the first has finished. Only the first caller sees an AppCloseError.
Register hooks with ctx.onClose(() async { ... }). Typical uses are flushing unsaved edits and closing your own resources. The notepad demo app registers its flush hook after starting peers, so on quit the last keystrokes are saved and announced while the hub is still open.
What the Flutter companion adds
import 'package:app_context_kit_flutter/app_context_kit_flutter.dart'; gives you everything below. Widget-test helpers are in package:app_context_kit_flutter/testing.dart, kept apart so a release build does not link flutter_test.
AppContextKit.run
Future<AppContextKitSession> AppContextKit.run({
required String manifestAsset,
required Widget Function(AppContext ctx) app,
// passed to AppContext.open:
Map<String, String>? env, String? platform, String? home, DaemonTransport? transport,
bool announce = true, Duration heartbeat = const Duration(seconds: 5), int fakeCapacity = 1,
// for tests:
AssetBundle? bundle, bool handleSignals = true, void Function(int code)? exitProcess,
void Function(String message)? report,
String errorTitle = 'This app could not start',
})
It ensures the Flutter binding, loads and validates the manifest from the bundle, opens the context, mounts AppContextScope(ctx:, child: app(ctx)) and wires the exit coordinator. If anything before that point fails, it mounts the startup error screen and returns a session whose error is set. If your app builder throws, the context is closed (so no roster entry is left) and the screen shows app-build-failed.
Other static members: AppContextKit.quit() (for a Quit button: closes once, then ends the process), AppContextKit.loadManifest(asset, {bundle}), AppContextKit.openContext(...) (the first half of run, without the lifecycle wiring) and AppContextKit.buildRoot(ctx, app).
AppContextScope
An InheritedWidget holding the context. AppContextScope.of(context) returns it or throws a FlutterError saying how to fix it; maybeOf returns null. Dependents rebuild only if a different context is swapped in, which happens in tests that relaunch.
The exit coordinator
ExitCoordinator funnels every way an app can end into one shutdown that calls ctx.close() at most once.
| Event | What happens | Process exit code |
|---|---|---|
| Window close, or quit from the menu (the lifecycle’s exit request) | closes, then agrees to exit | by the platform |
Quit through AppContextKit.quit() | closes, then ends the process | 0, or 1 if the close failed |
| SIGINT | closes, then ends the process | 130, or 1 if the close failed |
| SIGTERM | closes, then ends the process | 143, or 1 if the close failed |
onDetach (the only event mobile platforms give) | closes; does not exit |
Nothing exits before the close has finished. A failing close never blocks the exit: the error is written to stderr, in full. There is no timeout and no “second signal forces it”; a hung onClose hook is a bug, and SIGKILL is the way out. A platform that cannot watch a signal (Windows has no SIGTERM) is skipped. On mobile, the onDetach close is best effort; the OS may not give it time. With SIGTERM and SIGINT sent to a built macOS app, the roster entry was gone afterwards. The exit paths are otherwise tested through injected sources, not through a real window close.
Widgets
| Widget | What it shows |
|---|---|
ModeBadge.forContext(ctx) (or ModeBadge(isMock:, scratch:)) | Nothing in real mode with no scratch; otherwise a chip: mock, scratch or mock + scratch, with a tooltip and a semantics label |
NotesBanner.forContext(ctx) (or NotesBanner(notes:)) | Warnings and errors, each with an icon, the word “Warning” or “Error”, and its own colours. Info notes are hidden unless showInfo: true. dismissible: true adds a close button; onDismissed is told which note, and the list is not edited. NotesBanner.visibleOf(notes) returns what would be shown. It reads the list when built, so rebuild it after adding a note |
PeersIndicator.forHandle(handle) (or PeersIndicator(hub:, offlineReason:)) | “This copy: laptop #3fa2”, “Peers: N”, a chip per connected copy, “(K connecting)”, or “Peers: off (peers unavailable: …)”. One live region for screen readers. extra: adds a line of your own. It is exported from the main library and from package:app_context_kit_flutter/peers.dart |
StartupErrorScreen(error:) | A full MaterialApp showing the failure: a title, what happened, what to do, and Error code: <code> |
StartupErrorBody(error:) | The same content without the MaterialApp, for an app that has its own |
The startup error screen and its codes
AppContextKitStartupError has a stable code, a message (what happened), a hint (what to do) and the original cause. AppContextKitStartupError.from(error) sorts any failure into one:
| Code | Cause | What to do |
|---|---|---|
manifest-asset-missing | the asset could not be loaded from the bundle | list app.json under flutter: assets: in pubspec.yaml and rebuild |
not-json, not-object, bad-id, bad-name, bad-version, bad-kits, duplicate-kit, bad-prefix, bad-venue | the manifest’s own code, from the table above | fix app.json and rebuild |
bad-mode | APP_CONTEXT_MODE or <PREFIX>_MODE is not real or mock | set it to real or mock, or unset it |
missing-env | a Windows directory default needed LOCALAPPDATA, APPDATA or TEMP | set it, or set APP_CONTEXT_SCRATCH |
venue-required | venue.mode is on and Venue could not be used (no token, or no answer from the daemon) | provide a token and start Venue, or run with APP_CONTEXT_MODE=mock |
open-failed | a FileSystemException creating or reading the folders, or any other error from open | check the folder is writable, or set APP_CONTEXT_SCRATCH |
app-build-failed | your app builder threw | a bug in the app; run from a terminal for the stack trace |
Sharing one context with a Rust core
A Flutter app with a Rust core must not open the context twice: it would be in the roster twice, under two instance ids, and two code paths would write one config.json. The Dart side opens the context (AppContextKit.run) and hands the Rust side a snapshot; the Rust side adopts it. The full rule is spec section 8.
Dart, the owner. ctx.snapshot() returns a map {schema, appId, instance, mode, scratch, dirs: {data, config, state, cache, runtime}, roster} you can jsonEncode and pass over the bridge. It has no secrets and no handles, and asking for it announces nothing.
Rust, the adopter.
#![allow(unused)]
fn main() {
let snapshot = ContextSnapshot::parse(&json_from_dart)?; // strict: unknown schema, missing field, relative path are errors
let ctx = AppContext::adopt(&snapshot); // no roster entry, no heartbeat, no config.json written
let blocks = ctx.create_data_dir("blocks")?; // directories under data and state only
let file = ctx.data_file("heads.json")?; // a path under data (no `..`, nothing created)
let theme: String = ctx.settings().get_or("theme", "light".into()); // read-only: `set` and `save` return Err(ReadOnly)
}
One owner per file. The owner (the side that opened the context) owns config.json and the roster entry. The adopter reads settings, never writes them, and owns the files it creates under data and state. A setting the Rust core wants changed is a request to the Dart side. After the owner changes a setting, ReadOnlySettings::reload() shows it.
| Dart | Rust |
|---|---|
ctx.snapshot(), contextSnapshot(...) | ContextSnapshot::parse, from_value, to_json; AppContext::snapshot() |
| (the owner) | AppContext::adopt(&snapshot) gives an AdoptedContext: app_id(), instance(), mode(), dirs(), roster_dir(), data_file, state_file, create_data_dir, create_state_dir, settings() |
ctx.config (read and write) | ReadOnlySettings (read only) |
The roster
The roster answers “who is running?”. Each running copy writes one JSON file into the shared roster directory (see directories) and refreshes it.
- File name:
<ID>@<INSTANCE>.json, for examplecom.slashbuilder.demo.calc@1a2b3c4d.json. The instance is eight lower-case hex digits, drawn at random per opened context. - Content:
id,instance,name,version,pid,startedAtandheartbeatAt(UTC, ISO 8601).ctx.instanceis this copy’s instance id. - Heartbeat: the file is rewritten every 5 seconds (
heartbeat:), through a temporary file and a rename. A failed heartbeat is recorded inRosterAnnouncement.lastErrorand does not stop the app. - Two copies of one app are two files. Withdrawing one removes only its own.
- Stale: an entry whose
heartbeatAtis more than 15 seconds old (staleAfter:) is stale. A copy that exited cleanly deletes its file. A copy that crashed leaves it behind, and the Kit never deletes it.
Reading it, on ctx.roster (or Roster(dir)):
| Call | Returns |
|---|---|
running() | RosterEntry for every live entry. Stale and unreadable files are skipped, not deleted |
scan() | A RosterRecord for every *.json file, sorted by id then instance, with a status: live, stale or unreadable |
A RosterRecord has file, status, entry (null when unreadable), id and instance. For an unreadable file, id and instance come from the file name, so it can still be attributed. Use scan() to tell a crash (stale) from “never ran” (no record) from a damaged file (unreadable). A file named <ID>.json with no instance part, from before instance ids existed, has a null instance. parseRosterFileName(name) splits a name; the id contains dots, so it splits at the last @.
The PID in an entry means something only on the same machine and for the same user. The roster does not check that the process is alive; it trusts the heartbeat.
Spec: section 4 and section 5, item 5. The spec asks whether the roster belongs in the Kit, in Venue or nowhere; the Kit keeps the smallest version so that use can answer.
Peers
Peers are other running copies of the same app for the same account. They are opt-in: list "peers" in kits, or ctx.peerHub and ctx.startPeers throw KitNotDeclared. This guide covers only how the context supplies them; the design is in spec sections 6 and 7.
- Who this copy is:
ctx.peerAccount(APP_CONTEXT_ACCOUNT, else configaccount, elselocal),ctx.peerDevice(APP_CONTEXT_DEVICE, else configdevice; generated once asd-and eight hex digits and saved, or kept for this run only if the settings file is damaged) andctx.instance. The first call toctx.peerDevicewritesdeviceto the config file. - Provider by mode: an explicit provider if you pass one to
peerHub; inmockmode, an in-memory fake network (pass oneFakePeerNetworkto several contexts to make them see each other); otherwise the loopback provider overctx.peersDirectory(APP_CONTEXT_PEERS_DIR), which finds copies on one machine. ctx.startPeers(hook, {network, options})builds and starts a hub and never throws for a runtime condition. It returns aPeersHandleat once:hub(null when peers are unavailable),started(a future that completes when the hub is open or has failed, never with an error),error(a sentence startingpeers unavailable:),isStarting,isRunningandchanges(a broadcastStream<void>that fires when the hub opens or fails and on every peer change).handle.hub?.broadcast(...)is always safe.KitNotDeclaredis not swallowed: that is a bug in the app.ctx.peerHub(hook, {provider, network, authenticator, options})builds a hub without starting it; callhub.start(). Use it for a custom provider or authenticator.- The hook is an
AppCoordinationthat you write:channel,hello(),admit(peer, theirHello),onPeer(peer, change),onMessage(from, message)and an optionalattach(hub). The Kit never reads a payload; it carries bytes and an app-declaredschemainteger. PeerJsonhas helpers for JSON payloads:encode,decode(null on malformed input, never throws),fits,encodeWithin(throwsPeerPayloadTooLarge),quietandlabel.AppMessage.jsonBodydecodes a message body.
Hubs are closed with the context. The default authenticator accepts every peer.
Errors
| Type | Thrown by | Carries |
|---|---|---|
ManifestError | AppManifest.parse, fromJson | code: the manifest code |
ResolveError | resolve, AppContext.open | code: missing-env or bad-mode |
FileSystemException | AppContext.open | the folder that could not be made. The core does not wrap it; the companion turns it into open-failed |
DamagedConfigError | ConfigStore.set, save | file, problem |
AppCloseError | ctx.close, AppContext.run | errors, stackTraces: every failed step |
VenueRefused | hold, withHold | kind (doesNotFit, forbidden), reason, plainReason, retryable |
VenueUnavailable | AppContext.open (mode on), Venue calls | reason, status |
HoldWaitCancelled | withHoldWaiting | |
TlsPinException | the transport (surfaced as VenueUnavailable) | message |
KitNotDeclared | ctx.peerHub, ctx.startPeers | kit, appId |
PeerError and its subclasses | the peer hub | message. Subclasses: EnvelopeError, PeerNotAvailable, PeerLost, PeerOutboxFull, PeerTimeout, PeerClosed, PeerScopeError, PeerConnectError, PeerLinkClosed, PeerPayloadTooLarge |
AppContextKitStartupError | the Flutter companion | code, message, hint, cause |
The Rust crate
rust/ implements spec sections 1, 2, 5 and 8 and passes the same conformance fixtures. Names differ by language convention.
| Dart | Rust |
|---|---|
AppManifest, ManifestError | Manifest, ManifestError (with .code()) |
resolve, Resolved, AppDirs | resolve, Resolved, Dirs |
AppContext.open, .run, .close | AppContext::open, ::run, .close() (also on drop, errors lost) |
ctx.onClose(fn) | ctx.on_close(fn), ctx.on_close_fallible(fn) |
ConfigStore.get<T>, set, setAsideCorrupt | ConfigStore::get_or, set (returns Result), set_aside_corrupt |
AppCloseError | CloseErrors, each a CloseError { what: "roster" | "hook" | "venue", message }. A panic in a hook is caught and reported |
Roster.running, .scan | Roster::running(now, stale_after), ::scan(now, stale_after) with EntryStatus |
Not in Rust: peers, SessionSettings, AppJsonFile and ctx.dataJson, withHoldWaiting and FakeVenue.refuseNext (Rust has with_hold and with_hold_opts), a remote Venue session, and everything in the Flutter companion.
The Kit’s Rust crate has no remote Venue session. It has LocalVenue and FakeVenue only. It still looks for a token (the lookup order and its errors are the same as in Dart), but a token that is found cannot connect to anything: with venue.mode auto the session is local and its description says the crate has no remote Venue session (a warning note), and with on the context fails to open. VENUE_DAEMON and VENUE_DAEMON_FINGERPRINT are not read.
Venue guide
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
How an app built on the App Context Kit asks Venue for a share of a machine’s resources, and what happens when Venue says no, goes away, or the app dies.
Venue is a resource broker: an app asks it for a hold (a reservation of some capability, for example “run a container”), and Venue grants it, refuses it, or takes it back when the lease runs out.
This guide covers the Kit’s Venue session in Dart and Rust. The Kit’s Rust crate has no remote Venue session: it has the local and fake sessions only, so everything below that talks to a daemon (the venue session, pinned TLS) is Dart only. The rules behind it are in spec/APP-CONTEXT.md (sections 3, 5 and 7). Each snippet below was run: against the mock session, against the local fallback, and against a throwaway Venue daemon (see Testing against a daemon). The daemon is not part of the App Context Kit repository.
Contents: the session · choosing a session · API · refusals · leases and orphans · pinned TLS · registering an app · local-only · testing against a daemon · open items
The session
ctx.venue is a VenueSession. It has three implementations, and app code calls the same methods on all of them.
kind | When you get it | What a hold means |
|---|---|---|
venue | A daemon answered and a token was found (Dart only: the Rust crate has no remote session) | A real reservation. Other apps on the machine are held to the same limits. |
local | Venue is off, not configured, or not reachable (and the manifest allows that) | A bookkeeping entry inside your process. Nothing stops another app, or another copy of yours, from “holding” the same thing. |
fake | Mock mode (APP_CONTEXT_MODE=mock) | An in-memory node with a fixed capacity, for tests. |
ctx.venue.description is one sentence saying which you got, for example Venue: local (Venue not configured). The context also puts it in ctx.notes, with severity warning when Venue was expected but could not be used. Show it somewhere. The local session gives no cross-app protection, and the person using the app should be able to tell.
Hiding the difference is the point: the app has one code path. The cost is the local row above. The session cannot make a promise only a daemon can keep, so it tells you instead.
How a session is chosen
The manifest (app.json) has an optional venue block:
{
"id": "com.slashbuilder.demo.render-queue",
"name": "Render Queue",
"version": "0.1.0",
"kits": ["venue"],
"venue": { "mode": "auto", "consumer": "render-queue" }
}
venue.mode is auto (the default), on or off. venue.consumer is the name Venue knows this app by. It defaults to the last segment of id, so dev.a.notepad and org.b.notepad would both default to notepad. Set it explicitly if that could clash.
The context decides in this order and stops at the first step that applies.
- Mock mode (
APP_CONTEXT_MODE=mock, or<PREFIX>_MODE=mock): always thefakesession, whatevervenue.modesays. venue.modeisoff: alwayslocal, with the description “Venue is off for this app”.
Steps 1 and 2 never look at the token or the daemon. Otherwise:
| # | Condition | venue.mode: auto | venue.mode: on |
|---|---|---|---|
| 3 | VENUE_TOKEN_FILE is set but the file is missing, empty or unreadable | local; the description names the problem; the note has severity warning | open fails with VenueUnavailable |
| 4 | No token anywhere | local (“Venue not configured”) | open fails: “Venue is required but no token was found” |
| 5 | The daemon does not answer GET /healthz | local (“Venue did not answer at …”); severity warning | open fails: “Venue is required but … did not answer” |
| 6 | Otherwise | venue | venue |
Checked against the code and run when this page was written: off, auto and on with no token, on and auto with a dead daemon, and a missing token file.
Token lookup
First match wins:
VENUE_TOKEN: the token itself.VENUE_TOKEN_FILE: a path to a file holding the token.<config>/venue-token, where<config>is the app’s config directory (see Platforms).
Whitespace around the token is trimmed. An empty variable counts as unset.
A named-but-missing token file is an error, not a fall-through. If VENUE_TOKEN_FILE is set and the file is missing, empty or unreadable, the Kit does not go on to try <config>/venue-token. It reports the problem (step 3 above). A typo in the variable shows up as an error instead of being hidden by another token source. VENUE_TOKEN still wins over a broken VENUE_TOKEN_FILE. The default file (item 3 of the lookup) is different: if it is missing or empty, that just means “not configured”.
Dart and Rust implement this the same way, with tests in both. In Rust a token that is found cannot connect to anything: with auto the session is local and its description says the crate has no remote Venue session, and with on the context fails to open.
$ APP_CONTEXT_SCRATCH=$(mktemp -d) VENUE_TOKEN_FILE=/tmp/nonexistent-token dart run bin/hold.dart
Venue: local (VENUE_TOKEN_FILE is set to /tmp/nonexistent-token but the file does not exist)
ran under a local hold named job-1
Other variables
| Variable | Meaning |
|---|---|
VENUE_DAEMON | Where the daemon is. Default http://127.0.0.1:7878. Write it as scheme://host[:port] with no path. Only the Dart core uses it. |
VENUE_DAEMON_FINGERPRINT | SHA-256 fingerprint of the daemon’s TLS certificate. See Pinned TLS. |
APP_CONTEXT_MODE, <PREFIX>_MODE | real or mock. Mock gives you the fake session. |
The health check waits up to 2 seconds for an answer. Opening a connection to an unreachable address is capped at 5 seconds by the Dart transport, so it can delay startup. In auto mode that delay is followed by the local fallback.
The token belongs to one consumer name. Use the token issued for the name in venue.consumer. A token used under another name is refused by the daemon with HTTP 403 (forbidden, see Refusals); this was seen with a real daemon.
The health check needs no token, so a wrong token is not caught when the context opens. The session connects, and the first call fails with HTTP 401 as VenueUnavailable (status=401: holds: HTTP 401: {"error":{"code":"unauthorized",...}}, checked when this page was written).
The API
Dart (dart/app_context_kit/lib/src/venue/venue_session.dart) and Rust (rust/src/venue.rs) expose the same operations.
| Operation | Dart | Rust | What it does |
|---|---|---|---|
| ask | hold(need, {leaseSeconds = 300}) | hold(&need, lease_seconds) | Reserve. Returns a VenueHold. |
| begin | start(hold) | start(&hold) | Tell Venue the work has begun. |
| keep | renew(hold) | renew(&hold) | Extend the lease. |
| give back | release(hold) | release(hold) | Free the hold. Already gone (HTTP 404 or 410) counts as success. |
| list mine | holds() | holds() | This consumer’s own live holds. Other consumers’ holds are never listed. |
| nodes | nodes() | nodes() | What Venue offers, as raw JSON maps. |
| the loop | withHold(need, body, ...) | with_hold(&need, lease, body) and with_hold_opts | hold, start, renew in the background while body runs, then release, also on error. |
| the loop, patiently | withHoldWaiting(need, body, maxWait: ...) | not built | Like withHold, but retries a refusal for a while. |
In Rust the calls block and release consumes the hold, so releasing twice does not compile. In Dart everything returns a Future.
A VenueNeed is a name, a contract class, and optional parameters. The name must be unique among your live holds. The render-queue demo builds it from a run id plus a job number, because a leftover hold with the same name is refused. The Kit cannot choose the contract class for you; only the app knows what kind of work it is asking for. The examples use container.run.v1.
Take a hold
Dart (runs unchanged in real, mock and with no Venue at all):
import 'package:app_context_kit/app_context_kit.dart';
Future<void> main() async {
final manifest = AppManifest.fromJson({
'id': 'com.slashbuilder.demo.render-queue',
'name': 'Render Queue',
'version': '0.1.0',
'kits': ['venue'],
'venue': {'mode': 'auto', 'consumer': 'render-queue'},
});
await AppContext.run(manifest, (ctx) async {
print(ctx.venue.description);
const need = VenueNeed(name: 'job-1', contractClass: 'container.run.v1');
final result = await ctx.venue.withHold(
need,
(hold) async => 'ran under a ${hold.where} hold named ${hold.name}',
leaseSeconds: 30,
);
print(result);
});
}
Output with nothing configured, in mock mode, and against a daemon:
Venue: local (Venue not configured)
ran under a local hold named job-1
Venue: fake (mock mode)
ran under a fake hold named job-1
Venue: connected to http://127.0.0.1:7878 as render-queue
ran under a venue hold named job-1
To run it, depend on the package by path (it is not published) and set APP_CONTEXT_SCRATCH so the run writes to a temporary directory instead of your real app directories:
dependencies:
app_context_kit:
path: ../app-context-kit/dart/app_context_kit
APP_CONTEXT_SCRATCH=$(mktemp -d) dart run bin/hold.dart
Rust (app-context-kit = { package = "slashbuilder-app-context-kit", path = "../app-context-kit/rust" }; the crate has no remote Venue session, so this runs on the local session, or on the fake one in mock mode):
use app_context_kit::{
AppContext, HoldOptions, Manifest, OpenOptions, RefusalKind, VenueError, VenueNeed,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest = Manifest::parse(
r#"{
"id": "com.slashbuilder.demo.render-queue",
"name": "Render Queue",
"version": "0.1.0",
"kits": ["venue"],
"venue": { "mode": "auto", "consumer": "render-queue" }
}"#,
)?;
// Reads the process environment (VENUE_TOKEN_FILE, APP_CONTEXT_MODE, ...).
let mut ctx = AppContext::open(manifest, OpenOptions::default())?;
println!("{}", ctx.venue.description());
// Hold, start, renew in the background, release; also if the body panics.
let need = VenueNeed::new("job-1", "container.run.v1");
let outcome = ctx.venue.with_hold_opts(
&need,
30,
&HoldOptions {
on_lease_lost: Some(&|e: &VenueError| eprintln!("lease lost: {e}")),
},
|hold| format!("ran under a {} hold named {}", hold.kind, hold.name),
)?;
println!("{}", outcome.value);
println!("renewals failed: {}", outcome.hold.renewal_failure_count());
ctx.close()?;
Ok(())
}
with_hold is the same without the options and returns only the body’s value. with_hold_opts also hands back the hold, so you can read after the call whether any renewal failed.
What withHold does
hold. If Venue refuses, the body never runs and nothing is released.start. If that fails, the hold is released and the start error is what you see.- Renews every third of the lease (clamped to between 1 second and 1 hour), unless you pass
renewEvery. Renewals never overlap, and none is sent after release. - Runs your body.
- Releases, whether the body returned, threw, or (in Rust) panicked.
Two things are recorded, not thrown:
- A failed renewal sets
hold.leaseLost(Rust:lease_lost()), counts inrenewalFailures, and callsonLeaseLostonce, on the first failure. The body keeps running. Stopping the work is your decision.leaseLostis latched by a single failed renewal, so one network blip sets it even if most of the lease is still left. Renewing does not updatehold.expiresAt, so the hold object cannot tell you how much lease remains. - A failed release never replaces your body’s result or error. It is kept in
hold.releaseError.
The render-queue demo treats a lost lease as a failed job: it cancels the work, shows the reason, and offers Retry, because after a failed renewal the app cannot be sure Venue still counts the slot as its own. That is a policy choice for one app, not something the Kit does for you. It is tested with a session whose renewals fail. A daemon that stops answering mid-job was not tested.
The context does not release holds taken with hold() directly. ctx.close() closes the session and nothing else. Only withHold and withHoldWaiting release what they took. If you call hold() yourself, release it yourself.
Wait for room
withHoldWaiting retries a refused hold until it is granted or maxWait is used up.
import 'dart:async';
import 'package:app_context_kit/app_context_kit.dart';
Future<void> main() async {
final manifest = AppManifest.fromJson({
'id': 'com.slashbuilder.demo.render-queue', 'name': 'Render Queue', 'version': '0.1.0',
});
// Run with APP_CONTEXT_MODE=mock: the fake node has room for one hold at a time, so the second job has to wait.
final ctx = await AppContext.open(manifest, fakeCapacity: 1);
final venue = ctx.venue;
final first = await venue.hold(const VenueNeed(name: 'first', contractClass: 'container.run.v1'));
Timer(const Duration(seconds: 2), () => venue.release(first));
try {
final answer = await venue.withHoldWaiting(
const VenueNeed(name: 'second', contractClass: 'container.run.v1'),
(hold) async => 'got ${hold.name}',
maxWait: const Duration(seconds: 10),
retryEvery: const Duration(seconds: 1),
onWaiting: (refusal, attempt) => print('refused (${refusal.kind.name}), attempt $attempt: ${refusal.plainReason}'),
onLeaseLost: (hold, error) => print('lease lost on ${hold.name}: $error'),
);
print(answer);
} on VenueRefused catch (e) {
print('gave up: ${e.kind.name}${e.retryable ? " (could have been retried)" : ""}: ${e.plainReason}');
} on VenueUnavailable catch (e) {
print('Venue could not be asked: ${e.reason}');
} on HoldWaitCancelled {
print('cancelled');
} finally {
await ctx.close();
}
}
$ APP_CONTEXT_MODE=mock APP_CONTEXT_SCRATCH=$(mktemp -d) dart run bin/wait.dart
refused (doesNotFit), attempt 1: the fake node is full
refused (doesNotFit), attempt 2: the fake node is full
got second
Rules:
maxWaitis required.Duration.zeromakes it exactlywithHold(fail fast).- It retries
doesNotFitalways. It retriesforbiddenonly whileholds()shows this consumer still holds something. See the next section for why, and for when that is wrong. - It never retries
VenueUnavailable, and never retries an error thrown by your body. - Waiting is counted in naps of
retryEvery, not read from a clock, so it behaves under a test’s fake time. It naps at mostmaxWait ~/ retryEverytimes and then rethrows the last refusal. cancel(aFuture) ends the wait at once withHoldWaitCancelled. A hold granted in that same instant is released, not leaked.retryEverymust be positive.
Every waiting consumer polls Venue about once per retryEvery. Venue has no queue and no push, so there is no way to be told “there is room now”. In one earlier test with five waiting jobs against a real daemon, each job asked 15 to 20 times.
Rust has no with_hold_waiting. The refusal kinds are enough to write the loop yourself (see below), but the Kit does not ship it.
Refusals
Venue’s answer to hold is one of three things.
| Dart | Rust | HTTP | Meaning | Retry? |
|---|---|---|---|---|
VenueRefused(kind: doesNotFit) | VenueError::Refused { kind: RefusalKind::DoesNotFit, .. } | 409 | Nothing fits right now, or the hold name is already held. | Yes. It can clear when someone releases. |
VenueRefused(kind: forbidden) | VenueError::Refused { kind: RefusalKind::Forbidden, .. } | 403 | Not allowed: over the consumer’s live-hold limit, no grant for that kind of work, an observer token, or a token that does not match the consumer. | Not by itself. See below. |
VenueUnavailable | VenueError::Unavailable | anything else, or no answer | Venue could not be asked, or answered with something that is neither success nor a refusal: connection failure, timeout (15 seconds per request), 401, 5xx. | Not by the Kit. Your policy. |
VenueUnavailable.status carries the HTTP status when there was one. A hold Venue no longer has shows up this way too, as a failed renewal. Against a real daemon, renewing a hold that had been released gave renew: HTTP 404 with the code not_held. The Kit treats 404 and 410 on release as “already gone”. What renew returns for a lapsed lease was not observed.
VenueRefused.retryable is true only for doesNotFit. VenueRefused.reason is the daemon’s raw response body (shortened to 300 characters). plainReason unwraps the daemon’s error.message so you can show a sentence. In Rust the message is hold: HTTP 403: <raw body>; there is no plain_reason.
Real answers from a daemon (consumer calc, live-hold limit 3):
refused: kind=forbidden retryable=false
reason: calc already holds 3 live placements (limit 3); many small holds are how a node gets starved
kind=doesNotFit retryable=true: Placement/clash-doc is already held; renew it, release it, or choose another name
status=401: holds: HTTP 401: {"error":{"code":"unauthorized","message":"a valid token is required"}}
Handling them
Rust, telling the three apart:
#![allow(unused)]
fn main() {
match ctx.venue.hold(&need, 30) {
Ok(h) => ctx.venue.release(h)?,
Err(e) => match e.refusal_kind() {
Some(RefusalKind::DoesNotFit) => println!("full right now, retry later: {e}"),
Some(RefusalKind::Forbidden) => println!("not allowed, do not retry: {e}"),
None => println!("Venue could not be asked: {e}"),
},
}
}
With the fake session at capacity this prints full right now, retry later: Venue refused (doesNotFit): the fake node is full.
The documented limitation: one code for the limit and for everything else
Venue answers its per-consumer live-hold limit with HTTP 403 and the code forbidden. It gives the same code for a missing grant, an observer token, and a token that does not match the consumer. There is no machine-readable reason, so the Kit cannot tell “you are over your own limit” (will clear when you release something) from “you may never do this” (will not).
The Kit’s current rule: forbidden is not retryable. withHoldWaiting adds a heuristic: if the daemon says forbidden and holds() shows this consumer still has live holds of its own, assume it is the limit and wait. If you have none, give up at once.
The heuristic is wrong in one case. A consumer that both holds something and lacks the grant for a new kind of work will wait the full maxWait for nothing and then get the real refusal. Checking costs one extra GET /v1/holds per retry. Verified against a real daemon: a consumer at its limit waits, is granted when one of its own holds ends, and gives up at maxWait with forbidden when nothing is released. The case where the heuristic is wrong was reasoned from the code, not run.
The fix belongs to Venue: a distinct reason, or status, for “over the live-hold limit”. The Kit would then drop the heuristic. See Open items.
Leases, crashes and orphan holds
A hold is a lease. Venue frees it when the lease runs out unless you renew it. That is what gives a dead app’s reservation back, and it is also why a crash costs you something.
What happens to a killed app’s holds. They stay live, and keep counting against the consumer’s limit, until the lease lapses. A clean exit releases them; kill -9 does not.
Measured against a real throwaway daemon, three running holds with a 6 second lease, the process killed with kill -9, then the app relaunched at once:
| This run | Earlier run | |
|---|---|---|
| Orphans reported by the startup scan | 3, at 0.24 s after the kill, each with 4.4 s left | 3, at 0.23 s, each with about 4.0 s left |
| New job granted | 5.4 s after the kill | 4.3 s after the kill |
| Holds left at the daemon afterwards | 0 | 0 |
The spread is arithmetic. The app renews every third of the lease (2 s here), so the last renewal happened up to 2 s before the kill and the lease ends 4 to 6 s after it. The relaunched job then polled once a second, so it was granted at the next poll after the lapse.
With the Kit’s default lease of 300 seconds, the same crash keeps the consumer’s quota for up to five minutes. The render-queue demo’s 30 second lease (renewed every 10 s) would cost up to about 30 s.
So keep leases short. Choose the shortest lease that still survives your renewal being late by a couple of intervals. Tradeoff: a shorter lease means more renewal traffic and a smaller margin for a slow network or a stalled event loop. Do not take the default 300 seconds for work that lasts seconds.
A clean quit of the render-queue demo with three jobs running left no holds within 1.5 seconds. That works because the app’s close hook cancels its jobs and waits for each withHold to release. This was measured earlier and not re-run for this guide.
Finding orphans at startup
holds() lists this consumer’s own live holds. Subtract the ones this run took, and what remains came from a crash, or from another running copy of the same app.
import 'package:app_context_kit/app_context_kit.dart';
/// Holds this consumer still has that this run did not take: left by a crash, or taken by another copy.
Future<List<VenueHold>> findOrphans(VenueSession venue, String thisRunPrefix) async {
return [
for (final h in await venue.holds())
if (!h.name.startsWith(thisRunPrefix)) h,
];
}
Future<void> main() async {
final manifest = AppManifest.fromJson({'id': 'com.slashbuilder.demo.calc', 'name': 'Calc', 'version': '0.1.0'});
final ctx = await AppContext.open(manifest, fakeCapacity: 3);
final venue = ctx.venue;
// Pretend an earlier run died holding this.
await venue.hold(const VenueNeed(name: 'run-old-1', contractClass: 'container.run.v1'), leaseSeconds: 60);
final orphans = await findOrphans(venue, 'run-new-');
for (final h in orphans) {
final left = h.expiresAt?.difference(DateTime.now()).inSeconds;
print('orphan ${h.name}, lapses in about ${left}s');
await venue.release(h);
}
print('after release: ${(await venue.holds()).length} holds');
await ctx.close();
}
orphan run-old-1, lapses in about 59s
after release: 0 holds
This needs hold names that identify the run. The render-queue demo names holds rq-<runId>-<n>, shows “N holds from an earlier run still count against your quota”, lists each with its time to lapse, and offers a Release button.
Caveat: two running copies of one app share a consumer, so each sees the other’s holds as orphans. Check the roster (Roster.scan()) before telling the person a hold is stale; the demo does, and only changes its wording.
A second liveness clock
The roster (the presence file each app writes) and the Venue lease both answer “is this process still alive?”, with separate timers. After kill -9 they disagreed in an earlier measurement: Venue freed the hold in 6 s, the roster still said “running, stale” for up to 15 s. The Kit has not merged them. Treat the Venue lease as the answer for Venue, and the roster as the answer for “who is on this machine”.
Pinned TLS
By default the daemon is plain HTTP on the loopback interface. The bearer token travels in a header, in the clear. That is acceptable on 127.0.0.1. For any daemon on another machine, use HTTPS.
A self-signed daemon (venued --tls) prints its certificate fingerprint when it starts. Give the app that value:
VENUE_DAEMON=https://127.0.0.1:17879 \
VENUE_DAEMON_FINGERPRINT=sha256:<the 64 hex digits venued printed> \
VENUE_TOKEN_FILE=/path/to/token \
dart run bin/hold.dart
How the pin works:
- With a fingerprint, no certificate authority is trusted at all. Every certificate is shown to a check, and only one whose SHA-256 equals the pin is accepted. There is no hostname check and no expiry check. The pin is the identity.
- Nothing is sent, not even the token, to a server that does not present the pinned certificate.
- These spellings are accepted: with or without
sha256:, upper or lower case, with or without colons. They are normalised tosha256:and 64 lower-case hex digits. (Covered bydart/app_context_kit/test/transport_pin_test.dart; the Rust crate has no TLS client.) - Without a fingerprint, Dart falls back to the system’s trusted roots, so a CA-signed daemon should work (not tested).
What a wrong pin looks like depends on how you reach the daemon.
Through AppContext.open, the health check fails first, and the context reports only that the daemon did not answer. In auto mode:
Venue: local (Venue did not answer at https://127.0.0.1:17879)
The note has severity warning. In on mode, open fails with “Venue is required but https://127.0.0.1:17879 did not answer”. This is the same text whether the pin is wrong, missing or malformed. The reason is not in the text.
Through a session you build yourself (RemoteVenue with IoTransport(fingerprint: ...)), the reason is in the error. These are the three VenueUnavailable messages from a real TLS daemon:
GET /v1/nodes: the daemon's certificate (sha256:0aded5...5974) is not the one that was pinned (sha256:abab...abab)
GET /v1/nodes: the daemon's certificate is not trusted by the system; a self-signed venued needs its fingerprint (VENUE_DAEMON_FINGERPRINT=sha256:...)
GET /v1/nodes: the fingerprint "nonsense" is not 32 bytes of hex (sha256:...)
(The first line has the fingerprints shortened here.) If an app shows “did not answer” and you expect TLS, build a RemoteVenue by hand as above to see the cause.
Plain http:// pointed at a TLS port fails quickly rather than hanging (well under 10 s in Dart).
Verification: dart/app_context_kit/test/venue_tls_test.dart (14 daemon-backed tests) runs against a daemon that serves a self-signed certificate. It skips unless VENUE_TLS_DAEMON, VENUE_TLS_FINGERPRINT and VENUE_TOKEN_FILE are set, and the case list is in Testing. The 14 passed when this page was written, against a throwaway daemon; they were not re-run for the public release. A further 4 Dart tests that need no daemon (transport_pin_test.dart) are part of the normal Dart run (365 passed, 7 skipped).
Registering an app with Venue
Venue only grants holds to consumers it knows about, and only for the kinds of work they have been granted. Today, introducing an app to a daemon takes four manual steps. These are the four a throwaway registry needed for each demo app, written as the Venue project’s own command line (that command line is not part of the App Context Kit repository):
- Add an entry for the consumer to the registry’s
authority.yaml, with a class, a priority cap and a live-hold limit:- {name: render-queue, class: Consumer, maxPriority: 100, maxLiveHolds: 3} venue --as declared principal add render-queuevenue --as declared grant use container.run.v1 --principal render-queuevenue --as declared token add render-queue --manager render-queue, which prints the token.
Steps 2 and 3 need an operator with authority over the registry; the commands pass --as declared on steps 2 to 4; what that flag does is described in Venue’s own documentation. Grants are authority: they decide what a program may ask the machine to do.
The Kit cannot do this for the app, and should not. An app that could grant itself the right to run work would make the limit meaningless. So “Venue wired in from the first launch” cannot be done by the app on its own. The app can only ship its manifest and wait for someone with authority to provision it.
What a provisioning step would need (none of this is built; it is a description of the gap):
- Input. The manifest’s
venueblock gives the consumer name. It does not say which contract classes the app will ask for. Today that is in the app’s code (container.run.v1above). The manifest could declare it, so the provisioner can build the grants. The Kit does not. - Decisions only an operator can make. The priority cap and the live-hold limit (3 in the demo registry).
- Authority. Something that holds the right to edit the registry and run steps 2 to 4: an installer, an app store, or a person. Which of these runs it is undecided.
- Delivery. The token has to reach the app: a file at
<config>/venue-token, or an environment variable set by whatever launches it. Under the macOS sandbox, a token file outside the app’s container cannot be read; see Platforms. - Repeatability. Running it twice on upgrade should not fail or duplicate, and uninstalling should remove the grant. Not designed.
Running without Venue
Venue is optional. An app can run entirely local:
- Set
"venue": { "mode": "off" }and nothing ever connects. The session islocalwith the description “Venue is off for this app”. - Leave the mode at
autoand configure nothing. The session islocal(“Venue not configured”). If a daemon is configured later, the same binary connects.
What local does:
- Holds exist only in your process. A second hold with the same name in the same process is refused with
doesNotFit. Different names are all granted, with no limit. In an earlier test, five render jobs ran at once underlocal, where a real daemon with a limit of 3 would have run three. renewdoes nothing,nodes()is empty, andholds()lists this process’s holds.- Nothing protects against another app, or another copy of yours, using the same resource at the same time.
If your app can wait for capacity, the people using it get the right behaviour only on a venue session. On local, say so (the description already does).
Testing against a daemon
The App Context Kit repository does not contain a Venue daemon and does not ship a script that starts one. To run the real-daemon tests, start any compatible daemon yourself and give it what the tests expect:
- A consumer named
calc, grantedcontainer.run.v1, with a limit of 3 live holds (the limit test relies on it). Introduce it with the four registration steps above. - A token file for that consumer.
- A node whose heartbeat stays fresh. A daemon whose node was last probed about two minutes ago refuses every hold with 409
does_not_fit(“the node’s heartbeat has lapsed”); if you write your own setup, keep the node probed (for example every 30 seconds). - Plain HTTP on the loopback address is fine for these tests. TLS is only for the pinned-TLS tests.
- The
container.run.v1capability was chosen because the machine the tests were written on has Docker and no GPU. Whether it needs Docker, and any other contract class, was not tested.
Then:
cd dart/app_context_kit
VENUE_DAEMON=http://127.0.0.1:7878 \
VENUE_TOKEN_FILE=<path to calc's token file> \
dart test test/venue_real_test.dart
Without VENUE_TOKEN_FILE the tests skip with a message, and a skipped run proves nothing about the daemon. When this page was written the file passed 5 of 5 tests against a throwaway daemon; that was not re-run for the public release. The Rust crate has no real-daemon test, because it has no remote session.
The pinned-TLS tests are separate; see Testing.
Open items and who owns them
| Item | Owner |
|---|---|
A machine-readable reason (or its own status) for “over the live-hold limit”, so the Kit can drop the forbidden heuristic. | The Venue project |
| A wait queue or event push, so waiting apps stop polling. | The Venue project |
| A small typed client library for Venue in Dart and Rust. The Kit carries its own Dart HTTP client. The Rust crate has no client: the one it used before was a path dependency on the Venue project’s own client, which brought the dependency tree from 11 to 115 packages when measured, and could not be built without that project checked out. | The Venue project |
| A provisioning step: who runs the four registration steps, and how the token reaches the app. | Undecided; the Kit’s maintainers |
| Manifest declaration of the contract classes an app needs. | The Kit’s maintainers (spec decision) |
The default for venue.consumer (last segment of the id) can collide across vendors. | The Kit’s maintainers (spec decision) |
Daemon behaviour not explained: a hold’s startedAt was seen about 30 seconds after it was granted, later than expiresAt. The Kit does not rely on it. | The Venue project |
A Rust with_hold_waiting. | The App Context Kit (not built) |
| A remote Venue session for the Rust crate (needs the small typed client above first). | The App Context Kit (not built) |
| A real-daemon test of a lost lease because the daemon stopped answering (a released hold is tested). | The App Context Kit (not done) |
Coordination guide: copies of one app talking to each other
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
This guide is for a developer who wants two running copies of the same app to find each other, agree on who is there, and exchange messages. It covers the peer layer of the App Context Kit: what the Kit does, what you write, how to start it, and what it does not do.
Status, so you know what you are reading. The peer layer is implemented in the Dart core (dart/app_context_kit/lib/src/peers/). Two providers are built: an in-memory fake network (tests and mock mode) and a loopback provider (copies on one machine). No provider for a real network exists yet, and the Rust implementation has no peer code. Every example in this guide that has a file under examples/coordination/ runs as a test; the commands are in Run the examples.
Contents: the model · the scope key · the hook · starting a hub · the hub API · providers · the envelope · ordering and loss · delivery · backpressure · PeerJson and size limits · testing · three worked patterns · what the hook does not give you · real transports
1. The model
The Kit finds, authenticates, connects and routes. Your app supplies a hook and decides what the messages mean. The Kit never reads the payload.
| The Kit does | Your app does |
|---|---|
| Finds other copies in the same scope (account and app id) | Names the conversation (channel) |
| Opens a link to each one and runs a handshake | Says what to tell a new peer (hello) |
| Checks the peer is who it claims to be (through an authenticator) | Decides whether to talk to it (admit) |
| Keeps the list of peers and tells you when one is found, ready or lost | Reacts to that list changing (onPeer) |
Delivers each peer’s messages to you once, in order, and reports a gap as a jump in seq | Decides what a message means, and what to do about a missing one (onMessage) |
| Correlates a request with its reply and fails it on timeout or loss | Defines the message kinds, the payload format and its version |
| Bounds the outgoing queue and fails a send instead of dropping | Decides what is safe to drop, coalesce or retry |
“Never reads the payload” is literal. A message carries a kind string and a schema integer that you choose, and an opaque byte payload. The Kit routes on its own header (see the envelope) and hands you the bytes.
You get one hub per channel. A hub is one PeerHub object that owns the links to every other copy of your app. An app with several unrelated conversations either starts one hub per conversation or writes one hook that routes by kind prefix (a sample is MuxHook in sample_hooks.dart).
2. The scope key
Two copies are peers only if they have the same account and the same app id. Anything else is never offered to your hook and never admitted.
| Part | Where it comes from |
|---|---|
| App id | id in app.json (the manifest) |
| Account | APP_CONTEXT_ACCOUNT, else account in the app’s config.json, else local |
| Device | APP_CONTEXT_DEVICE, else device in config.json; if neither exists the Kit generates d- plus 8 hex digits and saves it |
| Instance | 8 random hex digits, drawn each time the app opens (ctx.instance) |
A peer is identified by (device, instance), the PeerId. Two copies on one device are two peers. local is the account of a machine that has no household account; it is a placeholder until something issues a real one.
Scope is checked in three places, so a bug in one does not open the others:
- The provider does not list candidates from another scope and refuses to dial or accept one. The loopback provider also keeps one directory per account and app, so it never reads another scope’s files.
- The hub drops a candidate or an incoming link whose claimed scope differs from its own, and counts it in
hub.stats.scopeViolations. - Every frame carries
accountandappin its header. A frame with a different scope cuts the link.
ping_test.dart checks this: a copy of the app, a copy with another app id and a copy with another account share one network, and none of the three sees any peer.
Scope is a name, not a credential. The default authenticator (OpenAuthenticator) accepts every peer in scope, and no per-app credential exists yet. Treat the scope key as a way to keep copies of different apps and accounts apart, not as access control. The authenticator is the seam where a real check goes; see per-app credentials. A security review is pending; details withheld.
Identifier rules for the loopback provider: account, app, device and instance become directory and file names, so they may contain only letters, digits, ., _ and -, may not start with ., and may be at most 128 characters. Anything else makes the hub fail to open, which ctx.startPeers reports in handle.error.
3. The hook: five jobs
A hook is a subclass of AppCoordination (coordination.dart). It has five jobs and one convenience:
| # | Member | When the Kit calls it | What you return or do |
|---|---|---|---|
| 1 | String get channel | Once, when the hub is built | The conversation this hook owns, such as "default" or "game". A peer on another channel is rejected at the handshake |
| 2 | Object? hello() | Each time a link opens | Anything JSON-encodable, or null. Describe your current state (a version, a digest, a protocol number). The Kit carries it and never reads it |
| 3 | FutureOr<Admit> admit(Peer, Object? theirHello) | After the Kit has checked identity, once per link | Admit.accept(), Admit.acceptReadOnly() or Admit.reject(reason). May be async; the link waits |
| 4 | void onPeer(Peer, PeerChange) | A peer is found, ready or lost | React. Runs before the same event appears on hub.changes |
| 5 | void onMessage(Peer from, AppMessage) | A message arrives from a ready peer | Your meaning lives here. It is synchronous: queue async work yourself |
void attach(PeerHub) | Once, from hub.start() | Keep the hub so you can call send, broadcast, request and reply. The default does nothing |
acceptReadOnly only sets peer.readOnly. The Kit does not filter anything for a read-only peer, and the other side is not told. You decide what read-only means, for example by ignoring that peer’s write messages in onMessage.
A hook must not throw. If it does, the hub catches the error, counts it in hub.stats.hookErrors, passes it to PeerHubOptions.onHookError if you set one, and carries on.
A complete hook: “ping”
This is the whole of a working app’s coordination code. Any copy can ping another, and the other answers with its device name. It is examples/coordination/lib/ping_hook.dart, unchanged.
import 'dart:async';
import 'package:app_context_kit/app_context_kit.dart';
/// A complete coordination hook: the five jobs, and nothing else.
///
/// "ping": any copy can ask another copy "are you there?", and the other copy answers with its device name.
class PingHook extends AppCoordination {
PeerHub? hub;
/// Peer changes as the Kit reported them, e.g. "ready desktop".
final List<String> events = [];
/// Messages this hook did not understand. A hook counts these instead of throwing.
int ignored = 0;
// 1. Which conversation inside the app this hook owns.
@override
String get channel => 'ping';
// The Kit hands the hook its hub once, from PeerHub.start().
@override
void attach(PeerHub hub) => this.hub = hub;
// 2. What to tell a peer when a link opens. Any JSON-encodable value.
@override
Object? hello() => {'proto': 1};
// 3. Whether to talk to this peer, given what it said in its hello.
@override
FutureOr<Admit> admit(Peer peer, Object? theirHello) =>
theirHello is Map && theirHello['proto'] == 1
? const Admit.accept()
: const Admit.reject('ping protocol 1 only');
// 4. A peer was found, became ready, or was lost.
@override
void onPeer(Peer peer, PeerChange change) =>
events.add('${change.name} ${peer.id.device}');
// 5. The app's meaning lives here. Answer a ping; count anything else.
@override
void onMessage(Peer from, AppMessage message) {
if (message.kind == 'ping' && message.schema == 1 && message.requestId != null) {
PeerJson.quiet(hub!.reply(message, PeerJson.encode({'pong': hub!.device}), schema: 1));
} else {
ignored++;
}
}
/// Ask [peer] if it is there. Returns the name it answered with, or null for an answer in the wrong shape.
/// Throws a PeerError (PeerTimeout, PeerLost, ...) if there is no answer.
Future<String?> ping(Peer peer) async {
final reply = await hub!.request(peer, PeerJson.encode({'n': 1}), kind: 'ping', schema: 1);
final body = reply.jsonBody;
return body is Map && body['pong'] is String ? body['pong'] as String : null;
}
}
And this is how two copies of it run in one test process. Each copy is an AppContext in mock mode; they share one FakePeerNetwork. The manifest must list "peers" in kits, or ctx.startPeers throws KitNotDeclared. From ping_test.dart:
/// The manifest must list "peers", or ctx.startPeers throws KitNotDeclared.
const manifestJson = {
'id': 'dev.example.ping',
'name': 'Ping',
'version': '0.1.0',
'kits': ['peers'],
};
/// One copy of the app: its own AppContext, in mock mode, on the shared fake network.
Future<({AppContext ctx, PingHook hook, PeersHandle peers})> copy(
String device,
FakePeerNetwork network, {
Map<String, Object?> manifest = manifestJson,
String account = 'home',
}) async {
final ctx = await AppContext.open(
AppManifest.fromJson(manifest),
env: {
'APP_CONTEXT_MODE': 'mock',
'APP_CONTEXT_SCRATCH': '${scratch.path}/$device',
'APP_CONTEXT_DEVICE': device,
'APP_CONTEXT_ACCOUNT': account,
},
platform: 'linux',
home: '/unused',
announce: false,
);
final hook = PingHook();
final peers = ctx.startPeers(hook, network: network);
await peers.started;
return (ctx: ctx, hook: hook, peers: peers);
}
test('two copies of one app find each other, ping, and get a pong', () async {
final network = FakePeerNetwork();
final laptop = await copy('laptop', network);
final desktop = await copy('desktop', network);
expect(await until(() => laptop.peers.hub!.readyPeers.isNotEmpty && desktop.peers.hub!.readyPeers.isNotEmpty), isTrue);
final desktopFromLaptop = laptop.peers.hub!.readyPeers.single;
expect(desktopFromLaptop.id.device, 'desktop');
expect(await laptop.hook.ping(desktopFromLaptop), 'desktop');
final laptopFromDesktop = desktop.peers.hub!.readyPeers.single;
expect(await desktop.hook.ping(laptopFromDesktop), 'laptop');
// The hook heard found, then ready, in that order, on both sides.
expect(laptop.hook.events, ['found desktop', 'ready desktop']);
expect(desktop.hook.events, ['found laptop', 'ready laptop']);
expect(laptop.hook.ignored + desktop.hook.ignored, 0);
await laptop.ctx.close();
await desktop.ctx.close();
});
until(...) is a small polling helper defined at the top of that file. Discovery and the handshake are asynchronous, so the test waits for readyPeers instead of sleeping.
Run the examples
cd docs/examples/coordination
dart pub get
dart test
The package depends on app_context_kit by path and on nothing else except test. It contains three test files: ping_test.dart (the example above), hub_behaviour_test.dart (tests for the behavioural claims in this guide: sequence numbers, loss, outbox, size caps, timeouts, admit and reject, hello) and loopback_test.dart (two copies over real TCP and announcement files).
4. Starting a hub
Declare the Kit in the manifest, then call ctx.startPeers with your hook.
{ "id": "dev.example.ping", "name": "Ping", "version": "0.1.0", "kits": ["peers"] }
ctx.startPeers(hook, {network, options}) builds a hub and starts it. It returns a PeersHandle at once, before the hub has opened, and it does not throw for a runtime condition. If the peers directory cannot be created or the provider fails to open, the handle carries the reason and your app keeps working alone.
PeersHandle member | Meaning |
|---|---|
hub | The PeerHub, or null when peers are unavailable. Non-null while the hub is still opening; set back to null (and the hub closed) if opening fails. handle.hub?.broadcast(...) is always safe |
started | A future that completes when the hub is open or has failed. It never completes with an error |
error | "peers unavailable: ...", or null |
isStarting, isRunning | isRunning is true when the hub is open and there is no error |
changes | A broadcast Stream<void>. Fires when the hub opens or fails and on every peer found, ready or lost. Ends when the context closes |
KitNotDeclared is the one exception that startPeers does throw: forgetting to list "peers" is a bug in the app, not something that happens to a person. The hub is closed with the context (ctx.onClose), so you do not close it yourself.
The provider is chosen from the mode, in this order:
- In mock mode (
<PREFIX>_MODE=mockorAPP_CONTEXT_MODE=mock): theFakePeerNetworkyou pass asnetwork:, or a private one if you pass none. A private network contains only your own context, so to have two contexts find each other in a test, pass the sameFakePeerNetworkto both. Apps do this by taking anetwork:parameter in their own production code; the demo apps’ constructors show the shape. - Otherwise: a
LoopbackProviderover the peers directory.
| Variable | Effect |
|---|---|
APP_CONTEXT_ACCOUNT | The account half of the scope key |
APP_CONTEXT_DEVICE | The device half of the peer id |
APP_CONTEXT_PEERS_DIR | Where loopback announcements are written. Default: the roster directory with -peers appended (for example /tmp/app-context-roster-peers) |
APP_CONTEXT_MODE, <PREFIX>_MODE | mock selects the fake network |
APP_CONTEXT_SCRATCH | Puts every app directory and the roster under one directory. Note that the default peers directory moves with the roster, so two copies with different scratch directories need a shared APP_CONTEXT_PEERS_DIR to find each other |
Building a hub writes a generated device value into the app’s config.json the first time, unless APP_CONTEXT_DEVICE is set. Two copies that share a config directory therefore share a device id and differ only by instance. Give each copy its own APP_CONTEXT_DEVICE, or its own config directory.
In a Flutter app, PeersIndicator.forHandle(handle) from package:app_context_kit_flutter/app_context_kit_flutter.dart shows “This copy: laptop #3fa2”, the number of peers, a chip for each ready copy, and “Peers: off (reason)” when handle.error is set. It follows handle.changes itself.
ctx.peerHub(hook, {provider, network, authenticator, options}) is the lower-level call. It builds the hub and registers it for closing with the context, but does not start it: you await hub.start() yourself and handle the failure. Use it when you need your own provider, a custom authenticator, or a specific LoopbackProvider configuration. It also throws KitNotDeclared when "peers" is missing.
5. The hub API
PeerHub (hub.dart) is the management interface.
| Member | What it does |
|---|---|
start() | Opens the provider and starts finding peers. Calls hook.attach(this) first. startPeers calls it for you |
peers | A snapshot list of every peer that is found, connecting or ready. Lost and rejected peers are not in it |
readyPeers | The ready subset of peers |
changes | A broadcast Stream<PeerEvent>: every found, ready and lost, in order, after your hook’s onPeer has seen it |
send(peer, payload, {kind, schema}) | Queues one message. Completes when the transport has taken the frame. See delivery |
broadcast(payload, {kind, schema}) | send to every ready peer. Returns a BroadcastResult with sent and a per-peer failed map; one bad peer does not throw |
request(peer, payload, {kind, schema, timeout}) | Sends a request and returns the reply as an AppMessage. Default timeout 5 s |
reply(requestMessage, payload, {schema}) | Answers a message that arrived with a requestId |
close() | Sends kit.bye to ready peers, closes every link and the provider. Idempotent |
id, identity, device, instance, stats, isClosed | Who this copy is, and counters (duplicatesDropped, gapsSkipped, malformedFrames, scopeViolations, protocolViolations, droppedBeforeReady, hookErrors, linksOpened, dialFailures) |
kind defaults to "msg" and schema to 0. kind is 1 to 256 characters and may not start with kit.; schema is 0 to 2,147,483,647. A bad value is an ArgumentError.
Peers and their states
A Peer is read-only to you. Its state is one of found, connecting, ready, rejected or lost. found means the provider announced it; connecting means a link is being opened or the handshake is running; ready means both sides admitted each other. peer.hello is the decoded hello once received, and peer.rejectReason and peer.lostReason say why a peer ended (goodbye, link closed, hub closed, withdrawn, and others).
A Peer object is never reused. A copy that goes away and comes back is a new Peer with the same PeerId. Keep the PeerId if you need to find the peer again; calling send with a Peer that has been replaced throws PeerNotAvailable. A rejected peer is not redialed until the provider withdraws it and announces it again.
A rejection shows up as a lost event on both sides, with peer.state == rejected. hub_behaviour_test.dart checks it: one side’s admit rejects with "proto 2 only", and the other side’s peer ends with rejectReason == 'rejected by peer: proto 2 only'.
Errors
All failures in this layer are subclasses of PeerError.
| Error | Raised when |
|---|---|
PeerNotAvailable | The peer is unknown, rejected, lost, or a stale Peer object |
PeerLost | The link ended while a message or request was outstanding |
PeerOutboxFull | The peer’s outgoing queue is full (backpressure) |
PeerTimeout | A request got no reply in time |
PeerClosed | The hub is closed |
EnvelopeError | The payload is over the 1 MiB cap, or a frame is malformed |
PeerPayloadTooLarge | PeerJson.encodeWithin found the value over your limit |
PeerLinkClosed, PeerConnectError, PeerScopeError | Provider-level: a send on a closed link, a candidate that cannot be reached, a candidate outside the scope |
If you call send without awaiting it, attach a handler or use PeerJson.quiet(...). A late failure of an un-awaited future is an uncaught async error.
6. Providers
The hub talks to two small interfaces, PeerProvider and PeerLink (provider.dart), so the same hook runs over any provider. A provider announces this copy, reports other copies in the same scope (discover()), dials one (connect), and hands over links that others dialed (incoming). A link is one ordered, message-framed connection: one send arrives as one inbound event.
Fake network: tests and mock mode
FakePeerNetwork (fake_network.dart) is an in-memory network. Several hubs join one network, each through its own provider. It enforces the scope key itself and lets a test change the world while it runs:
| Control | Effect |
|---|---|
latency, jitter | Delay every frame; jitter can reorder |
dropRate, duplicateRate | Probability that a frame is lost or delivered twice. Seeded, so a run repeats |
dropIf | A function you set, (from, to, bytes) => bool. Return true to drop that frame. Envelope.decode(bytes) lets you pick a victim by kind or content |
partition(a, b), isolate(a), heal(...), healAll() | Cut peers from each other, and undo it |
dropLinks(a, b) | Break the links but keep discovery: a flaky connection rather than a partition |
deliveries | A record of every frame that reached a link end, so a test can prove what did not arrive |
For deterministic time, build a PeerHub directly with a ManualPeerClock (see World in support.dart). ctx.startPeers and ctx.peerHub do not take a clock, so hubs they build run on real time.
Loopback: copies on one machine
LoopbackProvider (loopback_provider.dart) connects copies that run on one machine, as processes. It needs nothing but a shared directory.
Announcement files. Each copy listens on a TCP port on 127.0.0.1 (never a routable address; the operating system picks the port) and writes one file:
<peers dir>/<account>/<app id>/<device>@<instance>.json
with these fields. This file was written by a run of the stopwatch probe; loopback_test.dart asserts the key set and the path.
{"account":"local","app":"com.slashbuilder.demo.stopwatch","device":"desktop","instance":"48e784da","port":52367,"pid":63779,"beat":1792850562108}
beat is a wall-clock time in epoch milliseconds. The file is written to a temporary name and renamed, so a reader never sees half a file.
Finding each other. Each provider reads the directory for its own account and app every 100 ms (pollInterval). A file naming another scope than its directory is ignored. A candidate is offered if its file is under 4 KiB, parses, has a valid port, and has a recent beat.
Connecting. Only the copy with the lower PeerId dials, so two copies never open two links. A frame on the wire is a 4-byte big-endian length followed by the envelope bytes. The dialer’s first frame is a preface naming who it claims to be; the accepting side closes the socket if the preface names another scope or is garbage, before the hub sees a link. A dial that fails is retried after 0.2 s, doubling to a 5 s ceiling, at most 10 attempts; after that the hub waits for the provider to announce the copy again.
Beat, staleness and reaping. This is how a crashed copy disappears.
| Setting | Default | Meaning |
|---|---|---|
announceEvery | 1 s | Each provider rewrites its own file with a fresh beat |
staleAfter | 4 s | A file whose last beat is older is not offered, and a peer already known from it is withdrawn |
reapAfter | 60 s | A file whose last beat is older is deleted by whichever copy notices. A paused copy rewrites its file at its next beat |
A file with no beat (one made by hand) is aged by its modification time. The provider does not check whether the pid in the file is alive: a pid means nothing across pid namespaces, can be reused, and Dart has no portable check that does not spawn a process. The cost of the beat is one small write per second per process.
What a crash looks like. On a clean close() the copy sends kit.bye, deletes its file, and the other copy sees lost with reason goodbye; it is not redialed. If the process is killed, the operating system closes its sockets, so the survivor sees the link drop at once. But the file is still there and still has a recent beat, so the dead copy is still announced and the survivor sees it as found again. It stays that way until the beat is older than staleAfter. One death is therefore three events: lost, found, lost.
Measured today on macOS (Apple silicon) by dart test test/peers/two_process_test.dart -r expanded in dart/app_context_kit, which starts real operating-system processes over one temporary directory and kills one with SIGKILL. Two runs, one for each side of the dial rule:
| Observation after the kill | Survivor that dials | Survivor that waits to be dialed |
|---|---|---|
lost (“link closed”) | 6 ms | 7 ms |
found for the same dead copy | at the same instant | at the same instant |
final lost (“withdrawn”) | 3564 ms | 3591 ms |
| failed dial attempts | 4 | 0 |
dead copy in hub.peers after 12 s | gone | gone |
| announcement file on disk after 12 s | still present (deleted at 60 s) | still present |
The same test passes in a Linux container (numbers in Platforms). Before the beat was added, the dead copy stayed found for ever. The found after the first lost is a known rough edge: the hub cannot yet tell “link dropped” from “gone” earlier than the beat allows. Make onPeer idempotent, and do not assume found cannot follow lost.
Run as two macOS debug builds of the stopwatch, notepad and minesweeper apps, the second copy’s announcement appeared 257 to 439 ms after launch and a TCP link 31 to 35 ms after that. These figures are recorded in the apps’ notes; they were not re-run for this guide.
Tuning. ctx.startPeers and ctx.peerHub take PeerHubOptions but not the loopback timings. To change staleAfter or announceEvery, build the provider yourself and pass it: ctx.peerHub(hook, provider: LoopbackProvider(directory: ..., staleAfter: ...)).
Limits.
- One machine, one user. The loopback provider has no encryption and no authentication. Use it for same-user coordination on one machine and for tests, not across users or trust boundaries.
- On macOS, a sandboxed app has its own container. The peers directory (and the roster) must be reachable by every copy: the default location is inside the container and works for copies of one app; a path outside the container leaves the app working alone. The sandbox entitlements
com.apple.security.network.clientandcom.apple.security.network.serverare both needed to dial and to listen. Flutter’s macOS template grants onlynetwork.serverin Debug and neither in Release; the notepad and minesweeper projects add both. With onlyserver, the recorded observation was that two sandboxed copies listen but never connect, with nothing in the log. PLATFORMS.md has the full table; the observation was not re-run for this guide. - Environment variables you set in a shell reach an app you start from that shell. They do not reach one started from Finder or the Dock.
7. The envelope
Every frame between two copies is one envelope: a header the Kit reads and a payload it does not (envelope.dart). The wire form is UTF-8 JSON with the keys in a fixed order:
{"v":1,"account":"a","app":"dev.x","channel":"default","from":{"device":"d","instance":"i"},
"seq":1,"kind":"msg","schema":0,"rid":7,"payload":"<base64>"}
| Field | Meaning | Limits |
|---|---|---|
v | Envelope version. A different value is rejected | 1 |
account, app | The scope key | 1 to 256 characters, no control characters |
channel | The hook’s channel | same |
from.device, from.instance | The sender’s PeerId | same |
seq | Sequence number, per link and direction | 0 to 2^53 - 1 |
kind | Message type. Yours, or a reserved kit. kind | 1 to 256 characters |
schema | Your payload version. The Kit carries it and does not look at it | 0 to 2^31 - 1 |
rid | Request id. Present only on requests and replies | 1 to 2^53 - 1 |
payload | Your bytes, base64 | at most 1,048,576 bytes before encoding |
Unknown extra keys are ignored, so a later version can add fields.
The outer cap is 1 MiB. A payload over 1,048,576 bytes is an EnvelopeError on send. The largest frame the decoder will look at is 1,406,296 bytes (the base64 of a full payload plus 8,192 bytes of header room). That is the Kit’s limit. A provider may carry less; see real transports.
Reserved kinds. Kinds that start with kit. belong to the Kit, and your app cannot send them (ArgumentError). The Kit uses these:
| Kind | Purpose |
|---|---|
kit.hello | First frame each way: a nonce and your hello() |
kit.auth | The authenticator’s proof over the other side’s nonce |
kit.ready | “I admitted you” |
kit.reject | “I refuse you”, with a reason of at most 256 characters |
kit.bye | Graceful close; the peer is not redialed |
kit.reply | The answer to a request |
An unknown kit. kind from a peer is ignored. kit.reply is consumed by the hub and completes the request future; it never reaches onMessage.
Request and reply. request gives the outgoing frame an rid from a per-hub counter that starts at 1. The other side’s onMessage sees it as AppMessage.requestId; hub.reply(message, payload) sends a kit.reply with the same rid. The hub matches replies to waiting requests by (peer, rid), so the Kit’s correlation does not depend on a transport’s own message ids. A reply that is late or unknown is ignored. A request that times out cancels its frame if the frame has not been written yet.
8. Ordering and loss
seq numbers the frames in one direction of one link. It starts at 1 on every new link, so a reconnect restarts it. It counts the Kit’s own frames too. kit.hello, kit.auth and kit.ready take 1, 2 and 3, so the first message your app receives from a peer has seq 4. A kit.reply or kit.bye takes a number as well.
The hub delivers each peer’s frames to you in seq order, once:
- A frame that arrives early waits in a reorder buffer of 16 frames.
- A gap that is not filled within 500 ms, or a buffer that overflows, is skipped. The hub counts it in
hub.stats.gapsSkippedand delivers what it has. - A frame at or below the last delivered
seqis a duplicate or a late arrival after a skip, and is dropped (duplicatesDropped). - A peer that floods more than 256 undelivered frames on one link has the link dropped.
You see loss as a jump in AppMessage.seq. hub_behaviour_test.dart drops the second of three messages on the fake network. The receiver gets two:
expect(seqs, [4, 6]);
A jump says that one or more frames from that peer were skipped. It cannot say which: the lost frame might have been a reply. If you need to detect a lost message precisely, put your own counter or version in the payload.
Ordering is per sender. The Kit gives you no order between messages from two different peers.
9. Delivery
Delivery is best effort.
sendcompletes when the transport took the frame, not when the peer received or applied it.hub_behaviour_test.dartsets the fake network to drop every frame and shows thatsendstill completes without error.- If the link ends before a queued frame is written, the
sendfuture fails withPeerLost. Queued messages are dropped when the peer is lost. - There is no retry and no replay. A message sent before a peer exists is not kept for it: a
broadcastwith nobody ready returns an emptysentlist, and a copy that arrives later never sees it. sendto a peer that is found or connecting is queued and written once the peer is ready. App frames from a peer are not delivered to you before it is ready.request/replyis the one acknowledgement the Kit has: a reply proves the other hook ran. A request that gets no reply fails withPeerTimeout(the other hook may still have run).
If you need “it was applied”, build it from a reply. The minesweeper and notepad apps did; the stopwatch accepted a possible duplicate instead. See the patterns.
10. Backpressure
Each peer has an outgoing queue of messages not yet handed to the transport. It holds at most 256 messages or 8 MiB of payload (PeerHubOptions.outboxMessages, outboxBytes). When it is full, send, request and reply fail with PeerOutboxFull on the returned future, and broadcast reports that peer in failed. The Kit does not drop an older message to make room, because only your app knows what is safe to drop.
What to do depends on the message. For state (“the current note”, “the timer”), send the newest value and let PeerOutboxFull mean “skip this one; the next change carries the state anyway”. For events that all matter, await each send before the next. hub_behaviour_test.dart sets outboxMessages: 2, sends ten messages without awaiting, and counts the PeerOutboxFull failures.
11. PeerJson and size limits
Apps that speak JSON need the same few helpers, so they are in the Kit (PeerJson, peer_json.dart):
| Helper | What it does |
|---|---|
PeerJson.encode(value) | UTF-8 JSON bytes |
PeerJson.decode(bytes, {maxBytes}) | The decoded value, or null for malformed, non-UTF-8 or oversized input. Never throws, so hostile input cannot escape onMessage |
message.jsonBody | decode(message.payload) |
PeerJson.sizeOf(value), fits(value, maxBytes) | Size checks before sending |
PeerJson.encodeWithin(value, maxBytes) | Encode, or throw PeerPayloadTooLarge (a PeerError) |
PeerJson.quiet(future) | A send nobody awaits: failures are swallowed. Use it for state you resend anyway |
PeerJson.label(peerId) | "laptop #3fa2": the device and the first four characters of the instance |
decode returns null both for malformed input and for the JSON literal null. Send an object ({"v": 1}) rather than a bare value when you must tell “nothing” from “malformed”.
The Kit does not know your provider’s payload limit. You pass it. The fake and loopback providers carry up to the 1 MiB envelope cap; a Hearth transport would carry 64 KiB minus the Kit’s header. If your app may run on a smaller transport, size to the smaller one with fits or encodeWithin. Keep the hello small as well: it travels in one frame like any other message, so carry a version or a digest, not the data.
There is no second envelope inside the payload. The Kit envelope already has kind and schema, so switch on message.kind, check message.schema, then read the body with message.jsonBody. The three coordinating demo apps do.
12. Testing coordination
Run two copies in one process. Open two AppContexts in mock mode (APP_CONTEXT_MODE=mock), give each its own APP_CONTEXT_DEVICE, call ctx.startPeers(hook, network: network) with one shared FakePeerNetwork, wait for readyPeers, and drive the hooks. The example in section 3 is this setup. The demo apps use it for their hook tests, including a hostile peer that sends malformed JSON, wrong types and an unknown schema, and tests that cut the network with partition/heal.
For tests that depend on timing (reorder timeout, request timeout, redial backoff), build the PeerHub directly with a ManualPeerClock so the test controls time. That is how the Kit’s own tests in dart/app_context_kit/test/peers/ work. For a real process boundary, two_process_test.dart starts operating-system processes over a shared temporary directory.
Count what your hook ignores. The demo hooks keep an ignored counter, and their tests assert it after sending garbage.
13. Three worked patterns
The three demo apps that use peers (stopwatch, notepad and minesweeper, in The Kit Book) each used the hook for a different kind of meaning. The snippets below are copied from their code.
A signal: “the timer finished” (stopwatch)
The stopwatch app shares one countdown between copies. This pattern is only the announcement: when the countdown ends, exactly one copy should say so, and the others should show “finished on laptop #3fa2” instead of announcing it again.
Which copy announces is the app’s decision, not the Kit’s. Every start, pause or reset bumps a stamp, a pair of a revision number and the id of the copy that made the change (its “writer”). The rule: the writer of the current stamp announces. If the writer is no longer among hub.readyPeers, the lowest-numbered live copy announces. Any other copy waits for the timer.finished message and, if it has not arrived after a 2-second grace, announces itself. The announcement is a broadcast with a kind and schema the app chose. A late joiner is not told what it missed; the countdown state rides in its hello() instead (see the replay entry in section 14).
Delivery is best effort, so the announcement can be lost. The app does not try to prevent that with an acknowledgement; it prevents silence instead. Under a partition two copies may both announce. The app chose “at least once” on purpose, and the comment at the top of the file says so. It accepts a possible duplicate announcement rather than a possible missing one.
The Kit contributes the list of ready peers (hub.readyPeers, used to decide who is alive), the broadcast, and the guarantee that one peer’s messages arrive in order. The stopwatch also replicates the countdown itself with a last-writer-wins stamp; that is a second use of the same hook, and this section covers only the signal. From the stopwatch demo app’s lib/coordination/shared_timer.dart:
/// This copy's countdown ran out. Announce if we are the announcer, else wait for the announcer.
void countdownEnded(CountdownModel done) {
if (_told == stamp) return;
if (_isAnnouncer()) return _announce(done);
final at = stamp;
_grace = Timer(takeoverGrace, () {
if (_told != at && stamp == at) _announce(done);
});
}
bool _isAnnouncer() {
final h = hub;
if (h == null || stamp.rev == 0 || stamp.writer == _me) return true;
final ready = h.readyPeers;
if (ready.any((p) => p.id.toString() == stamp.writer)) return false; // the owner is alive: it announces
return ready.every((p) => h.id.compareTo(p.id) < 0); // owner gone: the lowest live copy does
}
void _announce(CountdownModel done) {
_told = stamp;
final label = formatMs(done.durationMs, tenths: false);
onFinished(Finished(label: label, by: hub?.id ?? const PeerId('local', 'local'), local: true));
final h = hub;
if (h != null) {
final ended = _stateJson(done)..['label'] = label;
PeerJson.quiet(h.broadcast(PeerJson.encode(ended), kind: 'timer.finished', schema: 1));
}
}
A sync with an app-defined conflict rule (notepad)
The notepad app keeps the same notes on every copy. The Kit carries bytes between copies; it has no store, no merge and no idea what a note is. The hook file is 29 lines and forwards each of the five jobs to a sync engine, about 500 lines in note_sync.dart, which is the app’s own.
The central choice is what happens when two copies edit the same note while apart. The engine tracks a version vector per note, so it can tell “you are behind” from “we both edited”. A timestamp cannot tell those apart: it always orders two edits, and one edit silently loses. When the versions are concurrent, the engine keeps both: the version that sorts later (by updatedAt, then device, then a content hash) keeps the note’s title and place, and the other becomes a new note titled <title> (conflict from <device>). The rule is symmetric and total, so both copies reach the same decision without talking, and they derive the same id for the new note, so they converge instead of duplicating. The person is told what happened. Edit beats delete: a delete never destroys a concurrent edit. Identical text on both sides is not a conflict.
Content moves by pull. Small sync.have messages say “note X is at version V”. A copy that is behind asks with request, one part at a time, because request/reply is the only acknowledgement the Kit has. A late joiner puts only a digest in its hello(); if the digests differ, each side sends its index and the one that is behind pulls.
None of this is in the Kit, by design: the Kit does not interpret payloads. The hook is the right shape for meaning; it is not a sync framework. From the notepad demo app’s lib/coordination/note_sync.dart:
void _applyRemote(Note remote, Vv rvv, String rby) {
final id = remote.id;
if (host.isDamaged(id)) return;
host.flushNote(id);
final local = meta[id];
final order = local == null ? VvOrder.before : local.vv.compare(rvv);
try {
switch (order) {
case VvOrder.equal || VvOrder.after:
return;
case VvOrder.before:
_put(remote, rvv, rby);
case VvOrder.concurrent:
final mine = host.note(id);
if (local!.deleted || mine == null) {
_put(remote, local.vv.join(rvv), rby); // edit beats delete
} else if (hashNote(mine) == hashNote(remote)) {
meta[id] = Meta(local.vv.join(rvv), by: local.by, hash: local.hash); // same text on both: only the histories merge
_save();
_announce([id]);
} else {
_conflict(mine, local, remote, rvv, rby);
}
}
} on FileSystemException catch (e) {
report('Could not store a synced note: ${e.message}. It will be tried again at the next sync.');
}
}
/// Does A sort after B? Symmetric and total, so both copies pick the same winner with no conversation.
bool _beats(Note a, String aBy, Note b, String bBy) {
final t = a.updatedAt.compareTo(b.updatedAt);
if (t != 0) return t > 0;
final d = aBy.compareTo(bBy);
return d != 0 ? d > 0 : hashNote(a).compareTo(hashNote(b)) > 0;
}
A handoff with confirmation (minesweeper)
The minesweeper app can hand a game to another copy. “Give the game away” changes state on two sides, and the Kit has no transaction, so the app builds the handoff from request/reply:
- The sender offers (
game.offer: difficulty, status, seconds, squares open; no board). - The receiving person says yes or no. An offer never changes a board by itself.
- The receiver requests the board (
game.take). The reply is the data. The sender serves it only for an offer it made, to the peer it made it to. - The receiver applies the board.
Minesweeper.fromJsonis strict: anything inconsistent is refused and nothing changes. - The receiver requests
game.taken. Only when that request arrives does the sender release its game.
If the confirmation is lost, both copies keep the game: a duplicate, never zero. The receiver is told “did not confirm”. The app accepts one known hole, and says so in its source: moves made on the sender between serving the board and hearing game.taken are lost to the receiver. Closing it needs a lock on UI state that the hook cannot own.
The same offer also starts “play together”, where each move is sent as a small absolute message plus a checksum of the sender’s board, and a receiver whose checksum differs pulls the whole board. Two players who make the first move at the same instant are resolved by an epoch, then the lower PeerId. All of that is the app’s. The Kit supplied request/reply, the ordered delivery per sender, and admit, which the hook uses to refuse a peer with another protocol number. From the minesweeper demo app’s lib/coordination/game_hook.dart:
/// The person said yes to [o]: pull the board, apply it, confirm. Returns what happened, in words.
Future<String> accept(Offer o) async {
final h = hub!;
final peer = h.readyPeers.where((p) => p.id == o.from).firstOrNull;
incoming.remove(o.from);
if (peer == null) return _done('${PeerJson.label(o.from)} is no longer connected.');
try {
final reply = await h.request(peer, PeerJson.encode({'offer': o.id}), kind: 'game.take', schema: 1);
final j = reply.jsonBody;
if (j is! Map || j['ok'] != true || j['board'] is! Map) return _done('${PeerJson.label(o.from)} no longer has that game to give.');
try {
host.adoptBoard((j['board'] as Map).cast<String, Object?>());
} on FormatException catch (e) {
return _done('The game from ${PeerJson.label(o.from)} was not accepted: ${e.message}');
}
if (o.mode == 'share') sharing = o.from;
try {
final ack = (await h.request(peer, PeerJson.encode({'offer': o.id}), kind: 'game.taken', schema: 1)).jsonBody;
if (ack is Map && ack['ok'] == true) return _done(o.mode == 'share' ? 'Playing together with ${PeerJson.label(o.from)}.' : 'Took over the game from ${PeerJson.label(o.from)}.');
} on PeerError {
// fall through
}
return _done('Took the game, but ${PeerJson.label(o.from)} did not confirm: it may still show the game too.');
} on PeerError catch (e) {
return _done('Could not get the game from ${PeerJson.label(o.from)}: ${e.message}');
}
}
14. What the hook does not give you
These are the places where the hook is not enough. Each has the pattern the demo apps used. None is built into the Kit. Section 7.1 of the spec records why large transfer was not built and what it would need.
| Missing | What you get instead | Pattern the demo apps used |
|---|---|---|
| Acknowledgement | send completes when the transport took the frame | Make the receiver answer. Minesweeper’s handoff is a request whose reply carries the board, followed by a second request as confirmation. Notepad pulls content by request. Stopwatch accepts at-least-once delivery of “finished” and adds a takeover after a grace period |
| Large transfer | One message is one frame, up to 1 MiB here and 64 KiB on Hearth. Nothing splits or reassembles | Notepad’s lib/kit_gaps/blob_pull.dart (79 lines) pulls a note part by part over request/reply: parts are sized under the smaller limit, every part carries a version tag and a changed tag restarts the pull (up to 3 attempts), a size cap and a part-count bound are checked as parts arrive, and the outcomes are kept apart: null for gone or changing, FormatException for a malformed reply, PeerError for a lost or silent peer. Payload bytes become base64 inside the envelope’s own base64, about 1.8 times larger on the wire. Not built into the Kit: one app wants it |
| Replay for late joiners | A copy that connects later sees nothing that was sent before it was ready | Put a version, digest or the small state itself in hello(), and let whoever is ahead push on ready. Stopwatch carries the whole countdown state in the hello, a small JSON object. Notepad carries {proto, digest, count} and exchanges indexes when digests differ. Minesweeper drops pending offers on lost and never replays them |
| Liveness ticks | No heartbeat or keepalive runs on a link. Loopback detects a dead process through the dropped TCP connection and a 1 s file beat; a silent but connected copy stays ready | Notepad runs its own 30-second sync.digest broadcast, which doubles as a repair for a lost message. A real network transport will need a heartbeat; the spec says the Kit must supply one on Hearth |
| Ordering across senders | Per-sender order only | Stopwatch orders writes by a Lamport stamp (rev, writer) instead of a clock. Minesweeper uses an epoch, then the lower PeerId, plus a checksum to notice when the order failed |
| Atomic exchange | No transaction. Two parties cannot change state together | Pull, apply, confirm, release. A failure costs a duplicate rather than a loss. See the handoff above |
| Per-app credentials | None. See section 2 | Every demo hook validates the shape, range and size of each message and counts what it refuses. Offers are shown to the person instead of applied. A security review is pending; details withheld |
Smaller gaps you will meet:
onMessageis synchronous. The hub processes one link’s frames one at a time. If a message needs async work, queue it in your hook (notepad has a pump with a bound).- One hub is one channel, and
admitis one answer per peer. “Accept this peer but not its offers” cannot be expressed. Use one hub per facet or a mux hook. - No “same device” signal. Two copies with one device id are one replica to your files. Notepad compares
peer.id.deviceitself inadmitand rejects them. - No place for hook state, and no clock. A stamp or a version that must survive a restart goes in your own file. A timer inside a hook is a plain
Timer. hello()is a snapshot taken at connect. A command can land between the hello andready; the stopwatch re-checks onreadyfor that reason.
15. Real transports
Only the fake network and the loopback provider are built. There is no provider for a real network, and no Hearth provider for the Dart hub. What follows is what a provider has to do, and what was learned about Hearth before writing one.
What every provider must do
The contract is in provider.dart. In short:
open(self)announces this copy and starts accepting.discover()replays what is known, then follows changes, and re-emits a candidate withavailable: falsewhen it goes away.connect(candidate)throwsPeerScopeErroroutside the scope andPeerConnectErrorwhen it cannot reach the candidate.incomingbuffers until listened to.close()is idempotent.- Never offer, dial or accept another account or app id. The hub checks too, but a provider that leaks scope is a bug.
- Discovery must be symmetric. Only the lower
PeerIddials, so both copies have to see each other, or neither connects. - A link delivers one
sendas one inbound event. Framing is the provider’s job.sendcompletes when the transport has taken the bytes. - A link’s
remoteidentity is a claim. The hub checks it against the frame headers and the authenticator’s proof and drops the link if the scope differs. Only a real authenticator makes the proof mean anything.
What Hearth would add
These come from the spec and from tests against a local Hearth hub. They are requirements for a provider, not code that exists in the Dart hub.
- Payload limit. Hearth’s effective payload cap is 64 KiB minus the Kit’s header. The 1 MiB figure is the whole envelope on the Hearth side, and above it the hub drops the connection. A provider should declare its maximum payload and
sendshould fail before the wire when it is exceeded. This is not built:PeerProviderhas no member for a payload maximum today, so the app passes its limit toPeerJson.fitsandencodeWithin. - Echo suppression. The hub delivers a broadcast back to its sender. Drop frames whose
from.deviceandfrom.instanceare this copy’s. - Its own correlation ids. The hub’s message id is not a safe key for matching replies. The Kit’s
ridplus(peer, rid)matching is already independent of it, and a provider must not rely on the transport’s ids. - Deduplication by message id across live delivery and catch-up, liveness by a heartbeat sent no more often than every 30 seconds (the hub’s idle timeout is 90 seconds, and it caps publishes per hour per connection and per account), and reconnect.
- Topics. The stock hub’s topic grammar rejects app-scoped topics. The Hearth tests carried app traffic on the stock topics, set
payload_type = ak.app/<app>, and filtered at the receiver. That is a filter, not access control.
What is built
| Piece | State |
|---|---|
| Dart hub, fake network, loopback provider | Built and tested (dart test in dart/app_context_kit: 360 passed, 7 skipped on the day of writing; six skips need a running resource-broker daemon and one is a Linux-only test) |
| A provider for a LAN or for Hearth | Not built |
| A real authenticator (keys checked against a paired set of devices, a per-app credential) | Not built. OpenAuthenticator accepts everyone; SharedSecretAuthenticator is a test fake |
See also: Architecture for the handshake and the roster versus the peers announcement, Context guide for the context the hub is built from, spec/APP-CONTEXT.md section 6 for the contract.
Testing
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
Two things are covered here: how to test an app built on the Kit, and how to run the App Context Kit repository’s own test suites.
The Kit is built so that an app’s tests need no network, no real home directory and no Venue. Two settings do that, and both are ordinary environment variables:
- Scratch (
APP_CONTEXT_SCRATCH) puts every directory and the roster under one throwaway folder, so a test never touches real data. - Mock (
APP_CONTEXT_MODE=mock) replaces Venue with an in-memory fake and peers with an in-memory fake network.
Part 1 is for app authors. Part 2 is for working on the Kit itself.
Part 1: testing an app
Pass the environment in; do not read the machine’s
AppContext.open takes env:, platform: and home:. When you pass env, nothing from the real process environment is read, so a developer’s own APP_CONTEXT_* variables cannot change a test. The Dart test in docs/examples/dart_cli/test/kit_test.dart opens every context like this:
final env = {'APP_CONTEXT_MODE': 'mock'}; // the scratch directory is added per test
Future<AppContext> launch(AppManifest m, [Map<String, String> more = const {}]) =>
AppContext.open(m, env: {...env, 'APP_CONTEXT_SCRATCH': scratch.path, ...more}, announce: false);
announce: false keeps the test out of the roster. scratch is a temporary directory made in setUp and deleted in tearDown:
late Directory scratch;
setUp(() => scratch = Directory.systemTemp.createTempSync('app-context-example-'));
tearDown(() => scratch.deleteSync(recursive: true));
Because the scratch directory outlives a context, “quit and launch again” is two opens on the same directory. This test is from the same file:
test('a setting written by one launch is read by the next, on the same scratch directory', () async {
var ctx = await launch(manifest);
ctx.config.set('launches', 1);
await ctx.close();
ctx = await launch(manifest);
expect(ctx.config.get<int>('launches', 0), 1);
await ctx.close();
});
Run it with dart test in that directory. Expected: All tests passed! (three tests).
To pin a platform for a test about paths, add platform: 'linux', home: '/x'. resolve() itself is a pure function, so you can also call it directly with a platform, home and env and compare the strings; that is what the conformance fixtures do.
Flutter: pumpKitApp and testAppContext
Import package:app_context_kit_flutter/testing.dart from test/ only; it depends on flutter_test.
A testWidgets body runs in fake async, and AppContext.open does real file I/O and starts real timers, so it cannot simply be awaited there. The helpers do that work inside tester.runAsync for you.
| Helper | What it does |
|---|---|
testAppContext(tester, manifest:) | Opens a context in a fresh temporary scratch directory, in mock mode, with no roster entry. Returns a KitTestContext |
pumpKitApp(tester, manifest:, app:) | testAppContext, then mounts app(ctx) under an AppContextScope, exactly as AppContextKit.run does |
manifest can be an AppManifest, the JSON text of app.json, or a decoded map. Optional parameters: env (added to the isolated environment, and it may override it: {'APP_CONTEXT_SCRATCH', 'APP_CONTEXT_MODE': 'mock', 'HOME'}), scratch (reuse a directory; it is then not deleted), announce (default false), heartbeat, platform, fakeCapacity, transport, and for pumpKitApp, settle (also pumpAndSettle). The directory it made is deleted when the test ends. The host’s environment is never consulted.
The KitTestContext it returns:
| Member | Meaning |
|---|---|
ctx | The current AppContext. After close, the closed one, whose files you can still inspect |
scratch | The directory everything lives under |
env | The environment the context was opened with |
close() | Closes the context as the real app does on exit: roster, onClose hooks, Venue. Idempotent |
reopen() | Closes if open, then opens a new context on the same files. Does not touch the widget tree |
quit() | Closes the context while the widgets are still mounted, then unmounts the tree |
relaunch() | quit(), then a new context on the same scratch directory, then the same app mounted again. Returns the new context |
This is the test from docs/examples/flutter_hello/test/hello_test.dart:
testWidgets('taps are saved and survive a quit and relaunch', (tester) async {
final kit = await pumpKitApp(
tester,
manifest: File('app.json').readAsStringSync(),
app: (ctx) => const HelloApp(),
);
expect(find.text('Taps: 0'), findsOneWidget);
expect(find.text('mock + scratch'), findsOneWidget);
await tester.tap(find.text('Tap'));
await tester.pump();
expect(find.text('Taps: 1'), findsOneWidget);
expect(kit.ctx.dataFile('taps.log').readAsStringSync(), '1\n');
await kit.relaunch(); // close, open again on the same scratch directory, mount again
expect(find.text('Taps: 1'), findsOneWidget);
});
Run it with flutter test in docs/examples/flutter_hello. Expected: All tests passed! (one test).
If your test is about something that must have happened before the app ends (a flush on quit, a hook), call kit.close() or kit.quit() yourself and then assert. The calculator demo app’s test/calculator_screen_test.dart shows a test that passes announce: true to see the real roster entry appear and disappear, and one that asserts every directory the app resolved is under the scratch directory.
To test the real manifest asset bundle, AppContextKit.loadManifest('app.json') works in a widget test; the calculator demo app’s test/manifest_asset_test.dart does exactly that.
The fake Venue
In mock mode, ctx.venue is a FakeVenue. It has one node and a capacity (the fakeCapacity parameter of AppContext.open, default 1), refuses a hold with doesNotFit when it is full, and logs each call. refuseNext(n, {kind}) makes the next n holds refuse whatever the capacity, so a wait-and-retry loop can be tested without arranging a full node. This test is from kit_test.dart:
test('mock mode gives a fake Venue that can be made to refuse, so a waiting loop is testable', () async {
final ctx = await launch(manifest);
final venue = ctx.venue as FakeVenue;
venue.refuseNext(2); // the next two holds are refused as "does not fit"
const need = VenueNeed(name: 'job', contractClass: 'container.run.v1');
final result = await venue.withHoldWaiting(need, (hold) async => 'done',
maxWait: const Duration(seconds: 5), retryEvery: const Duration(milliseconds: 2));
expect(result, 'done');
expect(venue.log, ['hold job', 'hold job', 'hold job', 'start job', 'release job']);
await ctx.close();
});
Use a small retryEvery in tests. withHoldWaiting counts waiting in naps rather than reading a clock, so it behaves under fake time. To test against a Venue that answers in particular ways without a socket, implement DaemonTransport (one send method and one close) and pass it as transport:. The App Context Kit repository’s own tests do this in dart/app_context_kit/test/support/fake_transport.dart.
The fake peer network
Peers are off unless the manifest lists "peers" in kits. In mock mode, ctx.peerHub and ctx.startPeers use an in-memory network. Give two contexts the same FakePeerNetwork and they find each other. Nothing touches the real network or the disk. This test is from kit_test.dart; Listener is a small AppCoordination defined in the same file, which records the messages it gets:
test('two copies of an app find each other on one fake network and exchange a message', () async {
final peerManifest = AppManifest.fromJson({'id': 'dev.example.hello', 'name': 'Hello', 'version': '0.1.0', 'kits': ['peers']});
final network = FakePeerNetwork(); // pass the same network to both copies
final laptop = await launch(peerManifest, {'APP_CONTEXT_DEVICE': 'laptop'});
final phone = await launch(peerManifest, {'APP_CONTEXT_DEVICE': 'phone'});
final listener = Listener();
final laptopHub = laptop.peerHub(Listener(), network: network);
final phoneHub = phone.peerHub(listener, network: network);
await laptopHub.start();
await phoneHub.start();
for (var i = 0; i < 100 && laptopHub.readyPeers.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(laptopHub.readyPeers, isNotEmpty);
await laptopHub.broadcast(PeerJson.encode({'text': 'hi'}), kind: 'greeting');
for (var i = 0; i < 100 && listener.received.isEmpty; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(listener.received, ['laptop: {text: hi}']);
await laptop.close();
await phone.close();
});
Set APP_CONTEXT_DEVICE per copy so the two have different device names; the instance ids are random. In a widget test, run the second copy inside tester.runAsync next to the one under pumpKitApp, and pass the same network to the app. The notepad demo app’s test/peers_ui_test.dart does this end to end.
FakePeerNetwork can also be made unreliable, to test how your hook copes:
| Control | Effect |
|---|---|
partition(a, b), isolate(a), heal(a, [b]), healAll() | Cut copies from each other, or from everyone, and restore them |
dropLinks(a, b) | Break the connections without cutting discovery, like a flaky link |
latency, jitter | A fixed delay, and a random extra delay (non-zero jitter can reorder frames) |
dropRate, duplicateRate | Probability that a frame is lost, or delivered twice |
dropIf | Drop a frame when your function says so |
deliveries, connectAttempts | What arrived where, and how many connections were tried |
FakePeerNetwork(seed: n) makes the randomness repeatable, and a ManualPeerClock passed as clock: makes time repeatable too. A test that checks a hook with startPeers should also check the case where peers cannot start: the handle’s error is set and the app keeps working alone. The demo apps each have such a test (for example the notepad demo app’s test/peers_ui_test.dart, “when peers cannot start the footer says why and the notes still work”).
The conformance fixtures
The fixtures are the contract in executable form. spec/conformance/manifest_cases.json has one case per manifest rule (valid and invalid), and spec/conformance/context_cases.json has one per directory or mode rule: given a manifest, a platform, a home directory and an environment, the expected directories, roster, scratch flag and mode. Both implementations load the same files:
- Dart:
dart/app_context_kit/test/conformance_test.dart - Rust:
rust/tests/conformance.rs
The files are generated, not written by hand. tools/gen_fixtures.py spells every expectation out from templates, and never asks an implementation what the answer should be. That is deliberate: an implementation that agrees with the fixture has been checked against the prose spec, not against itself.
Add a case
-
Add one line to the
caseslist (context) or themclist (manifest) intools/gen_fixtures.py. A manifest case isbad(name, manifest, code)orok(name, manifest, defaults):bad("kit-underscore", man(kits=["my_kit"]), "bad-kits"), -
Regenerate:
python3 tools/gen_fixtures.pyIt prints
25 context cases, 37 manifest cases(the counts with the example case added) and rewrites both JSON files. -
Update the counts in the Rust test.
rust/tests/conformance.rsasserts how many cases each file holds, so that a silently shortened fixture file cannot pass. Change the number in the matchingrun_all("manifest_cases.json", 36, ...)orrun_all("context_cases.json", 25, ...)line. Without this the Rust run fails withfixture count changed; update this test. The Dart test needs no change. -
Run both languages:
(cd dart/app_context_kit && dart test test/conformance_test.dart) (cd rust && cargo test --test conformance)Expected:
All tests passed!from Dart (+62with the example case) andtest result: ok. 2 passedfrom Rust.
If one implementation disagrees, the fixture is right unless the spec text says otherwise, and the prose is then corrected. A failing Dart case looks like this (shortened; here the expected code was wrong on purpose):
manifest conformance kit-underscore [E]
Expected: throws <Instance of 'ManifestError'> with `code`: 'bad-id'
Which: threw ManifestError:<ManifestError(bad-kits)>
This procedure was run on a copy of the App Context Kit repository with the example case; the checked-in fixtures were not changed. Regenerating the committed fixtures produces files identical to the ones in spec/conformance/.
Part 2: running the App Context Kit repository’s suites
Every command below was run on macOS (Apple silicon) when this page was written, and the Linux script in Docker. Counts will change as tests are added; what matters is the pass line. Paths are relative to the root of the App Context Kit repository. Nothing here needs anything outside it except the public package registries (pub.dev and crates.io).
| Suite | Command | A pass looks like |
|---|---|---|
| Dart core | cd dart/app_context_kit && dart pub get && dart test | All tests passed! (365 passed, 7 skipped) |
| Flutter companion | cd dart/app_context_kit_flutter && flutter test | All tests passed! (82 tests) |
| Rust crate | cd rust && cargo test | test result: ok. for four targets (83 unit, 2 conformance, 5 host_os, 4 snapshot_conformance) |
| Conformance only | cd dart/app_context_kit && dart test test/conformance_test.dart, then cd rust && cargo test --test conformance | Dart All tests passed! (61 tests); Rust test result: ok. 2 passed |
| Snapshot fixtures | cd dart/app_context_kit && dart test test/snapshot_test.dart, then cd rust && cargo test --test snapshot_conformance | Dart All tests passed! (5 tests); Rust test result: ok. 4 passed |
| Two processes | cd dart/app_context_kit && dart test test/peers/two_process_test.dart | All tests passed! (4 tests, about 30 seconds) |
| Real Venue daemon | see below | Dart All tests passed! (5 tests); skipped without a daemon |
| Pinned TLS | see below | Dart All tests passed! (14 tests); skipped without a daemon |
| Linux | tools/linux-test.sh | -- <step>: exit 0 -- for four steps; the script exits 0 |
The Dart core’s skipped tests are the five that need a real Venue daemon, the pinned-TLS group, and one that only applies on Linux. A skip is not a failure, but it means that test proved nothing on this machine.
The Rust crate builds from the App Context Kit repository alone. It has no remote Venue session, so it has no real-daemon or TLS tests: the real-daemon and TLS suites below are Dart only.
The conformance fixtures, again
Both languages must pass every fixture before anything else matters. Run the two conformance commands above first. If a fixture file was edited by hand, python3 tools/gen_fixtures.py will overwrite it.
Tests against a real Venue daemon
test/venue_real_test.dart talks to a real Venue daemon. The daemon is not part of the App Context Kit repository, and the Kit does not ship a script that starts one: start any compatible daemon yourself, then set the variables the test reads. The daemon needs a consumer named calc, granted container.run.v1, with a limit of three live holds (the limit test relies on it), a token file for that consumer, and a node whose heartbeat is kept fresh (without that, the daemon refuses every hold after about two minutes with does_not_fit). The Venue guide has the details.
cd dart/app_context_kit
VENUE_DAEMON=http://127.0.0.1:7878 VENUE_TOKEN_FILE=<path to calc's token file> dart test test/venue_real_test.dart
Expected: All tests passed! (5 tests). Without VENUE_TOKEN_FILE the file is skipped, and a skipped run proves nothing about the daemon.
Pinned TLS
test/venue_tls_test.dart checks pinned TLS against a daemon that serves a self-signed certificate. Start any compatible daemon with TLS, note the SHA-256 fingerprint of its certificate, and set three variables; without them the tests skip with a message.
cd dart/app_context_kit
VENUE_TLS_DAEMON=https://127.0.0.1:17879 VENUE_TLS_FINGERPRINT=sha256:<64 hex digits> VENUE_TOKEN_FILE=<path to calc's token file> \
dart test test/venue_tls_test.dart
Expected: All tests passed! (14 tests). They cover:
| Case | Result |
|---|---|
the right fingerprint: hold, start, holds(), release; withHold | pass |
the right fingerprint through AppContext.open with VENUE_DAEMON_FINGERPRINT | the session kind is venue |
| a well-formed but wrong fingerprint | VenueUnavailable, naming both fingerprints |
| no fingerprint against the self-signed certificate | VenueUnavailable, saying to set VENUE_DAEMON_FINGERPRINT |
| a malformed fingerprint | VenueUnavailable, naming the fingerprint |
plain http:// to the TLS port | VenueUnavailable, well under 10 s |
AppContext.open with a wrong or missing pin, mode auto | a local session (“did not answer”) and a warning note |
AppContext.open with a wrong pin, mode on | throws VenueUnavailable |
Four more tests need no daemon (test/transport_pin_test.dart): the fingerprint spellings and a malformed pin. The first run of these cases against a real daemon found three problems around a pin that was itself sound (SecurityContext(withTrustedRoots: false) plus the certificate callback accepts exactly the pinned certificate and no other): an upper-case or colon-separated fingerprint was silently refused, and a wrong or malformed pin surfaced as an opaque handshake error. Both are fixed (parseFingerprint normalises the spellings; TlsPinException says what is wrong and what to do), and these cases keep them fixed.
Linux
tools/linux-test.sh [dart|rust|both] runs the Dart and Rust cores in throwaway Docker containers, as a non-root user, with the App Context Kit repository mounted read-only. It needs Docker running and a network connection, because each container fetches its packages. Set LINUX_AS_ROOT=1 to run as root, where a few tests behave differently because root ignores file mode bits (for example, a token file with mode 000 is read anyway, so that test is skipped). It uses dart:stable and rust:1.94 unless DART_IMAGE and RUST_IMAGE say otherwise. It copies spec/, rust/ and dart/app_context_kit into a tmpfs and builds there; no other checkout is needed.
It was run with Docker 28.5.2 on an aarch64 Linux VM (kernel 6.12.54), Dart 3.13.6 and Rust 1.94.1. It printed -- conformance fixtures (Dart): exit 0 --, -- whole package (Dart): exit 0 --, -- conformance fixtures (Rust): exit 0 -- and -- whole crate (Rust): exit 0 --, then exited 0. The Dart package ran 366 tests with 6 skipped (the Linux-only test runs there, so one fewer skip than on macOS); run as root it ran 365 with 7 skipped, because the mode-000 token test is skipped when root can read the file. The Rust crate ran 83 unit tests, 2 conformance tests, 6 host_os tests and 4 snapshot-conformance tests (one host_os test is Linux-only, so 6 there against 5 on macOS).
The two-process test
Every peers test except this one runs inside one process. dart/app_context_kit/test/peers/two_process_test.dart starts each hub in its own operating-system process (two_process_worker.dart), over the loopback provider and one shared temporary directory. Two copies of one app and account find each other, exchange hello and admit (ready on both sides), send, broadcast and request/reply. A third process with a different app id and a fourth with a different account saw no peer event and no message in over 600 ms, and neither appeared in the others’ peer lists. A graceful quit is lost with the reason goodbye, and the announcement is gone.
It also kills one process with kill -9 to see what a dying process looks like to its neighbour, and prints the numbers it measures: how long the survivor takes to see the peer lost, and how long the dead copy’s announcement file stays behind. Run it with -r expanded to read them. APP_CONTEXT_GHOST_SECONDS (default 12) sets how long the survivor is watched after the kill. This variable is read by the test only; the Kit does not read it. The numbers and the fix they led to are in Platforms; test/peers/loopback_ghost_test.dart (6 tests) covers the heartbeat rules without a second process.
Architecture
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
This page explains how the App Context Kit is put together: the layers, what happens when an app starts and stops, how two copies of an app connect, and how the spec, the shared test fixtures and the two implementations fit together.
The Kit gives one running app a root object, AppContext. An app gets its identity (the reverse-DNS id in app.json), its directories, its settings, a test mode, a presence entry in a shared roster, a session with a resource broker, and an opt-in way to find and talk to other copies of itself.
Diagrams are Mermaid, which GitHub renders. Every diagram is checked against the code named under it.
Contents: layers · startup · shutdown · the peer handshake · roster and peers announcement · spec, fixtures, implementations · why these boundaries
1. Layers and the direction of dependency
flowchart TB
subgraph apps["Apps, for example the demo apps in The Kit Book"]
app["Six demo apps. main.dart is 5 to 11 lines"]
end
subgraph companion["Companion: dart/app_context_kit_flutter"]
comp["AppContextKit.run, ExitCoordinator, ModeBadge, NotesBanner, PeersIndicator, test helpers"]
end
subgraph core["Core: dart/app_context_kit - pure Dart, no Flutter import"]
ctx["AppContext: manifest, resolve, config, roster"]
broker["Resource-broker session (VenueSession). venue.mode in app.json: auto, on, off"]
peers["Peers (opt-in: app.json kits lists peers): PeerHub, envelope, providers"]
end
subgraph outside["Outside the Kit"]
daemon["Resource broker daemon, over HTTP"]
os["Files and loopback TCP on this machine"]
hearth["Hearth hub: no provider built yet"]
end
app --> comp
app --> ctx
app --> peers
comp --> ctx
ctx --> broker
broker --> daemon
ctx --> os
peers --> os
peers -.-> hearth
Arrows point from a layer to what it depends on. The dotted arrow is planned, not built.
What to notice. The core, dart/app_context_kit, imports no Flutter and depends on one package, crypto. The companion depends on the core and adds only what needs Flutter: loading app.json from an asset, one guarded exit path, a mode badge, a notes banner, a peers indicator, and widget-test helpers. Nothing in the Kit depends on an app. The Rust crate in rust/ is a separate implementation of spec sections 1 to 5 and is not part of this stack.
The apps use the core directly for their own logic and hooks, and the companion for startup and widgets.
Peers are opt-in in a literal way: ctx.startPeers throws KitNotDeclared unless app.json lists "peers" in kits. That is the only place the kits list gates anything today. The resource-broker session is controlled by venue.mode instead (off never connects, auto connects if a token exists and the daemon answers, otherwise falls back to a local session and says so, on fails to open if it cannot connect), and in mock mode it is always an in-memory fake.
2. Startup
sequenceDiagram
participant M as AppContextKit.run
participant C as AppContext.open
participant D as Directories and config.json
participant V as Resource-broker session
participant R as Roster
participant A as Your app
M->>M: load app.json from the asset bundle
M->>C: open(manifest, env)
C->>C: resolve platform, home, env into paths, mode, roster dir
C->>D: create data, config, state, cache, runtime directories
C->>D: read config.json (a damaged file becomes a note)
C->>V: open session (fake, local or remote)
C->>R: announce ID@INSTANCE.json and start a 5 s heartbeat
C-->>M: AppContext
M->>A: app(ctx) under AppContextScope
A->>C: ctx.startPeers(hook), ctx.onClose(...)
M->>M: install exit paths (window close, SIGINT, SIGTERM), runApp
Source: AppContextKit.run in app_context_kit.dart and AppContext.open in app_context.dart.
What to notice. The order is chosen so a failure leaves nothing behind. The resource-broker session opens before the roster entry is written, so a session that refuses to open (mode on) fails before anything is announced. If announcing fails, the session is closed again. If the manifest cannot be read or the context cannot open, AppContextKit.run shows an error screen with the error code instead of crashing. Your app’s builder runs before the exit paths are installed, so any hook it registers with ctx.onClose is in place before the window can be closed. If the builder throws, the context is closed again and the error screen shows app-build-failed.
3. Shutdown
sequenceDiagram
participant E as Exit path
participant X as ExitCoordinator
participant C as AppContext.close
participant R as Roster
participant H as onClose hooks
participant V as Resource-broker session
E->>X: window close, quit, SIGINT, SIGTERM or AppContextKit.quit
X->>C: close, once
C->>R: stop heartbeat, delete this copy's own file
C->>H: run hooks, last added first
Note over H: PeerHub.close sends kit.bye to ready peers, closes links, removes the peers announcement
C->>V: close the session, last
C-->>X: done, or all errors raised together
X->>E: allow exit, or end the process (130 SIGINT, 143 SIGTERM, 0 quit, 1 if close failed)
Source: ExitCoordinator in exit_coordinator.dart and AppContext._closeOnce in app_context.dart.
What to notice. Every exit path funnels into one shutdown that closes the context at most once; a second signal gets the same future instead of a second close. Every step runs even if an earlier one throws, and the errors are raised together afterwards. A failing close is written to stderr and does not block the exit, and there is no timeout, so a hung hook is a bug in the hook. SIGKILL skips all of this, which is why the roster and the peers announcement both carry a timestamp that goes stale.
4. The peer handshake
sequenceDiagram
participant A as Copy A, lower PeerId
participant B as Copy B
Note over A,B: Providers announce and watch. Each hub sees the other as found.
A->>B: connect (only the lower PeerId dials)
par on attach, both sides
A->>B: kit.hello (nonce, app hello)
and
B->>A: kit.hello (nonce, app hello)
end
A->>B: kit.auth (proof over B's nonce)
B->>A: kit.auth (proof over A's nonce)
Note over A,B: each side verifies the proof, then calls admit(peer, hello)
A->>B: kit.ready (or kit.reject with a reason)
B->>A: kit.ready
Note over A,B: ready means I admitted you and your kit.ready arrived. onPeer(ready) fires.
A->>B: app messages, requests and kit.reply
A->>B: kit.bye
Note over B: lost with reason goodbye, not redialed
Source: the protocol comment at the top of hub.dart, checked against _onHello, _onAuth and _checkReady.
What to notice. Only the copy with the lower PeerId dials, so two copies never open two links; the other side waits for the incoming link. That works only if discovery is symmetric, which it is for both built providers. “Ready” needs two things on each side: your admit said yes, and the other side’s kit.ready arrived. A link that is not ready after 5 seconds is dropped. A kit.reject marks the peer rejected on both sides, and it is not redialed until the provider withdraws and announces it again.
5. The roster and the peers announcement are different things
Both are small JSON files that a process rewrites on a timer and others treat as dead when the timer stops. They answer different questions.
| Roster | Peers announcement | |
|---|---|---|
| Question | What is running on this machine? | Which copies of my app can I connect to? |
| Written by | AppContext.open, for every app | LoopbackProvider, for apps that start peers |
| Path | <roster dir>/<app id>@<instance>.json | <peers dir>/<account>/<app id>/<device>@<instance>.json |
| Contents | id, instance, name, version, pid, startedAt, heartbeatAt | account, app, device, instance, port, pid, beat |
| Read by | Any viewer, such as the launcher app, through Roster.scan() | Each copy’s provider, which lists only its own account and app |
| Rewritten every | 5 s | 1 s |
| Treated as stale after | 15 s (a reader’s choice: scan() reports live, stale or unreadable) | 4 s (not offered); deleted by whoever notices after 60 s |
| Directory | One per machine, shared by all apps | One per account and app, so a hub never reads another scope’s files |
flowchart LR
subgraph roster["Roster directory, one per machine"]
rf["ID@INSTANCE.json: id, name, version, pid, heartbeatAt"]
end
subgraph peersdir["Peers directory, one subdirectory per account and app"]
pf["DEVICE@INSTANCE.json: account, app, device, instance, port, pid, beat"]
end
ctx["AppContext, every app"] -->|"writes, every 5 s"| rf
viewer["Launcher: Roster.scan"] -->|"reads"| rf
prov["LoopbackProvider, apps that list peers"] -->|"writes, every 1 s"| pf
other["Other copy of the same app"] -->|"reads, every 100 ms"| pf
other -->|"TCP to the port in the file"| prov
What to notice. The roster is for people and tools that want to see every app. The peers announcement is for copies of one app that want to connect, so it carries a port and is partitioned by scope. The staleness numbers differ because the cost of being wrong differs: a launcher that shows a crashed app as running for a few seconds is a cosmetic problem, while a hub that keeps a dead copy in its peer list wastes dials and shows a peer that is not there. Other providers have no files at all: the fake network is in memory, and a Hearth provider would use topics.
These are two separate implementations of one idea (write a file, rewrite it on a timer, treat it as dead when the timer stops) with different constants. Folding them into one shared primitive is a possible simplification that has not been done.
6. Where the spec, the fixtures and the two implementations meet
flowchart LR
spec["spec/APP-CONTEXT.md"]
gen["tools/gen_fixtures.py"]
fix["spec/conformance: manifest_cases.json 36 cases, context_cases.json 25 cases"]
dart["Dart: dart/app_context_kit, test/conformance_test.dart"]
rust["Rust: rust/, tests/conformance.rs"]
only["Spec sections 6 and 7: peers and helpers, Dart only, tested in test/peers"]
spec -->|"expectations written out from the text"| gen
gen --> fix
fix --> dart
fix --> rust
spec -.-> only
The fixtures cover the manifest rules (spec section 1) and path, mode and roster resolution (section 2). They are pure functions: no files, no network, which is what lets two languages check the same cases. tools/gen_fixtures.py writes the expectations out by hand from the spec text; no implementation computes them. Run the conformance checks with cd dart/app_context_kit && dart test test/conformance_test.dart and cd rust && cargo test --no-default-features --test conformance.
What to notice. The rule is that the fixture wins: if the prose and a fixture disagree, the prose is fixed. Behaviour that touches the outside world (config files, the roster, the resource-broker session, token lookup) is in the spec but not in the fixtures, so each language tests it itself. The Rust crate covers spec sections 1 to 5. The peer layer (section 6) and the later helpers (section 7) exist in Dart only, and are covered by Dart tests, including real processes in test/peers/two_process_test.dart.
Why these boundaries
The Kit does not interpret payloads. The same hub carries three very different meanings in the demo apps: a signal (stopwatch), replicated notes with a conflict rule (notepad), and handing a game to another copy (minesweeper). Adding coordination to those three apps changed no file under dart/app_context_kit. If the Kit knew what a message meant, the first app whose meaning was different would have needed a Kit change. Keeping it opaque also means the Kit has nothing to get wrong about your data, and nothing in your data can confuse it: the Kit parses its own header and passes the payload on untouched.
The core avoids Flutter. Closing an app cleanly on window close or quit needs Flutter’s lifecycle, and starting needs its asset bundle, so those live in the companion package. Everything else runs under plain dart test with no engine. The core test suite (365 passed, 7 skipped) runs in about half a minute, and the peers logic runs as a headless process; the three demo apps that use peers each have a tool/peer_probe.dart that runs the app’s own coordination code without a window. The same boundary is what makes a second implementation possible: the Rust crate follows the same spec without touching Flutter.
One spec, with shared fixtures. A written spec alone lets two implementations drift. The fixtures turn the pure parts of it into data that both languages must pass, and the rule that the fixture wins keeps the prose honest. The fixtures are generated from spelled-out expectations, so a bug in one implementation cannot leak into the expected answer.
The second implementation found things the first could not. The Rust crate passed all 41 fixtures that existed when it was first written, and still disagreed with the Dart core on several behaviours that no fixture covered. Among them: how a root path joins (/ plus an id gave //id in Dart), whether a trailing backslash on a Linux path is trimmed, and whether a later set() may overwrite a config file that was found damaged (Dart did; Rust refused until the file is set aside). Each became either a spec decision, a fixture, or a per-language test: the fixture set grew from 41 to 61 cases. Those cases came from comparing the two implementations, not from reading either one alone.
Apps do not edit the Kit. A demo app that needs something the Kit lacks writes it under lib/kit_gaps/ with a // KIT GAP: comment. The line count of those directories is the measurement of what the Kit is missing; the ledger is kept with the demo apps in The Kit Book.
See also: Overview for what the Kit is and is not, Design decisions for the decisions in more detail, Context guide for AppContext, Coordination guide for the peer layer.
Platforms
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
What runs where, how it was checked, and what was not. If a row says “not verified”, nobody has run it.
| Platform | Core packages (Dart, Rust) | Flutter apps | Peers over loopback | Venue |
|---|---|---|---|---|
| macOS (Apple silicon) | Tests pass | Debug builds run; a release build was made for one demo app (notes_rust) only | Real processes tested | Real daemon tested |
| Linux | Tests pass in containers (aarch64) | Built (release) and launched headless in a container (aarch64, virtual display); not tried on a real desktop | Tested in a container | Tests against stubs only; no real daemon |
| Windows | Path-resolution fixtures only | Not verified | Not verified | Not verified |
| iOS, Android | Not attempted | Not attempted | Not attempted | Not attempted |
The two cores are dart/app_context_kit (pure Dart) and rust/. Both read the same 61 conformance fixtures in spec/conformance/ (25 for path resolution, 36 for the manifest). The rules they implement are in spec/APP-CONTEXT.md. The Rust crate has no remote Venue session, so the Venue column describes the Dart core.
Where things go
For an app whose manifest id is ID, the Kit resolves five directories and a roster directory. home is the user’s home directory.
| Linux | macOS | Windows | |
|---|---|---|---|
| data | $XDG_DATA_HOME/ID, else home/.local/share/ID | home/Library/Application Support/ID/data | %LOCALAPPDATA%\ID\data |
| config | $XDG_CONFIG_HOME/ID, else home/.config/ID | home/Library/Preferences/ID | %APPDATA%\ID |
| state | $XDG_STATE_HOME/ID, else home/.local/state/ID | home/Library/Application Support/ID/state | %LOCALAPPDATA%\ID\state |
| cache | $XDG_CACHE_HOME/ID, else home/.cache/ID | home/Library/Caches/ID | %LOCALAPPDATA%\ID\cache |
| runtime | $XDG_RUNTIME_DIR/ID, else the default state directory plus /run | $TMPDIR/ID, else /tmp/ID | %TEMP%\ID |
| roster (shared by all apps) | $XDG_RUNTIME_DIR/app-context-roster, else /tmp/app-context-roster | $TMPDIR/app-context-roster, else /tmp/app-context-roster | %TEMP%\app-context-roster |
Overrides, from highest priority: <PREFIX>_<KIND>_DIR (used exactly as given), then APP_CONTEXT_SCRATCH (puts everything under one directory, for tests), then the table. On Windows a missing LOCALAPPDATA, APPDATA or TEMP is the error missing-env, but only if a default that needs it is actually used.
~/Library/Preferences/ID/config.json is an unusual place for a JSON file on macOS, where that folder is meant for property-list files. No problem was seen. It is an open spec question.
macOS
What was checked.
- Both cores’ tests pass natively. Run for this page: Dart 365 passed, 7 skipped by default (the pinned-TLS group and 5 real-daemon tests, which need a daemon, and 1 Linux-only test). Rust: 83 unit, 2 conformance, 5 host-OS and 4 snapshot-conformance tests. The Dart real-daemon tests (5) and TLS tests (14) passed against throwaway daemons when this page was first written; they skip without one, so the run for this page did not exercise them.
- All six demo apps were built with
flutter build macos --debugand launched during development. Cmd-Q and SIGTERM close the app cleanly (the roster entry is removed and Venue holds are released). Recorded in notes taken at the time; not re-run for this page. - Two real processes find each other over loopback, exchange messages, and see each other disappear after
kill -9(the link drop is seen in milliseconds; the peer is withdrawn in about 3.5 seconds). Tested indart/app_context_kit/test/peers/two_process_test.dart; it passed in the full run for this page. - A copy killed with
kill -9. The surviving copy sees the link drop at once (about 3 ms) and withdraws the dead copy’s announcement 3.5 to 3.8 s later (3.5 s on macOS, 3.8 s on Linux). Before the announcement carried a heartbeat the picture was worse: the survivor that had dialled gave up after 10 failed dials (about 31 s) but left the dead copy listed asfoundfor ever (watched for 45 s); a survivor that never dials never learned the announcement was dead at all; and a restarted copy with the same device id appeared beside the ghost. The fix is indart/app_context_kit/lib/src/peers/loopback_provider.dart: each provider rewrites its announcement file every second with a wall-clockbeat; a file whose last beat is older than 4 s is not offered, and a peer already known from it is withdrawn (the hub then reports the finallostand stops dialling); a file older than 60 s is deleted by whoever notices. Checking the announcing process id was rejected: it means nothing across process-id namespaces (containers), ids are reused, and Dart has no portable way to ask whether a pid is alive without starting a process. The cost is one small write per second per process. Not fixed: a brieffoundright after the firstlost, which for a crash lasts up to the 4 s limit (a graceful quit has none), because the hub deliberately re-announces a copy that is still advertised.
What was not checked. Release builds of the demo apps on macOS (only one, notes_rust, was built in release mode). Signed or notarized builds. The red window-close button on a real window (Cmd-Q and SIGTERM were). Launching from Finder or the Dock (see below). The text on screen in a real window: the display was off for most of the runs, so UI behaviour was read from the widget tree and from the apps’ own code, not seen.
Environment variables do not reach an app launched from Finder or the Dock. APP_CONTEXT_MODE, APP_CONTEXT_SCRATCH, VENUE_TOKEN_FILE and the rest are read from the process environment. To use them, start the app’s binary from a terminal. For a normal launch, put the Venue token at <config>/venue-token and use config.json for settings.
The App Sandbox
Flutter’s macOS template turns the App Sandbox on. A sandboxed app sees a private HOME and TMPDIR inside ~/Library/Containers/<bundle id>/Data. Apple requires the sandbox for Mac App Store apps (that is Apple’s rule, not something verified here).
The sandbox changes what the Kit means:
- Every directory in the table above moves into the container. Data lands under
Data/Library/Application Support/ID/, and the default roster isData/tmp/app-context-roster. Each app has its own, so the default roster is no longer shared between apps. APP_CONTEXT_SCRATCHand other overrides that point outside the container fail.
Today the demo apps (in The Kit Book) are mixed: the calculator, minesweeper and notepad have the sandbox on; the launcher, render-queue and stopwatch have it off (their entitlement files say app-sandbox is false). The reason is that no sandbox-on setup was found that works for all of them; see the roster row below.
How these results were obtained: copies of the built debug apps were re-signed ad hoc (no team ID) with the entitlement variants below, launched by running the binary directly, and observed through the container directory, the system log, and the widget tree. No entitlement file in the demo apps was changed by that work. These results come from notes taken at the time and were not re-run for this page.
Entitlements by capability
| Capability | Sandbox off | Sandbox on: what it needs |
|---|---|---|
| Talk to a local Venue daemon (including the health check) | Nothing | com.apple.security.network.client. Without it the session is local with “Venue did not answer at …”. Only the health check and GET calls were exercised in a sandbox; holds that POST were not, and are expected (reasoned) to need the same entitlement. |
| Read a Venue token file | Any readable path | A path outside the container is not readable (“the file cannot be read”). Use VENUE_TOKEN, or <config>/venue-token inside the container. |
| Loopback peers: dial another copy | Nothing | network.client. |
| Loopback peers: listen for another copy | Nothing | network.server. In the Flutter template, Debug has network.server only, and then two copies both listen and never connect, silently (6 seconds observed; nothing in the log; the UI would read as a peer that is not up yet). Adding network.client fixed it: the link came up 35 ms after the announcement. The template’s Release file has neither, so a Release build needs both added (reasoned; Release was not built). |
| Peers directory | Any path | The default (the roster directory plus -peers) is inside the container, and two copies of one app linked through it in the notepad run. A directory outside the container leaves the app running alone, with nothing written outside. |
| Roster shared between different apps | Works if the apps share a TMPDIR | Not possible with plain entitlements. Each app writes to its own container. It works with the temporary-exception entitlement com.apple.security.temporary-exception.files.absolute-path.read-write for one shared directory (measured: two sandboxed stopwatches and a sandboxed launcher shared it). That entitlement is a review problem for the Mac App Store. A shared team app group is the other route; it was not achieved, because ad-hoc signing could not create the group container. It needs a team ID and signing that were not available. Not tested with one. |
Scratch directory outside the container (APP_CONTEXT_SCRATCH=/tmp/...) | Works | Fails: the companion shows a start-error screen (“The app’s folders could not be created or read”). Earlier builds showed an unhandled exception instead. Works with the same temporary exception. |
Check another app’s process (kill -0) | Works | Reports a running sandboxed app as not existing. The launcher demo showed its rows as stale for live apps. With the sandbox on, the heartbeat is the only liveness signal. |
Desktop notification via osascript (stopwatch demo) | Fired when the app was in front; the banner was attributed to “Script Editor” | The child process starts and no sandbox denial was logged; whether the banner appears was not verified. |
| JIT / Dart VM service (Debug builds only) | cs.allow-jit, network.server | The template adds these to the Debug file only. |
Entitlements the demo apps carry today, from the files in each demo app’s macos/Runner/ (the demo apps are in The Kit Book):
| App | Sandbox | network.client (Debug / Release) | network.server (Debug / Release) |
|---|---|---|---|
| calculator | on | no / no | yes / no |
| minesweeper | on | yes / yes | yes / yes |
| notepad | on | yes / yes | yes / yes |
| launcher | off | no / no | yes / no |
| render-queue | off | no / no | yes / no |
| stopwatch | off | no / no | yes / no |
A sandboxed render-queue (talking to Venue) would need app-sandbox plus network.client. A sandboxed stopwatch alone runs with app-sandbox and keeps its data in its container. The files above are what the apps carry now; they are not those minimal sets.
Identity is declared four times
An app’s id appears in app.json, the macOS bundle id, the Linux application id, and (name and version) pubspec.yaml. Nothing makes them agree. The companion package ships a check:
cd dart/app_context_kit_flutter
dart tool/check_identity.dart <path to render-queue> <path to calculator>
Output for two of the demo apps (exit code 1, because of the first app; the headers repeat the paths that were given):
== render-queue
MISMATCH id: app.json says com.slashbuilder.demo.render-queue but the macOS bundle id (PRODUCT_BUNDLE_IDENTIFIER) is com.slashbuilder.demo.renderQueue
note name: app.json says "Render Queue" but the macOS PRODUCT_NAME is "render_queue" (same name, different letter case)
MISMATCH id: app.json says com.slashbuilder.demo.render-queue but the Linux application id (APPLICATION_ID) is com.slashbuilder.demo.render_queue
note name: app.json says "Render Queue" but the Linux window title is "render_queue" (same name, different letter case)
== calculator
note name: app.json says "Calculator" but the macOS PRODUCT_NAME is "calculator" (same name, different letter case)
note name: app.json says "Calculator" but the Linux window title is "calculator" (same name, different letter case)
ok: id, name and version agree everywhere they were checked
The manifest id grammar allows hyphens, but macOS bundle ids and Linux application ids are written differently on each platform, so a hyphenated id cannot match all three. Whether the manifest may contain hyphens is an open spec decision. The check reports; it never edits. It does not look at Windows, Android or iOS.
Linux
What was checked. Both cores’ tests ran in Docker containers, as a non-root user, on a 6.12 linuxkit kernel, aarch64 (so not x86_64), with the images dart:stable and rust:1.94 (override with DART_IMAGE and RUST_IMAGE). The App Context Kit repository is mounted read-only; each container copies spec/, rust/ and dart/app_context_kit into a tmpfs and builds there, so nothing is written back and nothing persists. The command is tools/linux-test.sh:
tools/linux-test.sh # both cores
tools/linux-test.sh dart # one
LINUX_AS_ROOT=1 tools/linux-test.sh
Re-run for this page: Dart (SDK 3.13.6) conformance 61 passed and the whole package passed (366 passed, 6 skipped; the Linux-only test runs here, and the real-daemon tests skip); Rust 1.94.1: 2 conformance, 83 unit, 6 host-OS and 4 snapshot-conformance tests passed. No other checkout is needed, because the Rust crate no longer has an optional dependency on a Venue client.
When the suites were first run on Linux, the outcomes of the 306 tests that existed then were compared by name against the macOS run: no test differed. That proves less than it sounds, because almost none of them touch the real operating system (they pass platform: in). So test/host_os_test.dart and rust/tests/host_os.rs were added with no platform override; they check the directory conventions of the host, the rename-based config write, and the default five-second roster heartbeat (a wait of about 5.6 s: the entry is rewritten and stays live).
Directories follow the XDG variables when set and the home-relative defaults when not. This is tested without a platform override in test/host_os_test.dart and rust/tests/host_os.rs. The roster is in /tmp/app-context-roster when XDG_RUNTIME_DIR is unset. Config writes are renames, and a reader polling during a rewrite never saw a partial file (tested on Linux and macOS, both languages).
Two real processes found each other over loopback in a container too, including after a kill -9.
Things that differ from macOS and are worth knowing:
- Run as root, a token file with permissions 000 is read anyway. A container that runs your app as root will use a token file the mode was meant to hide.
- Dart returns file modification times to the microsecond on Linux and truncated to whole seconds on macOS. Nothing in the Kit relies on file times.
- Reasoned, not tested:
/tmp/app-context-rosteris created with the first user’s umask, so a second user on the same machine probably cannot announce into it.
What was not checked. A real Linux desktop session. The demo apps (in The Kit Book) were built with flutter build linux --release and launched headless under a virtual display (Xvfb) in an aarch64 Ubuntu 24.04 container with Flutter 3.41.9; the test is make linux-smoke in The Kit Book. All seven launched, exited with status 143 on SIGTERM, wrote a roster file and withdrew it, and kept their state across a relaunch (minesweeper, notepad, stopwatch and notes_rust). Two copies of the stopwatch and two of the notepad found each other, and the notepad synced a seeded note. There were no uncaught exceptions; the only warnings were the container’s missing accessibility bus and missing GPU. Still unchecked: window close by click, a compositor or window manager, a real GPU, notify-send (used by the stopwatch demo), other distributions, Wayland, and x86_64. The Linux application id in each runner has to match the manifest; see the identity check above.
Windows
Verified: the path-resolution rules, as pure functions. Five of the 25 resolution fixtures use platform: "windows"; they cover the default locations, the missing-variable error, scratch needing no variable, trimming either kind of trailing separator, and a bad mode being reported before a missing variable. Both cores pass them. The fixtures never touch a filesystem.
Nothing else is verified. Neither core was built or tested on Windows. Not run: file I/O, the atomic config write, the roster and its heartbeat, loopback peers, the Flutter companion, any Venue session, the demo apps. Same-machine peers there would use the loopback provider, which has not been run on Windows. There are no Windows runner folders in the demo apps.
iOS and Android
Not attempted. The Flutter companion’s notes say there is no mobile exit story: window close, Quit and signals are desktop events, and the one mobile hook (onDetach) closes on a best-effort basis with no guarantee the OS gives it time. Nothing about directories, the roster, peers or Venue was checked on a phone.
Packaging checklist
For shipping an app that is built on the Kit. Each line says what to do and where it was observed to matter.
- Make the ids agree. Run
dart tool/check_identity.dart <app dir>(fromdart/app_context_kit_flutter). It must exit 0. Decide what a hyphenated manifest id does on each platform. - Declare what you use.
"kits": ["venue"]for Venue,"peers"for the peer layer.ctx.peerHubthrowsKitNotDeclaredifpeersis missing. - Choose sandbox on or off per channel, and add entitlements for every capability you use (table above). Debug and Release are separate files. For peers,
network.clientandnetwork.serverin both. - Put the Venue token where the app can read it. Under the sandbox that is
<config>/venue-tokeninside the container, orVENUE_TOKEN. Not a path outside it. - Provision the app with Venue before it ships. An app cannot register itself; see the Venue guide. Without that, the app runs on the
localsession and says so. - Use short Venue leases. A killed app keeps its quota until the lease lapses.
- Start through
AppContextKit.run(the companion package), so window close, Quit, SIGINT and SIGTERM all end in onectx.close(). Test Cmd-Q and SIGTERM on the built app.kill -9leaves a stale roster entry (hidden after 15 seconds) and any Venue holds until the lease lapses. - Test in mock and scratch modes (
APP_CONTEXT_MODE=mock,APP_CONTEXT_SCRATCH=<dir>) from a terminal; they do not reach a Finder or Dock launch. - Do not use the loopback peer provider across a trust boundary. It has no encryption or authentication. It is for copies of an app on one machine, run by one user.
- Run on the real target. On Linux the Kit has headless container evidence only (see “What was not checked” under Linux above); on Windows and mobile it has none.
Design decisions
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
Why the App Context Kit is shaped the way it is, what we tried instead, and what is still undecided. Each entry says what the evidence was, so you can disagree with the right thing.
Decisions that held
1. One written contract, two implementations, shared fixtures
The behaviour lives in spec/APP-CONTEXT.md. Dart and Rust both implement it, and both must pass the same fixtures in spec/conformance/.
Why. The first implementation passed its own 41 fixtures on the first run, which proved almost nothing because the same author wrote the spec, the fixtures and the code. The Rust implementation, written from the spec text, passed the same 41 and still disagreed with Dart on about a dozen behaviours no fixture covered (what happens to / as a base path, which separator gets trimmed, what a name made only of a byte-order mark is, what happens when a named token file is missing…). Closing them took 20 more fixtures. The suite is the product; the prose is commentary.
Cost. About twice the code in Rust as in Dart for the same behaviour, and a spec that must be kept honest.
2. The core is pure Dart; Flutter lives in a companion
The core package has no Flutter import, so it runs in a command-line tool, a server, or a test with no engine. Everything that needs Flutter (loading the manifest from an asset, exiting cleanly on window close or a signal, a mode badge, a notes banner, test helpers) is in app_context_kit_flutter.
Why. In all six demo apps the same shims were written by hand (a 20 to 56 line exit hook was rewritten six times out of six). They are Flutter-shaped, so they belong in a Flutter package, and the rest should not pay for them.
3. The Kit never interprets an app’s payload
The Kit finds peers, checks scope, connects and routes. Meaning is the app’s, supplied through a five-method hook (channel, hello, admit, onPeer, onMessage).
Why. The same hook expressed a live signal, a handoff and a conflict-resolving sync without the Kit knowing which was which. A Kit that knew about “notes” or “games” would have to be changed for every app.
Cost. The hook is only just enough for live play, and for sync it is a small shell around a lot of app code. See what the hook does not give you.
4. Test modes are part of the Kit, not a test helper
APP_CONTEXT_SCRATCH=<dir> puts every directory (and the roster) under one temporary root. <PREFIX>_MODE=mock swaps Venue and peers for in-memory fakes. Both are in the contract and in the fixtures.
Why. It was the part the demo apps liked most, and building them showed each app inventing its own way (two lookups for one override in one app, three different mock switches across three).
5. A damaged settings file is reported and never overwritten
Opening still works; set fails with a typed error until the app explicitly sets the damaged file aside.
Why. An early defect: the first set() after a damaged file silently destroyed it, and a file that was not valid UTF-8 stopped the app starting. The rule is simple enough to be a fixture-level promise.
6. The roster has one file per running copy
roster/<id>@<instance>.json, with a heartbeat. Withdrawal removes only your own file.
Why. With one file per app id, the first of two copies to exit erased the other’s presence. A viewer also needs to tell “crashed” (stale) from “never ran” (absent), so there is a scan that returns status, and a running() that hides the dead.
7. The Venue session hides “real”, “absent” and “fake”
App code calls the same methods against a real broker, no broker (a local session that says so in plain words), and a fake. Refusals are typed (doesNotFit, forbidden; everything else is unavailable), and a lease that is lost is reported, not swallowed.
Why. It let render-queue run in all three modes with no branches in app code, and it made the real daemon’s behaviour (a refusal that arrives as a different HTTP code than expected) testable.
Not hidden on purpose: registering an app with the broker. That needs an operator with authority (four manual steps today) and an app must not be able to grant itself access. The Kit documents the steps and stays out of them.
8. Peers are opt-in through the manifest
An app that wants peers lists "peers" in kits; asking for a hub without it is an error (KitNotDeclared).
Why. It keeps the core small and gives the manifest’s kits list a real job. It was the only piece of evidence for “load Kits as needed” that the demo apps produced; none of them wanted runtime loading.
9. Several small rules in the peer layer, each from a failure
- Scope is
(account, app id). A peer of another app or account is never offered, never admitted, and a test proves an app B peer cannot receive app A traffic. - Only the lower peer id dials. Otherwise two copies open two links to each other.
seqis per link and restarts at 1. A gap is visible to the app as a jump; a bounded reorder buffer delivers in order and skips a gap that stays open.- A full outbox fails
send; it never silently drops. Only the app knows what is safe to lose. - Announcements carry a heartbeat. A crashed copy’s announcement used to stay for 45+ seconds and cost ten failed dials; with a one-second beat, a beat older than four seconds is ignored and the copy is gone in about 3.5 seconds. (Process-id checks were rejected: they are unreliable across containers.)
10. Build a Kit piece when the second app copies it
The ledger: shim code across the six apps was 727 lines, 330 after the first migration, 777 after coordination was added (it rose, because every app wrote the same peers helpers), and 164 after those helpers moved into the Kit. Calculator, minesweeper and render-queue ended with none. The remaining four helpers (a notification helper, a large-blob transfer, a manifest scan, a process check) each have exactly one user and stay in their apps.
What we deliberately did not build
- Runtime Kit loading (BeOS add-ons). No demo app wanted it; Flutter builds are static anyway. Selection happens at build time and opt-in happens in the manifest.
- A looper/handler message model like BeOS’s. Dart and Rust each have an event loop; a third one would not map onto both.
- A sync engine. The notepad’s sync is about 700 lines of app code because what to do on a conflict is a product decision. A shared engine would have to make it for you.
- Launch, quit and focus from the launcher. A file roster can say who is running; it cannot ask an app to quit. A control socket per app is one possible design; it is not built.
- Authentication. The Kit scopes by account and app id and says so loudly; it does not prove either. See below.
Still open
These need a decision, and some need an owner other than the Kit’s maintainers.
| Question | Why it is open |
|---|---|
Is seq per link (restarts at 1; shows loss as a jump) or one counter per sender (orders, cannot show loss)? | The Dart core does the first; a Rust prototype of the envelope did the second. The first is the better semantics, but it is a contract decision. |
Is kind a closed set or a free string with a reserved kit. prefix? | Same split between the two prototypes. |
| What should the default resource-broker consumer name be? | Today it is the last segment of the app id, so dev.a.notepad and org.b.notepad collide. A derived <last>-<8 hex> works but changes a fixture. |
| May an app id contain a hyphen? | The pattern allows it (render-queue), but Linux application ids cannot contain one, so runner ids have to be derived. One demo app’s bundle and manifest ids disagree today. |
| How does “the same app” become a proof and not a string? | Needs a per-app credential and a signed capability. Today an app id is whatever a program types. |
| What is a transport’s maximum payload? | The spec’s 1 MiB is only the outer bound; one real transport allows about 64 KiB minus the header. Providers need to declare their own limit and fail before the wire. |
| How should acknowledgement, large transfer, replay for a late joiner and liveness work? | Every demo app that needed them built its own. They are the next candidates to move into the Kit once a second app copies one. |
| How do a Dart context and a Rust core in one app share one context? | Mostly answered, in spec section 8: if both sides opened a context they would announce twice and write one settings file twice, so one side (the owner) opens it and hands the resolved directories and instance id to the other, which adopts them. One demo app (in The Kit Book) exercises this, on macOS and, headless, in a Linux container; it has not been run on a real Linux desktop. |
| Account and device identity | Peers use local as the account and a generated device id until a real household identity exists. |
| Should sandboxed apps share a roster? | Needs a platform answer (an app group or an exception), not a Kit one. See Platforms. |
App Context Kit: the app context specification (v0.1)
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
This is the contract both implementations (Rust in rust/, Dart in dart/app_context_kit/) must satisfy. It is
checked by the shared fixtures in conformance/. If an implementer finds the text ambiguous, that is a finding:
write it down (as an issue or a note) and do not silently pick; the fixtures win over the prose.
Everything here is a pure function of its inputs (no I/O), so both languages can be tested without a filesystem.
1. The manifest (app.json)
JSON, not YAML, on purpose: it avoids choosing a Rust YAML library for now. Whether the real thing is YAML is an open question.
{
"id": "com.slashbuilder.demo.calc",
"name": "Calculator",
"version": "0.1.0",
"kits": ["venue"],
"envPrefix": "CALC",
"venue": { "mode": "auto", "consumer": "calc" }
}
Fields: id, name, version required. kits optional (default []). envPrefix optional. venue optional.
Validation, checked in this order; report the first failure only, as these codes. A field that is present as null is present and invalid (kits: null is bad-kits). Every pattern must match the whole string: a trailing newline fails (this matters to implementations whose $ also matches before a final newline).
| order | code | rule |
|---|---|---|
| 0a | not-json | the source text is not valid JSON (only for parse(text); a parsed value skips this) |
| 0b | not-object | the JSON is not an object (an array, a string, null…) |
| 1 | bad-id | id missing or not matching ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$ (reverse-DNS, at least two segments) |
| 2 | bad-name | name missing, null, not a string, or empty after removing ASCII whitespace (space, tab, CR, LF). Other characters, including U+FEFF, count as content |
| 3 | bad-version | version missing or not matching ^\d+\.\d+\.\d+$ |
| 4 | bad-kits | kits present but not a list of strings, or a name not matching ^[a-z][a-z0-9-]*$ |
| 5 | duplicate-kit | the same kit name twice (checked only after every name passed rule 4) |
| 6 | bad-prefix | envPrefix present and not matching ^[A-Z][A-Z0-9_]*$ |
| 7 | bad-venue | venue present but not an object, or venue.mode not one of auto, on, off, or venue.consumer present and not matching ^[a-z][a-z0-9-]*$ |
Defaults after validation: venue.mode = auto; venue.consumer = the last segment of id; env prefix (below).
2. Resolution
Inputs: manifest, platform (linux, macos, windows), home (string), env (string map).
Outputs: prefix, dirs.{data,config,state,cache,runtime}, roster (a shared directory), scratch (bool), mode (real/mock).
Prefix: manifest.envPrefix, else the last segment of id, upper-cased, with every character outside A-Z0-9 replaced by _.
(render-queue becomes RENDER_QUEUE.)
Path joining: on linux and macos join with /, on windows with \, whatever the host is.
Before joining, a trailing separator on an input base (for example TMPDIR=/var/T/) is dropped; the separators are those of the target platform only (/ on linux and macos; both / and \ on windows), so a trailing \ on a linux base is kept. A base that is only a separator (/) is not reduced to nothing: joining / and ID gives /ID, never //ID. Values used verbatim (overrides, below) are never trimmed.
Platform defaults (ID is the manifest id):
| linux | macos | windows | |
|---|---|---|---|
| data | $XDG_DATA_HOME else home/.local/share, then /ID | home/Library/Application Support/ID/data | %LOCALAPPDATA%\ID\data |
| config | $XDG_CONFIG_HOME else home/.config, then /ID | home/Library/Preferences/ID | %APPDATA%\ID |
| state | $XDG_STATE_HOME else home/.local/state, then /ID | home/Library/Application Support/ID/state | %LOCALAPPDATA%\ID\state |
| cache | $XDG_CACHE_HOME else home/.cache, then /ID | home/Library/Caches/ID | %LOCALAPPDATA%\ID\cache |
| runtime | $XDG_RUNTIME_DIR/ID if set, else the platform-default state directory (after XDG_STATE_HOME, before any override) plus /run | TMPDIR (else /tmp), then /ID | %TEMP%\ID |
| roster | $XDG_RUNTIME_DIR (else /tmp), then /app-context-roster | TMPDIR (else /tmp), then /app-context-roster | %TEMP%\app-context-roster |
(On windows, a missing LOCALAPPDATA, APPDATA or TEMP is the error missing-env, but only if a default that needs it is actually used: a directory fixed by an override or by scratch needs none.)
Overrides, highest priority first, for each directory kind K in DATA, CONFIG, STATE, CACHE, RUNTIME:
<PREFIX>_<K>_DIR: used exactly as given (no id appended, no trimming).- Scratch: if
APP_CONTEXT_SCRATCH=S(non-empty), the directory isS/ID/<kind>with<kind>lower-case, androsterisS/roster;scratchis true. (Scratch joins with/on every platform.) - The platform default.
roster has one override of its own: APP_CONTEXT_ROSTER_DIR, which beats scratch.
Mode: <PREFIX>_MODE, else APP_CONTEXT_MODE, else real. Allowed values real, mock; anything else is the error bad-mode. Only the variable that wins is checked: a garbage APP_CONTEXT_MODE is ignored when <PREFIX>_MODE is set. Error precedence: bad-mode is reported before missing-env.
An empty string counts as unset, for every variable above.
3. Venue (not in the fixtures; needs a network or a fake)
(v0.1 changes to this section are in section 5.)
Reuse Venue’s existing variable names so nothing new is invented: VENUE_DAEMON (URL, default http://127.0.0.1:7878), VENUE_TOKEN,
VENUE_TOKEN_FILE, VENUE_DAEMON_FINGERPRINT. Token lookup: see section 5, item 2 (a VENUE_TOKEN_FILE that is set but missing or empty is an error, never a fall-through). venue.mode: off never connects; auto connects if a token exists and the daemon answers /healthz, otherwise
degrades to a local session and says so in description (the way a larger app words it: “GPU lease: local (Venue not configured)”);
on fails to open if it cannot connect. In mock mode the session is always the in-memory fake. The Rust crate has no remote session: with a token found, auto gives a local session that says so (a warning) and on fails to open.
4. Lifecycle and roster (a minimal first version, not covered by fixtures)
AppContext.run(body): resolve, create the directories, write a presence file roster/<ID>@<INSTANCE>.json
({"id","name","version","pid","startedAt","heartbeatAt"}), start a 5-second heartbeat, open Venue, run body, then on exit or error
delete the presence file, run the close hooks and close Venue. (Holds are released by withHold, which is the only thing that tracks them; a context does not release holds it did not take through withHold.) A presence file is stale when heartbeatAt is older than 15 seconds. This is the
BRoster question: does it belong here, in Venue, or nowhere? The Kit ships the smallest version so the answer comes from use.
5. v0.1 decisions
Building the second implementation showed that two implementations can pass 41 fixtures and still disagree. These are the decisions that close the gaps it exposed.
Items marked (fixture) are covered by conformance/*.json; the rest are behaviours each implementation must test itself.
- (fixture) The manifest, resolution and mode rules above, including the new codes and precedences.
- Token lookup (replaces section 3’s order).
VENUE_TOKEN; else the file named byVENUE_TOKEN_FILE; else<config>/venue-token. IfVENUE_TOKEN_FILEis set but the file is missing or empty, that is a configuration error and the lookup does not fall through to<config>/venue-token:autodegrades to a local session whose description names the problem;onfails to open. - Config is never silently overwritten. A
config.jsonthat is not valid UTF-8 or not a JSON object is damaged.openreports it (problem) and still opens;setandsavethen fail with a damaged-config error until the caller explicitly sets the file aside (renamed toconfig.json.corrupt).get<double>accepts a whole number (JSON3reads as3.0). close()is idempotent and isolates hooks. The second call does nothing. EveryonClosehook runs even if an earlier one throws; after all have run, the errors are raised together. Venue is closed last.- Roster instance id. Each opened context draws a random 8-hex-digit instance id. The presence file is
roster/<ID>@<INSTANCE>.jsonand carries aninstancefield; withdrawal removes only the context’s own file, so two copies of one app no longer erase each other.running()hides stale and unreadable entries (as before);scan()returns every entry with its status (live,stale,unreadable) so a viewer can tell a crash from “never ran”. - Typed Venue answers.
VenueRefusedcarries akind:doesNotFit(HTTP 409) orforbidden(HTTP 403, including the live-hold limit).VenueUnavailableis everything else (transport failure, 401, 5xx). Venue gives no machine-readable reason for a 403 (the limit and other refusals share the codeforbidden): a finding for the Venue project; until it does,forbiddenis treated as not retryable. - Holds the app can see.
VenueSession.holds()lists this consumer’s own live holds (to find orphans after a crash).withHoldno longer swallows renewal failures: the hold records them and the optionalonLeaseLostcallback fires; a failingreleasenever replaces a successful body result (it is recorded on the hold). - Fake capacity is a parameter of
AppContext.open(fakeCapacity, default 1), not an environment variable. - Notes carry a severity.
ctx.notesis a list of{severity: info|warning|error, text}. - Flutter. The core packages stay free of Flutter. A companion package supplies the pieces a Flutter app needs: loading
app.jsonfrom an asset, a single guarded close on exit (window close, quit, SIGINT, SIGTERM), a mode badge and a notes banner. - Coordination. See section 6.
6. Coordination between copies of the same app (the Kit manages, the app defines)
The Kit does not decide what coordination means. The app supplies a hook; the Kit finds, authenticates, connects and routes.
- Scope key:
(account, app id). A peer is another running copy of the same app id for the same account, identified by(device, instance). - Envelope: a Kit-owned header
{account, app, channel, from:{device,instance}, seq, kind}and an opaque app-owned payload with an app-declaredschemainteger. The Kit never reads the payload. - Provider interface (how peers are found and carried; the Kit ships a fake and a loopback one):
discover()(a stream of candidate peers),connect(peer),send(peer, bytes), and an inbound stream. Hearth and LAN providers are later work and need the Hearth protocol owners. - Hook (the app’s):
channel;hello()(opaque, sent on connect);admit(peer, theirHello)returning accept, accept read-only, or reject;onPeer(peer, change)for found, ready, lost;onMessage(from, message). - Management interface (the Kit’s): a live
peersview,send(peer, payload),broadcast(payload),request(peer, payload)with a timeout, andclose(). - Delivery: best effort with per-sender ordering by
seq; anything stronger is the app’s. - Mock mode uses the fake provider so apps test coordination with no network.
6.1 As built in the Dart core (dart/app_context_kit/lib/src/peers/)
The first implementation departs from the sketch above in these ways. They are decisions to keep or reverse, not settled design.
admitmay be async (FutureOr<Admit>): a pairing prompt or a storage lookup needs it.AppCoordination.attach(hub)(default no-op) hands the hook its hub.hello()must be JSON-encodable; the hook receives the decoded value.- One hub serves one channel, and
admitis one answer per peer. An app with several facets (a timer, a notes sync, a game) uses one hub per facet or a mux hook that routes by kind prefix; the Kit has no per-facet admission. - Providers also have
open(self)andclose(); candidates carryavailable: falsefor withdrawal;rid(optional) correlates request and reply, whose reserved kind iskit.reply; thekit.prefix is reserved. - Only the lower
PeerIddials, so two copies never open two links.seqis per link and restarts at 1 on a new link; a bounded reorder buffer (16 frames, 500 ms) delivers in order and skips a gap that stays open; the app sees loss as a jump inAppMessage.seq. - A full outbox fails
sendwithPeerOutboxFull(256 messages or 8 MiB per peer) instead of dropping: only the app knows what is safe to drop. acceptReadOnlyonly tagspeer.readOnly; the decision is local and the other side is not told. A rejected peer is not redialed until the provider withdraws and re-announces it.sendcompleting means the transport took the frame, not that the peer applied it. No heartbeat or keepalive yet. No transfer for state over 1 MiB. No replay for a late joiner (use a version inhello()and let whoever is ahead push onready).
6.2 What the Hearth integration changed (from testing against a local Hearth hub)
- The app-scoped topic grammar is rejected by the stock hub (
TOPIC_INVALID) and needs a one-file hub change (the hub’s topic module, about 60 lines). Until the Hearth protocol owners rule on it, carry app traffic on the stock topics withpayload_type = ak.app/<app>and filter at the receiver. - The hub isolates accounts, not apps. Any device on the account can join any app’s topic, so the scope key is hygiene until a per-app credential and grant exist.
- Provider payload limits differ. Hearth’s effective payload cap is 64 KiB minus the Kit header (the 1 MiB figure is the whole envelope and above it the hub drops the connection). A provider must declare its maximum payload and
sendmust fail before the wire when it is exceeded; the 1 MiB cap in section 6 is the Kit’s outer limit only. - The Kit must do for itself on Hearth: Kit-owned correlation ids (the hub’s
message_idis not a safe key) and request deadlines; echo suppression byfrom.device+from.instance(the hub echoes to the sender); dedupe bymessage_idacross live delivery and catch-up; liveness by ano_retainheartbeat no more often than every 30 s (the hub’s idle timeout is 90 s and its hourly publish caps are 600 per connection and 6000 per account); reconnect.
6.3 Opting in, and who this copy is (ctx.peerHub)
- Peers are an opt-in Kit. An app lists
"peers"inapp.jsonkits;ctx.peerHub(hook)throwsKitNotDeclaredotherwise. This is the first place the manifest’skitslist gates a capability. - Account:
APP_CONTEXT_ACCOUNT, else configaccount, elselocal(a single-user machine with no household account yet; replaced by the real account when an identity service issues one). - Device:
APP_CONTEXT_DEVICE, else configdevice; generated once (d-plus 8 hex digits) and saved, or ephemeral for the run if the settings file is damaged. Instance: the context’s instance id. - Provider by mode: an explicit provider; else in
mockmode a fake network (pass one to share it between copies in a test); else the loopback provider overAPP_CONTEXT_PEERS_DIR(default: the roster directory plus-peers). Hubs are closed with the context.
7. Additions (helpers the demo apps each wrote for themselves; additions only, nothing removed or renamed)
Evidence: the apps’ lib/kit_gaps/ files, identical across apps. Each addition below replaces a copy; the apps still compile against their old copies until they are migrated (new names were chosen so they cannot collide with the copies).
ctx.startPeers(hook, {network, options})->PeersHandle(core,peers/context_peers.dart). Builds AND starts a hub and never throws for a runtime condition. An unwritable peers directory or a provider that fails to open becomeshandle.error("peers unavailable: ..."), and the app keeps working alone.KitNotDeclaredis NOT swallowed (a bug in the app). The handle:hub(null when unavailable; non-null while still opening; set back to null, and the hub closed, if opening fails, sohandle.hub?.broadcast(...)is always safe),started(completes when open or failed, never with an error),error,isStarting,isRunning,changes(Stream<void>, broadcast: fires when the hub opens or fails and on every peer found/ready/lost; ends with the context). Closed with the context; a close that fails on a hub that never opened is not reported. Usectx.peerHubfor a custom provider or authenticator.PeerJson(core,peers/peer_json.dart):encode,decode(bytes, {maxBytes})(null for malformed, non-UTF-8 or oversized input; never throws),sizeOf,fits(value, maxBytes),encodeWithin(value, maxBytes)(throwsPeerPayloadTooLarge, aPeerError),quiet(future)(a send nobody awaits),label(PeerId)(“laptop #3fa2”); andAppMessage.jsonBody. The Kit does NOT know a provider’s payload cap: the app passes the limit (Hearth: 64 KiB minus the Kit header). NoJsonMessage {kind, v, body}envelope was built: all three apps use the Kit envelope’s ownkindandschemaand put only the body in the payload, so a second envelope inside the payload has no user.VenueSession.withHoldWaiting(need, body, {required maxWait, leaseSeconds, retryEvery, renewEvery, cancel, onWaiting, onLeaseLost})(core). Retries adoesNotFit(409) refusal; retriesforbidden(403) only whileholds()shows this consumer still has live holds of its own; gives up aftermaxWait(counted in naps ofretryEvery, not read from a clock) with the LAST refusal;maxWait: Duration.zeroiswithHold.cancel(a Future) ends the wait at once withHoldWaitCancelled; a hold granted in that instant is released. Only the refusal of the hold is retried, never an error frombody, neverVenueUnavailable.withHoldnow shares its post-grant part (_runHeld) with it; behaviour unchanged.FakeVenue.refuseNext(n, {kind})makes the fake refuse n times.VenueRefused.plainReasonunwraps the daemon’serror.message. The 403 rule is a heuristic and has a known failure. The real daemon answers its per-consumer live-hold limit with 403, and also answers a missing grant or an observer token with 403, with the same codeforbidden. A consumer that holds something and is refused 403 for lack of a grant waitsmaxWaitfor nothing before it gets the real refusal. Finding for the Venue project: give “over the live-hold limit” a machine-readable reason (or its own status); then drop the heuristic. Verified against the real daemon (consumercalc, limit 3): waits while it holds its own, is granted once one hold ends, gives up atmaxWaitwithforbidden.PeersIndicator(companion,package:app_context_kit_flutter/peers.dart):PeersIndicator.forHandle(handle, {extra})orPeersIndicator(hub: hub, offlineReason:, extra:). Shows"This copy: <device> #<instance>", “Peers: N”, a chip per ready copy, “(K connecting)” and, when peers are unavailable, “Peers: off (peers unavailable: …)” with an icon. One live-region Semantics sentence; words and an icon, never colour alone. Same keys and texts as the apps’ copy. It is in its own library, not theapp_context_kit_flutter.dartbarrel, because an app that still has its ownPeersIndicatorimports both and would get an ambiguous name; re-export it from the barrel (additive) once the copies are deleted.AppJsonFile/AppJsonRead/ctx.dataJson(name)(core): atomic write, read that tells missing from damaged,quarantine().SessionSettings(store): aConfigStorewrapper whosesetkeeps the change in memory for the session whileconfig.jsonis damaged (readOnlysays so). Both are the apps’JsonFile/SessionConfigunder names that cannot collide with them; the migration renames call sites.
7.1 Not built, and why
- Large transfer (
notepad/lib/kit_gaps/blob_pull.dart): not built: one user. What a Kit-level chunked transfer would need, if a second app wants it: (1) the provider declares its maximum payload (maxPayloadBytes) and the transfer sizes parts from it, instead of the app guessing a constant; (2) a pull protocol over request/reply (the only reliable primitive today): the receiver asks for part 0, 1, 2 … one reply at a time; (3) an opaque versiontagon every part, pinned after part 0, and a restart (boundedattempts) when it changes mid-way; (4) a hardmaxBytesenforced before allocating, and a bound on the part count taken from the sender’s first reply; (5) base64 accounting: payload bytes become base64 inside the Kit’s own base64, about 1.8x on the wire (24 KiB of text is a 44 KB frame); (6) typed outcomes: gone, too large, stale, and malformed-reply (the caller counts those) versusPeerErrorfor a lost or silent peer; (7) a serving side (hub.serveBlob) that answers parts for bytes at a given tag; (8) progress callbacks, cancel, and request deadlines per part. Also unsolved: no acknowledgement and no replay (6.1). - Desktop notifications (
stopwatch/lib/kit_gaps/notifier.dart): one user, and not an App Context Kit concern (a UI toolkit or the platform). launchergaps (manifest_scan.dart,pid_alive.dart): one user each. A scan of a source tree is not an install registry;pidAliveViaKillbelongs with the roster (RosterRecord.processAliveor a control socket) and is only meaningful on the same machine and user.
8. Sharing one context between a Dart UI and a Rust core
A Flutter app with a Rust core is one process with two runtimes. If each side opened its own context, the app would announce itself twice (two roster files, two instance ids) and two code paths would write one config.json. So one side opens the context and the other adopts it.
- The rule. The side that opened the context (normally the Dart UI:
AppContext.open, orAppContextKit.runin Flutter) is the owner. It hands the other side a snapshot. The other side is the adopter: it callsAppContext::adopt(&snapshot)(Rust) and does not open a context of its own. An adopter does not announce: no roster entry, no heartbeat, no Venue session, no peers. - One owner per file. The owner owns
config.jsonand the roster entry. The adopter only reads settings (the Rust crate’sReadOnlySettingsrefuses every write) and owns the files it creates underdataandstate. It may create directories under those two and under no other. If the Rust core needs a setting changed, it asks the owner; the owner writes it. - The snapshot is JSON, produced by
AppContext.snapshot()(Dart) orAppContext::snapshot()(Rust):
{ "schema": 1, "appId": "com.slashbuilder.demo.notes-rust", "instance": "0a1b2c3d", "mode": "real", "scratch": false,
"dirs": { "data": "/abs/data", "config": "/abs/config", "state": "/abs/state", "cache": "/abs/cache", "runtime": "/abs/runtime" },
"roster": "/abs/roster" }
instance is the owner’s 8-hex-digit instance id; the adopter has none of its own. mode is real or mock. Every path is absolute (a relative APP_CONTEXT_SCRATCH is made absolute by the owner). The snapshot has no secrets (no Venue token, no environment) and no file handles; it is a copy taken at one moment, not a live view.
- Parsing is strict. First failure only, in this order:
not-json,not-object,unknown-schema(anyschemaother than the number 1; a missingschemaismissing-field),missing-field(the shape’s order; nested ones are dotted,dirs.data),unknown-field(the schema is the only versioning: an addition bumps it),bad-field(wrong type or pattern:appIdis reverse-DNS,instanceis^[0-9a-f]{8}$,modeisrealormock,scratchis a bool, a path is a non-empty string), thenrelative-path. A path is absolute by its own syntax on any host:/x,\\server\share,C:\x,C:/x. - Fixtures.
conformance/snapshot_cases.jsonholds snapshots written by the Dart testsnapshot_test.dart(real contexts, except the Windows shape, which the host cannot open), each with the inputs it came from; Rust adopts each and asserts that its ownresolveover those inputs gives the same directories, and the same instance.conformance/snapshot_negative_cases.jsonlists snapshots that must be refused, with the error code and field. They are separate from the 61 resolution and manifest cases.
dart_cli
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
A command-line program on the App Context Kit core (pure Dart, no Flutter). It opens an AppContext, counts its launches in a setting, writes a file under the data directory and prints where things are. test/kit_test.dart is the testing example used in Testing.
Run it (from this directory). APP_CONTEXT_SCRATCH keeps everything in one throwaway folder instead of your real application folders:
dart pub get
APP_CONTEXT_SCRATCH=/tmp/app-context-demo dart run bin/main.dart
It prints this, and launches goes up by one each time you run it:
app dev.example.hello 0.1.0
mode real (scratch)
data /tmp/app-context-demo/dev.example.hello/data
config /tmp/app-context-demo/dev.example.hello/config
launches 1
venue Venue: local (Venue is off for this app)
Run the tests: dart test (expected: All tests passed!, three tests).
rust_cli
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
The same program as ../dart_cli, using the Rust crate in rust/: same app.json, same directories, same settings file.
Run it (from this directory). APP_CONTEXT_SCRATCH keeps everything in one throwaway folder:
APP_CONTEXT_SCRATCH=/tmp/app-context-demo-rs cargo run
It prints this, and launches goes up by one each time you run it:
app dev.example.hello 0.1.0
mode real (scratch)
data /tmp/app-context-demo-rs/dev.example.hello/data
config /tmp/app-context-demo-rs/dev.example.hello/config
launches 1
venue Venue: local (Venue is off for this app)
The crate has no remote Venue session, so the last line always reports the local session. See Getting started.
flutter_hello (package name: hello)
Reference page pulled from app-context-kit at commit 4797e81. Edit it in the Kit, not here.
A small Flutter desktop app on the App Context Kit: a button that counts taps, saves the count as a setting and writes it to a file under the data directory. It is the app built step by step in Getting started.
This directory holds the Dart code, the manifest and a widget test. It has no platform runner folders (macos/, linux/, windows/). The test needs none:
flutter pub get
flutter test
Expected: All tests passed! (one test).
To run it as a desktop app, generate the runners once, then run:
flutter create --platforms=macos,linux,windows --project-name hello --org dev.example .
flutter run -d macos # or -d linux, -d windows
flutter create . leaves lib/main.dart, pubspec.yaml and test/hello_test.dart as they are, but it adds the template’s test/widget_test.dart, which does not fit this app: delete it. On macOS the generated runner is sandboxed; see the macOS note in the guide before pointing APP_CONTEXT_SCRATCH anywhere.
Make targets
Everything is driven from the Makefile at the repository root. It works with the system make on macOS (3.81) and Linux and uses nothing newer. make on its own runs make help, which lists every target with one line. The Makefile follows the convention of the Kit repositories (help, all, build, lint, test, fmt) so the CI job and people use the same names.
The scripts behind the targets are in tools/. They are plain POSIX sh (and Python 3 for the link checker and the website tools), so they run on the /bin/sh and /bin/bash 3.2 that ship with macOS. The one exception is the catalog generator behind docs-catalog, a maintainer tool that also needs PyYAML; nothing a reader runs depends on it. docs-pull is also a maintainer tool, but it is Python 3 standard library only.
Variables
| Variable | Used by | Meaning |
|---|---|---|
KITS | setup, fetch | public (default) fetches only public Kits. all also fetches private Kits and needs a GitHub login |
SETUP_INSTALL | setup | 1 prints the install commands for missing required tools and runs them after a y/N question. Default: never installs |
APP | app, run | An app folder name under apps/: calculator, minesweeper, notepad, stopwatch, render-queue, launcher, notes_rust |
KIT | lock | A Kit name from kits.lock. Empty means every Kit |
LOCK_YES | lock | 1 skips the y/N question before kits.lock is rewritten (the difference is still printed) |
GITHUB_TOKEN | fetch, lock | A token that can read private Kits, used when the GitHub CLI is not logged in |
VENUE_TOKEN_FILE | test, test-integration | A resource-broker token file. When set, the render-queue tests that need a running broker run for real instead of skipping |
CHECK_PUBLIC_STRICT | check-public | 1 also fails while kits.lock lists a private Kit with role core. A private app-dep Kit only prints a warning that names the apps that will be skipped for a stranger. go-public-check sets it |
REGISTRY | docs-catalog | Maintainers only: the path of the Kit registry file (kits.yaml). If empty, KITS_REGISTRY from the environment is used. With neither, docs-catalog stops with a message |
KITS_REGISTRY | docs-catalog | The same path, from the environment, for people who regenerate often |
REGISTRY_COMMIT | docs-catalog | The commit of the registry the catalog is generated from; the catalog index prints it as “Generated from registry commit abc1234” |
OFFLINE | docs-catalog | 1 does not ask GitHub who is public and reuses tools/kit-visibility.tsv as it is |
PYTHON | docs-catalog, test-tools, test-fast | The Python 3 to use for the catalog generator and the tool tests (default python3). For docs-catalog it must have PyYAML; docs-pull always uses plain python3 |
SITE_DOMAIN | site, site-check, publish-site | The domain the website is served at (default kits.slashbuilder.com). It is written into the CNAME file of the published branch. Set in the Makefile, in one place |
SITE_TITLE | site | The title shown on every page of the website (default The Kit Book) |
SITE_REPO_URL | site | The repository address used for the “edit this page” links and for the rewritten links into apps/ and labs/ (default https://github.com/slash-builder/kit-book) |
SITE_REPO_BRANCH | site, publish-site | The branch those links point at (default main); publish-site only pushes from this branch |
SITE_REWRITE_REPO_LINKS | site | 1 (default) rewrites links from the book to files outside book/ into GitHub addresses; 0 leaves them as relative links, which do not work on the website |
SITE_INDEXABLE | site, site-check, publish-site | 0 (default) puts <meta name="robots" content="noindex"> on every page; 1 leaves it out so search engines may index the site (mdBook keeps noindex on its own print.html and toc.html). Set 1 only when the site is meant to be found |
PUBLISH | publish-site | 1 allows the push to the gh-pages branch, and only if the repository behind origin is public. Anything else is a dry run |
MDBOOK | site | The path of the mdbook program to use. Otherwise mdbook on the PATH, then ~/.cache/mdbook/mdbook |
STRANGER_REPO_URL | stranger-test | The repository to clone (default https://github.com/slash-builder/kit-book; a local path works for a rehearsal) |
STRANGER_DRY | stranger-test | 1 prints the plan and what would be removed from the environment, and clones nothing |
STRANGER_STEPS | stranger-test | Which steps to run after the clone, in order (default doctor setup test-fast) |
STRANGER_KEEP | stranger-test | 1 keeps the temporary clone and prints where it is |
GO_PUBLIC_OFFLINE | go-public-check | 1 skips the checks that need the network and ends INCOMPLETE (exit 3), never a pass |
LINUX_SMOKE_APPS | linux-smoke | The app folders to test, separated by spaces (default: every app that can be built here; an app whose Kit is not fetched is skipped with a line) |
LINUX_SMOKE_IMAGE | linux-smoke, linux-smoke-image | The Docker image name (default kit-book-linux-smoke:flutter-3.41.9) |
LINUX_SMOKE_PLATFORM | linux-smoke, linux-smoke-image | A Docker --platform (default: the daemon’s own, linux/arm64 on Apple silicon) |
LINUX_SMOKE_UP, LINUX_SMOKE_SHOT_AT | linux-smoke | Seconds to keep each app up (default 10) and the second of the screenshot (default 6) |
LINUX_SMOKE_PEERS | linux-smoke | The apps for the two-copies check (default stopwatch notepad; empty skips it) |
LINUX_SMOKE_NOCACHE | linux-smoke | 1 does not use the two cache volumes for Dart packages and Rust crates |
NO_COLOR | all | Turns off colours in the output |
KITS_LOCK | the scripts in tools/ | Read a different lock file (used for testing the fetch script) |
Targets
help
The default. Prints every target with a one-line description.
all
What CI runs: lint, then test-fast, then build.
doctor
Checks the toolchain and prints a table: tool, whether it is required, status, the version found and the version wanted. For each missing or too-old tool it prints the install command for your system (Homebrew on macOS, apt on Debian and Ubuntu). It installs nothing and exits non-zero only when a required tool is missing or too old.
- Required: Flutter and Dart (the minimum Dart is read from the apps’
pubspec.yamlfiles), Rust and Cargo (the minimum is the highestrust-versionamong the Kits’ crates and the apps’ Rust cores),curl,tar, Python 3, and on macOS the Xcode command line tools.flutter_rust_bridge_codegenis required only when an app with a Rust core (apps/notes_rust) is one that can be built here, at exactly the version that app pins in itspubspec.yaml(flutter_rust_bridge: 2.12.0today); it is looked for onPATHand in~/.cargo/bin, wherecargo installputs it. An app that is skipped (its Kit is not fetched) adds no requirement: not this tool, not its Rust version, not CocoaPods;doctorlists the skipped apps under the table. - For apps: needed only to build and run the demo apps. On macOS, the full Xcode app, and CocoaPods when an app has a native plugin (the Rust core’s build glue is one). On Linux,
clang,cmake,ninja,pkg-configand the GTK 3 development files.make appsrequires them;make test-fastdoes not. - Optional: Docker (for the Linux container checks),
redis-server,protoc,flutter_rust_bridge_codegen(optional only when no app in the tree needs it), the GitHub CLI (gh, for private Kits) andmdbook(formake site).
setup
Runs doctor, then fetch, then flutter pub get or dart pub get for every app and package, then cargo fetch for each Kit’s Rust crate and for each app’s own Rust core. An app whose Kit is not fetched is skipped with a one-line reason (see kits.need); that is never an error. Safe to run again. KITS=all also fetches private Kits. SETUP_INSTALL=1 offers to run the install commands for missing required tools; the default never installs.
fetch
Reads kits.lock and, for each Kit (unless kits.override names a local checkout for it), downloads the archive at the pinned commit into .kits/<name>/. Public Kits come from a plain curl download. Private Kits come from the GitHub CLI (gh api) when it is logged in, else from GITHUB_TOKEN; with neither, the Kit is skipped with a one-line explanation. After unpacking it writes .kits/<name>/.fetched containing the commit, and skips the Kit next time when that matches. It checks that the archive really is the commit asked for. No git and no submodules are involved. One line per Kit: fetched, up to date, or skipped (why).
lock
Shows how the pins would move: for each Kit it asks GitHub for the newest commit of the default branch and prints old -> new with a compare link. It rewrites kits.lock only after you answer y. make lock KIT=app-context-kit does one Kit. After it, run make setup and make test-fast.
test
Runs every fetched Kit’s own test suite, then labs/interface_candidates, then every demo app (an app with a Rust core runs its cargo test first and then flutter test, which uses an in-memory stand-in for the core, so it needs no native build), then the integration suites that can run on this computer. A Kit with a Makefile is tested with its own make test-fast (or make test); a Kit without one is tested with its native commands (dart test, flutter test, cargo test). Prints a table with each suite’s passed, failed and skipped counts and its seconds; logs are in build/test-logs/. An app whose Kit is not fetched is skipped whole, with a line such as skipped apps/notes_rust: needs storage-kit (private; fetch with KITS=all) and one row in the table that says so. Exits non-zero when any suite that ran has a failure; a skip is not one.
test-fast
The same as test without anything that needs Docker, Redis or a running daemon. This is the everyday command. It also runs the unit tests of the maintainer tools (see test-tools) when PyYAML is installed, and skips them with a message when it is not.
test-integration
Only the suites that need Docker, Redis or a daemon. Each one skips with a clear message when its tool is absent; skipping is never a failure.
| Suite | Needs | What it does |
|---|---|---|
| Linux containers | The Docker daemon running | Runs a Kit’s Dart and Rust cores in throwaway Linux containers, if the Kit ships a script for it (the App Context Kit does) |
| Resource-broker daemon tests | VENUE_TOKEN_FILE set to a token for a running broker | Runs the render-queue tests that otherwise skip themselves |
| App integration tests | A desktop to run an app on (macOS, or Linux with a display) and the Kits the app builds against | Runs an app’s integration_test/ inside the built app with flutter test integration_test -d macos, so a Rust core is the real native library. apps/notes_rust has one |
| Message-hub probes | Redis | These run in a private repository; nothing here runs yet |
apps
Builds every demo app in debug mode for this computer (macOS or Linux desktop). Needs the “for apps” tools from doctor and a fetched App Context Kit. An app whose Kit is not fetched is skipped with a one-line reason, and the others are built.
app
Builds one app: make app APP=notepad. If you name an app whose Kit is not fetched, it says which Kit and what to run (make setup KITS=all for a private one) and exits non-zero, because the app you asked for was not built.
run
Runs one app on this computer: make run APP=notepad. Like app, it stops with the fix if the app’s Kit is not fetched.
frb
Regenerates the Flutter-to-Rust bridge (flutter_rust_bridge_codegen generate) for every app that has a flutter_rust_bridge.yaml, which is apps/notes_rust today. Run it after changing the bridge layer (rust/src/api/*.rs) and commit the result: the generated files are committed so that make app works without this tool. It refuses to run with a codegen whose version differs from the one the app pins. An app that is skipped is passed over with a one-line reason.
build
An alias for apps.
lint
Runs flutter analyze over labs/interface_candidates and every app, then docs and check-public.
fmt
Runs dart format over apps/ and labs/.
docs
Checks every relative link and heading anchor in book/ and in the README files (tools/check-links.py), using GitHub’s heading slugs. A code fence whose opening line says copied-from=<path> is also checked against that file. It also checks that every Makefile target is described on this page. It writes nothing.
docs-catalog
For maintainers; readers never need it. Regenerates the Kit catalog in book/30-kits/ from the Kit registry, then runs docs and check-public on the result. The registry is in a private repository, so the generated pages are committed here and make docs never asks for it.
python3 -m pip install pyyaml # once; only this tool needs PyYAML
gh auth login # once; the tool asks GitHub which repositories are public
make docs-catalog REGISTRY=/path/to/kits.yaml REGISTRY_COMMIT=abc1234
make docs-catalog REGISTRY=/path/to/kits.yaml OFFLINE=1 # no GitHub calls: reuse tools/kit-visibility.tsv
With neither REGISTRY nor KITS_REGISTRY set it stops and says so. It runs tools/gen-catalog.py, which writes:
- one status card per Kit in the registry (
book/30-kits/<name>.md), for every Kit: public, private, banked and archived; this repository is the book, not a Kit, and is never listed; - the catalog index (
book/30-kits/README.md), the status vocabulary and the page for things that are not in the registry; - the block of
book/SUMMARY.mdbetween<!-- catalog:start -->and<!-- catalog:end -->(the markers are added if missing; nothing else in that file is touched); tools/kit-visibility.tsv: for each Kit (by name),public,private,missing(the repository does not exist) orunknown, whether a public repository has a README ordocs/, and the day that was checked. It never records where a Kit lives: no repository name and no organisation, so it is safe in a public repository. A repository link appears on a card only forpublic, and the repository is read from the registry each time.
Running it twice gives identical files: nothing in the output carries a time. Prose from the registry passes through a sanitiser that drops, rather than rewords, any sentence or parenthetical that names people, roles, agents, tickets, hostnames, local paths, notes-vault paths, Jira or dates, and check-public scans the result again. A card that comes out thin is thin because the registry entry behind it is.
docs-pull
For maintainers; readers never need it. Pulls each Kit’s own reference documentation out of the fetched Kit (.kits/<name>/) into the book, then runs docs and check-public on the result. The Kits keep their documentation with their code, so it versions with the code; the book copies it in at the commit kits.lock pins, and the copies are committed, so a reader needs neither make setup nor access to a private Kit’s repository to read them. make docs never needs .kits/.
make setup KITS=all # once, and after every pin move: the Kit must be fetched at the pinned commit
make docs-pull # then commit book/ (the pulled pages and the docs-pull block of SUMMARY.md)
It runs tools/pull-docs.py (Python 3 standard library only), which reads kits.lock and tools/docs-pull.conf. For each Kit with a section in the configuration, the configuration says where the pages land, which source files are pulled, their order in the table of contents, and whether the Kit is public (auto reads the visibility column of kits.lock). Today only the App Context Kit is pulled; because it is not in the registry its pages land in book/30-kits/app-context-kit/. A Kit in the registry lands in book/30-kits/<slug>/reference/, and its catalog card then lists the pages.
What it writes, and nothing else:
index.md: the Kit’sdocs/README.md, followed by a generated table of every pulled page (title from the page’s first heading, one line from its first paragraph);- one page per pulled source file, named in lower case with
docs/dropped (docs/GETTING-STARTED.mdbecomesgetting-started.md, a folder’sREADME.mdis named after the folder), and any images those pages use underassets/; - the block of
book/SUMMARY.mdbetween<!-- docs-pull:start -->and<!-- docs-pull:end -->, which nests the pages under one entry per Kit. It sits in its own part, “Kit reference”, after the catalog (the part and the markers are added before “Reference” if missing). It is not inside the catalog’s list because mdBook drops the first of two lists in one item when an HTML comment separates them, which would delete the catalog from the contents.
Every pulled page starts with a banner comment and carries the visible line “Reference page pulled from <kit> at commit <sha7>. Edit it in the Kit, not here.” Do not edit a pulled page: the next pull overwrites it, and a content fix belongs in the Kit. The tool changes the Kit’s text only by rewriting links:
| A link in a Kit page that points to… | becomes |
|---|---|
| a web address, a mail address, or a heading on the same page | itself, unchanged |
a pulled page, or a folder whose README.md is pulled, or the Kit’s docs/README.md | a relative link to the pulled copy, with its #anchor kept (the headings are not changed, so the anchors still exist) |
the Kit’s root README.md or CONTRIBUTING.md, when the Kit is private and its section sets home | a link to the Kit’s entry in the catalog (the page named by home), so “Repository home” and “Contributing” lead somewhere |
| anything else in the Kit (code, other folders), when the Kit is private | plain text: the link text stays, the link goes |
| anything else in the Kit, when the Kit is public | a GitHub address at the pinned commit (blob for a file, tree for a folder), with any #anchor or #L10 kept |
| a place outside the Kit, or a file the Kit does not have | plain text, and a warning naming the page, so it can be fixed in the Kit |
an #anchor that is not a heading of the page it names | the link to the page without the anchor, and a warning |
| an image that exists in the Kit | a copy under assets/, linked there |
| an image that does not exist | dropped, with the visible note “image not included” |
Code blocks and code spans are never touched. Running it twice gives identical files: nothing in the output carries a time.
It refuses to run if .kits/<name>/.fetched is not exactly the commit kits.lock pins (or the Kit is not fetched at all), so the book never mixes versions. It says so, writes nothing, and prints the fix: make fetch (with KITS=all for a private Kit). It exits with status 2 on a refusal and on a configuration error.
docs-pull-check
For maintainers. Pulls in memory exactly as docs-pull does and writes nothing; it fails (status 1) and prints each file that differs, is missing, or is stale, with the first changed lines, if the committed pages are not what a fresh pull would give. Use it to see that nobody edited a pulled page by hand and that the pages were re-pulled after a pin moved. A Kit that is not in .kits/ is skipped with a message (and the SUMMARY.md block is then not judged), so it is safe to run anywhere. A Kit fetched at a different commit than the pin is a refusal, as for docs-pull.
test-tools
For maintainers. Runs the unit tests of the tools in tools/:
- the catalog generator,
tools/tests/test_gen_catalog.py: sanitising, idempotence, the public/private link rule, the missing-repository note, status grouping, the rewrite of theSUMMARY.mdblock and the listing of pulled reference pages on the cards. They need PyYAML and skip themselves with a message when it is missing; - the reference-docs puller,
tools/tests/test_pull_docs.py: each class of link rewrite, the banner, anchors, idempotence, the pin refusal and--check. They need nothing beyond Python 3; - the launch tooling:
tools/tests/test_go_public_check.py(every check ofgo-public-check, withcurlandghreplaced by stand-ins),tools/tests/test_stranger_env.py(the sanitised environment ofstranger-testand its dry run),tools/tests/test_app_needs.py(the per-app Kit requirements: the gate,test-run.shandfrb.shin a throwaway repository with stand-influtterandcargo),tools/tests/test_check_public_strict.py(the strict mode ofcheck-public),tools/tests/test_render_community.pyandtools/tests/test_gen_third_party_notices.py. They need nothing beyond Python 3; - the website tools:
tools/tests/test_site_stage.py(the link rewrite and the README rename),tools/tests/test_site_check.py(the checks ofsite-check) andtools/tests/test_publish_site.py(the dry run and the push guard, against a local bare repository and a stubgh; needsgit). They need nothing beyond Python 3.
Plain python3 -m unittest from the repository root runs the same tests.
site
Builds the book as a website with mdBook into build/site/, ready to be served at the root of SITE_DOMAIN. If mdBook is not installed it prints how to install it and exits 0. How the site is built and published is in Publishing the website; the steps in order:
- Stage the book.
book/is copied tobuild/site-src/, and in the copy every link to a path outsidebook/(the demo apps, the labs,CONTRIBUTING.mdand so on) becomes a link to the file or folder on GitHub (SITE_REPO_URL,SITE_REPO_BRANCH). Links that stay inside the book are left alone. It prints how many links it rewrote. While the repository is private those addresses answer 404 for everyone without access, andmake sitesays so.SITE_REWRITE_REPO_LINKS=0turns the rewrite off. The same step renames an in-book link to a folder’sREADME.mdtoindex.md, because mdBook publishes that page asindex.html.book/itself is never changed. - Build with mdBook. The configuration is generated into
build/mdbook/, so there is nobook.tomlto keep in step. The site is served at/, search is on, every page has an “edit this page” link, and there is a404.html. The default favicon is kept. - Diagrams. The
mermaidcode blocks of the pulled Kit pages are drawn in the browser by a pinned copy of mermaid that is committed intools/site/vendor/and copied into the site; nothing is loaded from another host. The page’s light or dark theme decides the diagram colours. - Robots. Every page gets
<meta name="robots" content="noindex">unlessSITE_INDEXABLE=1. - Third-party notices.
THIRD-PARTY-NOTICES.mdis copied into the site root, because the vendored diagram script is a bundle of other people’s code and their licences ask for the notice to travel with it. - Publish staging.
build/site-publish/isbuild/site/plus aCNAMEfile holding exactlySITE_DOMAINand an empty.nojekyll. That folder is whatpublish-sitecommits.
Preview it with python3 -m http.server -d build/site 8000. The first line of output says how many pages were built. make docs does not need mdBook and is unchanged.
site-check
Checks the site that make site built (it stops with a message if there is none) and prints one line per check. It uses only Python 3.
- Links: every internal link, script, stylesheet and image address in every generated page resolves to a file, and every
#fragmentis an id that exists in the page it points to. Addresses to other sites are counted but not fetched. - Publish folder:
build/site-publish/has aCNAMEthat holds exactlySITE_DOMAIN, an empty.nojekyll, anindex.html, and the same pages asbuild/site/. - Robots: with
SITE_INDEXABLE=0every page carries the noindex tag; withSITE_INDEXABLE=1none does, exceptprint.htmlandtoc.html, which mdBook always marks noindex itself. - Diagrams: the vendored
tools/site/vendor/mermaid.min.js, the copy in the site and the copy in the publish folder all match the sha256 recorded intools/site/vendor/README.md, the licence file is there, and every page with a diagram loads the init script. - Notices:
THIRD-PARTY-NOTICES.mdis in the site root and in the publish folder, byte for byte the one at the repository root.
Exits non-zero on any failure.
publish-site
Maintainers only. Removes the old build, runs make site and make site-check, then commits the contents of build/site-publish/ as one orphan commit (no parent) to a local branch gh-pages. The commit is built with a scratch index and no checkout, so main, your index and your working tree are never touched.
- By default it is a dry run: it prints the commit, its file count, its
CNAME, the repository’s visibility, the push it would have made, and why it did not, and exits 0. - It pushes only when all of these hold:
PUBLISH=1;gh repo viewsays the repository behindoriginisPUBLIC; the working tree has no uncommitted changes; and the current branch isSITE_REPO_BRANCH. The push isgit push --force-with-lease=refs/heads/gh-pages:<what origin holds now> origin gh-pages:refs/heads/gh-pages. It never pushes any other branch. - It does not turn GitHub Pages on, change any GitHub setting, or touch DNS. Those steps, and their order, are in Publishing the website.
Undo the local branch with git branch -D gh-pages.
check-public
Scans every file in the tree except .kits/ and build output for anything that must not be public: internal hostnames, ticket ids, local paths that contain a user name (except the invented user name ada that the Kits’ reference pages use in examples), notes-vault paths, references to agent instruction files, repository URLs that are not public Kits in kits.lock or on the card of a Kit that tools/kit-visibility.tsv marks public, people named in credits or licence text other than the author, and secret patterns. The generator tools/gen-catalog.py and its tests are not scanned, because they spell the patterns out; what they generate, including tools/kit-visibility.tsv, is. This repository’s own address (github.com/slash-builder/kit-book, which the website links to) is allowed. One more file is skipped: the vendored tools/site/vendor/mermaid.min.js, third-party minified JavaScript that nobody here edits. It is skipped only while its sha256 equals the one recorded in tools/site/vendor/README.md; if the file changes it is scanned like everything else, and the README and licence next to it are always scanned. It prints file:line for every hit and exits non-zero on any. While kits.lock lists private Kits it also prints a note that each must become public (or stop being needed) before this repository is made public.
With CHECK_PUBLIC_STRICT=1 it also fails while kits.lock lists a private Kit with role core. A private app-dep Kit is only a warning that names the apps that will be skipped for a stranger. THIRD-PARTY-NOTICES.md is exempt from the rule about people named in credits (it exists to name the copyright holders that third-party licences require); every other rule scans it.
community
For maintainers. Renders CODE_OF_CONDUCT.md and SECURITY.md at the repository root from the templates in tools/community/ and the two contacts in tools/contacts.conf (CONDUCT_CONTACT, SECURITY_CONTACT), with tools/render-community.py. The Code of Conduct is the Contributor Covenant 2.1, unmodified except for the enforcement contact; the template records the sha256 of the official text it was made from. An empty contact is written as the visible marker [CONTACT NOT SET] with a draft banner at the top of the file, and the tool prints a warning; python3 tools/render-community.py --final fails instead, and --check fails if the committed files are not a fresh render. Edit the template or contacts.conf, never the rendered file.
go-public-check
For maintainers. The launch gate: a single read-only run that prints a checklist table, one row per check, ok, warn or FAIL, and exits 1 if any row is FAIL (a warn says what to do and does not fail it). It writes nothing to the working tree (the website is built in a temporary copy), changes no GitHub setting and touches no DNS. It takes about a minute.
| # | Check |
|---|---|
| a | CHECK_PUBLIC_STRICT=1 make check-public passes; a private app-dep Kit is a warning that names the apps that will be skipped |
| b | kits.lock: each URL ends in a repository named like the Kit; no RENAMED AT RELEASE comment; no application-kit, application_kit or appkit; every pin is 40 hex characters |
| c | every core Kit’s repository answers HTTP 200 to a plain curl with no token, and github.com/<org>/<repo>/commit/<sha> of its pin answers 200 (a redirect is not a 200) |
| d | make docs, make docs-pull-check, and make site and make site-check with SITE_INDEXABLE=1 pass |
| e | LICENSE and LICENSE-docs exist; THIRD-PARTY-NOTICES.md exists and names the vendored mermaid version and sha256 recorded in tools/site/vendor/README.md |
| f | tools/contacts.conf has both contacts (else: set CONDUCT_CONTACT and SECURITY_CONTACT in tools/contacts.conf); CODE_OF_CONDUCT.md and SECURITY.md exist, carry no placeholder ([CONTACT NOT SET], [INSERT, {{) and are a fresh render |
| g | no tracked file spells an old Kit name (appkit, application_kit, application-kit) except where tools/go-public-allow.txt allows it (never kits.lock) |
| h | this repository’s own visibility, from gh repo view --json visibility: PRIVATE is a warning, “make the repo public, then re-run” |
| i | the built site’s CNAME equals SITE_DOMAIN |
| j | the working tree is clean and on main (a warning), and every apps/<app>/kits.need names a Kit that kits.lock lists |
GO_PUBLIC_OFFLINE=1 skips the two network checks (c, h) and ends INCOMPLETE with exit 3, never a pass. The pure parts of the checks have unit tests (tools/tests/test_go_public_check.py). What to do with the result, and in which order, is in Publishing the website.
stranger-test
For maintainers, after the repository is public. Does what a newcomer with no GitHub account would do, and shows what they would see: clones STRANGER_REPO_URL into a fresh temporary folder, then runs make doctor, make setup (with KITS=public, the default) and make test-fast in the clone. Everything runs in a sanitised environment (tools/stranger-env.py): the GitHub token variables are removed, git may not ask for a password and has no credential helper (-c credential.helper=, GIT_TERMINAL_PROMPT=0), and gh is not on PATH, so a step that needs a login cannot quietly use yours. A fetch that skips for want of a login does not make make setup fail, so afterwards the script checks that every core Kit in kits.lock is public and was fetched at its pinned commit, and fails loudly if not (A STRANGER COULD NOT GET ...). It ends with a table of steps and exits non-zero if anything failed.
STRANGER_DRY=1 make stranger-test prints the plan and the names (never the values) of the environment variables it would remove, and clones nothing. STRANGER_STEPS="doctor setup" runs fewer steps; STRANGER_KEEP=1 keeps the clone. Apps that need a Kit that is still private are skipped with a message, which is the expected result; see kits.need.
linux-smoke-image
Builds the Docker image that linux-smoke runs in, from tools/linux-smoke/Dockerfile: Ubuntu 24.04, the Flutter Linux desktop prerequisites (clang, cmake, ninja-build, pkg-config, GTK 3 and liblzma development files), Xvfb, ImageMagick and xdotool, a software OpenGL (Mesa) and one font (without a font Flutter draws no text), Flutter 3.41.9 (the version of the maintainer’s machine; Flutter publishes no Linux arm64 archive, so it is cloned at the tag and the commit is checked) and Rust 1.98.1 through a hash-checked rustup-init. The sources and hashes of the pins are printed at the end and kept in the image as /opt/linux-smoke-pins.txt. The first build takes a few minutes and the image is about 4 GB; after that it is cached by Docker and linux-smoke does not need the network except for flutter pub get and Rust crates (cached in two named Docker volumes). The build log is build/linux-smoke/image-build.log.
linux-smoke
Builds every demo app for Linux in a container and launches each one under a virtual display. Needs Docker, make linux-smoke-image once, and the Kits fetched (make setup KITS=all while they are private; notes_rust is skipped without the Storage Kit like everywhere else). The repository is mounted read-only; each app is copied to the container’s own disk (without build/ and .dart_tool) and the host is never written to except build/linux-smoke/.
For each app: flutter pub get, flutter build linux --release (seconds and bundle size are recorded), then a launch under xvfb-run (1280x800x24, no window manager) with APP_CONTEXT_SCRATCH set, APP_CONTEXT_MODE=mock and the app’s own <PREFIX>_MODE=mock. It keeps the app up 10 s, takes a screenshot at 6 s, sends SIGTERM and records whether it was still running, the exit code, whether the roster entry appeared and was withdrawn, any Gtk/GLib warning and uncaught exception in the output, and the files the app wrote under the scratch directory. For calculator, stopwatch, minesweeper and notepad the first launch is driven with xdotool (a few clicks, and typing in the notepad) so there is something to save; a second launch on the same scratch directory then shows what survived. Finally two copies of stopwatch and of notepad run side by side in real mode with the same APP_CONTEXT_PEERS_DIR and different APP_CONTEXT_DEVICE values; the harness checks that each announced, that the two processes hold a TCP connection to each other, and, for notepad, that a note written into copy A’s folder arrives in copy B’s. The apps do not log peers, so the screenshot (each footer reads Peers: 1) is the other evidence.
Results: build/linux-smoke/results.md (a table, the environment and the details, including every warning line) and the screenshots build/linux-smoke/<app>-first.png, -second.png and -peers.png. build/ is ignored by git. Exit status is non-zero when an app did not build or was not running at 10 s.
What it proves and what it does not. It shows that the Linux runners build and that the apps start, draw, take mouse and keyboard input, write their state, survive a relaunch and exit on SIGTERM, headless under a virtual display, in a container, on one architecture (linux/arm64 on Apple silicon; linux/amd64 was not run). It is not a real desktop session (no window manager or compositor, no window-close click, no notification daemon), not a real GPU (software rendering), and says nothing about other distributions, Wayland, or X11 with a real server. The GTK accessibility-bus warnings it lists come from the container having no accessibility bus.
clean
Removes build output: build/, each app’s and lab’s build/ and .dart_tool/, an app’s Rust core target/ folder, and Rust target/ folders inside .kits/. Keeps the fetched Kits.
clean-all
clean, and also deletes .kits/. Run make setup to fetch again.
kits.lock
One line per Kit, five fields separated by spaces; lines starting with # are comments.
name repo-url commit visibility role
| Field | Meaning |
|---|---|
name | The folder it is fetched into: .kits/<name>/ |
repo-url | https://github.com/<org>/<repo> |
commit | The full 40-character commit id. A branch name is not accepted, so a pin never moves by itself |
visibility | public (plain download, no account) or private (needs a GitHub login and KITS=all) |
role | core (documented here, tests run by make test), app-dep (another Kit needs it to build; fetched, not tested) or reference (documented here, not needed to build the apps) |
kits.override
Optional. One line per Kit, name path, with the path relative to the repository root; lines starting with # are comments. For a Kit named here, make fetch and make setup link .kits/<name>/ to that local checkout instead of downloading the pin in kits.lock, and say so in their output, because the pin is then not what you are building against. The file exists for a Kit change that is not merged yet. It is not meant to stay: the steps for removing a line are in the file’s own header comment.
kits.need
Optional, one per app, at apps/<app>/kits.need. The Kits the app needs besides the App Context Kit, one name from kits.lock per line; # starts a comment. Today only apps/notes_rust/kits.need exists, and it lists storage-kit, which is private for now.
An app whose needed Kit is not in .kits/ is skipped, never failed, by setup, test, test-fast, test-integration, apps, frb and doctor. Each prints one line such as
skipped apps/notes_rust: needs storage-kit (private; fetch with KITS=all)
and the test table shows the app as skipped, so a stranger who runs make setup && make test-fast with only public Kits gets a green run that says plainly what it left out. doctor also drops everything that app alone required (flutter_rust_bridge_codegen, its Rust version, CocoaPods). make app APP=<app> and make run APP=<app>, which name one app, say which Kit is missing and what to run, and exit non-zero. With KITS=all (and a GitHub login) nothing is skipped and every test runs as before. make go-public-check fails if a kits.need names a Kit that kits.lock does not list.
Publishing the website
The book is also a website, served at https://kits.slashbuilder.com from this same repository. This page says how the site is built and published, in what order the steps happen, and what is done by hand. The commands are described in Make targets.
How the site is built
make site turns book/ into a static website with mdBook. The output is in build/site/; nothing in it is committed to main.
- Links out of the book. On GitHub a page can link to
apps/,labs/orCONTRIBUTING.mdwith a relative path. On the website those paths do not exist, so the build rewrites them, in a staged copy, to the file or folder on GitHub. The number of rewritten links is printed. Links inside the book are untouched. - Diagrams. The architecture diagrams are
mermaidcode blocks. GitHub draws them itself; on the website a pinned copy of the mermaid library, committed intools/site/vendor/, draws them in the browser, in light or dark colours to match the page. The library is never fetched from the internet by the build or by a reader’s browser. - Search is mdBook’s built-in search: it runs in the browser from an index in the site.
- Not indexed yet. Until the site is announced, every page carries
<meta name="robots" content="noindex">.SITE_INDEXABLE=1removes it. - Checks.
make site-checkverifies the built site: every internal link and#fragmentresolves, the files GitHub Pages needs are there, the robots tag matchesSITE_INDEXABLE, and the vendored library is the file whose checksum is recorded.
Preview it locally with python3 -m http.server -d build/site 8000.
How it is published
Publishing is a branch, not a workflow. The book does not use GitHub Actions (the Kits’ own repositories do: each public Kit runs its tests there). make publish-site builds and checks the site, then commits the built files as a single new commit with no history to a branch named gh-pages, and GitHub Pages serves that branch (“Deploy from a branch”). The commit is made without checking anything out, so main and your working tree are never touched.
Two files are added to every published commit:
CNAME, holding exactly the domain (kits.slashbuilder.com). With branch-based Pages, the custom domain lives in this file in the published branch. Because every publish replaces the whole branch, a publish without it would silently remove the custom domain from the site..nojekyll, an empty file, so that Pages serves the files as they are and does not run its own site generator over them.
The push is a force push (every publish is a new root commit) guarded by --force-with-lease, and it happens only when PUBLISH=1 is given and the repository is public. In every other case make publish-site builds the commit on a local gh-pages branch, prints what it would have pushed and exits successfully. Pages on the Free plan works only for public repositories, which is why a private repository is previewed locally.
Before you publish
Run the gate first, as often as you like. It is read-only: it changes no file, no GitHub setting and no DNS record, and builds the website in a temporary copy.
make go-public-check
It prints one row per check (ok, warn, FAIL) and exits 1 while any row is FAIL. Every check is described in Make targets; what a failing row means and what clears it:
| Row says | What it means | What clears it |
|---|---|---|
a strict scan fails | a file contains a name, path, id or secret that must not be public, or a core Kit in kits.lock is still private | fix the file (never weaken the scan); make the Kit public |
b kits.lock rows | a URL does not end in the Kit’s own name, the rename comment is still there, an old Kit spelling is left, or a pin is not 40 hex characters | do the Kit rename switch |
c a core Kit does not answer 200 | the Kit’s repository is private, does not exist, was moved (a redirect is not a 200), or its pinned commit is not in it | create or publish the Kit’s repository, re-pin with make lock KIT=<name> |
d docs or site | a link is broken, a pulled page is stale (make docs-pull), or the site does not build or check with SITE_INDEXABLE=1 | fix the page; make docs-pull; install mdBook |
e licences and notices | LICENSE, LICENSE-docs or THIRD-PARTY-NOTICES.md is missing, or the notices do not name the vendored mermaid version and checksum | regenerate the notices after upgrading the vendored script (tools/gen-third-party-notices.py) |
f community files | a contact in tools/contacts.conf is empty (the files then carry the visible marker [CONTACT NOT SET]), or the files are not a fresh render | set CONDUCT_CONTACT and SECURITY_CONTACT in tools/contacts.conf, run make community, commit both files |
g old Kit spelling | a tracked file still says appkit, application_kit or application-kit | change it, or, if the old name is the point of the sentence, allow that one line in tools/go-public-allow.txt with the reason (never kits.lock) |
h visibility is warn | this repository is still private | make it public, then run the gate again; the row turns ok |
i CNAME | the built site’s CNAME is not SITE_DOMAIN | look at SITE_DOMAIN in the Makefile |
j working tree or kits.need | uncommitted changes, another branch than main, or an app lists a Kit kits.lock does not know | commit; switch to main; fix the name |
A warn never fails the gate, but read it: some warnings are expected until the step they name is done (a private repository, a private app-dep Kit whose apps are then skipped for a stranger).
The order of a launch. Every step that makes something public is a decision by the owner at the time, not a build step.
- Before anything flips: set the two contacts and commit the community files; make sure the notices match the vendored script; verify the domain for the organisation (step 1 below); run
make go-public-checkand read the table. The only rows allowed to be red are the ones the later steps clear: the Kit rename switch (b,c), the repository’s visibility (h). - Make the Kits public, starting with the ones the book documents: create and publish each repository, then do the rename switch in
kits.lockand re-pin. - Make this repository public, then
make go-public-checkagain: every row should now beok. - Publish the site and switch Pages on, then add the DNS record last (the steps below).
- Run the stranger test:
make stranger-test(preview withSTRANGER_DRY=1) clones the public repository with no GitHub login and runsmake doctor,make setupandmake test-fastthere. Apps that need a Kit that is still private must skip with a message, not fail. Fix whatever breaks before announcing. - Check every link in the announcement, then announce.
Each step has an undo (make the repository private again, remove the gh-pages branch, remove the DNS record), but making something public and then private again does not unpublish copies, forks or caches. The checks before the flip matter more than the rollback.
Going public, in order
The order matters. Nothing before step 5 changes what the public sees.
- Verify the domain for the organisation on GitHub (organisation settings, Pages, verified domains: add
slashbuilder.com). GitHub shows a DNS TXT record to add at the DNS provider. This stops anyone else from claiming a name under the domain. - Decide on indexing. Leave
SITE_INDEXABLE=0to publish a site that search engines are asked to ignore, or publish withSITE_INDEXABLE=1when the site is ready to be found. - Make the repository public, after
make go-public-checkshows noFAIL(the visibility row stays a warning until you do) and every Kit the book documents is public. This is a deliberate decision by the owner, not a build step. - Publish:
make publish-site PUBLISH=1, from a cleanmain. Then in the repository settings, Pages: source Deploy from a branch, branchgh-pages, folder/ (root). The custom domain field then shows the value read from theCNAMEfile; do not clear it. - Add the DNS record last: a
CNAMErecord forkitspointing at the organisation’sgithub.ioname, set to DNS only (no proxy) so GitHub can issue the certificate. A name that points at GitHub before anything claims it is how subdomains get taken over, which is why the record comes after steps 1 and 4. - When the certificate is ready (Pages shows it), tick Enforce HTTPS.
The Kit rename and the pin
The Application Kit was renamed App Context Kit before going public. Its repository, slash-builder/app-context-kit, was created from the reviewed release candidate: the candidate is one commit with no parents, pushed as it is, so the commit id the repository starts from is the commit id this book already pinned. kits.lock names that repository and that commit. Until the repository is public the line is marked private.
At release, before step 3 above:
- Make
slash-builder/app-context-kitpublic, then switch on what a private repository on the free plan cannot have: private vulnerability reporting, branch protection onmain, and required reviewers on thereleaseenvironment. - In
kits.lock, mark theapp-context-kitlinepublic. If the release candidate had to change, re-pin (make lock KIT=app-context-kit) to the commit the repository starts from. - Run
make clean-all && make setup,make docs-pull,make docsandmake check-public.make docs-catalogthen records the new repository’s visibility. - Run
make go-public-check. Rowsb1tob4andc1,c2check this switch: they refuse akits.lockURL that does not end in the Kit’s name, a leftover rename comment, an old spelling, and a Kit whose repository or pinned commit does not answer HTTP 200 without a login.tools/go-public-allow.txtmay never allowkits.lock.
What is manual
Everything on the list above except building and committing: domain verification, making the repository public, switching Pages on and choosing the branch, the DNS record, and the HTTPS setting are all done by a person in the GitHub and DNS settings. The tools in this repository never change a GitHub setting or a DNS record, and neither do make go-public-check and make stranger-test (the second only clones).
To take the site down, remove the DNS record first, then switch Pages off.
Glossary
The words these pages use, in alphabetical order.
Admit. The hook method that decides whether a peer may join an app’s coordination: accept, accept read-only, or reject.
App id. An app’s reverse-DNS name, such as com.slashbuilder.demo.notepad, set in its manifest.
App Context Kit. The Kit that is the root namespace for one running app: identity, directories, settings, modes, a roster entry, a session with a resource broker, and an opt-in way to find other copies of the app.
Candidate. A UI component proposed for the shared interface Kit, built from what several apps duplicate. See labs/interface_candidates. A candidate marked THIN has fewer than three real users.
Context. What an app receives from the App Context Kit when it starts: its directories, settings, notes for the person, session and peers. In Flutter, AppContextScope.of(context).
Core (role). In kits.lock, a Kit this book documents and whose own tests run in make test. The other roles are app-dep (needed by another Kit to build; fetched, not tested here) and reference (documented but not needed to build the apps).
Fetch. Downloading a Kit at its pinned commit into .kits/ with make setup or make fetch. No git is needed.
Hold. A reservation on a shared machine’s resources, taken from the Venue for the length of a job and released afterwards. A hold lasts for a lease; a crashed app keeps its hold until the lease runs out.
Hook. The five small methods an app supplies to say what coordination means for that app: channel, hello, admit, onPeer and onMessage. The App Context Kit finds and connects copies and routes messages; it never reads a payload.
Kit. A focused piece of SlashBuilder’s platform that owns one job. See the introduction.
Kit gap. Code an app wrote itself because a Kit lacked it. By rule it lives in the app’s lib/kit_gaps/ with a // KIT GAP: comment, and the line count of those folders measures what the Kits are missing.
Manifest. An app’s app.json: id, name, version, the Kit features it uses and its Venue settings.
Mock mode. A mode, switched on with APP_CONTEXT_MODE=mock, in which the outside world is replaced by in-memory fakes. Together with scratch mode (APP_CONTEXT_SCRATCH=<dir>, which puts every directory under one folder) it lets tests run without touching real files.
Peer. Another copy of the same app, on the same account, that the App Context Kit found and connected to.
Pin. The commit of a Kit that this repository uses, recorded in kits.lock. make lock shows how to move it.
Probe. A small program that exercises one thing and prints what happened. The apps’ tool/ folders hold probes that run an app’s own coordination code without a window. Probes of other Kits run in a private repository; the book carries only a summary.
Refusal. A typed answer from the Venue saying it will not grant a hold, for example because the app already holds its limit.
Roster. The set of presence files, one per running copy of an app, that lets apps (and the launcher demo) see who else is running on the machine.
Venue. The resource broker: a daemon that grants and tracks holds on a machine’s resources.