Skip to main content

Domain model

Every entity, what owns it, and where it lives. Types here are pseudocode — illustrative shape, not final signatures.

The three layers

LayerEntitiesLives inMutable?
ContractBlock, RegistryCodeOnly by a commit
ContentDocument, DocumentVersion, NodeAuthoring databaseYes, by authors
OutputArtifact, RoutePointerArtifact storeArtifacts never; pointers only

Relationships

NODE ||--o{ NODE is the slot tree the compiler recurses over; DOCUMENT }o--o| DOCUMENT is a page pointing at its layout. That edge is why layouts and presets share one table despite behaving oppositely.

Contract layer

Block

A registered component plus the schema describing what it accepts — the unit of curation. If it is not a block, an author cannot place it. A block is an organism, a self-contained page section (hero, FAQ, carousel) — never a button or an input; atoms and molecules are the consumer's design system, and Nubbin has no opinion on them.

interface Block<Schema, Component> {
name: string; // stable identity, referenced by every Node — renaming is a migration
schema: Schema; // Standard Schema; props are inferred from it
component: Component; // generic, so core never imports React
version: number; // bumped when the schema changes incompatibly
status?: "active" | "deprecated"; // deprecated stays resolvable; hidden from the studio's placement palette
slots: Record<string, SlotConstraint>; // named regions, and what may go in them
}

interface SlotConstraint {
allow?: readonly string[]; // block names permitted here, each resolved at registration; omitted means any registered block
min?: number;
max?: number;
}

Slots carry constraints, not just names — a region that takes one-to-six section blocks is structure a bare string[] cannot express. Editing hints are not on Block — they live in a parallel ui structure keyed by field path. See Where UI hints live.

data (static vs. request-time resolution) is not a field on Block either — a block-level flag forces an all-or-nothing choice per block. It lives per field instead. See Data lifecycle is a field hint, not a block flag.

Structural change

Reshaping an old document is neither something Block declares nor something compile does: a schema change is a republish, not a migration. A rename, a split into two blocks, or a slots change is a dataset-wide pass over DocumentVersions, run by a script through the document operations in core — an adapter concern (invariant 5).

Registry

The curated set for one application. Resolves a Node.block string to a Block.

interface Registry {
get(name: string): Block | undefined;
names(): string[];
}

What an artifact records about the registry, and what the guardrail compares, is on the blocks reference.

Deletion is two steps: status: "deprecated" keeps a block resolvable (registry.get() still returns it, so existing Nodes keep rendering) while hiding it from the studio's placement palette. Hard removal follows once nothing references it — a scan over elements values for block === name, across every DocumentVersion.

Content layer

Document

The authored thing. One row per route, not one per environment — staging and production cannot drift.

interface Document {
id: string;
kind: "page" | "layout" | "preset";
route: string | null; // pages have one; layouts and presets do not
layoutId: string | null; // the layout a page renders inside
head: number; // the version currently considered current
}

publishedVersion is not stored here — it would duplicate a fact the route pointer already owns, in a second datastore with no shared transaction. It is derived on read: resolve document.route's pointer, read the artifact it names, take its documentVersion.

DocumentVersion

Versions are immutable; authoring appends. Publishing moves a pointer rather than mutating a row, which makes rollback symmetrical with publish and gives history for free.

Autosave and versioning are different things:

LayerGranularityLives
Undo / redoPer operationClient working copy — IndexedDB, survives tab close
Autosave slotDebounced tick, overwrites in placeThe authoring store — mutable, not a version
DocumentVersionExplicit save, periodic checkpoint, or publishThe authoring store — append-only
ArtifactPer publishThe artifact store
interface AutosaveSlot {
documentId: string;
tree: Node[];
slots: Record<string, Node[]>;
updatedAt: string;
}

The identifiers in this model do not imply an account system or a central id service:

ValueMeaningWho supplies it
Document.idStable address for a documentCaller, CLI, or editor; "home" is valid
Node.idStable address inside the authored graphCaller, CLI, or editor; generated ids are a convenience
Draft revisionOpaque compare-and-save tokenHost callback; a content hash is sufficient
createdByNon-authoritative provenance labelAuthoring surface; "studio" or "anonymous" is sufficient

None must identify a person or be globally unique. A single-author host can preserve the same document and node ids, overwrite one file, and derive its revision from the file contents. See The studio does not own identity.

A tick overwrites AutosaveSlot in place and never enters the version log; the slot is promoted into a new DocumentVersion only at the three events above. Undo never touches the version log either — reverting a draft is distinct from rollback, which moves the published pointer.

interface DocumentVersion {
documentId: string;
version: number;
roots: readonly string[]; // ordered entry elements — see Node, below
elements: Record<string, Node>;
meta: DocumentMeta; // title, description, robots, canonical
createdAt: string;
createdBy: string;
}

A layout's named slots need no separate field: they are the slots of the nodes roots names. Why a page lists entry elements rather than naming one block that contains them is A document has many roots.

ConcernAnswer
Client storageIndexedDB, not JS memory alone — a tab crash loses at most the last tick
Debounce800ms — losing undo history on reload is an acceptable, bounded trade
ReconnectDiscard the pending diff and re-serialize the working copy from memory, rather than trust a patched diff buffer that may have silently diverged
Second tab / deviceEvery save compares an opaque revision. A stale writer reconciles both descendants from their shared base before retrying; presence and node locks may reduce how often that path is needed.
Crash during saveThe host keeps the prior revision current unless it atomically commits the complete next value.

Not a required CRDT: {roots, elements} maps onto a map-of-records from the flat editing shape. The save contract requires revision comparison and three-way reconciliation, not one sync engine. A host may use a CRDT, a transactional row or another implementation behind that contract.

Node — flat while authoring, nested once published

The same composition takes two shapes: authoring wants random access, rendering wants a self-contained tree. See Flat while authoring, nested once published for why.

// Every editor operation is by id — see DocumentVersion above for the full record.
interface Node {
id: string;
block: string; // resolves against the Registry
props: UnknownProps; // validated against the block's schema at compile
slots?: Record<string, readonly string[]>; // slot name → ordered child ids
}
// Artifact — resolved. No lookups, no dangling references possible.
interface ArtifactNode {
id: string;
block: string;
props: UnknownProps; // frozen fields only — literal values
holes?: Record<string, "request" | { revalidate: number }>; // path → how the rest resolve at render
slots?: Record<string, ArtifactNode[]>;
}

Every prop lands in exactly one place: a frozen literal in props, or an entry in holes, decided per field by ui.fields[path].data (default: static) — see Data lifecycle is a field hint, not a block flag.

The flat {roots, elements} shape (children as id references, not a nested tree) earns its place in four ways:

  • Editing is elements[id] = {…}, not an immutable deep rebuild — selection, patching, moving, and undo all key on ids that are already map keys.
  • Cycles become impossible to publish — a graph containing one cannot flatten into a tree, so compile fails with no special-case depth guard needed in the walk.
  • Dangling references and orphans are detectable in the same pass.
  • "Which documents reference block X" is a scan over values — the capability needed before a block can be safely deleted from the registry.

id is generated once and never regenerated — undo, selection, and diffing depend on it. Every clone path (copy/paste, duplicate page, instantiating from a preset) must remap the whole subtree to fresh ids, via one shared utility in core — explicit in the flat shape, where a deep object copy could silently share ids by accident.

Layout and Preset

BehaviourLayoutPreset
RelationshipReferenced by pagesCopied into a new page
Editing itReferenced by every page using it; propagation to published pages is unresolvedAffects nothing already created
Stored asDocument with kind: "layout"Document with kind: "preset"
CompositionPage tree fills the layout's named slotsPage starts as a clone of the tree

Naming these apart early is cheap; separating them later is a data migration. preset rather than template because Atomic Design's template is this model's Layout — see the decision.

Output layer

Artifact

The compiled result of one document version. Immutable and content-addressed.

interface Artifact {
hash: string; // content address — the identity
route: string; // literal, param pattern, or prefix — see Route pointer
documentId: string;
documentVersion: number;
blockVersions: Record<string, number>; // what this was compiled against
tree: ArtifactNode[]; // resolved, validated — static fields frozen, request/revalidate fields left as holes
meta: DocumentMeta;
compiledWith: string; // nubbin version
}

Route pointer

The only mutable state in the output layer — one independently-writable record per route, not one global document with a version. A single-key write is atomic everywhere it matters (S3 object, DB row, file); two concurrent publishes to different routes never contend, and a publish to the same route is a last-write-wins race scoped to that one key.

interface RoutePointer {
route: string; // literal, param pattern, or prefix
matchKind: "exact" | "param" | "prefix";
hash: string; // artifact currently live at this route
updatedAt: string;
}
KindExampleMatches
Exact/aboutThat literal path only
Param/guides/[city]One path segment, captured at render
Prefix/collections/*Any path under it

matchKind is parsed from route at publish, not caller-supplied — [name] means param, a trailing /* means prefix, anything else is exact. Precedence is most-specific-first: exact beats param, param beats prefix. Whether authors can create pattern routes is open.

manifest() is not a stored document — it is an advisory aggregation read over every RoutePointer, for the studio's route list and CI. No render path reads it; a request resolves through one pointer.

interface Manifest {
routes: RoutePointer[];
generatedAt: string;
}

Publishing writes an artifact, then writes one route pointer. Unpublishing deletes the pointer; the artifact stays, so republishing is a pointer move rather than a recompile.

ArtifactStore

The output layer's whole IO surface. Authoring does not mirror it with one storage interface: the CLI config and Studio each inject the callback their workflow needs. The host decides what those callbacks persist, as settled by the infrastructure boundary.

interface ArtifactStore {
read(hash: string): Promise<Artifact | null>;
write(artifact: Artifact): Promise<void>;
manifest(): Promise<Manifest>; // advisory aggregation — every pointer
pointer(route: string): Promise<RoutePointer | null>; // single-route read; what a request resolves through
publish(route: string, hash: string): Promise<void>; // writes one route pointer — matchKind parsed from `route`
unpublish(route: string): Promise<void>;
}

Compile and publish

Validation happens before an artifact exists, so an invalid page is unpublishable rather than a render-time failure. The render path only ever sees trees already proven valid.

Publication state

Editing a published document never touches what is live — it appends a version, and the route pointer keeps pointing at the old artifact until someone publishes. Rollback and republish are both pointer moves; rollback additionally runs the compatibility check below first.

Rollback

rollback is publish(route, oldHash) — the same pointer move, reusing a hash instead of one just compiled. A bare pointer move risks resurrecting props frozen against a schema that has since changed shape underneath them. checkRollback reads what the target artifact recorded — blockVersions — and compares it to the registry live now, before the pointer moves.

function checkRollback(artifact: Artifact, registry: Registry): RollbackCheck;

type RollbackCheck =
| { compatible: true }
| { compatible: false; drifted: string[] }; // block names whose registered version has moved since compile

A plain function in core — no adapter or CI runner required — so the studio can call it before offering "rollback," and a script can call it from a terminal outside any pipeline.

RollbackCheckResponse
compatible: truestore.publish(route, hash) proceeds
compatible: falseReject with drifted, or recompile the historical DocumentVersion through compile() and publish the fresh hash instead — a compile that fails names the nodes to rewrite first

What this model has not settled

Three things above are deliberately undecided, so they get settled on purpose rather than by whoever implements first:

UndecidedCost of deciding late
Layout slot merge: may a page contribute to several layout slots or exactly one?The choice changes both the document shape and layout composition rules.
Who owns meta: the document version or a block placed in the tree?Ownership determines validation, inheritance, and editing behavior.
Localization: one locale per DocumentVersion or many?The choice affects document identity, routing, and publishing.