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

A Role, Not a Machine

~15 min · ownership, configuration, migration, boundaries

Level 0Loose Parts
0 XP0/52 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

The Address Moved Again

The previous lesson handed ports and hosts back to the family's registry: no template variable may carry an address, and every script asks the registry at run time. That made the registry the one owner of where things are. In late October the owner of the whole family asked a harder question of it: what happens when the family moves to a different Mac?

His ruling set the bar in one sentence. Moving the family from one machine to another should be one ledger edit plus a data sync — and it should work the same way for every later move, not only this one. A registry that names machines cannot do that. Every claim in it said, in effect, "this service runs on that Mac", so a move meant rewriting every claim, and every consumer that had learned a machine's name would have to be found.

Claims Name a Job

So the port ledger learned to name roles. A claim can say @pippa — the machine that runs every service, holds the live data and owns the single-instance writers. It can say @workbench — where code is written and where the fleet's writer runs. It can say @each — a per-machine instance that every Mac runs as itself. Or it can still name one machine, when something is pinned there on purpose, like a tool bound to one Mac's hardware. A separate roles table says which machine holds each role today. A move is a change to that table.

Three details carry the design:

  • Roles are resolved before the collision checks. The generator that renders the registry still refuses a member claimed on two hosts, so a role cannot be used to smuggle a duplicate past the rule.
  • Roles are read live. The kit's readers consult this machine's copy of the ledger at the moment of asking. A flip of the role, or its revert during a rollback, reaches a running service without a restart and without a kit deploy.
  • A saved choice is not a default. A host somebody chose and saved for a specific purpose keeps meaning that machine. Only defaults follow the role; changing the role must never retarget a choice a person made.

Who Is Allowed to Speak

Moving addresses is the easy half. The hard half is everything that writes: scheduled jobs, a chat bot, a publisher, the single writer of the coordination database. During a move there are two machines that could each believe they are home. If both answer, a message gets two replies and a post goes out twice.

So every such writer asks the kit one question before it acts — is the single writer here? — and the answer is yes on the machine that holds the role. The one other yes is a standby replica the family can be hosted from in an emergency, and there every such writer stays held until the owner switches it on. A Mac being prepared for the family stays silent; so does the old Mac after the move. A writer that reaches outside its machine asks one more thing: has this machine heard the ledger recently? A Mac that follows origin by itself records each follow that landed. If it has not confirmed one for thirty minutes, its outward writers hold, because the family may have moved and it would not know. A rollback written at origin reaches such a machine within minutes, even when nobody can log into it.

Name the job, not the machine doing it, and make every writer ask whether this is where the job lives right now. An address that names a role survives a move; an address that names a machine is a migration waiting to be found. And a machine that cannot hear where the job went must stop speaking for it.

A Guard That Reads Use, Not Words

Roles only help if nobody pins a machine by hand again. So the kit gained a guard that checks every newly staged line in every repository, through the family's shared commit hook, and fails a runtime default that names a physical machine. A default that should follow a role must ask the role; that class of finding cannot be exempted.

Its first version taught the guard's real rule. Most of the family's machine names are ordinary English words, and a checker that matched the word refused to publish a diary sentence that merely mentioned one. The owner called the checker itself the defect. The guard now matches a name only where it is used as a machine: as the target of a remote shell or copy command, as a host in a URL, as a key in a mapping of machines, as a value assigned to something that holds a host, as a fallback default. In prose, a comment or an identifier it is a word, and words pass.

What the Ledger Could Not Move

The first move ran over three days in late October, and every service has run on the new machine since its second day. It did not turn out to be one edit. The code-side facts moved with the role; what the code could not see did not — privacy permissions granted to one machine, keychain items, network mounts, addresses already installed on phones. Each of those became a step in a written runbook, with a check before it and a check after, so the next move starts from the inventory instead of discovering it. The role turned a migration into an edit for everything that asks the ledger. The runbook exists for everything that never did.

Code

Claims resolve through roles read live, and a writer speaks only where the role is — and only while it can hear·python
from __future__ import annotations

import json
import time
from pathlib import Path

LEDGER = Path("config/port-ledger.json")    # this machine's copy, follows origin
LEASE = Path(".cache/ledger-confirm.json")  # written each time a follow lands
LEDGER_HOLD = 30 * 60                       # seconds without a confirmed follow


def roles() -> dict[str, str]:
    """Read at the moment of asking, never cached at start-up: a flip of
    the ledger, or its revert, reaches a running service with no restart."""
    return json.loads(LEDGER.read_text())["roles"]


def resolve(claim: str, here: str) -> str:
    """A claim names a role ('@pippa'), a per-machine instance ('@each'),
    or - pinned on purpose - one machine."""
    if claim == "@each":
        return here
    if claim.startswith("@"):
        return roles()[claim[1:]]               # unknown role: KeyError, loudly
    return claim


def single_writer_here(here: str) -> bool:
    """Crons, bots, publishers: only the machine holding the role writes.
    A machine being prepared, or the old one after a move, stays quiet.
    (Simplified: the family's version also answers yes on a standby
    replica, whose writers stay held until the owner switches them on.)"""
    return roles()["pippa"] == here


def outward_writer_here(here: str, now: float | None = None) -> tuple[bool, str]:
    """A writer that reaches outside its machine also needs to have HEARD
    the ledger recently: a machine that cannot hear a move holds."""
    if not single_writer_here(here):
        return False, "not the machine that holds the writer role"
    if not LEASE.exists():
        return True, ""                         # where origin is written: no lease
    confirmed = json.loads(LEASE.read_text())["confirmed_at"]
    silent = (time.time() if now is None else now) - confirmed
    if silent > LEDGER_HOLD:
        return False, (f"ledger unconfirmed for {silent // 60:.0f} min: "
                       "the family may have moved")
    return True, ""

External links

Exercise

List every place in one system you run that names a specific machine — a host in a config, an address in a script, a default in code. For each, ask whether it names the machine or the job the machine does. Rewrite one as a role, and then find every job that writes outward — sends, posts, publishes — and write down the one question it would have to ask to stay silent during a move.
Hint
Search for the machines' names first, then for their addresses, then for defaults that fall back to either. The writers are the hard part: a scheduled job that posts somewhere is usually the first thing to run twice.

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.