Skip to content
docs
Arxo ↗

Change the model: swap the canon without rewriting the app

For LLMs9 sections
  • 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-contract

    The 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 bump
    src/query.ts question names and argument shapes
    src/missing.ts ASKABLE allowlist for the new predicates
    form hints/types labels, widgets, generated declarations

Three different moves hide under “change the model”, and only the first two keep the rest of the app:

  1. A compatible update (new canon build, same questions and facts). Only the spec string and the installed package change.
  2. 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.
  3. 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.

The files listed under Files above name the model. Everything else stays untouched by moves 1 and 2:

Output
src/input-schema.ts the form shape does not follow the canon
src/read-result.ts the four AppResult kinds are engine-wide
src/capture.ts CAPTURE_FORMAT deadline-app.capture/1 is the app's own
src/server.ts routes and the example API do not move

That 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.

The move follows a checklist, in order:

  1. Pin the new spec and install the new canon package. Nothing else may change in this step.
  2. 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.
  3. 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 raises FactError (unknown relation), it does not quietly answer NEITHER. 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.
  4. Update to-case.ts: predicate names, quantity units, the deadline policy URN, and bump ADAPTER_VERSION (currently 1.0.0) — the capture records which adapter wrote it, and a silent adapter change would corrupt that.
  5. Update query.ts for renamed questions and the ASKABLE list in missing.ts for the new suppliable names.
  6. Refresh form hints and generated types from the new manifest, so the editor completes the new names and rejects the old ones.

The contract check automates steps 2 and 3 plus the hand-counted example expectation:

src/check-contract.ts
// 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'];
Terminal
node --import tsx src/cli.ts check-contract
Output
spec: de.bgb.fristen@0.1.0 (opened de.bgb.fristen@0.1.0)
pass open — opened de.bgb.fristen@0.1.0 offline
pass inModel(frist_ereignis) — frist_ereignis is executable in de.bgb.fristen@0.1.0
pass inModel(frist_dauer_tage) — frist_dauer_tage is executable in de.bgb.fristen@0.1.0
pass inModel(frist_ende) — frist_ende is executable in de.bgb.fristen@0.1.0
pass example-case — example case computes 2026-03-20

An unknown spec fails closed with a named reason and exit code 1 — it never throws an unhandled error:

Terminal
node --import tsx src/cli.ts check-contract --spec de.bgb.fristen@9.9.9
Output
spec: 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)

The three checks that approve the move, in order:

Terminal
# 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 names
npm run typecheck
# 3. the suite passes with the new expectations
npm test

inModel 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:

Output
check-contract .................. all pass, exit 0
npm run typecheck ............... clean
npm test ........................ all passing
ADAPTER_VERSION ................. bumped when the mapping changes, recorded in captures

Three 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[].

  • FactError with a nearest list after the swap means a predicate was renamed in the new model. The fix lands in to-case.ts (facts) or query.ts (questions) — never in the reader or the view.
  • NEITHER everywhere after the swap usually means the questions are asked of a model that declares but no longer derives them. inModel confirms or excludes that in one call — and if a predicate is not declared at all, expect FactError, not NEITHER.
  • Captures keep their model spec; 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.

Run the contract check against the pinned spec and an unknown one:

Terminal
node --import tsx src/cli.ts check-contract
node --import tsx src/cli.ts check-contract --spec de.bgb.fristen@9.9.9; echo "exit: $?"
Output
pass open — opened de.bgb.fristen@0.1.0 offline
pass example-case — example case computes 2026-03-20
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)
exit: 1

Predict 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.

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.