Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Draft: this page sits outside the guide's focus.

VerificationLesson

A schema that only grows

Some records are written once and never touched again. If their schema changes, every record already written must still pass it. That rules out most changes: a schema like this can only grow. Terrarium's session logs work this way.

Know schema evolution already? Skip to In Terrarium.

Outside the box · General

The concept

A schema change is safe for old records when every value that passed before still passes. Adding a field with .optional() is safe: old records simply lack it. Most other changes are not. A new required field fails every old record, a renamed field fails every record that has the old name, and a narrower type, such as an enum with one value fewer, fails the records that used it. On a strict object, so does removing a field: the old records that carry it now hold an unknown key.

When a breaking change is needed anyway, add a new version beside the old one. Give each version a literal field, z.literal(1) and z.literal(2), and accept either with z.union([v1, v2]). Old records still match the first branch, new ones the second, and code that reads them checks the version.

.default(value) fills in a missing field when a record is parsed, so it changes what code sees, not what was stored.

Full referenceZod 3 docs: Optionals

Looking inside

In Terrarium

Each Claude Code session writes a , and scripts/session/log-session.ts commits it straight to main. No session edits another's landed log (ADR-0009), and pnpm validate:content, a step of the , checks every one, current or archived, against the schema in shared/schemas/session.ts. One old log that fails would fail the Gate.

The schema's first field says how it may change:

shared/schemas/session.ts ↗39–44as of
39export const sessionSchema = z
40  .object({
41    // Absent ⇒ 1. A breaking change adds a v2 object with z.literal(2) and makes
42    // this z.union([v1, v2]); never .default(1), which would blur absent ⇒ v1.
43    // Readers tolerate older or unknown versions. Policy: ADR-0009.
44    schemaVersion: z.literal(1).optional(),

At this revision there is only version 1: the fields added over time are optional. Fields the landing script derives from the transcript, such as durationSec and filesEdited, are all optional, so logs written before them still pass. The object is .strict(), so a field that is no longer written stays in the schema, marked as deprecated, unless a Trusted user removes a field few logs ever used, from the schema and every log, in one PR a human merges:

shared/schemas/session.ts ↗90–92as of
90    // DEPRECATED — no longer written; shell reads fold into `docsRead`. The key
91    // stays so older logs validate (ADR-0009).
92    docsReadViaShell: z.array(z.string()).optional(),

A new field has one more hurdle. A log lands on main, but the session that writes it may run on a branch that changed the schema. So the landing script checks each log against main's schema as well as the branch's. A log that uses a field main doesn't know yet is held back, and lands once the schema change has merged.

Why it's built this way

Session logs and their grow-only schema: ADR-0009 (docs/adr/0009-session-logs-commit-directly-to-main.md).

The session log as a Collection kind: ADR-0025 (docs/adr/0025-cross-tenant-catalog-and-collection-kinds.md).

What to watch for

  • Optional means every reader copes without it. A page that shows a new field must handle logs that don't have it, which is every log written before it.
  • A version is a promise to read both. Once a second version exists, each reader handles both shapes. That cost is why a version waits for a change that truly can't be additive.
  • .default hides what was written. After .default([]) a reader can't tell an empty list in the file from a missing one. The schema uses .optional() where that difference matters, such as the optional learnings and ideas notes.

Try it in your project

Pick a schema for records you keep, such as saved settings or event logs. Before you change it, run the new schema over a sample of old records and count the failures; a change is safe only at zero:

const failures = oldRecords.filter((record) => !newSchema.safeParse(record).success)
console.log(`${failures.length} old records would no longer pass`)

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/drafts/verification/schema-only-grows