docs← Back to article

Markdown for LLMs

Change the model: swap the canon without rewriting the app

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Change the model: swap the canon without rewriting the app

## 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](/build/start/) guards
  every step.
- **Run:**

  ```bash
  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:

  ```text
  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 kinds of model change

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.

## What moves and what stays

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

```text
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.

## Follow the migration checklist

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.

## Run the contract check

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

```ts
// 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'];
```

```bash
node --import tsx src/cli.ts check-contract
```

```text
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:

```bash
node --import tsx src/cli.ts check-contract --spec de.bgb.fristen@9.9.9
```

```text
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)
```

## Approve the move with three checks

The three checks that approve the move, in order:

```bash
# 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:

```text
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[]`.

## Limits and errors

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

## Check your understanding

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

```bash
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: $?"
```

```text
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.

<details>
<summary>The new canon renames `frist_dauer_tage`, and you forget to update `to-case.ts`. What does the app see?</summary>

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

</details>

<details>
<summary>A fact is sent with one argument too many. Does `npm run typecheck` catch it?</summary>

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.

</details>

<details>
<summary>When do you bump `ADAPTER_VERSION`?</summary>

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.

</details>

## Next

- [Troubleshooting](/build/troubleshooting/)
- [Connect an AI assistant](/guide/mcp/) to explore the new model first
- [Loading and pinning](/build/sdk/loading-and-pinning/) and [compatibility](/build/sdk/compatibility/) before swapping the spec