Feature Specification: Schema Changes and Migration

Created: 2026-09-22 Status: Implemented Input: Reconstructed from the code at d225ebe (src/migrate.ts, src/diff-schema.ts, src/schema-old-fields.ts, src/old-field-renderer.ts, src/diff-highlight.ts, their unit and DOM tests), docs/usage.md, demo/schema-changes/, the history of the former panel/forms-island directory in the fediversity monorepo (value migration, schema difference), and the Claude Code logs of the maintainers.

User Scenarios & Testing (mandatory)

User Story 1 - A renamed option keeps its value (Priority: P1)

A module renamed an option after the user stored a value. The embedder gives the stored value and a rename table to migrateValue, and mounts the migrated value. The form shows the old value under the new name.

Why this priority: without migration, the form does not show the old key, and the value of the user is lost at the next save (constitution principle V).

Independent Test: the unit tests of migrateValue with literal and wildcard renames.

Acceptance Scenarios:

  1. Given a value with key a.b and a rename from a.b to a.c, When the embedder calls migrateValue, Then the result holds the value at a.c, not at a.b, and applied contains the rename.
  2. Given a rename with a wildcard segment (null) for a map or list level, When the value holds several entries at that level, Then each entry is migrated, and applied contains one concrete rename for each entry.
  3. Given a value that holds data at both the old and the new path, When the embedder calls migrateValue, Then the old data stays, and unmigrated contains the rename with the reason target-occupied.

User Story 2 - See which options changed since the baseline (Priority: P1)

A module changed between the version of the baseline and the current version. The embedder gives the old schema, the new schema and the rename table to computeSchemaChanges, and passes the result as schemaChanges at mount. The form marks each option that was added, renamed or changed, and shows each removed option.

Why this priority: the user must see what the upgrade of a module changes before they apply it.

Independent Test: open the schema-changes page of the demo, which mounts two versions of the demo module.

Acceptance Scenarios:

  1. Given an option that exists only in the new schema, When the form mounts, Then the option has the "added" marker.
  2. Given an option that exists only in the old schema and a baseline value for it, When the form mounts, Then the form shows a read-only field with the old value.
  3. Given an option whose type changed, When the form mounts, Then the form shows the old value in a read-only field next to the new control.
  4. Given an option whose description or default changed, When the form mounts, Then the option has the "changed" marker, and the old and new properties are available as a hint.
  5. Given a rename, When the form mounts, Then the new option shows the old path, and the rename does not show as a removal and an addition.

User Story 3 - Show a rename only where it moved data (Priority: P2)

The rename table grows over time. The form marks a rename only when it moved data of this value.

Why this priority: markers for old renames that did not affect the value are noise.

Independent Test: the unit tests of restrictRenamesToApplied.

Acceptance Scenarios:

  1. Given a rename that did not move data, When the embedder restricts the changes to the applied renames, Then the rename marker is removed.
  2. Given an option that was renamed and changed, and the rename did not move data, When the embedder restricts the changes, Then the "changed" marker stays, without the old path.

Edge Cases

  • The old and the new schema are equal and there are no renames: the change map is empty and the form shows only value differences.
  • The embedder cannot get the old schema: it passes no schemaChanges, and the form shows only value differences.
  • A removed option had no value in the baseline: the form shows no old field for it.
  • A secret option had no value in the baseline: the form shows no old field for it, also when its type changed.
  • An option is secret in the old schema but not in the current schema, or the opposite: the widget treats it as secret.
  • A rename inside a branch of a tagged union: the migration does not go into oneOf branches. [NEEDS CLARIFICATION: are renames inside a tagged union in scope? A monorepo issue reports this gap. (#43)]
  • A name that a module retired is used again later for a different option. [NEEDS CLARIFICATION: how must the rename table and the migration treat a reused name? (#43)]
  • The migration does not change the stored value. The embedder decides if and when it stores the migrated value.
  • The stored value has a key that the current schema does not offer, such as an option that upstream removed: pruneUnoffered drops it from the value that the form edits, and the embedder shows the dropped paths. The removal needs no entry in the rename table, as the schema shows what it offers.

Requirements (mandatory)

Functional Requirements

  • FR-001: migrateValue(value, renames) MUST return the migrated value, the list of applied concrete renames, and the list of renames that it could not apply with a reason. It MUST NOT change its input.
  • FR-002: A rename MUST be a pair of paths from and to. A path segment is a key or null, where null matches each key of a map or list level. The wildcards of from and to align by position.
  • FR-003: migrateValue MUST NOT overwrite data at the target path.
  • FR-004: computeSchemaChanges(oldSchema, newSchema, renames) MUST return a map from the current path (or the old path for a removed option) to a change of kind added, removed, renamed or changed. A rename MUST hide the removal and the addition of its two paths.
  • FR-005: For a changed option, the change MUST name the changed schema properties with their old and new values.
  • FR-006: restrictRenamesToApplied(changes, appliedPaths) MUST remove rename markers for paths that the migration did not move.
  • FR-007: For a removed option, and for an option whose type changed, the form MUST show the baseline value in a read-only field. The widget adds these fields to the schema with the x-nmf-old marker and the __nmfOld key suffix.
  • FR-008: The value that the widget reports (through onChange, validate and onDiffChange) MUST NOT contain the read-only old fields.
  • FR-009: The form MUST open the collapsed sections that contain a schema change.
  • FR-010: The widget MUST NOT show the old value of an option that the old or the current schema marks as secret. For such an option, the old field MUST show only the label and a note that the value is not shown, and the difference marker MUST show only that the value changed. The live field of a secret string MUST hide its value until the user selects the eye icon.
  • FR-011: This widget MUST define the format of the rename table. Producers of the table, such as the build of an embedder, follow that format.
  • FR-012: pruneUnoffered(value, schema) MUST return a copy of the value without each object key that the schema does not offer, and the paths of these keys. In a union, a key MUST be dropped only if each branch does not offer it or one of its parent keys. It MUST NOT drop a value of the wrong type, a list element, or a key that the schema accepts. It MUST NOT change its input.

Key Entities

  • Rename table: a list of { from, to } path pairs, one for each option rename of the module.
  • Migration result: the migrated value, the applied renames and the renames that were not applied.
  • Prune result: the value without the unoffered keys, and the paths of the dropped keys.
  • Schema change map: for each affected path, the kind of change, the old path and the old schema where they apply.
  • Old field: a read-only field that the widget adds to show a baseline value that the current schema cannot hold.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: After migration, no value of a renamed option is lost when the target was free.
  • SC-002: Each removed option that had a value is visible in the form with that value. A secret option is visible with a note in place of the value.
  • SC-003: The value that the widget reports never contains a key that ends with __nmfOld.

Assumptions

  • The embedder gets the old schema and the rename table from its own storage or build. The widget does not fetch them.
  • The old values come from the baseline (see 003-baseline-diff).
  • The schema marks a secret option with the JSON Schema keyword writeOnly: true, on the option or on a $ref or nullable wrapper of it. module-schema does not emit writeOnly yet (fediversity/module-schema#67), so a secret shows as a secret only in a schema that an embedder marks.

Known Deviations

None.