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

Roles, Not Devices

~14 min · roles, fallback, refusal, naming, verification

Level 0Closed Lens
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Callers Ask for a Role

Nobody asking Oriel for a picture should need to know a unique ID. Pippa asks for "the game", Dad's page shows "his camera", a session records "both decks". Those are roles, stored in settings and remembered against unique IDs, so that swapping hardware is a settings change and never a code change.

The interesting part is how the roles are named. The first version named them by what they showed: game, camera, face. That broke as soon as the studio's wiring was understood properly. Each capture card sits behind one deck of an external recorder, and which picture a deck carries depends on the deck's own input selection: the same card can carry the console one hour and a desk camera the next. A role named after its content would lie whenever the deck switched. So on 2026-11-02 the roles were renamed after the thing that does not change, the deck a card is wired to:

  • right_deck and left_deck: the two decks. A deck has exactly one holder.
  • look: the look-around pool, any number of movable webcams Pippa can choose between.
  • none: a device that records nothing.

The old names still work everywhere, as aliases, and a database written before the rename is migrated when it opens. And because a deck name alone does not tell a model what it is looking at, every answer that names a deck carries a short line saying what that deck can carry (the inputs its recorder deck may be switched to), so a brain reads the possibilities instead of guessing from a label. The line is fixed text; nothing detects which input a deck has selected right now, so the picture itself is still the evidence.

One Holder, Two Clicks

Because a deck has one holder, giving a deck to an input releases whoever held it. Swapping two identical cards between the decks is therefore two calls, not one clever "swap" operation, and both calls go through the same rule. The engine also refuses to change the role of an input that an open session is recording: a recording that silently changed meaning halfway would be worse than a refusal.

The Pool: Choose by Name, Never by Index

The look-around pool is different by design. Dad's words when it was set up were that Pippa's eyes should switch devices when she wants to. So any number of cameras hold look, and Pippa names the one she means: by a part of its name, by its unique ID, or by input id. look alone means "the one camera that is here", and it works only when exactly one is. With none present it refuses and lists the pool and why each is absent; with several present it refuses and names them all, so the choice is the caller's. Nothing in the resolver ever picks by index.

A Missing Device Refuses; a Fallback Is Named

The most important rule here is about absence. When a role a session would record is held by a device that is not connected (or the recorder is down), start_session refuses and names the role, the device and its unique ID. It does not open something else in its place. With two capture cards that share a name and a model, "something else" would often be the other deck, quietly recording the wrong picture under the right label.

A stand-in is allowed, but only when it is named. The setting role_fallbacks maps a deck role to the unique ID of the one device that may stand in when the holder is absent. Engine.resolve is the single door every caller goes through: holder present, the holder; holder absent and fallback present, the fallback, with a note saying so (a look carries that note inside its budget); both absent, a refusal naming both. A fallback is a decision someone wrote down, never a guess the code makes.

Verified by Looking

How do you know the role table is right? Oriel's answer is blunt: by looking. A command-line tool opens each role's holder by its unique ID and keeps one frame, so a person can see that "the deck with the console" really shows the console. The architecture document records the role table with the note that it was verified by peeking each holder, and the house sign states the principle: a role is verified by looking, never by guessing.

Code

A deck resolves to its holder, a named fallback, or a refusal (trimmed excerpt, backend/engine.py)·python
def _deck(self, role: str):
    holder = self.by_role(role)
    if holder is None:
        raise EngineError(f"no enabled input plays {role}; assign one in settings", 404)
    absent = self.absent(holder)
    if absent is None:
        return holder, None
    stand_in = self.fallback(role)
    if stand_in is not None and stand_in.id != holder.id:
        stand_absent = self.absent(stand_in)
        if stand_absent is None:
            # Short on purpose: a look carries it inside its token budget.
            return stand_in, (f"{role} holder {holder.row.get('device_uid')} absent; fallback "
                              f"{stand_in.row['name']} ({stand_in.row.get('device_uid')}) stands in")
        raise EngineError(f"{role}: {absent}, and its fallback {stand_absent}", 409)
    raise EngineError(f"{role}: {absent}", 409)
The look-around pool: exactly one, or the caller chooses (trimmed excerpt, backend/engine.py Engine.resolve)·python
pool = self.pool_sources()
if role == "look":
    picked = [s for s in pool if self.absent(s) is None]
    if len(picked) == 1:
        return picked[0], None
    if not picked:
        known = "; ".join(self.absent(s) or s.row["name"] for s in pool) or "none assigned"
        raise EngineError(f"no look-around camera is here now (pool: {known})", 409)
    raise EngineError("several look-around cameras are here; name one: " + ", ".join(
        f"{label_for(s.row)} ({s.row.get('device_uid')})" for s in picked), 409)
Roles with aliases, one holder per deck, and refusal instead of substitution (runnable sketch)·python
ALIASES = {"game": "right_deck", "camera": "left_deck", "face": "look"}
DECKS = {"right_deck", "left_deck"}

inputs = {
    "card-a": {"role": "right_deck", "present": True},
    "card-b": {"role": "left_deck", "present": False},   # unplugged
    "webcam": {"role": "look", "present": True},
}
fallbacks = {}   # e.g. {"left_deck": "webcam"} only if someone decided so


def resolve(name):
    role = ALIASES.get(name, name)
    holders = [i for i, row in inputs.items() if row["role"] == role]
    if role in DECKS:
        holder = holders[0]
        if inputs[holder]["present"]:
            return holder, None
        stand_in = fallbacks.get(role)
        if stand_in and inputs[stand_in]["present"]:
            return stand_in, f"{role} holder {holder} absent; {stand_in} stands in"
        raise LookupError(f"{role}: {holder} is not connected")
    raise LookupError(f"no role {name}")


print(resolve("game"))
try:
    resolve("camera")
except LookupError as exc:
    print("refused:", exc)
fallbacks["left_deck"] = "webcam"
print(resolve("camera"))

External links

Exercise

Run the sketch, then add a second pool camera with role look and extend resolve so that look returns the one present pool camera, refuses with the list when none is present, and refuses naming both when two are present. Write three small asserts, one per case.
Hint
The tempting shortcut is return pool[0]. That is picking by index, and it makes the answer depend on dictionary order. The refusal that names the candidates is the feature, not an error path.

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.