Fastest first needs a push
One of Spark's invariants is fastest first: show each layer the moment it exists. Pippa's reading of a menu streams in line by line, and the engine records each line as a result.add as soon as it parses. A phone that polled every few seconds would still see those lines arrive in clumps. So the engine pushes. Every op that names a moment goes straight to whoever is watching that moment.
Publishing from the one door
You have already seen where this starts: the last step of Corpus.record. Any op that carries a moment_id, or is itself a moment.* op, is published under that moment as {"type": op name, "record": body}. That rule leaves a gap: mark.remove and link.remove carry only their own id, so un-keeping a phrase or unlinking a crumb reaches no watcher until the client reads the moment again. Turn ops are special. They go out as the mirror holds them, status included, because some turns are started by the engine on its own (a cold review that follows a finished version) and a client learns about those only through this event.
A few events are deliberately not corpus. Corpus.publish sends a transient event to the watchers and records nothing. The practical turn uses it for turn.prose, Pippa's words to Dad as they grow around the structured results, and the show-card interpreter uses it for its exchange.* progress events. A client that misses one misses progress, and the practical turn still records its finished prose when it ends.
One process, many loops
The fan-out is a small in-process class, Broadcaster, in the first code block. It maps a moment id to a set of subscribers, and each subscriber is a pair: the event loop that created it and an asyncio.Queue.
The pair is the interesting part. record runs on a worker thread, because corpus writes are pushed off the event loop through asyncio.to_thread. An asyncio queue is not thread-safe, so the worker thread must not put into it directly. publish copies the subscriber list under a plain threading lock, then hands each delivery to the subscriber's own loop with call_soon_threadsafe. If that loop has already closed, the call raises RuntimeError and the subscriber is dropped quietly.
No broker, no Redis, no message bus. That is enough because one engine process is the only writer; every event that can exist is born inside it.
The stream itself
The second code block is the whole route, GET /api/moments/{id}/events, served as Server-Sent Events:
- An unknown moment answers 404 before any stream opens.
- It subscribes first, then reads a snapshot of the moment and sends it as the first event. In the other order, an op recorded between the read and the subscribe would be in neither. In this order it may appear in both, and that is harmless because clients key results by id.
- Then it forwards events as they arrive. After 15 seconds of silence it sends
: keepalive, an SSE comment that clients ignore and that keeps the connection from looking idle. - When the client goes away the generator is closed, and
finallyunsubscribes.
Two response headers matter: Cache-Control: no-cache, and X-Accel-Buffering: no so a proxy in between does not hold events back in a buffer. A test subscribes to a moment, records a result from a worker thread, publishes a turn.prose, and asserts both arrive in order, then checks that nobody is still watching once it unsubscribes.
The browser's EventSource reconnects by itself; the phone opens the same route from Swift. Since the phone reaches the engine only over the tailnet, a lost moment stream on the phone tells Dad to check Tailscale, and the web adds the same hint to a failed fetch.
The engine's shape
| Concern | How Spark does it |
|---|---|
| Process | One launchd LaunchAgent, com.cwk.spark-serve, running python -m backend.serve with RunAtLoad, KeepAlive and a 10-second throttle; output goes to serve.log under the data root |
| Listening | uvicorn on 127.0.0.1, on the port the family registry gives Spark (7900) |
| Reach | Tailscale Serve publishes an HTTPS twin (7901) onto that loopback port, plus an HTTP twin where the registry declares one |
| Siblings | cwkPippa, the family control facade and cwkBellows, each dialled on loopback at the origin the registry gives |
| Web client | The built frontend mounted last, so every /api route wins over the page fallback |
| Schedules | None. The data root is covered by the family's daily backup pass |
The settings module explains the loopback rule in its docstring: nothing there may name a LAN host, because a launchd agent cannot pass macOS Local Network gating. The engine never listens on a wildcard address; the tailnet reaches it only through Serve. Every port, origin and data root comes from the family registry, and environment variables remain as explicit overrides. Even the tracked plist is a template, with tokens such as @@HOME@@ and @@DATA_ROOT@@ that the install script renders for the Mac it runs on.
An install that checks where it is
The install script is generated from the family kit's template, one shape for every engine (Sibling Kit Quest teaches why vendored copies beat a shared package). The third code block is its first act: ask the registry whether Spark belongs on this Mac, and refuse to continue if not. Then it builds the conda environment and the frontend, renders the plist, boots the old job out and waits past its exit timeout, bootstraps the new one with retries, and polls /api/health fifteen times, two seconds apart. Last, it publishes the tailnet twins through a second script that asks the registry the same question before it touches Tailscale.
One line in it records a lesson the family paid for: there is no launchctl kickstart -k after the bootstrap. A fresh load of a RunAtLoad job has already started it, and the kickstart that used to follow killed that first boot, possibly in the middle of a database migration.