strata — design
Why it is shaped the way it is. The build plan lives in docs/plans/versioning.md; this is what was decided and why, kept beside the code.
The problem it was built for
Every nisc artifact is JSON with a schema, and those artifacts are already
stored — integration_actions.definition, vex_cache.dsl and prism_ir,
atrium's bundles, lyra's theme layouts. Nothing recorded which grammar wrote
them. Underneath that, three packages created and altered their own tables on
every boot with IF NOT EXISTS, outside any ledger: moss added twelve columns
that way, tide dropped three. A deployment could not say what shape it was in,
and a later change had nowhere to go but another IF NOT EXISTS.
strata is one path for both: the documents stored in databases and written in source files, and the tables a database holds.
Decisions
Sequences per owner, versions by position. tldraw's model: an append-only list per owner, a version that is a count. A hand-bumped integer is a second source of truth beside the list; a count cannot disagree with it. Owners are namespaced so a package and an app never collide, and Kubernetes' lesson on granularity applies — one sequence per owner, not per table and not one for the world.
One ledger for everyone. Django's model: every package ships its own
migrations into the app's single ledger, ordered by the order given and bent by
explicit dependsOn. The order is deterministic — tldraw rebuilt its migration
system after ordering across sequences drifted between releases.
Checksum the effect, not the prose. The ledger stores a SHA-256 of each
migration's steps. Descriptions, dependsOn and full-line SQL comments are
excluded: rewording documentation is not editing history. Changing a step is —
the database already holds the old effect — and is refused (EDITED).
Refuse, don't guess. A database migrated by newer code (TOO_NEW), a hole
in the ledger, an unknown dependency, a cycle: each is a refusal with a
sentence, never a best effort. Verify mode refuses pending work, so a
production boot can require that a deploy step migrated first.
One transaction, one lock. A run takes pg_advisory_xact_lock on the
ledger's name, then creates the ledger if needed, reads it, plans and applies —
all in one transaction. Postgres DDL is transactional, so a failed step leaves
nothing behind. The lock comes first because two processes creating the ledger
at once race in the catalog before either reaches a row.
Pure core, structural pool. The plan is a pure function of sequences and
ledger rows; the runner is the only thing that touches a database, through the
{ query, transaction } shape nisc already passes everywhere. So the same code
runs in a server, a test and the showroom page (PGlite in the browser), and
checksums use WebCrypto rather than a Node module.
Baselines converge. Each package's first migration is exactly the
IF NOT EXISTS DDL its boot used to run. That makes adoption free: whatever
earlier shape a deployment is in, migration 1 lands the current one, keeps the
rows, and is recorded. No schema diffing, no "mark as applied" step that
assumes. Every package pins its baselines' checksums in a test, so an edit is
caught in CI rather than by a refusing production boot.
What moved when strata landed
moss's server now runs nisc.moss (integrations, their actions, the generation
pointer), nisc.moss.sessions (when the deployment uses moss's credential) and
the vex cache's sequence in one run, before introspection — as the tables
it replaced were. Two consequences, named rather than discovered later:
- The generation table used to be created after introspection; it is now
created before, and
strata_ledgeris a new table. Both enter the introspected grant universe like every engine table (moss's stance: engine tables are ordinary and grantable, and a grant is a charter's decision). A charter selecting*.readreached them — and, it turned out, had always reached moss'sintegrations(integration key hashes). relay's charter check resolved against relay's tables only, so it never showed. Fixed in relay: the check builds the universe the way boot does, and*.readroles deny the engine's tables. Any app granting a data wildcard should do the same. initIntegrations,initSessions,cache.init()and the tide store'smigratekept their names and went through the ledger from then on, so a host that is not moss's server gets the same guarantees. (Since theninitIntegrationsandinitSessionshave gone from what moss exports: the integration tables are part ofMOSS_SEQUENCE, and the sessions table is made where a deployment keeps moss's stored sessions (session: 'sessions');createServerandmigrateTablesrun both. The other two are as they were, and the tide store is tide's own,@niscorp/tide/postgres.)
Documents (S2)
Two kinds of owner. Tables live in one database, so their version is the database's — a ledger. Documents travel: an add-on built on older code submits older documents, a row written by one replica is read by another, an artifact sits in a repo for a year. Their version has to travel with them — a stamp per document. A sequence owns one or the other; the same number cannot mean both.
Version vector, not one number. tldraw's answer to nested, pluggable
formats: the stamp names every grammar the writer spoke (nisc.nova,
nisc.prism, the app's acme.kit), so a kit's props and nova's layout evolve on
their own schedules and nothing composes by hand.
The grammar owns the nesting; the migration owns one node. Embedding paths are the grammar's declaration of where documents sit inside documents. The walker applies each step to every document of its kind, deepest first, re-reading each node from the current document so a parent sees — and keeps — its children already rewritten (the first implementation captured targets up front and wrote stale parents over rewritten children; a test caught it). The consequence is the useful one: a migration is flat. The relay rename that needed 22.5 KB of unrolled Prism as a whole-tree transform is a dozen lines as a per-node one, and Prism needs no recursion of its own for anything a grammar's embeddings describe.
Two wildcards. * crosses a record's values, [] an array's items. nova's
children is a single node or an array; one wildcard for both would treat a
lone child's props as a record of layouts.
Injected transform. strata knows nothing of Prism: a document step's
transform is opaque data run by the evaluator the host injects — nova's own
socket shape, (config, source) => unknown. moss injects Prism, so a
migration runs through the engine that runs endpoints, with the same guarantee
that no code executes. That is what makes a third party's migration safe to run.
Refuse ahead, upgrade behind. Strict grammars make every addition break
older readers, so a document ahead of the code on any grammar the code was given
is TOO_NEW — at boot a refusal to start, at intake "the host must be updated
first", on a read path the one action left out with a sentence. The reader
upgrades first. A stamp entry for a grammar the code was not given says nothing
to it — the code cannot be ahead of or behind what it does not have — and an
upgrade keeps the entry as it was: it is the document's record for code that
does have that grammar, which would otherwise run its migrations a second time.
Not every table of documents is a store. vex_cache holds DSL and compiled
Prism IR, but its protected rows are re-seeded from source on every boot and its
generated rows are a cache invalidated by schema fingerprint and irVersion. It
is not stamped: there is nothing there to preserve across a grammar change.
integration_actions is — its rows are the only copy of what an add-on submitted.
Prism as the migration language (S3)
Why Prism, not a second grammar. Cambria proved migrations-as-data with a
closed lens vocabulary, but nisc already has one transform language (the
toolbox: Prism is "every transform"), and a second would be a second thing to
version. Prism gained what migrations need — $has (presence, not
non-null-ness), $renameKeys (order kept), $update (one deep path),
$assert (refuse with a sentence), $walk (rules over every node) — and a
config stays data: stored as a row, run in any host, safe when a third party
wrote it.
The walker made $walk rarer than expected. Embeddings already find every
document of a kind at any depth, so most migrations are a flat rule over one
node. $walk is for what a grammar's embeddings do not describe: reaching
inside a Prism config, or a free-form tree.
A migration cannot migrate the language it is written in. So the base case
is a rule, not a mechanism: a Prism op is never removed or reshaped; an old form
stays as sugar. EVERY_OP_EVER in Prism's tests fails on a removal.
irVersion stays 1 — it versions the IR's container; the op set is versioned by
nisc.prism.
The gate (S4)
A snapshot only says something moved. Each kind's JSON Schema, taken through the schema's own Standard JSON Schema hook, recorded per version as the validator wrote it. Same version, different schema: a migration is owed. No JSON Schema compatibility classifier is trusted to judge — none is reliable (2026).
The comparison ignores prose and spelling, the record keeps both. Before
comparing, both sides lose the description keyword (a reworded .describe()
is not a grammar change — but a field named description is) and are read in
one spelling: definitions inlined unless recursive, allOf: [{ $ref }] as
$ref, a union of bare types as a type list. zod 4.3 → 4.6 respelled both nisc
grammars without changing either; that must not be a migration. Keeping the
normalizing out of the record means strata can refine it without touching a
recorded file.
A snapshot is a fingerprint, not history. The grammar version is history —
its migrations and the documents captured at its stamp. The snapshot is what a
RECORDER (the validator, then strata) made of it, and a recorder can be wrong
or improve: zod 4.3 described Prism's tuples as open, and strata once dropped a
field named description. Then the current version is re-recorded —
pnpm strata:snapshot --rebaseline, refused unless the corpus passes, in a
commit that changes nothing else in the grammar. Not a marker migration: that
would bump every app's lock and every stored stamp to say nothing happened.
The corpus judges. Real documents from the lab apps, captured at their stamp, must upgrade to the current grammars and parse the current strict schemas. That is what caught a breaking rename in the rehearsal: 122 real documents would have failed. An early-stamp corpus file is history.
Arrays in schemas are sets. A union's anyOf gains a branch and every index
shifts, so the gate's diff matches schema-array elements by what they describe
(for Prism's union, the op) — its first real catch, Prism's own grammar change,
read like a changelog because of it. Document diffs stay positional:
layout.children[1] is how a person finds the edit.
Source (S5)
No codemods. A codemod would have to understand every way a person writes an object. strata instead fixes the TARGET: the exact JSON each artifact must become, and a report of where (the file its id is in, and — found by searching the source for the values that change — the file the edit actually lives in, usually an imported layout).
Verify is exact, and it is the only way the lock moves. Every planned artifact equals its expected JSON (key order aside — that is how a person happened to write it), every untouched one is still one no migration would change, nothing was added or removed since the plan. A person, an agent, a script — anyone may edit; the check does not trust any of them. A migration whose result fails the current schema stops the plan: no edit could pass.
The lock is the truth, not a formality. The lab apps' locks were written at
nisc.prism 0 — their source predated the marker — and moved through the loop.
init exists only for a source that never had one.
Open
midas captures its own corpus and keeps its own lock, on its own schedule.
Closed since this was written: npm publishing ran on 2026-10-03 (every package
shipped its first version with this in it), and lyceum adopted — its tables are
the lyceum.app sequence, its source has a lock, its documents are in the
corpus.