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.
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
# 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.
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.
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
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.
# 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.
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
- I can say what
__post_init__runs after, and why the guard aroundcompleted_atmatters 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
asdictrecurses into and which it copies through, and I can predict the exact error adatetimefield 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.
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.