Chapter 13

Canonical Records — The Architecture of Persistence

When more than ten different code paths can create the same kind of object, each one grows its own implicit schema for what to write. The result is a class of bugs we ended up naming: parallel tracks that look unified but live in separate worlds.

The Problem

A virtual-world object — a chair, a wall, a torch, anything a user can place — can be born through more than ten different code paths. Users create them through the build tool. Users duplicate them with a keyboard shortcut. Worlds import them in bulk from another platform. Players pull them out of inventory. Builders upload mesh files. Generative systems scatter them across terrain. Scripts spawn them at runtime. Future AI companions create them via tool commands.

Each of those paths was written at a different time. Each was written while a different problem was being learned. Each quietly evolved its own implicit answer to the question what does a record of this object look like? — and none of them ever sat down together and agreed.

What Goes Wrong

When paths disagree, bugs that look unrelated turn out to be instances of one pattern. A user creates a textured cube with the build tool: the texture renders perfectly until the page reloads, at which point the cube is gray because the create-path quietly dropped the texture data. A duplicate of the same cube loses its colors after a multi-user broadcast. A linkset of objects imported from another world disappears from one place when deleted in another, because the deletion code walked the wrong index.

Three independent bug reports. One underlying pattern.

"Parallel code tracks that look unified but live in separate worlds."
— the pattern, named the day the third bug report landed.

This is the sister to the cross-engine bridge problem in the previous chapter. There, two systems disagreed about a single convention, and the disagreement got pasted into a transform field that seven readers consumed. Here, ten paths inside one system disagree about what fields to write, and the gaps surface only when a downstream consumer walks state that one path produced but another path didn't.

The Audit

We did the natural first thing: we wrote a 754-line audit walking every column of the object table, every field of its metadata, every code path that wrote to either. The audit's purpose was not to fix anything. Its purpose was to make the asymmetric pattern visible — to tabulate which fields each path emits, so the gaps are no longer findable only after a user reports a bug.

15+ Birth Channels
40+ Metadata Fields
11 Asymmetric Findings
4 Classes of Bug

The audit's most valuable output was not the list of bugs. It was the realization that every bug fit into one of four classes — and the class determined what kind of fix was needed.

1
Write Asymmetry
Birth-channel drops fields
Path A emits the field. Path B drops it silently. Reload surfaces the gap.
Fix: canonical type
2
UI → ∅
UI without persistence
User clicks the control. Handler fires. Nothing in storage actually changes.
Fix: close the wiring gap
3
∅ → UI
Persistence without UI
Data lives in storage. Renderer shows it. Inspector reads elsewhere and shows nothing.
Fix: single-source
4
Cleanup Drift
Cleanup walks the wrong index
Destroy code keys on a single column. Objects from elsewhere get swept along. The only class that destroys data.
Fix: scope every predicate
The four asymmetry classes. Class 2 and Class 3 are inverses surrounding a missing domain-model piece. Class 4 is the only one where the bug fingerprint is destroyed-without-trace.

Four Classes of Asymmetry

1

Birth-channel write asymmetry

The same field is emitted on path A and dropped on path B at write time. The user sees the difference at reload, when path B's incomplete state is what the system loads back. Fix shape: canonical record type that all paths must satisfy.

2

UI without persistence

A control exists in the interface, the user clicks it, nothing in storage changes. The handler was wired halfway: from button to function but not from function to database. Fix shape: trace the path; close the gap or remove the control.

3

Persistence without UI reflection

The database has the data. The renderer shows it. The inspector reads from a third source and shows nothing. The user sees the texture painted on the object but the edit panel claims the object has no texture. Fix shape: single-source the inspector against the same payload the renderer reads.

4

Cleanup walks the wrong index

Destroy or lifecycle code keys on a column that doesn't match the actual scope. Objects from one place get swept along with objects from another, because the cleanup query couldn't tell them apart. Fix shape: scope every cleanup query by every relevant predicate — never trust single-column matching for "is this referenced elsewhere?"

Two observations make this vocabulary load-bearing.

Classes 2 and 3 are inverses. Class 2 has user intent that doesn't reach storage; Class 3 has stored data that doesn't reach user intent. Together they surround a missing piece in the domain model. Ratify a canonical record type and the canonical channel that writes it, and both classes close at once.

Only Class 4 destroys data. The other classes produce visible inconsistency — missing textures, lost colors, blank inspectors. Class 4 is the one where things disappear. Cleanup queries that match too loosely don't just hide content; they remove it. Class 4 deserves the most caution because by the time the symptom is visible, the data is already gone.

The Decomposition That Made The Type Tractable

Once the audit was complete, the obvious next step was a canonical record type that every path would write through. The temptation was to make it one flat record with all forty fields. The right decomposition was already sitting in the build tool's user interface.

Every object in the build tool is editable across five tabs:

User-Facing Tabs
Shape
Look
Physics
Code
Own
Implicit Layer
Transform (gizmo-edited)
System-Managed
Structural (identity + linkset binding)

Each tab owns a distinct slice of the object's state. That mapping isn't arbitrary — it's how the user already thinks about the object. Shape is the geometry. Look is materials and textures. Physics is collision and gravity. Code is scripts and contents. Own is name, description, attribution, license. Plus the implicit Transform layer that the gizmo edits, and the system-managed Structural layer for identity and linkset binding.

Physics tab of the build panel showing four behavior options (Solid, Moveable, Walk-through, Floor), a Collision Shape dropdown, a Block-camera checkbox, and a Land Impact readout of 1
Phys tab — the Physics sub-record
Own tab of the build panel showing identity (name + AI-augmented description), ownership (creator + owner + group), a 4-by-4 permissions matrix for Owner / Next-owner / Group / Everyone, and a Commerce section with For-sale toggle and Lock-object affordance
Own tab — the Ownership sub-record
Two of the five tabs. Each one is the user-facing surface for a single sub-record. When a canonical record migration is needed, the tabs tell you exactly what slices to write.

The canonical record carries one sub-record per slice. The bug class then maps cleanly: "duplicate loses materials" becomes "duplicate drops the Look sub-record." "Imported objects lose creator attribution" becomes "import drops the Own sub-record." The scattered bugs across forty fields collapse into a handful of per-slice gaps with one obvious shape: make every birth channel write every sub-record.

The Channel Equals The Broadcast Equals The Permission Gate

A subtle but load-bearing property of the architecture: the channel that persists an object's state is simultaneously the channel that broadcasts it to other users, and simultaneously the channel where permission gates apply.

When a builder creates an object, three things happen with the same payload:

1

Persist.

The builder's client sends the payload to the multi-user backend, which writes it to durable storage. This is the "remembered on reload" channel.

2

Broadcast.

The backend emits an event to every other client in the same world. This is the "everyone sees the new object immediately" channel.

3

Recreate (later).

When the builder — or anyone else — reloads the world, the stored record is read back and used to reconstruct the object. This is the "permanence" channel.

sequenceDiagram autonumber participant C as Creator's client participant B as Multi-user backend participant DB as Storage participant O as Other clients participant L as Reload (any client) C->>B: Create object (payload) B->>DB: Persist payload B->>O: Broadcast payload B-->>C: Confirm Note over C,O: All three consumers read the SAME payload.
If the payload is incomplete, all three suffer. L->>DB: Read payload DB-->>L: (whatever was persisted) L->>L: Recreate object
Persistence-equals-broadcast. One payload, three failure surfaces. Fixing the payload structure fixes single-user reload, multi-user sync, and broadcast fidelity at the same time.

All three operations consume the same data shape. If a birth channel drops the Look sub-record, then:

Three apparent bugs — "single-user reload regression," "multi-user sync bug," "broadcast-fidelity bug" — reduce to one structural defect. Fixing the channel fixes all three. The architectural rule that falls out of this:

"If it persists, it broadcasts. If it doesn't broadcast, it didn't persist."
Persistence-equals-sync — one channel, three failure surfaces.

The same channel is also where permission gates live. When a user tries to build in a world that they don't own without explicit permission, the canonical channel rejects the write. When a script tries to spawn an object, the canonical channel verifies the script's owner has rights. When an AI companion uses a tool to place an asset, the same gate applies. The channel is the permission boundary, not a thing every caller has to remember to check.

Asset Is The Hub

Most canonical records reference other canonical records. Objects reference their geometry asset. Inventory items reference their underlying assets. Worn attachments wrap inventory. Environments reference textures. NPCs are mesh-backed assets. Avatars are bone-rigged assets. Terrain layers reference texture assets. Drawn as a graph, one record type sits at the center, and every other points at it:

flowchart TB ASSET["Asset Record
storage + identity
+ license attribution"] PRIM["Object Record
(placement)"] INV["Inventory Item"] UATT["Worn Attachment"] HATT["HUD Attachment"] ENV["World Environment
(water, sky, terrain)"] NPC["NPC Record"] AVA["Avatar Appearance"] SCAT["Scatter Manifest"] PRIM -->|references| ASSET INV -->|references| ASSET UATT -->|wraps| INV HATT -->|wraps| INV ENV -->|texture refs| ASSET NPC -->|mesh + animation refs| ASSET AVA -->|body-part refs| ASSET SCAT -->|collection refs| ASSET classDef hub fill:#1e1e1e,stroke:#0099ff,stroke-width:3px,color:#0099ff classDef record fill:#2a2a2a,stroke:#404040,color:#e0e0e0 class ASSET hub class PRIM,INV,UATT,HATT,ENV,NPC,AVA,SCAT record
The asset record sits at the hub. Once it has a stable canonical shape, every record that references it inherits stable identity, license attribution, and reference counting. The audit pattern that ratifies it can be cloned for each spoke.
Hub
Asset Record
Records That Reference Assets
Object (placement)
Inventory Item
Worn Attachment
HUD Attachment
World Environment
NPC
Avatar Appearance
Scatter Manifest

Once the asset record is canonical, every record that references it inherits stable identity, license attribution, and reference counting. The pattern that ratifies the object record can be cloned for each downstream type: empirical audit, canonical record, single persistence channel, migrated paths. What gets ratified is a template, not just a contract for one table.

What This Costs

Ratifying a canonical record across ten-plus paths is not cheap. The paths were built at different times, by different authors, against different states of the surrounding system. Each path's owner has invested in their path's idiosyncrasies; some of those idiosyncrasies turn out to be load-bearing.

The discipline that makes the cost manageable:

1

Audit before refactor.

Write the full asymmetry tabulation before writing the canonical type. The audit's job is to make sure the type covers everything that's already in the field. Without the audit, the type becomes another implicit schema.

2

Walk the audit with someone who built the paths.

Static audits miss the runtime nuance that built up over months. Walking the audit with the people who shipped each path surfaces the load-bearing idiosyncrasies the static read missed. "Why is this field this way?" is the question that catches the gaps.

3

Migrate path-by-path, behind the channel.

Don't migrate everything at once. Ratify the canonical channel first; let new paths use it; migrate old paths one at a time. The old data stays valid until the path that wrote it is replaced. Big-bang migrations break things no audit caught.

4

Lock the type with comprehension gates.

The biggest risk after ratification is a new path that grows its own implicit schema. A pull-request review question of "does this path write through the canonical channel?" prevents that drift cheaply. Constitutional rules need operational forms.

Why It Matters Beyond One Object Type

The pattern this chapter described — parallel paths that look unified but live in separate worlds — recurs across every persistence domain in a complex system. The bug fingerprint is always the same: a field is present on some records and absent on others, no error is raised, a downstream consumer notices the gap in a way that looks like an unrelated symptom.

The discipline that resolves it is reusable: audit, decompose, canonical type, single channel, migrate behind the channel, comprehension gate. Object records. Inventory items. Worn attachments. World environment. NPC. Avatar appearance. Each is a future audit in the same shape, and the methodology is the work you only have to do once.

What's Next

The third chapter in this set turns from data persistence to interaction persistence — specifically, the design rule that prevents user interfaces from accidentally trapping people in states their workflow didn't anticipate.

Chapter 14 The no-trap principle: a design rule for any UI that hides controls behind progressive disclosure — and the cascade of cross-coupled design principles it surfaced during a single two-instance design session.