Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "2270f95ef02870d652785f7eadc2bbec80b5c261adb92195c369af4b490eff08"
"shardHash": "b3fa3e17669980fa59955fd6f68b542e464615a67c70070307b9cf7dcb19c667"
}
6 changes: 3 additions & 3 deletions .derived/spec-registry/by-spec/006-template-upgrade-verb.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@
}
],
"id": "006-template-upgrade-verb",
"implementation": "in-progress",
"implementation": "complete",
"sectionHeadings": [
"006: template upgrade verb",
"1. Cross-repo dependencies (read first)",
"2. Behavior",
"3. Acceptance",
"4. Out of scope",
"5. Status (2026-07-15)"
"5. Status (2026-07-23)"
],
"specPath": "specs/006-template-upgrade-verb/spec.md",
"status": "approved",
"summary": "The upgrade half of the 2026-07-14 packaging decision: templates stay small because the chassis ships as versioned npm packages (enrahitu spec 018), and upgrading a stamped app is a verb, not a migration project. `statecraft template upgrade`, run in a stamped app checkout, reads template.toml, bumps the chassis package pins, applies template-shipped codemods, runs the contract verify verb, and commits on a branch. The CLI orchestrates; all structure knowledge stays in the template and its packages. This verb is the boundary that keeps the CLI from ever becoming a build daemon.\n",
"title": "statecraft template upgrade: chassis upgrades as a governed verb"
},
"shardHash": "7ad323fce9c96006b9804b90069aa11eb436cdd447d7120cf09b524f0fb4375c",
"shardHash": "cf99472525340adb40708bf097fc02471e4aebcbf82d1cd05f21fbdcd8df9a88",
"specVersion": "1.1.0"
}
251 changes: 156 additions & 95 deletions specs/006-template-upgrade-verb/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ id: "006-template-upgrade-verb"
title: "statecraft template upgrade: chassis upgrades as a governed verb"
status: approved
created: "2026-07-14"
implementation: in-progress
implementation: complete
depends_on:
- "002-crate-scaffold"
establishes:
Expand All @@ -24,13 +24,21 @@ summary: >

## 1. Cross-repo dependencies (read first)

Requires enrahitu spec 018 implemented (chassis packages exist and
template.toml `[requires]` names the toolchain range). Reads only the
contract surface (spec 009 discipline: anything not in template.toml
is not the factory's or the CLI's business). If run against a
pre-018 stamped app (no chassis packages in package.json), report
"this app predates the packaged chassis" and point at the manual
re-import path; do not attempt a tree merge.
Requires the packaged chassis to exist and `template.toml`
`[requires]` to name its version range. enrahitu spec 018 established
this; the packages have since moved from the retired `@enrahitu` scope
to `@statecrafting` (statecrafting spec 002 for the toolchain, spec
003 for the `hiqlite-native` addon), and they are now versioned
independently rather than in lockstep: `@statecrafting/toolchain` is
published at 0.1.0/0.2.0/0.3.0 while `@statecrafting/hiqlite-native`
and `@statecrafting/kernel-native` remain at 0.1.0. The verb reads
only the contract surface (spec 009 discipline: anything not in
template.toml is not the factory's or the CLI's business) and never
hardcodes a scope, so the rename cost it nothing; the independent
versioning is what reshaped the resolution model (§2, §5). If run
against a pre-018 stamped app (no chassis packages in package.json),
report "this app predates the packaged chassis" and point at the
manual re-import path; do not attempt a tree merge.

## 2. Behavior

Expand All @@ -40,13 +48,24 @@ re-import path; do not attempt a tree merge.
1. **Preflight**: refuse on a dirty working tree; require
template.toml present; parse `[template]`, `[contract]`,
`[requires]`, and (when present, enrahitu spec 012)
`[provenance]`. Resolve the target: latest published template
version compatible with the app's contract major, or `--to`.
2. **Pin bump**: update the chassis package pins
(@enrahitu/toolchain, @enrahitu/hiqlite-native, and any pins the
contract's `[requires]` names) in package.json, then refresh the
lockfile with `npm install --package-lock-only` (never a full
platform-pruning install; same rule as enrahitu spec 014 §3).
`[provenance]`.
2. **Per-package target resolution and pin bump**: resolve each
discovered chassis package to its own target independently, because
the chassis packages no longer move in lockstep (§1). A package the
contract names in `[requires]` (a seed, e.g.
`@statecrafting/toolchain`) resolves the greatest published version
its named range allows; a companion discovered by shared scope but
unnamed (e.g. `@statecrafting/hiqlite-native`,
`@statecrafting/kernel-native`) resolves the greatest published
version within a caret of its current pin, so an unnamed companion
never crosses its own major silently. `--to <version>` overrides the
resolved target of the primary seed only (the one chassis package
the contract's range drives); every other package still
auto-resolves, so `--to` can never force a companion onto a version
it never published. Write the per-package pins into package.json,
then refresh the lockfile with `npm install --package-lock-only`
(never a full platform-pruning install; same rule as enrahitu spec
014 §3).
3. **Codemods (reserved hook)**: if the target template version ships
an `[upgrade]` table (codemod scripts, ordered, idempotent), run
them; v1 implements the hook execution but the template may not
Expand All @@ -61,18 +80,29 @@ re-import path; do not attempt a tree merge.
nothing.
6. JSON envelope (--output json) with {from, to, pins, codemodsRun,
verify: pass|fail} so the platform (and later the factory's
fleet-wide upgrade sweep) can consume the result.
fleet-wide upgrade sweep) can consume the result. Top-level
`from`/`to` are the primary seed's headline versions; `pins[]`
carries each package's own `from`/`to`, which can differ across
packages under per-package resolution.

## 3. Acceptance

- Mock-fixture tests: a fake stamped tree with pinned old versions
upgrades cleanly (pins bumped, lock refreshed, verify stub run);
dirty-tree refusal; pre-018 tree refusal with the right message;
dry-run mutates nothing; contract-major mismatch refuses with a
"major upgrade requires the migration path" error.
- Live check when upstream allows: upgrade a real stamped app across
a template patch release, verify verb green, documented in the
commit message.
per-package resolution bumps a seed and its companion to their own
independently-resolved targets; dirty-tree refusal; pre-018 tree
refusal with the right message; dry-run mutates nothing;
contract-range mismatch on a forced `--to` refuses with a "major
upgrade requires the migration path" error.
- Live check: run the release binary against a stamped-app checkout
that consumes the real published `@statecrafting/*` packages, hit
the live npm registry to resolve targets, and confirm the verb
upgrades the seed while leaving each companion at its own resolved
version, verify verb green, documented in the commit message. (The
packages are single-version-per-minor today, so a green in-range
bump uses a stamped app whose `[requires]` range spans the published
toolchain versions; forcing a `--to` outside the range still refuses
as a migration.)
- ci.yml + spine gates green.

## 4. Out of scope
Expand All @@ -81,86 +111,117 @@ re-import path; do not attempt a tree merge.
it will call this verb's logic through the API or a shared crate,
designed there).
- Building, packaging, or publishing chassis packages (enrahitu spec
018 owns the packages; this verb only consumes published versions).
018 established the packaging; the `@statecrafting/*` packages are now
owned by statecrafting specs 002/003; this verb only consumes
published versions).
- Automatic PR creation and merge (deliberate: a human or the
platform decides; v1 prints the suggestion).

## 5. Status (2026-07-15)
## 5. Status (2026-07-23)

Implemented: `statecraft template upgrade` in the new
Implemented and live-checked: `statecraft template upgrade` in the
`statecraft_cli::verbs::template` module (the symbol this spec
establishes). It is the one *local* governed verb: unlike the spec 004
verbs it never calls the control plane. It reads `template.toml`, bumps
the chassis package pins in `package.json`, refreshes the lockfile,
runs the reserved codemod hook, runs the contract's verify verb, and
commits on a `template-upgrade/<from>-<to>` branch, emitting the §2.6
`{from, to, pins, codemodsRun, verify}` result inside the shared
`{ok,data|error}` envelope. The enrahitu spec 018 gate is cleared
(chassis packages published at v0.1.0, `implementation: complete`), so
this verb is no longer parked.

v1 decisions (surfaced here for review rather than encoded silently):

- **Target ↔ pin coupling.** The template version and chassis package
version move in lockstep (enrahitu ships root, `template.toml`, and
all `@enrahitu/*` packages at one version), so the resolved target is
applied as the exact pin for each discovered chassis package.
- **Compat gate = the contract's own ranges.** A `--to` (or resolved
latest) that does not satisfy `[requires]`'s chassis range is the
"major upgrade requires the migration path" refusal
(`incompatible_target`). When a contract names more than one chassis
range, the target must satisfy *all* of them, so no range can be
crossed silently. The version policy stays in the template.
- **Chassis discovery is scope-derived.** Seeds are `package.json`
deps whose unscoped name matches a `[requires]` key (so `node` drops
out and `toolchain` resolves to `@enrahitu/toolchain`); the bump set
is every exact-pinned dep sharing a seed's scope, catching companions
like `@enrahitu/hiqlite-native` with no hardcoded scope in the CLI.
Accepted trade-off: this assumes every exact-pinned package under a
chassis scope releases in lockstep with the chassis (true for
`@enrahitu/*` today); an independently-versioned same-scope package
would need an allowlist, revisited if enrahitu ever ships one.
verbs it never calls the control plane. It reads `template.toml`,
resolves and bumps the chassis package pins in `package.json`,
refreshes the lockfile, runs the reserved codemod hook, runs the
contract's verify verb, and commits on a `template-upgrade/<from>-<to>`
branch, emitting the §2.6 `{from, to, pins, codemodsRun, verify}`
result inside the shared `{ok,data|error}` envelope.

**Model correction (2026-07-23).** The original v1 (2026-07-15) coupled
every chassis package to one target: the template and all `@enrahitu/*`
packages were assumed to ship at one version in lockstep, so the
resolved target was pinned to each discovered package. That assumption
is now falsified. The chassis packages moved from the retired
`@enrahitu` scope to `@statecrafting` (statecrafting spec 002 for the
toolchain, spec 003 for `hiqlite-native`) and are versioned
independently: `@statecrafting/toolchain` is published at
0.1.0/0.2.0/0.3.0 while `@statecrafting/hiqlite-native` and
`@statecrafting/kernel-native` are still 0.1.0. Under the old model a
run would have pinned the companions to the toolchain's 0.3.0, a
version they never published, and the lockfile refresh would have
failed. The verb's own §5 trade-off had flagged exactly this ("an
independently-versioned same-scope package would need [revisiting] if
enrahitu ever ships one"). Per the coherence guard the spec was amended
first (§1/§2/§3), then the code, rather than the reverse.

Current decisions (the per-package model):

- **Per-package resolution.** Each discovered chassis package resolves
its own target. A seed (a package the contract names in `[requires]`,
e.g. `@statecrafting/toolchain`) resolves the greatest published
version its named range allows; a companion discovered by shared
scope but unnamed (e.g. `@statecrafting/hiqlite-native`,
`@statecrafting/kernel-native`) resolves the greatest published
version within a caret of its current pin, so an unnamed companion
never crosses its own major silently and is never pinned to a version
it did not publish. `pins[]` carries each package's own `from`/`to`;
the top-level `from`/`to` are the primary seed's headline versions.
- **`--to` overrides the primary seed only.** The one chassis package
the contract's range drives. Every other package (secondary seeds and
companions) still auto-resolves, so `--to` can never force a companion
onto a nonexistent version. Documented limitation: with more than one
named seed, `--to` targets the primary and the others auto-resolve.
- **Compat gate = the primary seed's `[requires]` range.** A forced
`--to` outside that range is the "major upgrade requires the migration
path" refusal (`incompatible_target`). Auto-resolved targets satisfy
their own range by construction, so they need no gate.
- **Chassis discovery is scope-derived** (unchanged). Seeds are
`package.json` deps whose unscoped name matches a `[requires]` key (so
`node` drops out and `toolchain` resolves to `@statecrafting/toolchain`);
the bump set is every exact-pinned dep sharing a seed's scope, with no
hardcoded scope in the CLI, which is why the `@enrahitu`->`@statecrafting`
rename cost the logic nothing (only doc comments and fixtures moved).
- **Refusal taxonomy** (all exit 1, JSON `error.kind`): `not_stamped`
(no `template.toml`/`package.json`), `pre_chassis` (spec 006 §1: no
chassis packages), `local_chassis` (chassis present but as `file:`
links, i.e. a template-development tree, not a stamped app),
`bad_target`, `incompatible_target`, `dirty_tree`. Preflight order:
structural refusals first, then the dirty-tree gate (before target
resolution, so a dirty tree fails fast with no wasted registry read),
then target/compat. A verify failure is not a refusal: matching how
`stamp status --watch` renders a terminal `failed` job, it is a
completed run whose `{ok:true, data}` result carries `verify:"fail"`
and whose exit code (1) carries the failure, with the branch left for
inspection. The two-shape envelope contract holds (`ok:true` -> read
`data`, `ok:false` -> read `error`); the verdict lives in the data.
- **Dependency added:** `semver` (pure Rust) for correct
version/range comparison. Couples to spec 002's Cargo territory; the
standing `Spec-Drift-Waiver` covers the crate-file edits. Note: this
is Cargo-flavored semver, where a bare `[requires]` range (`"0.1.0"`)
parses as caret, not an npm exact pin; contract authors must write an
explicit operator (`^0.1`, `>=24`), as enrahitu's contract does.
- **`--to` omitted** resolves the greatest published version the range
allows via an `npm view` registry read, behind the runner seam so
tests stay offline.
(no `template.toml`/`package.json`), `pre_chassis` (§1: no chassis
packages), `local_chassis` (chassis present but as `file:` links, a
template-development tree, not a stamped app), `bad_target`,
`incompatible_target`, `dirty_tree`. Preflight order: structural
refusals first, then the dirty-tree gate (before target resolution, so
a dirty tree fails fast with no wasted registry read), then
per-package resolution/compat. A verify failure is not a refusal: it
is a completed run whose `{ok:true, data}` carries `verify:"fail"` and
whose exit code (1) carries the failure, with the branch left for
inspection, matching how `stamp status --watch` renders a terminal
`failed` job.
- **Registry-shape robustness.** `npm view <pkg> versions --json`
returns a JSON array, but for a package with exactly one published
version some npm builds return a bare JSON string; the runner accepts
both, so single-version companions resolve.
- **Dependency:** `semver` (pure Rust). Couples to spec 002's Cargo
territory; the standing `Spec-Drift-Waiver` covers the crate-file
edits. Cargo-flavored semver: a bare `[requires]` range (`"0.1.0"`)
parses as caret, so contract authors write an explicit operator
(`^0.3`, `>=24`), as enrahitu's contract does.

Covered by tests (all offline; every git/npm/node effect is behind a
`Runner` trait): scope-derived discovery with companions, the
`Runner` trait, which resolves per-package versions from a configured
published set via the same `select_latest` the real runner uses):
scope-derived discovery marking seed vs companion; the
`pre_chassis`/`local_chassis`/`not_stamped`/`incompatible_target`/`dirty_tree`
refusals, the multi-range gate refusing when any seed range excludes
the target, format-preserving pin rewrite, the dry-run plan that
mutates nothing, latest-resolution when `--to` is omitted, the no-op
that still bumps a companion left behind the primary, the happy path
(branch -> lock -> verify -> commit), the verify-failure path that
leaves the branch uncommitted, and the result envelope staying
`{ok:true, data:{verify:"fail"}}` with a `Rendered` exit 1. Three
end-to-end binary checks drive the offline paths (not-stamped,
pre-018, dry-run) with the process working directory set to a fixture.

Outstanding: the §3 live check cannot run yet. It needs a real stamped
app carrying published-version chassis pins plus a newer published
template version to upgrade across; enrahitu's scaffold verb (spec 014)
is still v0 and "edits no dependencies", so no such app exists, and
there is only the v0.1.0 release to pin to. This spec stays
`implementation: in-progress` until a stamped app and a patch release
exist; at that point the only open item is the live upgrade transcript
(no code change), and the commit records its pointer.
refusals; per-package pin rewrite to distinct targets; a companion
bumped on its own track independently of the forced primary; the
regression itself (a companion that never published the seed's target
stays on its own latest and is not rewritten to a nonexistent version);
the dry-run plan that mutates nothing; latest-resolution when `--to` is
omitted; the no-op; the happy path (branch -> lock -> verify -> commit);
the verify-failure path that leaves the branch uncommitted; and the
result envelope staying `{ok:true, data:{verify:"fail"}}` with a
`Rendered` exit 1. Four end-to-end binary checks drive the offline
paths (not-stamped, pre-018, dry-run) with the process working
directory set to a fixture.

**Live check (2026-07-23).** The release binary was run against a
stamped-app checkout that consumes the real published `@statecrafting/*`
packages (a git repo pinning toolchain/hiqlite-native/kernel-native at
0.1.0, contract `toolchain = ">=0.1, <0.4"` so an in-range target
exists, verify `true`). Against the live npm registry the verb resolved
per-package and upgraded cleanly: `@statecrafting/toolchain`
0.1.0 -> 0.3.0 while both companions stayed at their own 0.1.0; the real
`npm install --package-lock-only` resolved all three
(`toolchain@0.3.0`, `hiqlite-native@0.1.0`, `kernel-native@0.1.0`),
which the old lockstep model could not have produced; verify passed,
the bump committed on `template-upgrade/0.1.0-0.3.0`, exit 0. The
mirror case (a `^0.1` contract, `--to 0.3.0`) refused with
`incompatible_target`, exit 1. This satisfies §3; the verb is complete.
Loading
Loading