Change the model: swap the canon without rewriting the app
At a glance
Section titled “At a glance”-
Goal: move the application to a new canon version — or a new canon — by changing only the files that name the model, and approve the move with checks that fail loudly on incompatibility.
-
You need: the full application from chapters 1 through 7. This chapter changes it; the suite from Start guards every step.
-
Run:
Terminal node --import tsx src/cli.ts check-contractThe contract check opens the pinned spec offline, verifies every predicate the app sends or asks, and recomputes the hand-checked example case. Exit code 0 approves the spec; 1 names the failure.
-
Files: a compatible update touches one line; a same-app model swap touches the model-naming layer:
Output src/to-case.ts MODEL_SPEC string (every move), predicate names, units, policy, ADAPTER_VERSION bumpsrc/query.ts question names and argument shapessrc/missing.ts ASKABLE allowlist for the new predicatesform hints/types labels, widgets, generated declarations
Three kinds of model change
Section titled “Three kinds of model change”Three different moves hide under “change the model”, and only the first two keep the rest of the app:
- A compatible update (new canon build, same questions and facts). Only the spec string and the installed package change.
- A new model for the same application (a different periods canon with renamed predicates). The adapter, queries, and askable list change; the schema, reader, capture format, and server stay.
- A new domain (from periods to duties, positions, anything else). New input fields and new result rendering are legitimately needed — that is a different application, not a design failure.
No second published canon exists for this app today, so this chapter demonstrates the move as the check sequence, not as a live migration: the contract check that approves a spec, and the controlled refusal of one that cannot serve the app.
What moves and what stays
Section titled “What moves and what stays”The files listed under Files above name the model. Everything else stays untouched by moves 1 and 2:
src/input-schema.ts the form shape does not follow the canonsrc/read-result.ts the four AppResult kinds are engine-widesrc/capture.ts CAPTURE_FORMAT deadline-app.capture/1 is the app's ownsrc/server.ts routes and the example API do not moveThat split is deliberate: the adapter, the queries, and the askable list name the model; the schema, the reader, the capture format, and the server do not. Move 3 — a new domain — justifiably changes the second group too.
Follow the migration checklist
Section titled “Follow the migration checklist”The move follows a checklist, in order:
- Pin the new spec and install the new canon package. Nothing else may change in this step.
- Ask the manifest what the new model offers:
questions('en')returns its inventory (package,questions,facts,symbols,rules, and more) — read what the new build declares. - Check each predicate the application sends or asks with
passport().inModel(name). A question the new model does not carry is an integration error — the call raisesFactError(unknown relation), it does not quietly answerNEITHER. Find that out here, not from a confused user: an undeclared predicate, an underived claim, and a missing user fact are three different states with three different handlers. - Update
to-case.ts: predicate names, quantity units, the deadline policy URN, and bumpADAPTER_VERSION(currently1.0.0) — the capture records which adapter wrote it, and a silent adapter change would corrupt that. - Update
query.tsfor renamed questions and theASKABLElist inmissing.tsfor the new suppliable names. - Refresh form hints and generated types from the new manifest, so the editor completes the new names and rejects the old ones.
Run the contract check
Section titled “Run the contract check”The contract check automates steps 2 and 3 plus the hand-counted example expectation:
// Every predicate name this app depends on: facts it sends plus the// question it asks.export const APP_PREDICATES = ['frist_ereignis', 'frist_dauer_tage', 'frist_ende'];node --import tsx src/cli.ts check-contractspec: de.bgb.fristen@0.1.0 (opened de.bgb.fristen@0.1.0)pass open — opened de.bgb.fristen@0.1.0 offlinepass inModel(frist_ereignis) — frist_ereignis is executable in de.bgb.fristen@0.1.0pass inModel(frist_dauer_tage) — frist_dauer_tage is executable in de.bgb.fristen@0.1.0pass inModel(frist_ende) — frist_ende is executable in de.bgb.fristen@0.1.0pass example-case — example case computes 2026-03-20An unknown spec fails closed with a named reason and exit code 1 — it never throws an unhandled error:
node --import tsx src/cli.ts check-contract --spec de.bgb.fristen@9.9.9spec: de.bgb.fristen@9.9.9 (not opened)FAIL open — cannot open de.bgb.fristen@9.9.9 offline: package de.bgb.fristen@9.9.9 is not in a canon package or cache (offline)Approve the move with three checks
Section titled “Approve the move with three checks”The three checks that approve the move, in order:
# 1. the spec serves this app (exit 1 on any failure)node --import tsx src/cli.ts check-contract --spec <new-spec># 2. the compiler accepts the new namesnpm run typecheck# 3. the suite passes with the new expectationsnpm testinModel failing means the predicate is not executable — stop and
pick names the model carries. The type check failing means a renamed
predicate or a mistyped application object slipped through — the
generated declarations caught the name before any run. But the
compiler does not check arity or per-predicate argument types: the
published signature takes args: FactArg[], a plain array, so a
missing, extra, or mistyped argument still compiles. That
conformance is the SDK’s runtime job (FactError on unknown
relations and wrong arity), and the suite’s job is the rest — if it
fails on values, the new model computes differently, so read the
diff as a change in the law’s formalization, update the expectation
deliberately, and record why.
When all three pass, the move is approved:
check-contract .................. all pass, exit 0npm run typecheck ............... cleannpm test ........................ all passingADAPTER_VERSION ................. bumped when the mapping changes, recorded in capturesThree levels, three owners: the compiler checks names and shapes
the declarations express; the SDK at runtime checks the request
against the relation’s declaration; the scenario tests check the
computed answers. Predicate-indexed tuple types would move the
arity check left, but the SDK does not generate them — do not read
that guarantee into FactArg[].
Limits and errors
Section titled “Limits and errors”FactErrorwith anearestlist after the swap means a predicate was renamed in the new model. The fix lands into-case.ts(facts) orquery.ts(questions) — never in the reader or the view.NEITHEReverywhere after the swap usually means the questions are asked of a model that declares but no longer derives them.inModelconfirms or excludes that in one call — and if a predicate is not declared at all, expectFactError, notNEITHER.- Captures keep their
modelspec; old captures replay against the old canon, not the new one. Replaying an old capture against a different open model is refused with the spec mismatch — versions are never mixed silently.
Check your understanding
Section titled “Check your understanding”Run the contract check against the pinned spec and an unknown one:
node --import tsx src/cli.ts check-contractnode --import tsx src/cli.ts check-contract --spec de.bgb.fristen@9.9.9; echo "exit: $?"pass open — opened de.bgb.fristen@0.1.0 offlinepass example-case — example case computes 2026-03-20FAIL open — cannot open de.bgb.fristen@9.9.9 offline: package de.bgb.fristen@9.9.9 is not in a canon package or cache (offline)exit: 1Predict before you open each answer.
The new canon renames `frist_dauer_tage`, and you forget to update `to-case.ts`. What does the app see?
check-contract fails on inModel(frist_dauer_tage), and a call that
sends the old name raises FactError with a nearest list. It does
not quietly answer NEITHER: an undeclared predicate is an
integration error. The fix lands in to-case.ts.
A fact is sent with one argument too many. Does `npm run typecheck` catch it?
No. The published signature takes args: FactArg[], a plain array, so
a wrong arity still compiles. The SDK catches it at runtime with
FactError, and the scenario tests check the computed answers.
When do you bump `ADAPTER_VERSION`?
When the form-to-facts mapping changes — predicate names, units, or
the policy in to-case.ts. Every capture records the adapter that
wrote it, and a silent mapping change would corrupt that record.
- Troubleshooting
- Connect an AI assistant to explore the new model first
- Loading and pinning and compatibility before swapping the spec
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.