From d887f93d64408d428785db3ffb9d53b8c1cd2a88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 07:42:02 +0000 Subject: [PATCH 1/5] docs: spec for `mops deployed` command Co-authored-by: Kamil Listopad --- specs/mops-deployed.md | 204 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 specs/mops-deployed.md diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md new file mode 100644 index 00000000..977ac47e --- /dev/null +++ b/specs/mops-deployed.md @@ -0,0 +1,204 @@ +# Spec: `mops deployed` + +Status: Draft / proposal + +## Problem + +`mops build` emits three artifacts from a single `moc` invocation into the output +dir (`.mops/.build` by default): `.wasm`, `.did`, `.most`. They +are internally consistent by construction. + +The `.wasm` is what gets deployed. The `.did` (Candid interface) and `.most` +(stable signature) are the **deployed reference**: the inputs to the next upgrade's +safety checks — + +- `mops check-stable` runs `moc --stable-compatible `, +- candid compatibility compares `` against `[canisters.].candid`. + +For these checks to be meaningful, the committed reference (`[canisters.].candid` +and `[canisters..check-stable].path`) **must match the code currently running +on-chain**. Today that's maintained by hand: deploy, then remember to copy the new +`.did`/`.most` into the committed paths. Forget, and the reference drifts from +on-chain reality → false passes (silent data loss on upgrade) or false failures. + +`mops deployed` makes advancing the deployed reference a first-class, scriptable +step that the [icp-cli Motoko recipe](https://github.com/dfinity/icp-cli-recipes) +can call in its post-install `sync` phase. + +## Goals + +- Promote the **already-built** `.did`/`.most` into the configured reference paths. +- Bootstrap baseline reference files before the first deployment. +- Be DRY: reference paths live only in `mops.toml`; callers pass only a canister + name (mirrors how the recipe already delegates building via `mops build `). +- Follow existing canister-selection conventions (`mops build` / `mops check`). + +## Non-goals + +- **No regeneration / compilation.** The command never runs `moc`. It strictly + reuses the artifacts left by `mops build`, so the reference can never decouple + from the deployed wasm. If the artifacts are missing, it errors. +- **No reading from chain or wasm metadata.** Files are managed locally and + committed to the repo. +- **No network/environment dimension** (see Open Questions). + +## Why "deployed update", not "deployed sync" + +`update`, for these reasons: + +- `mops sync` already exists and means "reconcile `mops.toml` deps with code + imports". Reusing `sync` for a different reconcile target is confusing. +- `icp` already calls its post-install phase `sync`; `mops deployed sync` inside an + `icp sync` step reads badly. +- The action is "advance the saved reference forward to the just-deployed version" + — an update/bump, not a two-way reconciliation. It matches the existing semantics + of `mops update` and `mops toolchain update` ("move forward to newer"). + +## Command surface + +``` +mops deployed update [canisters...] # promote built .did/.most → reference paths +mops deployed init [canisters...] # create baseline reference files (first deploy) +mops deployed status [canisters...] # (extension) report drift, no writes +``` + +`deployed` is a subcommand group, like `mops toolchain`. + +### `mops deployed update [canisters...]` + +The main use case. For each selected canister ``: + +| Artifact | Source | Destination | +|----------|--------------------------------|----------------------------------------------| +| Candid | `/.did` | `[canisters.].candid` | +| Stable | `/.most` | `[canisters..check-stable].path` | + +- **Source dir**: `[build].outputDir ?? .mops/.build`, overridable with + `--output ` (mirrors `mops build --output`, so the recipe can pass the same + value it built with). +- **Copy-or-error**: if a source artifact is missing, error + (`No built artifact found at . Run \`mops build \` first.`). Never + regenerate. +- **Create parent dirs** of destinations (`mkdir -p`), e.g. + `.old/src/backend/dist/backend.most`. +- **Missing destination config**: if a canister has `candid` but no `check-stable` + (or vice-versa), update the configured one and note the skipped one. If a canister + selected **by name** has neither configured → error. When iterating **all** + canisters, silently skip those with neither. +- Always overwrites the destination — advancing the reference is the point. + +Flags: + +- `--output ` — source directory (default as above). +- `--check` — CI gate. No writes; exit non-zero if any destination differs from the + built artifact (or is missing). Lets CI enforce "the committed reference matches + the latest build" (run `mops build && mops deployed update --check`). + +### `mops deployed init [canisters...]` + +Bootstrap the reference before the first deployment, so the very first +`mops check` has a baseline to compare against. For each selected canister, create +any configured reference file that does **not** yet exist: + +- `check-stable` path ← empty-actor stable signature: + ```most + // Version: 1.0.0 + actor { }; + ``` +- `candid` path ← empty service: `service : {}` (any future interface is a valid + supertype, so the first candid check passes). *Exact baseline content TBD — see + Open Questions.* + +Never overwrites existing files; `--force` to recreate. This replaces the manual +"create a trivial `.most`" step currently documented for new projects. + +### `mops deployed status [canisters...]` (extension) + +Read-only drift report: for each canister, show whether the committed reference +matches the latest built artifact (in sync / stale / missing / not built). Same +information as `update --check` but human-readable and non-failing. Useful before +deciding to deploy. + +## Canister selection (single vs multiple) + +Identical to `mops build` / `mops check`: + +- **No argument** → all canisters that have a reference configured. A + single-canister project therefore "just works" with a bare + `mops deployed update` — no special-casing. +- **One or more names** → only those canisters; unknown names error + (`filterCanisters`), and a named canister with no reference config errors + (strict, like `check-stable`'s `required` path). + +The icp-cli integration always uses the **named** form: `icp` expands the recipe +**once per canister**, so the recipe's `sync` step runs per canister with its own +name. Multi-canister projects are thus handled by `icp` iterating, not by the +command fanning out: + +```yaml +# recipes/motoko/recipe.hbs (sync phase) +sync: + steps: + - type: script + commands: + - mops deployed update "{{ _.canister.name }}" --output .mops/.build +``` + +The no-argument "all" form is for humans running mops directly. + +## Configuration + +No new `mops.toml` fields. The command reads the existing reference paths: + +```toml +[canisters.backend] +main = "src/backend/main.mo" +candid = "src/backend/backend.did" # ← did destination (also the compat-check input) + +[canisters.backend.check-stable] +path = ".old/src/backend/dist/backend.most" # ← most destination (also the stable-check input) +``` + +This is the DRY payoff: the same paths that are already the check **inputs** are the +promotion **outputs**. Callers (recipe, CI, humans) reference only the canister name. + +## Lifecycle + +``` +# once, before first deploy +mops deployed init backend # writes empty baselines + +# every change +mops check backend # new vs committed baselines +mops build backend # produces .mops/.build/backend.{wasm,did,most} +icp deploy # installs the wasm, then sync runs: + mops deployed update backend # advances baselines → committed paths +git add ... && git commit # commit the new deployed reference +``` + +## Edge cases + +- **Migrations**: `mops build` already injects `--enhanced-migration` when + `[canisters..migrations]` is set, so the emitted `.most` reflects it. The + command copies as-is; no migration awareness needed. +- **`--output` mismatch**: the recipe forces `--output .mops/.build` for its build; + `mops deployed update` must read the same dir. Both default to + `[build].outputDir ?? .mops/.build`; the recipe passes `--output` explicitly to + guarantee a match. +- **Standalone `mops deployed update` with a stale/empty output dir**: errors + (copy-or-error). This is intentional — it prevents promoting artifacts that don't + correspond to a fresh build. + +## Open questions + +1. **Network/environment dimension.** `icp` environments can deploy different + versions to `staging` vs `ic`, but `mops.toml` has a single reference per + canister. The reference currently means "the latest deployed version" (typically + prod). A future `--env`/per-environment reference path may be needed if teams + deploy divergent builds. +2. **Candid baseline for `init`.** Confirm `service : {}` is accepted by the candid + compatibility check as a valid "old" interface, or pick the right empty form. +3. **Deployed manifest (speculative).** `mops deployed` could also record a small + manifest (wasm hash, `moc` version, timestamp) per canister to make "what is + deployed" auditable from the repo — without touching the chain. Out of scope for + v1. From 26c5b22e2508db38bc99e554b9283b53993da973 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 07:55:23 +0000 Subject: [PATCH 2/5] docs: refine `mops deployed` spec (stable/candid asymmetry, init config, partial tracking) Co-authored-by: Kamil Listopad --- specs/mops-deployed.md | 192 +++++++++++++++++++++++++---------------- 1 file changed, 120 insertions(+), 72 deletions(-) diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md index 977ac47e..b3fd4cf9 100644 --- a/specs/mops-deployed.md +++ b/specs/mops-deployed.md @@ -8,18 +8,16 @@ Status: Draft / proposal dir (`.mops/.build` by default): `.wasm`, `.did`, `.most`. They are internally consistent by construction. -The `.wasm` is what gets deployed. The `.did` (Candid interface) and `.most` -(stable signature) are the **deployed reference**: the inputs to the next upgrade's -safety checks — +The `.wasm` is what gets deployed. The `.most` (stable signature) is the **deployed +reference**: the input to the next upgrade's stable-compatibility check — -- `mops check-stable` runs `moc --stable-compatible `, -- candid compatibility compares `` against `[canisters.].candid`. +- `mops check-stable` runs `moc --stable-compatible `. -For these checks to be meaningful, the committed reference (`[canisters.].candid` -and `[canisters..check-stable].path`) **must match the code currently running +For that check to be meaningful, the committed reference +(`[canisters..check-stable].path`) **must match the code currently running on-chain**. Today that's maintained by hand: deploy, then remember to copy the new -`.did`/`.most` into the committed paths. Forget, and the reference drifts from -on-chain reality → false passes (silent data loss on upgrade) or false failures. +`.most` into the committed path. Forget, and the reference drifts from on-chain +reality → false passes (silent data loss on upgrade) or false failures. `mops deployed` makes advancing the deployed reference a first-class, scriptable step that the [icp-cli Motoko recipe](https://github.com/dfinity/icp-cli-recipes) @@ -27,8 +25,10 @@ can call in its post-install `sync` phase. ## Goals -- Promote the **already-built** `.did`/`.most` into the configured reference paths. -- Bootstrap baseline reference files before the first deployment. +- Promote the **already-built** stable signature (`.most`) into the configured + reference path. +- Bootstrap the reference — both the baseline file **and** its `mops.toml` entry — + before the first deployment, so users don't configure paths by hand. - Be DRY: reference paths live only in `mops.toml`; callers pass only a canister name (mirrors how the recipe already delegates building via `mops build `). - Follow existing canister-selection conventions (`mops build` / `mops check`). @@ -40,8 +40,41 @@ can call in its post-install `sync` phase. from the deployed wasm. If the artifacts are missing, it errors. - **No reading from chain or wasm metadata.** Files are managed locally and committed to the repo. +- **No auto-management of the embedded `candid` file** (see asymmetry below). - **No network/environment dimension** (see Open Questions). +## Stable vs Candid: an important asymmetry + +The `.most` and `.did` references are **not** symmetric in mops, which determines +what `mops deployed` can safely manage. + +- **`.most` via `[check-stable].path`** is a pure *deployed snapshot*. mops only ever + reads it (`moc --stable-compatible `) and never embeds + it anywhere. It is meant to lag behind the working tree — it represents what is + on-chain. Advancing it after a deploy is exactly correct. ✅ safe to auto-manage. + +- **`.did` via `[canisters.].candid`** is a *curated interface input*, not a + snapshot. During `mops build`, when `candid` is set, mops: + 1. checks the freshly generated interface is compatible with the committed + `candid` file, then + 2. **embeds the committed `candid` file** (not the generated one) into the wasm as + `candid:service` (`cli/commands/build.ts:184-200`). + + So `candid` is expected to be *ahead of or equal to* the code — you declare the + interface you want and the build conforms to it. If `mops deployed` overwrote + `candid` with the just-built `.did` *after* deploying, the reference would lag one + version behind, and the **next** build would embed the previous interface into the + new wasm — i.e. ship a wasm advertising the wrong Candid. ❌ unsafe to auto-manage + by overwriting `candid`. + +**Conclusion:** `mops deployed` manages the **stable** reference. Candid is +intentionally out of scope for auto-update in v1 — its backward-compat is already +enforced by the curated `candid` + build check. A rolling *deployed-candid* +snapshot, if ever wanted, needs its **own** config field, separate from the embedded +`candid` (see Open Questions). This also directly answers "what if there's a most +path but no candid?": that is the **normal, recommended** configuration, not a +degraded one. + ## Why "deployed update", not "deployed sync" `update`, for these reasons: @@ -57,8 +90,8 @@ can call in its post-install `sync` phase. ## Command surface ``` -mops deployed update [canisters...] # promote built .did/.most → reference paths -mops deployed init [canisters...] # create baseline reference files (first deploy) +mops deployed update [canisters...] # promote built .most → reference path +mops deployed init [canisters...] # configure + create baseline reference (first deploy) mops deployed status [canisters...] # (extension) report drift, no writes ``` @@ -66,74 +99,84 @@ mops deployed status [canisters...] # (extension) report drift, no writes ### `mops deployed update [canisters...]` -The main use case. For each selected canister ``: - -| Artifact | Source | Destination | -|----------|--------------------------------|----------------------------------------------| -| Candid | `/.did` | `[canisters.].candid` | -| Stable | `/.most` | `[canisters..check-stable].path` | +The main use case. For each selected canister ``, copy +`/.most` → `[canisters..check-stable].path`. - **Source dir**: `[build].outputDir ?? .mops/.build`, overridable with - `--output ` (mirrors `mops build --output`, so the recipe can pass the same + `--output ` (mirrors `mops build --output`, so the recipe passes the same value it built with). -- **Copy-or-error**: if a source artifact is missing, error - (`No built artifact found at . Run \`mops build \` first.`). Never +- **Copy-or-error**: if `/.most` is missing, error + (`No built stable signature at . Run \`mops build \` first.`). Never regenerate. -- **Create parent dirs** of destinations (`mkdir -p`), e.g. - `.old/src/backend/dist/backend.most`. -- **Missing destination config**: if a canister has `candid` but no `check-stable` - (or vice-versa), update the configured one and note the skipped one. If a canister - selected **by name** has neither configured → error. When iterating **all** - canisters, silently skip those with neither. +- **Create parent dirs** of the destination (`mkdir -p`). - Always overwrites the destination — advancing the reference is the point. Flags: - `--output ` — source directory (default as above). -- `--check` — CI gate. No writes; exit non-zero if any destination differs from the - built artifact (or is missing). Lets CI enforce "the committed reference matches - the latest build" (run `mops build && mops deployed update --check`). +- `--check` — CI gate. No writes; exit non-zero if the destination differs from the + built `.most` (or is missing). Lets CI enforce "the committed reference matches the + latest build" (run `mops build && mops deployed update --check`). + +**When a canister has no `check-stable` configured:** + +- Selected **by name** → error, pointing at `mops deployed init `. +- Iterating **all** (no argument) → skip it, but report it in the summary so the user + isn't left wondering why nothing happened, e.g. + `Updated 1 canister (backend). Skipped 2 with no deployed reference: frontend, ledger. Run \`mops deployed init \` to set one up.` ### `mops deployed init [canisters...]` -Bootstrap the reference before the first deployment, so the very first -`mops check` has a baseline to compare against. For each selected canister, create -any configured reference file that does **not** yet exist: +Prepare a canister for deployed-reference tracking so the very first `mops check` +has a baseline — without the user editing `mops.toml` by hand. For each selected +canister: + +1. If `[canisters..check-stable].path` is **not** set, add it to `mops.toml` + using the default convention `deployed/.most`. Existing paths are never + changed. +2. If the file at that path does not exist, create it with an empty-actor baseline: + ```most + // Version: 1.0.0 + actor { }; + ``` + +Idempotent: re-running is a no-op once configured and present. `--force` recreates +the baseline file (it never rewrites an existing configured path). + +This replaces the manual "create a trivial `.most`" step currently documented for +new projects. -- `check-stable` path ← empty-actor stable signature: - ```most - // Version: 1.0.0 - actor { }; - ``` -- `candid` path ← empty service: `service : {}` (any future interface is a valid - supertype, so the first candid check passes). *Exact baseline content TBD — see - Open Questions.* +> **Candid is not configured by `init`.** Setting `candid` changes build behavior +> (compat check + embedding), so `init` must not add it implicitly. Users who want a +> curated `candid` interface set it themselves, as today. -Never overwrites existing files; `--force` to recreate. This replaces the manual -"create a trivial `.most`" step currently documented for new projects. +> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → +> `TOML.stringify` (`cli/mops.ts:229`), which reserializes the whole file and drops +> comments / custom formatting. This already happens for `mops add` / `mops remove`, +> so it is accepted behavior, but `init` should only write when it actually adds a +> missing path (no-op writes must be avoided). ### `mops deployed status [canisters...]` (extension) Read-only drift report: for each canister, show whether the committed reference -matches the latest built artifact (in sync / stale / missing / not built). Same -information as `update --check` but human-readable and non-failing. Useful before -deciding to deploy. +matches the latest built `.most` (in sync / stale / missing / not built). Same +information as `update --check` but human-readable and non-failing. ## Canister selection (single vs multiple) Identical to `mops build` / `mops check`: -- **No argument** → all canisters that have a reference configured. A - single-canister project therefore "just works" with a bare - `mops deployed update` — no special-casing. +- **No argument** → all canisters that have a stable reference configured (for + `update`/`status`) or all `[canisters]` entries (for `init`). A single-canister + project therefore "just works" with a bare command — no special-casing. - **One or more names** → only those canisters; unknown names error - (`filterCanisters`), and a named canister with no reference config errors - (strict, like `check-stable`'s `required` path). + (`filterCanisters`), and for `update` a named canister with no reference config + errors (strict, like `check-stable`'s `required` path). -The icp-cli integration always uses the **named** form: `icp` expands the recipe +The icp-cli integration always uses the **named** form. `icp` expands the recipe **once per canister**, so the recipe's `sync` step runs per canister with its own -name. Multi-canister projects are thus handled by `icp` iterating, not by the -command fanning out: +name. Multi-canister projects are handled by `icp` iterating, not by the command +fanning out: ```yaml # recipes/motoko/recipe.hbs (sync phase) @@ -148,31 +191,31 @@ The no-argument "all" form is for humans running mops directly. ## Configuration -No new `mops.toml` fields. The command reads the existing reference paths: +No new `mops.toml` fields. `update` reads the existing stable reference path; `init` +can populate it: ```toml [canisters.backend] -main = "src/backend/main.mo" -candid = "src/backend/backend.did" # ← did destination (also the compat-check input) +main = "src/backend/main.mo" [canisters.backend.check-stable] -path = ".old/src/backend/dist/backend.most" # ← most destination (also the stable-check input) +path = "deployed/backend.most" # ← read by update; added by init if missing ``` -This is the DRY payoff: the same paths that are already the check **inputs** are the -promotion **outputs**. Callers (recipe, CI, humans) reference only the canister name. +This is the DRY payoff: the same path that is already the check **input** is the +promotion **output**. Callers (recipe, CI, humans) reference only the canister name. ## Lifecycle ``` -# once, before first deploy -mops deployed init backend # writes empty baselines +# once, before first deploy — configures mops.toml + writes empty baseline +mops deployed init backend # every change -mops check backend # new vs committed baselines +mops check backend # new vs committed baseline mops build backend # produces .mops/.build/backend.{wasm,did,most} icp deploy # installs the wasm, then sync runs: - mops deployed update backend # advances baselines → committed paths + mops deployed update backend # advances baseline → committed path git add ... && git commit # commit the new deployed reference ``` @@ -185,20 +228,25 @@ git add ... && git commit # commit the new deployed reference `mops deployed update` must read the same dir. Both default to `[build].outputDir ?? .mops/.build`; the recipe passes `--output` explicitly to guarantee a match. -- **Standalone `mops deployed update` with a stale/empty output dir**: errors - (copy-or-error). This is intentional — it prevents promoting artifacts that don't - correspond to a fresh build. +- **Standalone `update` with a stale/empty output dir**: errors (copy-or-error). + Intentional — prevents promoting artifacts that don't correspond to a fresh build. ## Open questions -1. **Network/environment dimension.** `icp` environments can deploy different +1. **Default path convention for `init`.** Proposed `deployed/.most`. A + `deployed/` dir is self-documenting and groups references; alternatives are next + to source or an `.old/` mirror (as in current docs). Pick one. +2. **Network/environment dimension.** `icp` environments can deploy different versions to `staging` vs `ic`, but `mops.toml` has a single reference per canister. The reference currently means "the latest deployed version" (typically prod). A future `--env`/per-environment reference path may be needed if teams deploy divergent builds. -2. **Candid baseline for `init`.** Confirm `service : {}` is accepted by the candid - compatibility check as a valid "old" interface, or pick the right empty form. -3. **Deployed manifest (speculative).** `mops deployed` could also record a small +3. **Deployed-candid reference (separate field).** If teams want rolling Candid + backward-compat against the *deployed* interface (not just a curated one), add a + dedicated field — e.g. `[canisters..check-candid].path` — that mops reads + for a `--candid-compatible`-style check and that `mops deployed update` may safely + write, **without** touching the embedded `candid`. Out of scope for v1. +4. **Deployed manifest (speculative).** `mops deployed` could also record a small manifest (wasm hash, `moc` version, timestamp) per canister to make "what is deployed" auditable from the repo — without touching the chain. Out of scope for v1. From 6ad643bac6135ee951a9c2397e941e77590c039c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 09:07:18 +0000 Subject: [PATCH 3/5] docs: rework `mops deployed` spec around committed deployed/ snapshot (most + did) Co-authored-by: Kamil Listopad --- specs/mops-deployed.md | 285 +++++++++++++++++++---------------------- 1 file changed, 134 insertions(+), 151 deletions(-) diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md index b3fd4cf9..20fe42dd 100644 --- a/specs/mops-deployed.md +++ b/specs/mops-deployed.md @@ -8,90 +8,87 @@ Status: Draft / proposal dir (`.mops/.build` by default): `.wasm`, `.did`, `.most`. They are internally consistent by construction. -The `.wasm` is what gets deployed. The `.most` (stable signature) is the **deployed -reference**: the input to the next upgrade's stable-compatibility check — - -- `mops check-stable` runs `moc --stable-compatible `. - -For that check to be meaningful, the committed reference -(`[canisters..check-stable].path`) **must match the code currently running -on-chain**. Today that's maintained by hand: deploy, then remember to copy the new -`.most` into the committed path. Forget, and the reference drifts from on-chain -reality → false passes (silent data loss on upgrade) or false failures. - -`mops deployed` makes advancing the deployed reference a first-class, scriptable -step that the [icp-cli Motoko recipe](https://github.com/dfinity/icp-cli-recipes) -can call in its post-install `sync` phase. +The `.wasm` is what gets deployed. The other two need to be **committed to the repo +as the deployed snapshot**, for two different consumers: + +- **`.most` (stable signature)** — input to the next upgrade's stable-compatibility + check: `mops check-stable` runs `moc --stable-compatible `. + It must match the code currently running on-chain or the check is meaningless + (false passes → silent data loss on upgrade; false failures → blocked upgrades). +- **`.did` (Candid interface)** — a committed record of the deployed interface, used + by external tooling: `@icp-sdk/bindgen` generates TypeScript clients from it, + `icp canister call` encodes arguments with it, and the repo reflects what's + deployed. **mops itself does not consume it** — it is a pure output artifact the + icp-cli integration asked mops to place. + +Today the snapshot is maintained by hand: deploy, then remember to copy the new +`.most`/`.did` into committed paths. Forget, and they drift from on-chain reality. + +`mops deployed` makes producing this snapshot a first-class, scriptable step the +[icp-cli Motoko recipe](https://github.com/dfinity/icp-cli-recipes) can call in its +post-install `sync` phase. ## Goals -- Promote the **already-built** stable signature (`.most`) into the configured - reference path. -- Bootstrap the reference — both the baseline file **and** its `mops.toml` entry — - before the first deployment, so users don't configure paths by hand. -- Be DRY: reference paths live only in `mops.toml`; callers pass only a canister - name (mirrors how the recipe already delegates building via `mops build `). +- Promote the **already-built** `.most` and `.did` into a committed `deployed/` + snapshot, reusing the exact artifacts that produced the deployed wasm. +- Bootstrap the stable reference — file **and** `mops.toml` entry — before the first + deployment, so users don't configure paths by hand. +- Be DRY: callers pass only a canister name (mirrors `mops build `). - Follow existing canister-selection conventions (`mops build` / `mops check`). ## Non-goals -- **No regeneration / compilation.** The command never runs `moc`. It strictly - reuses the artifacts left by `mops build`, so the reference can never decouple - from the deployed wasm. If the artifacts are missing, it errors. -- **No reading from chain or wasm metadata.** Files are managed locally and - committed to the repo. -- **No auto-management of the embedded `candid` file** (see asymmetry below). +- **No regeneration / compilation.** The command never runs `moc`; it strictly + copies the artifacts left by `mops build`. If they're missing, it errors. This is + what keeps the snapshot atomically tied to the deployed wasm. +- **No reading from chain or wasm metadata.** Files are managed locally and committed. +- **No Candid compatibility check.** mops does not consume the snapshot `.did`; a + rolling deployed-interface check (`check-candid`) is out of scope (see Open + Questions). +- **The existing `[canisters.].candid` field is ignored** — not read, not + written. It is an optional *curated interface to embed in the wasm* (a different, + opposite-in-time concept); see Appendix. - **No network/environment dimension** (see Open Questions). -## Stable vs Candid: an important asymmetry - -The `.most` and `.did` references are **not** symmetric in mops, which determines -what `mops deployed` can safely manage. - -- **`.most` via `[check-stable].path`** is a pure *deployed snapshot*. mops only ever - reads it (`moc --stable-compatible `) and never embeds - it anywhere. It is meant to lag behind the working tree — it represents what is - on-chain. Advancing it after a deploy is exactly correct. ✅ safe to auto-manage. - -- **`.did` via `[canisters.].candid`** is a *curated interface input*, not a - snapshot. During `mops build`, when `candid` is set, mops: - 1. checks the freshly generated interface is compatible with the committed - `candid` file, then - 2. **embeds the committed `candid` file** (not the generated one) into the wasm as - `candid:service` (`cli/commands/build.ts:184-200`). - - So `candid` is expected to be *ahead of or equal to* the code — you declare the - interface you want and the build conforms to it. If `mops deployed` overwrote - `candid` with the just-built `.did` *after* deploying, the reference would lag one - version behind, and the **next** build would embed the previous interface into the - new wasm — i.e. ship a wasm advertising the wrong Candid. ❌ unsafe to auto-manage - by overwriting `candid`. - -**Conclusion:** `mops deployed` manages the **stable** reference. Candid is -intentionally out of scope for auto-update in v1 — its backward-compat is already -enforced by the curated `candid` + build check. A rolling *deployed-candid* -snapshot, if ever wanted, needs its **own** config field, separate from the embedded -`candid` (see Open Questions). This also directly answers "what if there's a most -path but no candid?": that is the **normal, recommended** configuration, not a -degraded one. +## The `deployed/` snapshot + +All committed snapshot files for a canister live together, anchored on the single +path mops already consumes — `[canisters..check-stable].path`: + +- the `.most` is written to `[check-stable].path` (consumed by `check-stable`), +- the `.did` is written next to it, as `.did` in the same directory. + +`mops deployed init` defaults `[check-stable].path` to `deployed/.most`, so by +default both files land in a `deployed/` directory at the project root, giving the +icp side a predictable path (`deployed/.did`) to reference. Users who point +`[check-stable].path` elsewhere get both files in that directory instead. There is +exactly one anchor and no new config field. + +```toml +[canisters.backend] +main = "src/backend/main.mo" + +[canisters.backend.check-stable] +path = "deployed/backend.most" # ← anchor: most lands here, did lands beside it +``` ## Why "deployed update", not "deployed sync" `update`, for these reasons: - `mops sync` already exists and means "reconcile `mops.toml` deps with code - imports". Reusing `sync` for a different reconcile target is confusing. + imports". Reusing `sync` for a different target is confusing. - `icp` already calls its post-install phase `sync`; `mops deployed sync` inside an `icp sync` step reads badly. -- The action is "advance the saved reference forward to the just-deployed version" - — an update/bump, not a two-way reconciliation. It matches the existing semantics - of `mops update` and `mops toolchain update` ("move forward to newer"). +- The action is "advance the saved snapshot forward to the just-deployed version" — + an update/bump, matching `mops update` and `mops toolchain update`. ## Command surface ``` -mops deployed update [canisters...] # promote built .most → reference path -mops deployed init [canisters...] # configure + create baseline reference (first deploy) +mops deployed update [canisters...] # promote built .most + .did → deployed snapshot +mops deployed init [canisters...] # configure check-stable + write .most baseline mops deployed status [canisters...] # (extension) report drift, no writes ``` @@ -99,84 +96,78 @@ mops deployed status [canisters...] # (extension) report drift, no writes ### `mops deployed update [canisters...]` -The main use case. For each selected canister ``, copy -`/.most` → `[canisters..check-stable].path`. +The main use case. For each selected canister ``: + +- copy `/.most` → `[check-stable].path`, +- copy `/.did` → `/.did`. + +Details: - **Source dir**: `[build].outputDir ?? .mops/.build`, overridable with `--output ` (mirrors `mops build --output`, so the recipe passes the same value it built with). -- **Copy-or-error**: if `/.most` is missing, error - (`No built stable signature at . Run \`mops build \` first.`). Never - regenerate. -- **Create parent dirs** of the destination (`mkdir -p`). -- Always overwrites the destination — advancing the reference is the point. +- **Copy-or-error**: if a source artifact is missing, error + (`No built at . Run \`mops build \` first.`). Never regenerate. +- **Create parent dirs** of destinations (`mkdir -p`). +- Always overwrites — advancing the snapshot is the point. Flags: - `--output ` — source directory (default as above). -- `--check` — CI gate. No writes; exit non-zero if the destination differs from the - built `.most` (or is missing). Lets CI enforce "the committed reference matches the - latest build" (run `mops build && mops deployed update --check`). +- `--no-did` — write only the `.most` (for projects that don't want the interface + committed). Default is to write both. +- `--check` — CI gate. No writes; exit non-zero if a destination differs from the + built artifact (or is missing). Lets CI enforce "the committed snapshot matches the + latest build" (`mops build && mops deployed update --check`). -**When a canister has no `check-stable` configured:** +**When a canister has no `[check-stable]` configured** (no anchor): - Selected **by name** → error, pointing at `mops deployed init `. -- Iterating **all** (no argument) → skip it, but report it in the summary so the user - isn't left wondering why nothing happened, e.g. - `Updated 1 canister (backend). Skipped 2 with no deployed reference: frontend, ledger. Run \`mops deployed init \` to set one up.` +- Iterating **all** (no argument) → skip it, but report it in the summary, e.g. + `Updated 1 canister (backend). Skipped 2 with no deployed snapshot configured: frontend, ledger. Run \`mops deployed init \`.` ### `mops deployed init [canisters...]` -Prepare a canister for deployed-reference tracking so the very first `mops check` -has a baseline — without the user editing `mops.toml` by hand. For each selected -canister: +Prepare a canister for snapshotting so the first `mops check` has a baseline — +without hand-editing `mops.toml`. For each selected canister: 1. If `[canisters..check-stable].path` is **not** set, add it to `mops.toml` - using the default convention `deployed/.most`. Existing paths are never - changed. + as `deployed/.most`. Existing paths are never changed. 2. If the file at that path does not exist, create it with an empty-actor baseline: ```most // Version: 1.0.0 actor { }; ``` -Idempotent: re-running is a no-op once configured and present. `--force` recreates -the baseline file (it never rewrites an existing configured path). - -This replaces the manual "create a trivial `.most`" step currently documented for -new projects. +No `.did` baseline is created — mops doesn't consume the `.did`, so it simply appears +after the first `mops deployed update` (and `icp build` generates one on demand for +bindings before then). Idempotent; `--force` recreates the baseline file. -> **Candid is not configured by `init`.** Setting `candid` changes build behavior -> (compat check + embedding), so `init` must not add it implicitly. Users who want a -> curated `candid` interface set it themselves, as today. - -> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → -> `TOML.stringify` (`cli/mops.ts:229`), which reserializes the whole file and drops -> comments / custom formatting. This already happens for `mops add` / `mops remove`, -> so it is accepted behavior, but `init` should only write when it actually adds a -> missing path (no-op writes must be avoided). +> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → `TOML.stringify` +> (`cli/mops.ts:229`), which reserializes the whole file and drops comments / custom +> formatting. This already happens for `mops add` / `mops remove`, so it's accepted +> behavior, but `init` must only write when it actually adds a missing path. ### `mops deployed status [canisters...]` (extension) -Read-only drift report: for each canister, show whether the committed reference -matches the latest built `.most` (in sync / stale / missing / not built). Same -information as `update --check` but human-readable and non-failing. +Read-only drift report: per canister, whether the committed snapshot matches the +latest built artifacts (in sync / stale / missing / not built). Same info as +`update --check` but human-readable and non-failing. ## Canister selection (single vs multiple) Identical to `mops build` / `mops check`: -- **No argument** → all canisters that have a stable reference configured (for +- **No argument** → all canisters with `[check-stable]` configured (for `update`/`status`) or all `[canisters]` entries (for `init`). A single-canister - project therefore "just works" with a bare command — no special-casing. -- **One or more names** → only those canisters; unknown names error - (`filterCanisters`), and for `update` a named canister with no reference config - errors (strict, like `check-stable`'s `required` path). + project "just works" with a bare command — no special-casing. +- **One or more names** → only those; unknown names error (`filterCanisters`), and + for `update` a named canister with no anchor errors (strict, like `check-stable`). The icp-cli integration always uses the **named** form. `icp` expands the recipe -**once per canister**, so the recipe's `sync` step runs per canister with its own -name. Multi-canister projects are handled by `icp` iterating, not by the command -fanning out: +**once per canister**, so the `sync` step runs per canister with its own name — +multi-canister projects are handled by `icp` iterating, not by the command fanning +out: ```yaml # recipes/motoko/recipe.hbs (sync phase) @@ -187,66 +178,58 @@ sync: - mops deployed update "{{ _.canister.name }}" --output .mops/.build ``` -The no-argument "all" form is for humans running mops directly. - -## Configuration - -No new `mops.toml` fields. `update` reads the existing stable reference path; `init` -can populate it: - -```toml -[canisters.backend] -main = "src/backend/main.mo" - -[canisters.backend.check-stable] -path = "deployed/backend.most" # ← read by update; added by init if missing -``` - -This is the DRY payoff: the same path that is already the check **input** is the -promotion **output**. Callers (recipe, CI, humans) reference only the canister name. - ## Lifecycle ``` -# once, before first deploy — configures mops.toml + writes empty baseline +# once, before first deploy — configures mops.toml + writes empty .most baseline mops deployed init backend # every change -mops check backend # new vs committed baseline +mops check backend # new vs committed .most baseline mops build backend # produces .mops/.build/backend.{wasm,did,most} icp deploy # installs the wasm, then sync runs: - mops deployed update backend # advances baseline → committed path -git add ... && git commit # commit the new deployed reference + mops deployed update backend # snapshots .most + .did into deployed/ +git add deployed/ && git commit # commit the new deployed snapshot ``` ## Edge cases - **Migrations**: `mops build` already injects `--enhanced-migration` when - `[canisters..migrations]` is set, so the emitted `.most` reflects it. The - command copies as-is; no migration awareness needed. -- **`--output` mismatch**: the recipe forces `--output .mops/.build` for its build; - `mops deployed update` must read the same dir. Both default to - `[build].outputDir ?? .mops/.build`; the recipe passes `--output` explicitly to - guarantee a match. + `[canisters..migrations]` is set, so the emitted `.most` reflects it; the + command copies as-is. +- **`--output` mismatch**: the recipe forces `--output .mops/.build`; `mops deployed + update` must read the same dir. Both default to `[build].outputDir ?? .mops/.build`; + the recipe passes `--output` explicitly to guarantee a match. - **Standalone `update` with a stale/empty output dir**: errors (copy-or-error). Intentional — prevents promoting artifacts that don't correspond to a fresh build. ## Open questions -1. **Default path convention for `init`.** Proposed `deployed/.most`. A - `deployed/` dir is self-documenting and groups references; alternatives are next - to source or an `.old/` mirror (as in current docs). Pick one. +1. **Did snapshot without `check-stable`.** v1 anchors the whole snapshot on + `[check-stable].path`, so committing a `.did` requires stable tracking to be set + up. If icp users want the `.did` committed without caring about stable checks, + we'd need a standalone anchor (e.g. a `deployed` dir config independent of + `check-stable`). 2. **Network/environment dimension.** `icp` environments can deploy different - versions to `staging` vs `ic`, but `mops.toml` has a single reference per - canister. The reference currently means "the latest deployed version" (typically - prod). A future `--env`/per-environment reference path may be needed if teams - deploy divergent builds. -3. **Deployed-candid reference (separate field).** If teams want rolling Candid - backward-compat against the *deployed* interface (not just a curated one), add a - dedicated field — e.g. `[canisters..check-candid].path` — that mops reads - for a `--candid-compatible`-style check and that `mops deployed update` may safely - write, **without** touching the embedded `candid`. Out of scope for v1. -4. **Deployed manifest (speculative).** `mops deployed` could also record a small - manifest (wasm hash, `moc` version, timestamp) per canister to make "what is - deployed" auditable from the repo — without touching the chain. Out of scope for - v1. + versions to `staging` vs `ic`, but `mops.toml` has one snapshot per canister + (effectively "latest / prod"). A future per-environment path may be needed. +3. **`check-candid` (deferred).** A rolling deployed-interface compatibility check + would need its own config field and a check wired into `mops check`, distinct from + the embedded `candid`. Out of scope now. +4. **Deployed manifest (speculative).** Record wasm hash / `moc` version / timestamp + per canister to make "what is deployed" auditable from the repo, without touching + the chain. + +## Appendix: why the snapshot `.did` is not the `candid` field + +`mops build` already generates and embeds the Candid interface in the wasm by default +(`--public-metadata candid:service`), so the deployed canister self-describes without +any file. The optional `[canisters.].candid` field *overrides* that: when set, +the build verifies the generated interface is a subtype of the file and **embeds the +file** (`cli/commands/build.ts:184-200`). It is a *curated, ahead-of-code* contract. + +The snapshot `.did` is the opposite: a *generated, lags-code* record of what was just +deployed. Writing the snapshot into `candid` would make the next build embed the +previous version's interface into the new wasm. They are different artifacts in +opposite directions in time, which is why `mops deployed` ignores `candid` entirely +and writes the snapshot beside the `.most`. From 939eb5a9f29242c9dbb824a8a433292f0e2e6469 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 09:13:48 +0000 Subject: [PATCH 4/5] docs: decouple deployed snapshot dir from check-stable.path (configurable [deployed].dir + warning) Co-authored-by: Kamil Listopad --- specs/mops-deployed.md | 128 +++++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 55 deletions(-) diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md index 20fe42dd..54a7f758 100644 --- a/specs/mops-deployed.md +++ b/specs/mops-deployed.md @@ -30,9 +30,9 @@ post-install `sync` phase. ## Goals -- Promote the **already-built** `.most` and `.did` into a committed `deployed/` - snapshot, reusing the exact artifacts that produced the deployed wasm. -- Bootstrap the stable reference — file **and** `mops.toml` entry — before the first +- Promote the **already-built** `.most` and `.did` into a committed snapshot + directory, reusing the exact artifacts that produced the deployed wasm. +- Bootstrap the stable check — file **and** `mops.toml` entry — before the first deployment, so users don't configure paths by hand. - Be DRY: callers pass only a canister name (mirrors `mops build `). - Follow existing canister-selection conventions (`mops build` / `mops check`). @@ -51,26 +51,42 @@ post-install `sync` phase. opposite-in-time concept); see Appendix. - **No network/environment dimension** (see Open Questions). -## The `deployed/` snapshot +## The deployed directory -All committed snapshot files for a canister live together, anchored on the single -path mops already consumes — `[canisters..check-stable].path`: +`mops deployed` owns a single, configurable output directory. It is the **only** +thing that determines where the snapshot is written — independent of any other path. -- the `.most` is written to `[check-stable].path` (consumed by `check-stable`), -- the `.did` is written next to it, as `.did` in the same directory. +```toml +[deployed] +dir = "deployed" # optional; default "deployed" (relative to mops.toml) +``` -`mops deployed init` defaults `[check-stable].path` to `deployed/.most`, so by -default both files land in a `deployed/` directory at the project root, giving the -icp side a predictable path (`deployed/.did`) to reference. Users who point -`[check-stable].path` elsewhere get both files in that directory instead. There is -exactly one anchor and no new config field. +For a canister ``, `update` always writes: -```toml -[canisters.backend] -main = "src/backend/main.mo" +- `/.most` +- `/.did` + +The directory is overridable per invocation with `--dir `. All canisters share +the one directory, namespaced by canister name. +### Relationship to `check-stable` (synergy, not coupling) + +`mops deployed` does not read `[check-stable].path`. They are wired together by +`init`, which sets `[check-stable].path` to `/.most` so that the file +`update` writes is exactly the file `check-stable` reads: + +```toml [canisters.backend.check-stable] -path = "deployed/backend.most" # ← anchor: most lands here, did lands beside it +path = "deployed/backend.most" # set by `mops deployed init` to match the snapshot +``` + +A user may point `[check-stable].path` elsewhere. That's allowed, but then +`check-stable` won't see what `update` writes, so `update` and `init` **warn**: + +``` +WARN: [canisters.backend.check-stable].path is "old/backend.most" but + `mops deployed` writes "deployed/backend.most". check-stable will not + see the snapshot. Set them to the same path (see `mops deployed init`). ``` ## Why "deployed update", not "deployed sync" @@ -87,8 +103,8 @@ path = "deployed/backend.most" # ← anchor: most lands here, did lands beside ## Command surface ``` -mops deployed update [canisters...] # promote built .most + .did → deployed snapshot -mops deployed init [canisters...] # configure check-stable + write .most baseline +mops deployed update [canisters...] # promote built .most + .did → +mops deployed init [canisters...] # write .most baseline + point check-stable at it mops deployed status [canisters...] # (extension) report drift, no writes ``` @@ -98,46 +114,49 @@ mops deployed status [canisters...] # (extension) report drift, no writes The main use case. For each selected canister ``: -- copy `/.most` → `[check-stable].path`, -- copy `/.did` → `/.did`. +- copy `/.most` → `/.most`, +- copy `/.did` → `/.did`. Details: -- **Source dir**: `[build].outputDir ?? .mops/.build`, overridable with - `--output ` (mirrors `mops build --output`, so the recipe passes the same +- **Source dir** (``): `[build].outputDir ?? .mops/.build`, overridable + with `--output ` (mirrors `mops build --output`, so the recipe passes the same value it built with). +- **Destination dir** (``): `[deployed].dir ?? deployed`, overridable with + `--dir `. - **Copy-or-error**: if a source artifact is missing, error (`No built at . Run \`mops build \` first.`). Never regenerate. -- **Create parent dirs** of destinations (`mkdir -p`). +- **Create the destination dir** (`mkdir -p`). - Always overwrites — advancing the snapshot is the point. +- **Warns** when `[check-stable].path` is set but differs from `/.most` + (see above). Flags: -- `--output ` — source directory (default as above). +- `--output ` — source directory (build output). +- `--dir ` — destination directory (snapshot). - `--no-did` — write only the `.most` (for projects that don't want the interface - committed). Default is to write both. + committed). Default writes both. - `--check` — CI gate. No writes; exit non-zero if a destination differs from the - built artifact (or is missing). Lets CI enforce "the committed snapshot matches the - latest build" (`mops build && mops deployed update --check`). - -**When a canister has no `[check-stable]` configured** (no anchor): - -- Selected **by name** → error, pointing at `mops deployed init `. -- Iterating **all** (no argument) → skip it, but report it in the summary, e.g. - `Updated 1 canister (backend). Skipped 2 with no deployed snapshot configured: frontend, ledger. Run \`mops deployed init \`.` + built artifact (or is missing). Enforce "the committed snapshot matches the latest + build" with `mops build && mops deployed update --check`. ### `mops deployed init [canisters...]` -Prepare a canister for snapshotting so the first `mops check` has a baseline — +Prepare a canister for stable checking so the first `mops check` has a baseline — without hand-editing `mops.toml`. For each selected canister: -1. If `[canisters..check-stable].path` is **not** set, add it to `mops.toml` - as `deployed/.most`. Existing paths are never changed. -2. If the file at that path does not exist, create it with an empty-actor baseline: +1. If the baseline file `/.most` does not exist, create it with an + empty-actor signature: ```most // Version: 1.0.0 actor { }; ``` +2. Point `check-stable` at it: + - if `[canisters..check-stable].path` is **unset** → set it to + `/.most`; + - if it is **set to a different path** → leave it unchanged and **warn** that it + won't coincide with `mops deployed update`. No `.did` baseline is created — mops doesn't consume the `.did`, so it simply appears after the first `mops deployed update` (and `icp build` generates one on demand for @@ -146,23 +165,25 @@ bindings before then). Idempotent; `--force` recreates the baseline file. > **Caveat — TOML rewrite.** mops writes config via `writeConfig` → `TOML.stringify` > (`cli/mops.ts:229`), which reserializes the whole file and drops comments / custom > formatting. This already happens for `mops add` / `mops remove`, so it's accepted -> behavior, but `init` must only write when it actually adds a missing path. +> behavior, but `init` must only write when it actually changes config. ### `mops deployed status [canisters...]` (extension) Read-only drift report: per canister, whether the committed snapshot matches the -latest built artifacts (in sync / stale / missing / not built). Same info as -`update --check` but human-readable and non-failing. +latest built artifacts (in sync / stale / missing / not built), and whether +`check-stable.path` coincides with the snapshot. Same info as `update --check` plus +the coincidence check, but human-readable and non-failing. ## Canister selection (single vs multiple) Identical to `mops build` / `mops check`: -- **No argument** → all canisters with `[check-stable]` configured (for - `update`/`status`) or all `[canisters]` entries (for `init`). A single-canister - project "just works" with a bare command — no special-casing. -- **One or more names** → only those; unknown names error (`filterCanisters`), and - for `update` a named canister with no anchor errors (strict, like `check-stable`). +- **No argument** → all `[canisters]` entries. A single-canister project "just works" + with a bare command — no special-casing. +- **One or more names** → only those; unknown names error (`filterCanisters`). + +`update` does not require `[check-stable]` to be configured — it always writes the +snapshot to ``. The check-stable coincidence is a warning, not a gate. The icp-cli integration always uses the **named** form. `icp` expands the recipe **once per canister**, so the `sync` step runs per canister with its own name — @@ -181,7 +202,7 @@ sync: ## Lifecycle ``` -# once, before first deploy — configures mops.toml + writes empty .most baseline +# once, before first deploy — writes empty .most baseline + points check-stable at it mops deployed init backend # every change @@ -205,14 +226,11 @@ git add deployed/ && git commit # commit the new deployed snapshot ## Open questions -1. **Did snapshot without `check-stable`.** v1 anchors the whole snapshot on - `[check-stable].path`, so committing a `.did` requires stable tracking to be set - up. If icp users want the `.did` committed without caring about stable checks, - we'd need a standalone anchor (e.g. a `deployed` dir config independent of - `check-stable`). +1. **Config surface for the directory.** Proposed global `[deployed].dir` (default + `deployed`) plus `--dir`. A per-canister override could be added later if needed. 2. **Network/environment dimension.** `icp` environments can deploy different - versions to `staging` vs `ic`, but `mops.toml` has one snapshot per canister - (effectively "latest / prod"). A future per-environment path may be needed. + versions to `staging` vs `ic`, but the snapshot is single (effectively + "latest / prod"). A future per-environment directory may be needed. 3. **`check-candid` (deferred).** A rolling deployed-interface compatibility check would need its own config field and a check wired into `mops check`, distinct from the embedded `candid`. Out of scope now. @@ -232,4 +250,4 @@ The snapshot `.did` is the opposite: a *generated, lags-code* record of what was deployed. Writing the snapshot into `candid` would make the next build embed the previous version's interface into the new wasm. They are different artifacts in opposite directions in time, which is why `mops deployed` ignores `candid` entirely -and writes the snapshot beside the `.most`. +and writes the snapshot to its own directory. From 990dce5186ab4710729b0bab7c13a1810a09deb3 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Mon, 1 Jun 2026 16:48:56 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20split=20.most=20vs=20.did=20?= =?UTF-8?q?=E2=80=94=20`mops=20deployed`=20for=20stable=20signatures,=20`m?= =?UTF-8?q?ops=20generate=20candid`=20for=20interface=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two file types have distinct lifecycles and consumers — coupling them under one command was muddying the model: - `.most` is a post-deploy snapshot consumed by `mops check-stable`; its reference must advance exactly when a deploy succeeds. `mops deployed` is now a focused post-deploy hook the user (or icp-cli `sync`) calls to promote the just-built `.most` into a committed reference path, plus `mops deployed init` for first-time baseline + check-stable wiring. No `--check`, no `status`, no `.did` handling. - `.did` is a build-input contract: `mops build` subtype-checks against it and embeds it into the wasm; `@icp-sdk/bindgen` reads the same file for frontend bindings. Refresh is interface-change-driven, not deploy-driven. New `mops generate candid ` (noun-namespace, extensible to future generators) (re)generates the curated `.did` from source via `moc --idl`, optionally setting `[canisters.].candid` on first use. Default destination is next to `main`. Drift detection flagged as a follow-up extension of `mops check`. Co-authored-by: Cursor --- specs/mops-candid.md | 173 ++++++++++++++++++++++ specs/mops-deployed.md | 320 ++++++++++++++++------------------------- 2 files changed, 297 insertions(+), 196 deletions(-) create mode 100644 specs/mops-candid.md diff --git a/specs/mops-candid.md b/specs/mops-candid.md new file mode 100644 index 00000000..4be69188 --- /dev/null +++ b/specs/mops-candid.md @@ -0,0 +1,173 @@ +# Spec: `mops generate candid` + +Status: Draft / proposal + +> Sibling to [`specs/mops-deployed.md`](mops-deployed.md). That spec +> handles `.most` (post-deploy snapshot); this one handles `.did` +> (build-input contract). + +`mops generate` is a noun-namespace for source-derived artifacts. v1 ships +one member (`candid`); future members like `mops generate most` or `mops +generate migration` can join later without restructuring. + +## User story + +A Motoko canister's Candid interface lives in two places: + +- **Embedded in the wasm** as `candid:service` metadata, written by every + `mops build` (the deployed canister self-describes). +- **As an on-disk `.did` file** that downstream tooling reads — most + importantly `@icp-sdk/bindgen` (the Vite plugin generates frontend + TypeScript clients from a file path, not the wasm). The `icp-cli` + `hello-world` template demonstrates the canonical pattern: one committed + `backend.did` shared by recipe (build input) and frontend (bindgen + input). + +mops surfaces this via **`[canisters.].candid`** — an optional path. +When set, `mops build` subtype-checks the auto-generated interface against +that file and **embeds the file** verbatim into the wasm as +`candid:service` (`cli/commands/build.ts:184-200`). It's a *curated, +ahead-of-code contract*. + +What's missing is **maintenance**. When the Motoko interface changes, +someone has to write the new `.did` by hand. When the field is unset, +there's no committed `.did` at all — bindgen has only +`.mops/.build/.did`, which is private and unstable. + +**`mops generate candid `** fills that gap. It (re)generates +the curated `.did` from current source: + +- if `[canisters.].candid` is **set** → overwrite that file; +- if **unset** → write to a default path (`.did` next to + `main`) *and* set the field in `mops.toml`. + +The lifecycle is **interface-change-driven**, not deploy-driven. Refresh +when the interface changes; commit; bindgen and `mops build` pick up the +new file on the next read. + +## Non-goals + +- **No interface diffing / approval workflow.** Always overwrites. Hand + edits (doc comments, reordering) are lost — edit by hand when drift is + intentional. +- **No standalone drift command.** CI drift detection lives in + `mops check`, not here (see § "Interaction with `mops check`"). +- **No wasm metadata interaction.** Embedding is `mops build`'s job; + unchanged. +- **No on-chain reads.** Generated from local source, never fetched from + the deployed canister. +- **No deploy coupling.** Independent of `mops deployed`. + +## Command surface + +``` +mops generate candid [canisters...] # (re)generate curated .did from source +``` + +Flags: + +- `-o ` / `--output ` — one-off override (single-canister + only). Writes to `` and does **not** touch `mops.toml`. Use for + ad-hoc generation to a non-tracked location; ignore it for the normal + flow. Without this flag, the field-or-default logic above applies. + +Canister selection mirrors `mops build`: no argument → all `[canisters]` +entries; named → only those (unknown names error). + +## How the `.did` is generated + +Invokes `moc --idl` directly (no `.wasm` / `.most` / `ic-wasm` side +effects). The moc-argument logic — sources, packages, actor aliases, +canister `args`, migration flags — must be **extracted from `build.ts` +into a shared helper** (e.g. `buildMocArgs(canister)`) and called from +both code paths. DRY: a single source of truth for "how moc is invoked +for canister X" prevents the two flows from drifting (which would risk +`mops build` failing the subtype check on a freshly-regenerated `.did`). + +The `mops.toml` write (when needed) uses the same `writeConfig` / +`TOML.stringify` machinery as `mops deployed init`; see the TOML-rewrite +caveat there. + +## Destination path + +Priority order: + +1. `-o ` if given — write there; never updates `mops.toml`. If the + field is also set, the field-pointed file is left untouched (and now + stale relative to source — caller's responsibility). +2. `[canisters.].candid` if set — overwrite in place; no toml + update. +3. Default: `.did` in `dirname([canisters.].main)` — write + file *and* set the field. + +Any resolved path inside `.mops/` is rejected — see Edge cases. The +"next to `main`" default works for any layout: `main = src/Backend.mo` +→ `src/backend.did`, `main = backend/app.mo` → `backend/backend.did`, +`main = Main.mo` → `backend.did` at project root. + +## Interaction with `mops check` + +A committed `[canisters.].candid` can drift from the current Motoko +source (someone edited the source, forgot to regenerate). The natural +home for that drift check is **`mops check`**, not a sibling +`mops generate candid check` — mops already has one place for "is this +project healthy?" and a Candid drift gate fits alongside type-checking +and stable-compatibility. + +> **Follow-up: extend `mops check`.** If drift detection isn't already +> in `mops check`, add it: compare `[canisters.].candid` against +> what moc would auto-generate from current source; warn (or error +> under `--strict`) when they differ. Subsumes the standalone CI-gate +> role we'd otherwise need here. Out of scope for this spec; flagged so +> it's not forgotten. + +## Lifecycle + +``` +# initial setup (per canister), once +mops generate candid backend # writes /backend.did, sets [canisters.backend].candid + +# every interface change +# (edit Motoko source...) +mops generate candid backend # refresh from source +git add src/backend.did mops.toml + +# CI +mops check # also catches stale .did (see "Interaction with mops check") +``` + +`mops generate candid` is independent of `mops deployed`: + +| When | Command | +|---|---| +| Motoko interface changes | `mops generate candid ` (refresh curated `.did`) | +| Just deployed to chain | `mops deployed ` (snapshot `.most`) | +| First-time setup | `mops generate candid ` + `mops deployed init ` | + +## Edge cases + +- **No `main`** → error before invoking moc (same check as `mops build`). +- **Resolved destination inside `.mops/`** → reject, regardless of source + (`-o`, field, default). The point is a user-visible, committable path; + no exception even if the user mis-set the field. +- **`-o` collides with another canister's `candid`** → warn. Sharing a + `.did` path between canisters is almost always a mistake. +- **moc fails** → don't touch destination or `mops.toml`. Surface the + error verbatim. +- **`candid` set but file doesn't exist** → overwrite (create) at that + path. The field is source of truth for *where*, not *whether*. +- **Multi-canister + `-o`** → error. `-o` is single-canister only. + +## Out of scope + +- **`[candid].dir` config.** Global directory for `.did` files + (`did/.did`), analogous to `[deployed].dir`. Default + (next to `main`) keeps `.did` co-located with source, which is what + we want for now. +- **Preserving curated edits.** Users may hand-curate `.did` with doc + comments / method ordering moc doesn't preserve. v1 overwrites; a + future `--merge` mode could detect and warn. +- **Future `mops generate` members.** `mops generate most`, `mops + generate migration `, etc. share this namespace; each gets its + own spec when it lands and inherits the conventions (canister + selection, `-o`, error semantics). diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md index 54a7f758..dc4c72d2 100644 --- a/specs/mops-deployed.md +++ b/specs/mops-deployed.md @@ -2,193 +2,137 @@ Status: Draft / proposal -## Problem - -`mops build` emits three artifacts from a single `moc` invocation into the output -dir (`.mops/.build` by default): `.wasm`, `.did`, `.most`. They -are internally consistent by construction. - -The `.wasm` is what gets deployed. The other two need to be **committed to the repo -as the deployed snapshot**, for two different consumers: - -- **`.most` (stable signature)** — input to the next upgrade's stable-compatibility - check: `mops check-stable` runs `moc --stable-compatible `. - It must match the code currently running on-chain or the check is meaningless - (false passes → silent data loss on upgrade; false failures → blocked upgrades). -- **`.did` (Candid interface)** — a committed record of the deployed interface, used - by external tooling: `@icp-sdk/bindgen` generates TypeScript clients from it, - `icp canister call` encodes arguments with it, and the repo reflects what's - deployed. **mops itself does not consume it** — it is a pure output artifact the - icp-cli integration asked mops to place. - -Today the snapshot is maintained by hand: deploy, then remember to copy the new -`.most`/`.did` into committed paths. Forget, and they drift from on-chain reality. - -`mops deployed` makes producing this snapshot a first-class, scriptable step the -[icp-cli Motoko recipe](https://github.com/dfinity/icp-cli-recipes) can call in its -post-install `sync` phase. - -## Goals - -- Promote the **already-built** `.most` and `.did` into a committed snapshot - directory, reusing the exact artifacts that produced the deployed wasm. -- Bootstrap the stable check — file **and** `mops.toml` entry — before the first - deployment, so users don't configure paths by hand. -- Be DRY: callers pass only a canister name (mirrors `mops build `). -- Follow existing canister-selection conventions (`mops build` / `mops check`). +> Sibling to [`specs/mops-candid.md`](mops-candid.md). That spec handles +> `.did` (build-input contract); this one handles `.most` (post-deploy +> snapshot). + +## User story + +After a Motoko canister is deployed (today: via `icp deploy` or another +deployment tool), mops needs to know about it — there's bookkeeping to do +before the **next** dev cycle so the in-repo state still corresponds to +what's on chain. **`mops deployed [canisters...]` is the post-deploy hook** +the user — or the icp-cli `sync` step — calls to communicate that fact. + +In v1, the only bookkeeping is **promoting the just-built `.most` into a +committed reference path** so `mops check-stable` has the right baseline on +the next build. Future versions may also record a deploy manifest (wasm +hash, `moc` version, timestamp — see Open Questions), but the entry point +stays the same. The command is named for the situation it marks, not the +action — "I just deployed" is the story; the `.most` copy is the v1 mechanic. + +### Why `.most` is in scope + +`mops check-stable` compares the new code's stable signature against the +currently-deployed one. For the check to be meaningful, the on-disk +reference must advance exactly when a deploy succeeds — drift either +silently corrupts upgrade safety (false passes) or blocks valid upgrades +(false failures). Today it's maintained by hand; forget the copy and the +reference rots. + +### Why `.did` is *not* in scope + +`[canisters.].candid` is a curated **build-input contract**, not a +deploy artifact: `mops build` subtype-checks the auto-generated interface +against it and embeds the *curated* file into the wasm +(`cli/commands/build.ts:184-200`). The same file is what `@icp-sdk/bindgen` +reads for frontend bindings (see the `icp-cli` `hello-world` template, +where one committed `backend.did` is shared between recipe and frontend). + +Refreshing `.did` is interface-change-driven, not deploy-driven — and when +`candid` is set, the auto-generated `.did` mops would copy here differs +from the curated one actually embedded in the deployed wasm, so the +"snapshot" framing would be misleading. The `.did` lifecycle lives in +[`mops-candid.md`](mops-candid.md). ## Non-goals -- **No regeneration / compilation.** The command never runs `moc`; it strictly - copies the artifacts left by `mops build`. If they're missing, it errors. This is - what keeps the snapshot atomically tied to the deployed wasm. -- **No reading from chain or wasm metadata.** Files are managed locally and committed. -- **No Candid compatibility check.** mops does not consume the snapshot `.did`; a - rolling deployed-interface check (`check-candid`) is out of scope (see Open - Questions). -- **The existing `[canisters.].candid` field is ignored** — not read, not - written. It is an optional *curated interface to embed in the wasm* (a different, - opposite-in-time concept); see Appendix. -- **No network/environment dimension** (see Open Questions). +- **No regeneration / compilation.** Copies the `.most` left by `mops + build`; errors if missing. Keeps the reference atomically tied to the + deployed wasm. +- **No reading from chain or wasm metadata.** Local file management only. +- **No `.did` handling** — see `mops-candid.md`. +- **No network/environment dimension** (Open Questions). ## The deployed directory -`mops deployed` owns a single, configurable output directory. It is the **only** -thing that determines where the snapshot is written — independent of any other path. - ```toml [deployed] dir = "deployed" # optional; default "deployed" (relative to mops.toml) ``` -For a canister ``, `update` always writes: - -- `/.most` -- `/.did` - -The directory is overridable per invocation with `--dir `. All canisters share -the one directory, namespaced by canister name. - -### Relationship to `check-stable` (synergy, not coupling) - -`mops deployed` does not read `[check-stable].path`. They are wired together by -`init`, which sets `[check-stable].path` to `/.most` so that the file -`update` writes is exactly the file `check-stable` reads: - -```toml -[canisters.backend.check-stable] -path = "deployed/backend.most" # set by `mops deployed init` to match the snapshot -``` - -A user may point `[check-stable].path` elsewhere. That's allowed, but then -`check-stable` won't see what `update` writes, so `update` and `init` **warn**: - -``` -WARN: [canisters.backend.check-stable].path is "old/backend.most" but - `mops deployed` writes "deployed/backend.most". check-stable will not - see the snapshot. Set them to the same path (see `mops deployed init`). -``` - -## Why "deployed update", not "deployed sync" +`mops deployed` writes `/.most` per canister. Overridable per +invocation with `--dir `. All canisters share the one directory. -`update`, for these reasons: +### Synergy with `check-stable` (not coupling) -- `mops sync` already exists and means "reconcile `mops.toml` deps with code - imports". Reusing `sync` for a different target is confusing. -- `icp` already calls its post-install phase `sync`; `mops deployed sync` inside an - `icp sync` step reads badly. -- The action is "advance the saved snapshot forward to the just-deployed version" — - an update/bump, matching `mops update` and `mops toolchain update`. +`mops deployed` does not read `[canisters..check-stable].path`. +They're wired together by `init`, which sets that field to +`/.most` so the file `mops deployed` writes is exactly the +file `check-stable` reads. If the user later points the field elsewhere, +both `mops deployed` and `init` **warn** — the on-disk reference and the +configured baseline have diverged; `check-stable` won't see updates from +the hook. ## Command surface ``` -mops deployed update [canisters...] # promote built .most + .did → -mops deployed init [canisters...] # write .most baseline + point check-stable at it -mops deployed status [canisters...] # (extension) report drift, no writes +mops deployed [canisters...] # post-deploy hook: promote .most → +mops deployed init [canisters...] # baseline .most + [check-stable].path setup ``` -`deployed` is a subcommand group, like `mops toolchain`. +Subcommands are resolved before positional args (Commander), so `init` +is effectively a reserved canister name in this command — `mops deployed +init` always means the subcommand. Not worth working around for v1; flag +in docs if it becomes a real conflict. -### `mops deployed update [canisters...]` +### `mops deployed [canisters...]` -The main use case. For each selected canister ``: +For each selected canister, copy `/.most` → +`/.most`. -- copy `/.most` → `/.most`, -- copy `/.did` → `/.did`. - -Details: - -- **Source dir** (``): `[build].outputDir ?? .mops/.build`, overridable - with `--output ` (mirrors `mops build --output`, so the recipe passes the same - value it built with). -- **Destination dir** (``): `[deployed].dir ?? deployed`, overridable with +- **Source** (``): `[build].outputDir ?? .mops/.build`, + overridable with `--output ` (mirrors `mops build --output`). +- **Destination** (``): `[deployed].dir ?? deployed`, overridable with `--dir `. -- **Copy-or-error**: if a source artifact is missing, error - (`No built at . Run \`mops build \` first.`). Never regenerate. -- **Create the destination dir** (`mkdir -p`). -- Always overwrites — advancing the snapshot is the point. -- **Warns** when `[check-stable].path` is set but differs from `/.most` - (see above). - -Flags: - -- `--output ` — source directory (build output). -- `--dir ` — destination directory (snapshot). -- `--no-did` — write only the `.most` (for projects that don't want the interface - committed). Default writes both. -- `--check` — CI gate. No writes; exit non-zero if a destination differs from the - built artifact (or is missing). Enforce "the committed snapshot matches the latest - build" with `mops build && mops deployed update --check`. +- **Copy-or-error**: missing source errors (`No built .most at . Run + \`mops build \` first.`). Never regenerates. +- Creates the destination dir (`mkdir -p`); always overwrites. +- Warns when `[canisters..check-stable].path` differs from + `/.most`. ### `mops deployed init [canisters...]` -Prepare a canister for stable checking so the first `mops check` has a baseline — -without hand-editing `mops.toml`. For each selected canister: - -1. If the baseline file `/.most` does not exist, create it with an - empty-actor signature: - ```most - // Version: 1.0.0 - actor { }; - ``` -2. Point `check-stable` at it: - - if `[canisters..check-stable].path` is **unset** → set it to - `/.most`; - - if it is **set to a different path** → leave it unchanged and **warn** that it - won't coincide with `mops deployed update`. - -No `.did` baseline is created — mops doesn't consume the `.did`, so it simply appears -after the first `mops deployed update` (and `icp build` generates one on demand for -bindings before then). Idempotent; `--force` recreates the baseline file. - -> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → `TOML.stringify` -> (`cli/mops.ts:229`), which reserializes the whole file and drops comments / custom -> formatting. This already happens for `mops add` / `mops remove`, so it's accepted -> behavior, but `init` must only write when it actually changes config. - -### `mops deployed status [canisters...]` (extension) +Pre-first-deploy bootstrap — separate subcommand because it also writes to +`mops.toml`. For each selected canister: -Read-only drift report: per canister, whether the committed snapshot matches the -latest built artifacts (in sync / stale / missing / not built), and whether -`check-stable.path` coincides with the snapshot. Same info as `update --check` plus -the coincidence check, but human-readable and non-failing. +1. If `/.most` does not exist, create it with an empty-actor + baseline (`// Version: 1.0.0\nactor { };`). +2. If `[canisters..check-stable].path` is unset, set it to + `/.most`. If already set elsewhere, leave it and **warn**. -## Canister selection (single vs multiple) +Idempotent: re-running is a no-op when both checks already hold. -Identical to `mops build` / `mops check`: +> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → +> `TOML.stringify` (`cli/mops.ts:229`), which reserializes the whole file +> and drops comments / custom formatting. Already accepted behavior for +> `mops add` / `mops remove`; `init` must only write when config actually +> changes. -- **No argument** → all `[canisters]` entries. A single-canister project "just works" - with a bare command — no special-casing. -- **One or more names** → only those; unknown names error (`filterCanisters`). +> **Follow-up: refresh `mops init`.** `mops init` today predates +> `[canisters]` scaffolding — no canister entry, no `[check-stable]` +> wiring, no `[toolchain]` pin, still dfx-centric (fetches default packages +> keyed off `dfx --version`). A modern project setup should create a +> `[canisters.]` block and then call `mops deployed init` so the +> stable-check loop is wired out of the box. Refreshing `mops init` is +> out of scope for this spec; flagged here so it's not forgotten. -`update` does not require `[check-stable]` to be configured — it always writes the -snapshot to ``. The check-stable coincidence is a warning, not a gate. +## Canister selection -The icp-cli integration always uses the **named** form. `icp` expands the recipe -**once per canister**, so the `sync` step runs per canister with its own name — -multi-canister projects are handled by `icp` iterating, not by the command fanning -out: +Identical to `mops build` / `mops check`: no argument → all `[canisters]` +entries; named → only those (unknown names error). The icp-cli integration +always uses the named form — `icp` expands the recipe once per canister, so +the `sync` step runs per canister with its own name: ```yaml # recipes/motoko/recipe.hbs (sync phase) @@ -196,58 +140,42 @@ sync: steps: - type: script commands: - - mops deployed update "{{ _.canister.name }}" --output .mops/.build + - mops deployed "{{ _.canister.name }}" --output .mops/.build ``` ## Lifecycle ``` -# once, before first deploy — writes empty .most baseline + points check-stable at it -mops deployed init backend +# once, before first deploy +mops deployed init backend # writes empty .most baseline + sets [check-stable].path # every change -mops check backend # new vs committed .most baseline -mops build backend # produces .mops/.build/backend.{wasm,did,most} -icp deploy # installs the wasm, then sync runs: - mops deployed update backend # snapshots .most + .did into deployed/ -git add deployed/ && git commit # commit the new deployed snapshot +mops check backend # new vs committed .most baseline +mops build backend # produces .mops/.build/backend.{wasm,did,most} +icp deploy # installs the wasm, then sync runs: + mops deployed backend # post-deploy hook: promotes .most into deployed/ +git add deployed/ && git commit ``` ## Edge cases -- **Migrations**: `mops build` already injects `--enhanced-migration` when - `[canisters..migrations]` is set, so the emitted `.most` reflects it; the - command copies as-is. -- **`--output` mismatch**: the recipe forces `--output .mops/.build`; `mops deployed - update` must read the same dir. Both default to `[build].outputDir ?? .mops/.build`; - the recipe passes `--output` explicitly to guarantee a match. -- **Standalone `update` with a stale/empty output dir**: errors (copy-or-error). - Intentional — prevents promoting artifacts that don't correspond to a fresh build. +- **Migrations**: `mops build` injects `--enhanced-migration` when + `[canisters..migrations]` is set, so the emitted `.most` already + reflects it; the command copies as-is. +- **`--output` mismatch**: both `mops build` and `mops deployed` default to + `[build].outputDir ?? .mops/.build`; the recipe passes `--output` + explicitly to guarantee a match. +- **Stale/empty output dir**: copy-or-error, by design — prevents promoting + an artifact that doesn't correspond to a fresh build. ## Open questions -1. **Config surface for the directory.** Proposed global `[deployed].dir` (default - `deployed`) plus `--dir`. A per-canister override could be added later if needed. -2. **Network/environment dimension.** `icp` environments can deploy different - versions to `staging` vs `ic`, but the snapshot is single (effectively - "latest / prod"). A future per-environment directory may be needed. -3. **`check-candid` (deferred).** A rolling deployed-interface compatibility check - would need its own config field and a check wired into `mops check`, distinct from - the embedded `candid`. Out of scope now. -4. **Deployed manifest (speculative).** Record wasm hash / `moc` version / timestamp - per canister to make "what is deployed" auditable from the repo, without touching - the chain. - -## Appendix: why the snapshot `.did` is not the `candid` field - -`mops build` already generates and embeds the Candid interface in the wasm by default -(`--public-metadata candid:service`), so the deployed canister self-describes without -any file. The optional `[canisters.].candid` field *overrides* that: when set, -the build verifies the generated interface is a subtype of the file and **embeds the -file** (`cli/commands/build.ts:184-200`). It is a *curated, ahead-of-code* contract. - -The snapshot `.did` is the opposite: a *generated, lags-code* record of what was just -deployed. Writing the snapshot into `candid` would make the next build embed the -previous version's interface into the new wasm. They are different artifacts in -opposite directions in time, which is why `mops deployed` ignores `candid` entirely -and writes the snapshot to its own directory. +1. **Per-canister directory override.** Global `[deployed].dir` + `--dir` is + the v1 surface. A per-canister override could be added later if needed. +2. **Network/environment dimension.** `icp` environments can deploy + different versions to `staging` vs `ic`; the reference is single + (effectively "latest / prod"). A future per-environment directory may + be needed. +3. **Deployed manifest** (speculative). Record wasm hash / `moc` version / + timestamp per canister to make "what is deployed" auditable from the + repo without touching the chain.