Problem
Today rivet has no schema migration support. Users must hand-rewrite artifact YAML when:
- Switching presets — they started on
dev (3 types) and outgrow into aspice (14 types) or stpa (10 types). Type renames, link-type renames, field shape changes — all manual.
- Upgrading a preset version — ASPICE 4.0 → 4.1 will be a real thing. There's no upgrade path.
- Refactoring overlays — adding/removing/renaming custom fields in a project-local overlay forces parallel rewrites of every affected artifact.
The existing-repo appendix in the quickstart says "redeclare every base field" — that's load-bearing advice. Right now there's no tooling to apply it mechanically. G.2 (overlay merge silently drops unlisted fields) is the trap that catches everyone.
Without migration tooling, the implicit user contract becomes "pick your preset on day 1, you're stuck with it." That contradicts the Path A → Path B story the rewritten quickstart promises.
Proposed solution
rivet schema migrate — a git rebase-style state machine for applying a structural rewrite to all artifacts in a project, with explicit support for pausing on conflicts.
Three classes of change (the rebase pick / edit / drop)
| Class |
Examples |
Auto-resolve? |
| Mechanical |
requirement → sw-req; satisfies → derives-from; enum value re-spelling (functional → functional-req) |
YES — diff source/target schemas, generate a rewrite map |
| Decidable with policy |
Source field doesn't exist on target. Default: drop. With --keep-as-orphan: stash under fields.legacy.*. With --strict: fail |
YES with explicit policy flag |
| Conflicting |
Source has priority: 5 (number); target requires priority: must (enum). Or: required link target type changed from [any] to [stakeholder-req] and current link points elsewhere |
NO — needs user input |
CLI surface
```bash
1. Plan (default = dry-run)
rivet schema migrate aspice
Reads current schemas + target preset
Writes .rivet/migrations//plan.yaml
Prints human summary: X auto, Y needs-policy, Z conflicts
Exits 0 even with conflicts (plan succeeds; apply blocks)
2. Apply mechanical changes; pause on first conflict (rebase semantics)
rivet schema migrate --apply
Walks plan in order. For each artifact:
- If auto-mappable: rewrite the file
- If conflict: write conflict markers into the YAML, pause
Exits non-zero if paused; exits 0 if fully applied
3. Conflict resolution flow (the rebase-interactive equivalent)
rivet schema migrate --status
Shows: applied N of M, current conflict on , action options
rivet schema migrate --continue
Validates the user resolved the current conflict
(no <<<<<<< markers remain in the affected file)
Resumes the walk to the next conflict or completion
rivet schema migrate --skip
Skip the current artifact (drop it from the migration);
the original is preserved unchanged — like rebase --skip
rivet schema migrate --abort
Restore from .rivet/migrations//snapshot/ — full pre-migration state
Like git rebase --abort
rivet schema migrate --edit
Re-open the conflict block on a previously-resolved artifact
for re-editing before --continue
4. After completion
rivet schema migrate --finish
Validates with rivet validate, deletes the snapshot
(snapshots are persisted across sessions to support multi-day migrations)
```
State machine
```
(no migration)
│
▼ rivet schema migrate
[PLANNED]
│
▼ --apply
[IN_PROGRESS]
┌───┴───┐
│ │
(no conflict) (conflict found)
│ │
│ ▼
│ [CONFLICT] ◄──┐
│ │ │
│ │ --continue (after edit)
│ │ --skip
│ │ --edit
│ │ --abort ────► [PLANNED] then [IDLE]
│ │
│ └─────────┘
▼
[COMPLETE]
│ --finish
▼
[IDLE]
```
Storage layout
```
.rivet/migrations/
└── 20260428-1900-dev-to-aspice/
├── plan.yaml # the full diff: per-artifact, per-field action
├── manifest.yaml # current state machine pointer + per-artifact status
├── state # one of: PLANNED | IN_PROGRESS | CONFLICT | COMPLETE
├── current-conflict # artifact id currently paused on (if state == CONFLICT)
└── snapshot/ # full pre-migration artifact YAML tree (for --abort)
└── artifacts/
└── ...
```
The directory survives across sessions — a multi-day migration is fine. Only one migration in flight at a time per project (later migrations error with "another migration is in progress").
Conflict markers in YAML (merge-conflict style)
When --apply hits a conflict, it writes the affected artifact's field as:
```yaml
- id: REQ-001
type: sw-req # was: requirement (auto-renamed)
fields:
priority: <<<<<<< source: dev (priority: number)
5
======= target: aspice (priority: enum [must|should|could|wont])
```
User edits the YAML, removes the conflict markers, runs rivet schema migrate --continue. The continue handler verifies no markers remain, runs rivet validate on just the touched artifact, then proceeds.
What ships in binary vs as recipes
- In binary: diff engine (two schemas → rewrite map), conflict-marker writer, state machine plumbing, auto-policies (
drop, keep-as-orphan, strict).
- As recipes (
schemas/migrations/<source>-to-<target>.yaml, hand-curated): named pre-baked migrations for common preset pairs (dev→aspice, aspice→score, stpa→stpa-sec, version bumps for the same preset). Used when the binary's auto-mapping needs human judgment.
Recipe shape:
```yaml
schemas/migrations/dev-to-aspice.yaml
migration:
name: dev-to-aspice
source: { preset: dev }
target: { preset: aspice }
description: |
Mechanical mapping for the most common dev → aspice transition.
type-rewrites:
- from: requirement
to: sw-req
field-map:
priority: priority # same name, but enum values constrained
# ... explicit per-field decisions
- from: feature
to: sw-arch-component
# human note in PR review: "this is a judgment call —
# could also be sys-arch-component for hardware-bound features"
- from: design-decision
to: design-decision # same name in both; identity rewrite
link-rewrites:
- from: satisfies
to: derives-from
policies:
unmapped-fields: keep-as-orphan
unmapped-link-types: drop
```
Test strategy
- Roundtrip property: for every reversible migration (
A → B where there's also a B → A recipe), running migrate(A→B) then migrate(B→A) must yield artifact YAML byte-identical to the original (modulo formatting). Catches asymmetric mapping bugs.
- Validation gate: every recipe ships with a fixture (a small valid project in source schema). Test runs
rivet init --preset <source>, populates with the fixture, runs migrate --apply, asserts rivet validate passes on the result.
- Conflict harness: handcrafted "hard" projects that intentionally contain every conflict class. Test runs
migrate, expects exact set of conflicts, then runs --continue after pre-baked resolutions.
- Abort symmetry:
migrate --apply --abort must leave the project byte-identical to before. Snapshot diff test.
Open questions
- Multi-overlay interaction. If a project has
[common, aspice, legacy-overlay] schemas registered, and the user runs migrate score, does the overlay get dropped, kept-as-orphan, or migrated separately? Probably: overlays remain, but get an automatic extends: retarget if their parent type was renamed. Document the rule.
- Dashboard surface. Should there be a
/migrations/<id> dashboard page showing the in-flight migration? MVP says no (CLI-only). Post-MVP: yes — render the per-artifact diff with click-to-resolve.
- Migration recipe distribution. Recipes ship with the binary today. As the recipe set grows, do we want them as a separate crate / pulled from a remote registry? Probably out of scope for v1.
- Schema-version bumps within the same preset. ASPICE 4.0 → 4.1 looks like a "type-rewrites: []" recipe with only field-level changes. The recipe format above already supports this; needs to be exercised in tests.
- Provenance trail. Each migrated artifact should carry an entry in its
provenance: field recording the migration (timestamp, recipe used, source preset). Out of scope for MVP but worth mapping.
- Cross-repo coordination. If a project uses cross-repo links (
rivet externals), and the local schema migrates but the externals don't, what happens? Document: cross-repo links are by ID and don't carry type info, so they're transparent to local migration. But: if both repos are migrating, coordination is the user's problem in v1.
MVP scope (v0.6.0 marquee)
Phase 1 (~2 weeks):
- The diff engine + the rewrite-map computation
rivet schema migrate <target> (plan only, dry-run)
rivet schema migrate --apply for mechanical-only migrations (no conflict resolution yet — just bail loudly if conflict)
--abort (full snapshot rollback)
- One canned recipe:
dev-to-aspice with full test coverage
- Property test: roundtrip on the canned recipe
Phase 2 (~2 weeks): the rebase-style state machine
- Conflict markers in YAML
--continue / --skip / --status
- The state file + snapshot persistence
- Conflict harness test
Phase 3 (post-MVP):
--edit <id> (re-open resolved conflict)
- Dashboard surface
- Recipe distribution (probably a
rivet recipes subcommand)
- Provenance entries
Trigger
Surfaced during the post-0.5.0 fresh-user dogfood: scenario B (Polarion → ASPICE bring-up) explicitly hand-wrote an overlay because the binary couldn't help. The compliance-lead persona — exactly the audience rivet's three-pillar pitch targets — is the one most affected. Today they spend 5–7 minutes on an overlay; with migrate they should spend 30 seconds on a recipe lookup.
Acceptance
Problem
Today rivet has no schema migration support. Users must hand-rewrite artifact YAML when:
dev(3 types) and outgrow intoaspice(14 types) orstpa(10 types). Type renames, link-type renames, field shape changes — all manual.The existing-repo appendix in the quickstart says "redeclare every base field" — that's load-bearing advice. Right now there's no tooling to apply it mechanically. G.2 (overlay merge silently drops unlisted fields) is the trap that catches everyone.
Without migration tooling, the implicit user contract becomes "pick your preset on day 1, you're stuck with it." That contradicts the Path A → Path B story the rewritten quickstart promises.
Proposed solution
rivet schema migrate— agit rebase-style state machine for applying a structural rewrite to all artifacts in a project, with explicit support for pausing on conflicts.Three classes of change (the rebase
pick/edit/drop)requirement→sw-req;satisfies→derives-from; enum value re-spelling (functional→functional-req)--keep-as-orphan: stash underfields.legacy.*. With--strict: failpriority: 5(number); target requirespriority: must(enum). Or: required link target type changed from[any]to[stakeholder-req]and current link points elsewhereCLI surface
```bash
1. Plan (default = dry-run)
rivet schema migrate aspice
Reads current schemas + target preset
Writes .rivet/migrations//plan.yaml
Prints human summary: X auto, Y needs-policy, Z conflicts
Exits 0 even with conflicts (plan succeeds; apply blocks)
2. Apply mechanical changes; pause on first conflict (rebase semantics)
rivet schema migrate --apply
Walks plan in order. For each artifact:
- If auto-mappable: rewrite the file
- If conflict: write conflict markers into the YAML, pause
Exits non-zero if paused; exits 0 if fully applied
3. Conflict resolution flow (the rebase-interactive equivalent)
rivet schema migrate --status
Shows: applied N of M, current conflict on , action options
rivet schema migrate --continue
Validates the user resolved the current conflict
(no <<<<<<< markers remain in the affected file)
Resumes the walk to the next conflict or completion
rivet schema migrate --skip
Skip the current artifact (drop it from the migration);
the original is preserved unchanged — like rebase --skip
rivet schema migrate --abort
Restore from .rivet/migrations//snapshot/ — full pre-migration state
Like git rebase --abort
rivet schema migrate --edit
Re-open the conflict block on a previously-resolved artifact
for re-editing before --continue
4. After completion
rivet schema migrate --finish
Validates with rivet validate, deletes the snapshot
(snapshots are persisted across sessions to support multi-day migrations)
```
State machine
```
(no migration)
│
▼ rivet schema migrate
[PLANNED]
│
▼ --apply
[IN_PROGRESS]
┌───┴───┐
│ │
(no conflict) (conflict found)
│ │
│ ▼
│ [CONFLICT] ◄──┐
│ │ │
│ │ --continue (after edit)
│ │ --skip
│ │ --edit
│ │ --abort ────► [PLANNED] then [IDLE]
│ │
│ └─────────┘
▼
[COMPLETE]
│ --finish
▼
[IDLE]
```
Storage layout
```
.rivet/migrations/
└── 20260428-1900-dev-to-aspice/
├── plan.yaml # the full diff: per-artifact, per-field action
├── manifest.yaml # current state machine pointer + per-artifact status
├── state # one of: PLANNED | IN_PROGRESS | CONFLICT | COMPLETE
├── current-conflict # artifact id currently paused on (if state == CONFLICT)
└── snapshot/ # full pre-migration artifact YAML tree (for --abort)
└── artifacts/
└── ...
```
The directory survives across sessions — a multi-day migration is fine. Only one migration in flight at a time per project (later migrations error with "another migration is in progress").
Conflict markers in YAML (merge-conflict style)
When
--applyhits a conflict, it writes the affected artifact's field as:```yaml
type: sw-req # was: requirement (auto-renamed)
fields:
priority: <<<<<<< source: dev (priority: number)
5
======= target: aspice (priority: enum [must|should|could|wont])
```
User edits the YAML, removes the conflict markers, runs
rivet schema migrate --continue. The continue handler verifies no markers remain, runsrivet validateon just the touched artifact, then proceeds.What ships in binary vs as recipes
drop,keep-as-orphan,strict).schemas/migrations/<source>-to-<target>.yaml, hand-curated): named pre-baked migrations for common preset pairs (dev→aspice,aspice→score,stpa→stpa-sec, version bumps for the same preset). Used when the binary's auto-mapping needs human judgment.Recipe shape:
```yaml
schemas/migrations/dev-to-aspice.yaml
migration:
name: dev-to-aspice
source: { preset: dev }
target: { preset: aspice }
description: |
Mechanical mapping for the most common dev → aspice transition.
type-rewrites:
- from: requirement
to: sw-req
field-map:
priority: priority # same name, but enum values constrained
# ... explicit per-field decisions
link-rewrites:
- from: satisfies
to: derives-from
policies:
unmapped-fields: keep-as-orphan
unmapped-link-types: drop
```
Test strategy
A → Bwhere there's also aB → Arecipe), runningmigrate(A→B)thenmigrate(B→A)must yield artifact YAML byte-identical to the original (modulo formatting). Catches asymmetric mapping bugs.rivet init --preset <source>, populates with the fixture, runsmigrate --apply, assertsrivet validatepasses on the result.migrate, expects exact set of conflicts, then runs--continueafter pre-baked resolutions.migrate --apply --abortmust leave the project byte-identical to before. Snapshot diff test.Open questions
[common, aspice, legacy-overlay]schemas registered, and the user runsmigrate score, does the overlay get dropped, kept-as-orphan, or migrated separately? Probably: overlays remain, but get an automaticextends:retarget if their parent type was renamed. Document the rule./migrations/<id>dashboard page showing the in-flight migration? MVP says no (CLI-only). Post-MVP: yes — render the per-artifact diff with click-to-resolve.provenance:field recording the migration (timestamp, recipe used, source preset). Out of scope for MVP but worth mapping.rivet externals), and the local schema migrates but the externals don't, what happens? Document: cross-repo links are by ID and don't carry type info, so they're transparent to local migration. But: if both repos are migrating, coordination is the user's problem in v1.MVP scope (v0.6.0 marquee)
Phase 1 (~2 weeks):
rivet schema migrate <target>(plan only, dry-run)rivet schema migrate --applyfor mechanical-only migrations (no conflict resolution yet — just bail loudly if conflict)--abort(full snapshot rollback)dev-to-aspicewith full test coveragePhase 2 (~2 weeks): the rebase-style state machine
--continue/--skip/--statusPhase 3 (post-MVP):
--edit <id>(re-open resolved conflict)rivet recipessubcommand)Trigger
Surfaced during the post-0.5.0 fresh-user dogfood: scenario B (Polarion → ASPICE bring-up) explicitly hand-wrote an overlay because the binary couldn't help. The compliance-lead persona — exactly the audience rivet's three-pillar pitch targets — is the one most affected. Today they spend 5–7 minutes on an overlay; with
migratethey should spend 30 seconds on a recipe lookup.Acceptance
rivet schema migrate dev-to-aspice(plan-only) on adevproject lists every type/link/field rewrite with the correct action class--applyon a conflict-free project produces arivet validate-clean output in target preset--abortrestores byte-identical pre-migration state--continue/--skipwalk through conflicts correctlydev-to-aspice) ships with documented round-trip property testrivet docs schema-migrationsdocuments the state machine + conflict marker formatrivet docs checkrecognizes<<<<<<<markers in artifact YAML and lists them as aMigrationConflictinvariant