Skip to content
C.W.K.
Lesson 06 of 06 · published

Where the Engine Is: the Registry, Not a Hostname

~13 min · configuration, registry, tailscale, ios

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The web never needs an address

In production the engine serves the web client from its own port, so every call in api.ts is a relative path such as /api/encounters. The page and the API share one origin, and there is nothing to configure. The engine itself listens on loopback only, and the Tailscale Serve HTTPS twin on port 7601 publishes it to the tailnet.

In development, Vite serves the page on port 5196 and proxies /api to the engine on this Mac's loopback. The port is not written in the frontend. When Vite starts, its config runs the vendored registry script with port cwkSizzle and uses whatever number the port ledger gives. Change the ledger, restart Vite, and the proxy follows.

The engine asks the registry too

The engine's settings.py derives its data root, its own port, and the loopback origins of cwkPippa and Firelink from family_registry. That module loads the same vendored kit_family_endpoints.py the installer uses from scripts/, so the engine keeps no second copy. The registry reads this Mac's host profile, so moving the family's stack onto another machine (Pippa's Ark) moves the data root and the placements with it. The ports stay the ledger's, because a host profile has no key for them. Each value can still be overridden by an environment variable. A second instance stays off production data only if it overrides the data directory, SIZZLE_DATA_DIR, and not just the port, and the test suite skips the variables altogether by handing the engine a temporary directory.

Sizzle's row in the registry is small: an engine port of 7600, an HTTPS twin of 7601, and a host entry the ledgers resolve. Which physical Mac that is stays the ledger's fact, and Sizzle's code never spells it. When the registry moved Sizzle to another Mac on 2026-10-22, the app's address code did not change. The commit that followed changed the tests, so they compare the default against the registry instead of against a name.

The phone takes the twin

The phone cannot use a relative path, so it needs an address. MobileSettings takes its default from FamilyEndpoints.twin("cwkSizzle"): HTTPS, on the MagicDNS name of whichever Mac the registry places Sizzle on, on port 7601. CwkKit renders that table from Firelink's port ledger and the network ledger, and the family's roles, kept on the phone, move the members that follow the Pippa machine. The kit file that holds the table opens with the reason it exists, in Dad's words:

이거 flint 만의 문제 아닐 거야. 제발 하드코딩 하지 말자니까.

This won't be Flint's problem alone. Please, I keep saying it: let's not hardcode.

The HTTPS twin is the one address that works from a fleet Mac, from a phone on cellular and from a laptop abroad, and App Transport Security accepts it with no exception. A hosted test asserts the phone's default is exactly the registry's twin: same host, the registry's HTTPS port, scheme https.

A saved address is upgraded, never guessed

Settings lets Dad type another address, and Engine default puts the registry's twin back. Reading the saved value runs it through canonicalLocation. An address the kit recognises as a family engine, spelled the old way with a raw tailnet IP, plain http or the engine's own port, becomes Sizzle's HTTPS twin and is saved that way once. Anything else, such as loopback or a host Dad typed for another machine, stays exactly as he typed it.

Setting a new address checks everything before it moves anything. The text is canonicalised, then parsed, and an unparseable one is refused as not an engine address. Plain http to a tailnet host is refused with a message asking for the https address. Only then does the app retire the old offline shelf, save the address, open a new client and a new shelf, and clear the image cache (lesson 5).

Following the Mac the engine lives on

One more step runs off the launch path, in a detached task. If the engine's host is loopback or a host the ledgers know, the phone clears any profile it kept. Otherwise it reads cwkPippa's open GET /api/host-profile on that same host over HTTPS and keeps the answer. A phone pointed at Pippa's Ark then dials the whole family there, and pointing it back returns it to the ledgers. It is best effort: an unreachable host or a document that does not parse leaves the kept profile as it was. On a Mac this step does nothing, because there the host-profile file rules.

When nothing answers

An unreachable engine is the commonest failure a phone sees, so it gets its own words. A request that reached nothing at all tells Dad to check Tailscale, and says so once: on the status line under every tab when a saved copy is showing, or on the plates' own error line when none is. An engine that answered with an error keeps its own reason, and a hosted test makes sure that reason is never replaced by the Tailscale hint.

Principle: Code names the member; the registry names the machine. Then a move between machines is a change to one record, and every client that reads it follows.

firelink-quest's One Owner Per Fact track is the general form of this rule, and swift-quest's Talking to the Engine at Home covers ATS and the HTTPS twin from the Apple side.

Code

cwkSizzle, frontend/vite.config.ts: the dev proxy asks the registry for the engine's port (verbatim, lines 7-21)·typescript
// The engine's port comes from the family registry's port ledger (the
// vendored kit_family_endpoints.py, cwkPippa todo #549), so no family
// port is spelled here; the dev proxy dials it on this Mac's loopback.
const enginePort = execFileSync(
  "/usr/bin/python3",
  [fileURLToPath(new URL("../scripts/kit_family_endpoints.py", import.meta.url)), "port", "cwkSizzle"],
  { encoding: "utf8" },
).trim();
const engine = `http://127.0.0.1:${enginePort}`;

// Tailwind is here for the kit components drawn with utility classes (the
// brain status chip); Sizzle's own screens use plain CSS in style.css.
export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: { port: 5196, proxy: { "/api": engine } },
cwkSizzle, backend/settings.py: data root, port and cwkPippa's origin from the registry, each overridable (verbatim, lines 14-29)·python
from .family_registry import engine_data_root, engine_port, loopback_origin

ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = Path(
    os.environ.get("SIZZLE_DATA_DIR", str(engine_data_root("sizzle")))
).expanduser()
FRONTEND_DIST = ROOT / "frontend" / "dist"

HOST = os.environ.get("SIZZLE_HOST", "127.0.0.1")
PORT = int(os.environ.get("SIZZLE_PORT", str(engine_port("cwkSizzle"))))

# The engine runs on service host beside cwkPippa and reaches it over loopback,
# like Forge, Vesta and Waystone. Loopback is outside macOS 26's Local
# Network gate and is the boundary cwkPippa trusts without a PIN; a
# tailnet origin here answers 401 pin_required (Easel, 2026-09-25).
PIPPA_BASE = os.environ.get("SIZZLE_PIPPA_BASE", loopback_origin("cwkPippa")).rstrip("/")
cwkSizzle, clients/apple/SizzleMobile/Sources/AppModel.swift: the phone's default is the registry's twin, and an old spelling is upgraded once (verbatim, lines 9-33)·swift
struct MobileSettings: @unchecked Sendable {  // UserDefaults is thread-safe; Foundation just does not say so
    /// Sizzle's name in the port ledger.
    static let member = "cwkSizzle"
    static var defaultEndpoint: EngineEndpoint { FamilyEndpoints.twin(member)! }
    static var defaultLocation: String { FamilyEndpoints.twinURL(member)!.absoluteString }
    private static let key = "sizzle.engineLocation"

    let defaults: UserDefaults

    /// The saved location, with service host named by an older spelling (its raw
    /// tailnet IP, plain http, the engine's own port) read as Sizzle's HTTPS
    /// twin and saved that way once (todo #442). Any other host stays.
    var engineLocation: String {
        get {
            guard let saved = defaults.string(forKey: Self.key)?.nonEmpty else { return Self.defaultLocation }
            let canonical = Self.canonicalLocation(saved)
            if canonical != saved { defaults.set(canonical, forKey: Self.key) }
            return canonical
        }
        nonmutating set { defaults.set(newValue, forKey: Self.key) }
    }

    var endpoint: EngineEndpoint {
        EngineEndpoint(location: engineLocation) ?? Self.defaultEndpoint
    }

External links

Exercise

List every place in one project of yours where a hostname, an IP address or a port is written: client code, a dev-server config, tests, scripts. Create one small registry file that maps each service name to its host role, port and HTTPS port, plus a tiny command that prints a service's port. Make the dev proxy and the server's settings read from it. In the mobile or desktop client, derive the default address from the registry, upgrade recognised old spellings once, and refuse plain http to a private-network name.
Hint
Serve the web client from the same origin as its API, and its address problem disappears. For the native client, keep any address the user typed for another machine untouched; only rewrite spellings you can prove belong to your own service. A test that compares the default with the registry's output will fail the day a literal sneaks back in.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.