GLaDOS Vol 6 · Ready for the House
ch 62 / 99
Chapter 62

The Record of the Build

Sixty-one chapters, and no page you can hand to anyone

She listens, transcribes, thinks, answers in her own voice, remembers last Tuesday, refuses a command she has no permission for, drives a microcontroller through a driver that turns a hardware fault into a returned dict, starts on boot, and prints her own manual. All of that is true right now, on your machine. None of it is written down in one place. The evidence is scattered across a config file, a wiring manifest, a command feed, a database and a systemd unit, and the only thing that ever assembled those into a picture was your attention while you worked.

The obvious ending is a victory lap: a script that prints a banner, a count, and a line she says out of Portal, then the terminal scrolls and it is gone. Six months from now the questions are specific. Which components were switched on when you called it done? Had you actually installed the service, or were you still launching it by hand? How many wires were in the rig? Did the memory database exist on the bench box, or only on the laptop? Console output cannot answer any of that, because console output is not a file.

So the last software chapter builds a small, boring artifact with one rule behind it: the record is assembled from the files the build already produced, never typed from memory. A typed list is a claim. A list computed from the component registry, the manual and the manifest is a measurement, and it goes stale the same instant the system does, which is to say never.

◆ Note — the record indexes files, it does not swallow them

Every artifact here is recorded by path, presence and size. Not contents. That keeps the record small enough to read in one screen, keeps it honest about where the real data lives, and means you can publish it without publishing your conversation history, your network layout or anything else your machine happens to know about you. If a reader wants the wiring, the record tells them which file holds it.

A record that fills in its own facts

▣ Build · stage 1 — the fields, and a timestamp nobody types
# labs/build_record.py
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class BuildRecord:
    project: str
    builder: str
    completed_at: str = ""
    volumes: list[str] = field(default_factory=list)
    capabilities: list[str] = field(default_factory=list)
    artifacts: dict[str, int | None] = field(default_factory=dict)
    open_questions: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not self.completed_at:
            self.completed_at = datetime.now().isoformat(timespec="seconds")


if __name__ == "__main__":
    record = BuildRecord(project="Building GLaDOS", builder="kryo")
    print(f"{record.builder} recorded {record.project} at {record.completed_at}")
    print(f"volumes: {len(record.volumes)}  capabilities: {len(record.capabilities)}")
$ uv run python -m labs.build_record
kryo recorded Building GLaDOS at 2026-08-22T09:14:03
volumes: 0  capabilities: 0

Your timestamp will read differently, obviously; that is the point of computing it. __post_init__ runs immediately after the constructor the decorator generated, and it is the one hook where a default can depend on something the caller did not pass. The test if not self.completed_at matters as much as the assignment: a record loaded back from disk arrives with its original timestamp already filled, and this guard leaves it alone instead of stamping the moment you happened to read the file. timespec="seconds" trims the microseconds, which no human has ever needed and which make two records look different when they are not.

▣ Build · stage 2 — capabilities read out of the registry
import json
from pathlib import Path

from labs.system_config import SystemConfig, load_config

PROJECT_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PATH = PROJECT_ROOT / "configs" / "system_config.json"
MANUAL_PATH = PROJECT_ROOT / "docs" / "voice_commands.json"
WIRING_PATH = PROJECT_ROOT / "glados" / "data" / "wiring_manifest.json"
SERVICE_UNIT = Path("/etc/systemd/system/glados.service")

CAPABILITY_TEXT = {
    "stt": "listens: 16 kHz capture, transcribed locally",
    "llm": "thinks: llama3.2:3b, on hardware you own",
    "tts": "speaks: her voice, synthesized on the same machine",
    "memory": "remembers: conversations kept in SQLite across restarts",
}


def capabilities(cfg: SystemConfig) -> list[str]:
    lines = [
        CAPABILITY_TEXT[c.name]
        for c in cfg.components
        if c.enabled and c.name in CAPABILITY_TEXT
    ]
    if MANUAL_PATH.exists():
        manual = json.loads(MANUAL_PATH.read_text())
        lines.append(f"answers {manual['count']} documented voice commands")
    if WIRING_PATH.exists():
        wiring = json.loads(WIRING_PATH.read_text())
        lines.append(f"drives {len(wiring['wires'])} checked connections to the ESP32")
    if SERVICE_UNIT.exists():
        lines.append("starts on boot and comes back after a power cut")
    return lines


if __name__ == "__main__":
    for line in capabilities(load_config(CONFIG_PATH)):
        print(" ", line)
$ uv run python -m labs.build_record
  listens: 16 kHz capture, transcribed locally
  speaks: her voice, synthesized on the same machine
  remembers: conversations kept in SQLite across restarts
  thinks: llama3.2:3b, on hardware you own
  answers 8 documented voice commands
  drives 10 checked connections to the ESP32
  starts on boot and comes back after a power cut

Seven lines and not one of them was typed as a fact. Four come from the enabled components in the registry, so disabling transcription and re-running deletes the claim that she listens. Two come from documents other chapters generate, so the eight and the ten are whatever those files say today. The last one asks the filesystem whether the unit is really installed, which is the difference between "I deployed it" and "I wrote a deployment script." A record built this way cannot flatter you, and every number in it has a file you can open to check.

▣ Build · stage 3 — presence and size, absence included
ARTIFACTS = [
    CONFIG_PATH,
    PROJECT_ROOT / "configs" / "personality.json",
    PROJECT_ROOT / "glados" / "data" / "memory.db",
    WIRING_PATH,
    MANUAL_PATH,
    PROJECT_ROOT / "glados" / "data" / "safety_audit.jsonl",
    PROJECT_ROOT / "glados" / "data" / "scored_ideas.json",
]


def artifact_index(paths: list[Path]) -> dict[str, int | None]:
    index: dict[str, int | None] = {}
    for path in paths:
        key = str(path.relative_to(PROJECT_ROOT))
        index[key] = path.stat().st_size if path.exists() else None
    return index


if __name__ == "__main__":
    index = artifact_index(ARTIFACTS)
    for name, size in index.items():
        state = "missing" if size is None else f"{size:,} bytes"
        print(f"  {name:<40} {state}")
    found = sum(1 for size in index.values() if size is not None)
    print(f"{found} of {len(index)} artifacts present")
$ uv run python -m labs.build_record
  configs/system_config.json               612 bytes
  configs/personality.json                 1,284 bytes
  glados/data/memory.db                    40,960 bytes
  glados/data/wiring_manifest.json         3,118 bytes
  docs/voice_commands.json                 1,673 bytes
  glados/data/safety_audit.jsonl           874 bytes
  glados/data/scored_ideas.json            missing
6 of 7 artifacts present

Sizes on your machine will differ; the database in particular grows with every conversation. The interesting entry is the last one. Scoring ideas was something you did on the laptop, and the bench box has never run that script, so the file is not here. None records that as a fact instead of a crash: the index says the file was looked for and was not found, which is a different claim from never having mentioned it. JSON writes None as null, so the distinction survives to disk. This is the honest half of the artifact: a record that only lists what happens to exist quietly hides the gaps.

Out to disk, and back into an object

▣ Build · stage 4 — write it, then read it back and compare
from dataclasses import asdict

RECORD_PATH = PROJECT_ROOT / "docs" / "build_record.json"


def save_record(record: BuildRecord, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = asdict(record)
    path.write_text(json.dumps(payload, indent=2) + "\n")


def load_record(path: Path) -> BuildRecord:
    return BuildRecord(**json.loads(path.read_text()))


if __name__ == "__main__":
    record = BuildRecord(project="Building GLaDOS", builder="kryo")
    save_record(record, RECORD_PATH)
    print("round-trip equal:", load_record(RECORD_PATH) == record)
$ uv run python -m labs.build_record
round-trip equal: True

Two functions, one boundary. asdict walks the record and returns plain dicts, lists, strings and numbers, which is the only vocabulary json speaks; BuildRecord(**data) maps the keys of the loaded dict onto the constructor's parameters and gives you attributes again. The comparison is the part that proves something, and it works because the decorator generated an __eq__ that compares field by field. True means the file holds everything the object held. A round-trip check costs one line and catches the day someone adds a field that JSON cannot carry.

▣ Build · stage 5 — the assembled module
# labs/build_record.py — full file
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path

from labs.system_config import SystemConfig, load_config

PROJECT_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PATH = PROJECT_ROOT / "configs" / "system_config.json"
MANUAL_PATH = PROJECT_ROOT / "docs" / "voice_commands.json"
WIRING_PATH = PROJECT_ROOT / "glados" / "data" / "wiring_manifest.json"
SERVICE_UNIT = Path("/etc/systemd/system/glados.service")
RECORD_PATH = PROJECT_ROOT / "docs" / "build_record.json"

VOLUMES = [
    "1. Her Voice: she speaks and she hears",
    "2. Her Mind: memory, a local model, a personality",
    "3. Her Craft: the loop she runs all day",
    "4. One System: a registry, a wired core, a wire to the board",
    "5. Alive on the Bench: watchdog, behaviors, mood, vitals",
    "6. Ready for the House: safety, drivers, deployment, sign-off",
]

CAPABILITY_TEXT = {
    "stt": "listens: 16 kHz capture, transcribed locally",
    "llm": "thinks: llama3.2:3b, on hardware you own",
    "tts": "speaks: her voice, synthesized on the same machine",
    "memory": "remembers: conversations kept in SQLite across restarts",
}

ARTIFACTS = [
    CONFIG_PATH,
    PROJECT_ROOT / "configs" / "personality.json",
    PROJECT_ROOT / "glados" / "data" / "memory.db",
    WIRING_PATH,
    MANUAL_PATH,
    PROJECT_ROOT / "glados" / "data" / "safety_audit.jsonl",
    PROJECT_ROOT / "glados" / "data" / "scored_ideas.json",
]

OPEN_QUESTIONS = [
    "how much current the arm draws when it lifts something",
    "whether the microphone array hears me from the kitchen",
    "what her eye should do while the model is still thinking",
]


@dataclass
class BuildRecord:
    project: str
    builder: str
    completed_at: str = ""
    volumes: list[str] = field(default_factory=list)
    capabilities: list[str] = field(default_factory=list)
    artifacts: dict[str, int | None] = field(default_factory=dict)
    open_questions: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not self.completed_at:
            self.completed_at = datetime.now().isoformat(timespec="seconds")


def capabilities(cfg: SystemConfig) -> list[str]:
    lines = [
        CAPABILITY_TEXT[c.name]
        for c in cfg.components
        if c.enabled and c.name in CAPABILITY_TEXT
    ]
    if MANUAL_PATH.exists():
        manual = json.loads(MANUAL_PATH.read_text())
        lines.append(f"answers {manual['count']} documented voice commands")
    if WIRING_PATH.exists():
        wiring = json.loads(WIRING_PATH.read_text())
        lines.append(f"drives {len(wiring['wires'])} checked connections to the ESP32")
    if SERVICE_UNIT.exists():
        lines.append("starts on boot and comes back after a power cut")
    return lines


def artifact_index(paths: list[Path]) -> dict[str, int | None]:
    index: dict[str, int | None] = {}
    for path in paths:
        key = str(path.relative_to(PROJECT_ROOT))
        index[key] = path.stat().st_size if path.exists() else None
    return index


def build_record(builder: str) -> BuildRecord:
    return BuildRecord(
        project="Building GLaDOS",
        builder=builder,
        volumes=VOLUMES,
        capabilities=capabilities(load_config(CONFIG_PATH)),
        artifacts=artifact_index(ARTIFACTS),
        open_questions=OPEN_QUESTIONS,
    )


def save_record(record: BuildRecord, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = asdict(record)
    path.write_text(json.dumps(payload, indent=2) + "\n")


def load_record(path: Path) -> BuildRecord:
    return BuildRecord(**json.loads(path.read_text()))


def main() -> None:
    record = build_record("kryo")
    print(f"{record.project} · {record.builder} · {record.completed_at}")
    for line in record.capabilities:
        print(" ", line)
    found = sum(1 for size in record.artifacts.values() if size is not None)
    save_record(record, RECORD_PATH)
    print(f"Wrote {RECORD_PATH.relative_to(PROJECT_ROOT)} "
          f"({len(record.capabilities)} capabilities, "
          f"{found} of {len(record.artifacts)} artifacts)")
    print("round-trip equal:", load_record(RECORD_PATH) == record)
    print(f"{len(record.open_questions)} questions the software cannot answer.")


if __name__ == "__main__":
    main()
$ uv run python -m labs.build_record
Building GLaDOS · kryo · 2026-08-22T09:14:03
  listens: 16 kHz capture, transcribed locally
  speaks: her voice, synthesized on the same machine
  remembers: conversations kept in SQLite across restarts
  thinks: llama3.2:3b, on hardware you own
  answers 8 documented voice commands
  drives 10 checked connections to the ESP32
  starts on boot and comes back after a power cut
Wrote docs/build_record.json (7 capabilities, 6 of 7 artifacts)
round-trip equal: True
3 questions the software cannot answer.
$ head -12 docs/build_record.json
{
  "project": "Building GLaDOS",
  "builder": "kryo",
  "completed_at": "2026-08-22T09:14:03",
  "volumes": [
    "1. Her Voice: she speaks and she hears",
    "2. Her Mind: memory, a local model, a personality",
    "3. Her Craft: the loop she runs all day",
    "4. One System: a registry, a wired core, a wire to the board",
    "5. Alive on the Bench: watchdog, behaviors, mood, vitals",
    "6. Ready for the House: safety, drivers, deployment, sign-off"
  ],

build_record takes the one argument that varies and computes the rest, so the entry point holds no data at all. Commit the JSON. It diffs like code, so the next time you run this after a month of changes, git diff shows you exactly which capabilities appeared, which artifacts grew and which one you finally generated on the bench box. The three open questions are the only hand-written lines in the file, and they are hand-written because no file on this machine knows the answers yet.

Why this works: one boundary, crossed deliberately in both directions

In memory you want a typed object: attribute access, a generated constructor, a printable representation, field-by-field equality. On disk you want the opposite of an object, a file with no code in it, no types and no dependence on the program that wrote it. Those two wants are in tension, and the dataclass pair resolves it. asdict goes out, the constructor with ** comes back, and neither direction needs a serialization library or a schema declaration.

The second idea is the one to carry into any project. A summary you type is a copy of the truth, and copies drift. A summary you compute is a view of the truth, and views cannot. You have applied that rule all volume: the service unit is generated from the environment, the manual is rendered from the same registry the dispatcher reads, the wiring card prints from the manifest that validates the wiring. The build record is the same move pointed at the whole project, which is the reason it is only about a hundred lines. Almost everything it says was already written down somewhere by a chapter that refused to let a fact live in two places.

⚠ Worked failure — asdict ran, and json still refused

Storing a timestamp as a string looks lazy when Python has a real type for it, so you fix it:

@dataclass
class BuildRecord:
    project: str
    builder: str
    completed_at: datetime | None = None
    ...

    def __post_init__(self) -> None:
        if self.completed_at is None:
            self.completed_at = datetime.now()
$ uv run python -m labs.build_record
Traceback (most recent call last):
  File "/home/kryo/src/GladOS/labs/build_record.py", line 148, in <module>
    main()
  File "/home/kryo/src/GladOS/labs/build_record.py", line 141, in main
    save_record(record, RECORD_PATH)
  File "/home/kryo/src/GladOS/labs/build_record.py", line 128, in save_record
    path.write_text(json.dumps(payload, indent=2) + "\n")
  File "/usr/lib/python3.11/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
  File "/usr/lib/python3.11/json/encoder.py", line 202, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/usr/lib/python3.11/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
  File "/usr/lib/python3.11/json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type datetime is not JSON serializable

Read the frames from the bottom. asdict is not in the traceback at all, so it did its job and handed back a dict; the failure is in the encoder, on a value inside that dict. The cause is a boundary in asdict that is easy to overstate: it recurses into dataclasses, dicts, lists and tuples, and every other value it simply copies through unchanged. A datetime is not a dataclass, so it arrives at json.dumps intact, and the encoder has no rule for it.

json.dumps(payload, default=str) makes the error disappear and quietly breaks something better: the file now holds a string where the object held a datetime, so load_record(path) == record reports False and the round-trip check you wrote in stage 4 starts failing for a reason no one can see in the output. The fix that keeps both properties is the original one: store the ISO string, because the string is what the file format can hold. Types that survive the boundary in both directions are the only types a serialized field should have.

Checkpoint, and a system that has run out of software to add

✓ Checkpoint — what you can now do
  • I can say what __post_init__ runs after, and why the guard around completed_at matters when a record is loaded from disk.
  • I can name the two calls that cross the object/file boundary in each direction, and the one-line check that proves nothing was lost in between.
  • I can explain why disabling a component deletes a line from the record, and why that is the property that makes the record trustworthy.
  • I know which types asdict recurses into and which it copies through, and I can predict the exact error a datetime field produces.
  • I can read a build record from another machine and say which artifacts existed there, which were absent, and which subsystems were switched on.
⚡ Exercises — try first, then reveal
Exercise 1 — the same record, in Markdown. Add to_markdown() to BuildRecord and write the result to README.md, so the project page and the JSON never disagree.

Build a list of lines and join it: a level-one heading from self.project, a line naming the builder and the timestamp, then lines.extend(f"- {c}" for c in self.capabilities) under a heading, and the artifacts as a two-column table with missing in the size column for the None entries. One object, two renderings, same numbers. Open the README on a code host afterwards: that page is the most-read artifact this project will ever produce, and now it regenerates.

Exercise 2 — make an absence fail. Split ARTIFACTS into required and optional, and have main() exit non-zero when a required artifact is missing.

The config, the personality file and the manual are required; the scored ideas and the safety audit are logs that a fresh machine legitimately lacks. Collect the missing required keys, print one line each, and finish with sys.exit(1). Run it on a clean clone before you copy anything over and watch it refuse. That turns the record from a report into the same kind of gate the manual audit became: a command that fails when the project is not what it claims to be.

Exercise 3 — let her read it back. Send a one-sentence summary of the record through the voice path so the last thing volume 6 does is spoken.

Compose the sentence from the record itself, never from a literal: something like f"Seven capabilities, {found} of {total} artifacts on disk, and three questions I cannot answer without a body." Then hand it to the same speak path the assistant uses. It is the whole chapter in one demonstration: the numbers came from files, the files came from the build, and the voice came from volume 1. Record the audio and keep it next to the JSON.

Look at what volume 6 added. She scores competing ideas against criteria fixed before any candidate is written down, and every action she takes clears a blocked-text filter and a permission table that deny by default and give a reason for each refusal. Devices sit behind one abstract contract whose shared read turns a hardware exception into data. Her service unit is generated from the environment instead of typed, her wiring is a self-validating manifest that catches a doubled pin before you cut a wire, and her manual, her JSON feed and her spoken help all render from the single list her dispatcher reads. The architecture became a graph with a search that finds the cycle a diagram hides, the seams between subsystems got checks that default to failed until an assertion says otherwise, and acceptance became eight numbers agreed on in advance instead of a feeling. She takes feedback into a profile that clamps, her latency is judged at the ninety-fifth percentile where the slow calls live, and today the whole build wrote itself down.

That is the software finished. Not perfect, and not final, but complete in the sense that matters: everything she does has a file behind it, a test that covers it, a permission that gates it and a number that says whether it is fast enough. Read the three open questions in docs/build_record.json again. Current draw under load. Whether the microphones hear you from the kitchen. What her eye does while the model thinks. No amount of Python answers any of them, because all three are questions about an object that does not exist yet.

Volume 7 builds it. A printed shell with tolerances that matter, a servo you understand before you trust, the eye mechanism and the LED ring that give her a face, a microphone array that hears across a room, a speaker and an amplifier loud enough to be heard over a kitchen, an arm with reach and limits, and a power budget that keeps all of it from browning out mid-sentence. Then the pin map, the assembly, the calibration pass, and a ninety-second test that exercises every subsystem at once while you watch. Same code. Same record. From here on, when it says she moves, something in the room moves.