Skip to content

settings: versioned store names, migrations and schema drift (plan 3) - #484

Merged
m4ttheweric merged 37 commits into
mainfrom
plan-settings-migrations
Sep 26, 2026
Merged

m4ttheweric merged 37 commits into
mainfrom
plan-settings-migrations

Conversation

@m4ttheweric

Copy link
Copy Markdown
Collaborator

Problem

A composite settings key could not change shape between releases. Plan 1 blocks a breaking schema change; nothing carried stored values forward, and old and new rt-clients share the same stores (synced user store, team store, apps pinned to an older rt-client).

Fix

Plan 3 of the settings work (spec docs/superpowers/specs/2026-09-25-settings-migrations-design.md, plan docs/superpowers/plans/2026-09-25-settings-migrations.md).

  • Versioned store names. A breaking change bumps storeVersion; the key is stored as key@N. Code keeps the plain key. An older reader keeps reading the name it knows.
  • Migrations on read. Registered migrateFrom steps (migrations/index.ts, zod-free) carry the highest older name forward in memory. A step that throws, or a result failing the schema, leaves the stored value in effect and labels the row nonconforming.
  • Baselines and labels. The first write of key@N into a section records $migrated hashes for older names there, in the same write. Older names are then leftover, stale or diverged (an old writer edited or recreated them).
  • Surfaces. Explain rows carry storeName, storedVersion, authored, olderNames; rt settings check fails on a diverged name; new rt settings migrate (dry run, --write, --prune with confirmation, --team, --force <key>); pruneStoreName; unset removes every name. Secret values never leave through any of them.
  • settings-kit. Wire rows carry the store-name fields, issues[] gains kind: "diverged", and POST {base}/prune deletes one older name behind the same gates as /unset.
  • CI and release. A breaking change needs the bump plus a migrateFrom entry equivalent to main's lock (a never-shipped key, renames included, may still be acknowledged in CI). Release preflight checks the whole chain against the last tag, lists bumps for the release notes, and runs the candidate's rt settings check against the real stores.
  • Authoring. Deterministic sample generator plus a proof test for every step; zod rebuilt from the lock; rt settings schema diff --draft writes the step and the previous schema (mechanical when replaying ops reproduces the new schema, otherwise a TODO stub; every drafted delete carries a note).
  • Versions. @mattstack/rt-client 0.33.0, @mattstack/settings-kit 0.5.0 (peer >=0.33.0 <1). Not published.

No registry key is bumped here; the registries ship empty.

Acceptance

  • Full local gate green: repo-purity, tsc, 1320 unit tests, docs:check, picker:check, lock in sync, both package builds, e2e settings, compile, startup bench (median 56.4ms)
  • bun run cli.ts settings check against real stores exits 0 (read-only)
  • Review Focus 1 to 5 from the plan pinned by tests
  • CI green
  • CodeRabbit reviewed

Side decision

v2.13.1 has no schema.lock.json, so until the next tag every key counts as never shipped (plan Decision 3). Cutting a release soon after merge closes that window.

🤖 Generated with Claude Code

m4ttheweric and others added 27 commits September 25, 2026 14:58
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r review)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…older names

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ort newer ones

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ery name; pruneStoreName

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…unmigratable values

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…onfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t fields in --json

OlderName now carries authored (the as-stored value) so a forced prune of
a diverged name can print it, and print it before the delete rather than
after pruneStoreName returns. Secret defs now omit value/olderValue/
currentValue/authored from --json entirely instead of substituting a
placeholder string, matching check.ts. Text output keeps the "(secret)"
placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… /prune

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…migrateFrom, in CI and at release

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…etic shipped-ref tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in the current schema

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…seeding on unvalidated leaves

sampleValues no longer loses union branches or enum values past its
per-node cap: branch lists round-robin interleave before any
downstream slice, and gen() filters every candidate against its own
node's schema so an invalid leaf seed (0 under exclusiveMinimum 0, ""
under minLength 1) never poisons every sibling variant built from it.
Lifted the duplicate isObject/isSchema plain-object guard into schema.ts
as isSchema, used by both schema-diff.ts and sample-values.ts.
migration-proof.ts now reuses def.layerSchema when present instead of
recomputing it, matching checkSchema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…igrations

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d rename drafts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…st when it started empty

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	packages/rt-client/src/settings/__tests__/schema-examples.ts
Preflight's new settings-stores row runs the checkout's own
`settings check --json` read-only against the real stores, so a
release cannot ship a migration that fails against what people
actually have on disk. index-surface.test.ts pins the migration
verbs on rt-client's public entry point and keeps the authoring
tools off it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Documents the key@N store-version scheme end to end: the release
skill's stale-row guidance, settings-architecture.md's new section
and footguns, and the rt-client/settings-kit READMEs (including the
listUnregisteredSettings/defs "newer" flag). Fixes resolve.ts and
server.ts doc comments that still called explain's value AS
AUTHORED, now that value is migrated and authored is the stored
form; server.ts's WireIssue doc also covers the diverged kind's
fields. command-tree-def.ts and checks.yml now describe the real
storeVersion + migrateFrom acceptance rule instead of the old
"bump and acknowledge" wording (bun run docs:gen ran after).

Bumps rt-client to 0.33.0 and settings-kit to 0.5.0 (peer range
>=0.33.0 <1); never published.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the release skill

docs/settings-architecture.md's acceptance paragraph still described
the old "bump and acknowledge" rule; it now says what
checkLockAgainst actually enforces: a storeVersion bump by one plus
a matching migrateFrom entry, with the breaking-schema-changes.json
reason standing in only for a never-shipped key in CI, and a removed
key needing a rename heir or that same reason. rt-release/SKILL.md's
parenthetical claimed the never-shipped hatch applies at release
time; it does not there (a key in the tag lock has shipped), so it
now names the one case the hatch still covers at release: a removed
key with no rename heir. Also lists the new settings-stores check in
step 1's preflight summary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
POST /prune skipped the composite, scopes and isWritable checks /unset
enforces, so a composite key was refused by /set and /unset but pruned
anyway. Add the same gates in the same order with the same messages,
and a test proving /prune refuses a composite key under the default
allowComposite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A property renamed while another was added in the same object drafted
a delete plus an add with no note, so every gate accepted a
data-dropping migration. Every drafted delete now carries a note
pointing at renameProperty as the alternative; existing delete tests
that asserted empty notes are updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- write.ts: correct removeFromStore's doc comment (it takes a path
  planner, not a single jsonPath, and detects a no-op by text
  comparison, not by zero edits from modify).
- react.ts: rephrase a process citation ("spec 3's Remove the older
  name") as the console's own action name.
- write.ts: the diverged-older-name unset refusal now also says a
  team-store name needs --team and that prune lists every name it
  will delete before confirming.
- lib/release/preflight.ts: build a settings-check finding's location
  from scope and repo unconditionally, so a repo-only finding (no
  scope) still names its repo; add a test for it.
- settings-schema.test.ts: pass shippedLock: null on the --draft test
  so it never shells out to real git.
- resolve.ts: reword listUnregistered's warning to drop its em dash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 56 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 87 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 565fc9a7-5442-427a-a60e-7a486e8a22ec

📥 Commits

Reviewing files that changed from the base of the PR and between 6150fdb and 0fc217a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (62)
  • .github/workflows/checks.yml
  • commands/__tests__/release-preflight.test.ts
  • commands/__tests__/settings-check.test.ts
  • commands/__tests__/settings-keys-render.test.ts
  • commands/__tests__/settings-migrate.test.ts
  • commands/__tests__/settings-schema.test.ts
  • commands/settings-keys.ts
  • commands/settings-schema.ts
  • docs/settings-architecture.md
  • docs/superpowers/plans/2026-09-25-settings-migrations.md
  • lib/command-tree-def.ts
  • lib/release/__tests__/preflight.test.ts
  • lib/release/preflight.ts
  • lib/settings/migrate-stores.ts
  • lib/settings/migrate.ts
  • lib/settings/schema-draft.ts
  • packages/rt-client/README.md
  • packages/rt-client/package.json
  • packages/rt-client/src/index.ts
  • packages/rt-client/src/settings/__tests__/check-migrations.test.ts
  • packages/rt-client/src/settings/__tests__/migrate-section.test.ts
  • packages/rt-client/src/settings/__tests__/migrate-stores.test.ts
  • packages/rt-client/src/settings/__tests__/migrate.test.ts
  • packages/rt-client/src/settings/__tests__/migration-helpers.test.ts
  • packages/rt-client/src/settings/__tests__/migration-proof.test.ts
  • packages/rt-client/src/settings/__tests__/migrations-acceptance.test.ts
  • packages/rt-client/src/settings/__tests__/registry.test.ts
  • packages/rt-client/src/settings/__tests__/resolve-migrations.test.ts
  • packages/rt-client/src/settings/__tests__/resolve.test.ts
  • packages/rt-client/src/settings/__tests__/sample-values.test.ts
  • packages/rt-client/src/settings/__tests__/schema-draft.test.ts
  • packages/rt-client/src/settings/__tests__/schema-lock.test.ts
  • packages/rt-client/src/settings/__tests__/with-migration.ts
  • packages/rt-client/src/settings/__tests__/write-migrations.test.ts
  • packages/rt-client/src/settings/__tests__/zod-source.test.ts
  • packages/rt-client/src/settings/check.ts
  • packages/rt-client/src/settings/migrate-stores.ts
  • packages/rt-client/src/settings/migrate.ts
  • packages/rt-client/src/settings/migration-proof.ts
  • packages/rt-client/src/settings/migrations/helpers.ts
  • packages/rt-client/src/settings/migrations/index.ts
  • packages/rt-client/src/settings/migrations/schemas.ts
  • packages/rt-client/src/settings/registry-machinery.ts
  • packages/rt-client/src/settings/resolve.ts
  • packages/rt-client/src/settings/sample-values.ts
  • packages/rt-client/src/settings/schema-diff.ts
  • packages/rt-client/src/settings/schema-draft.ts
  • packages/rt-client/src/settings/schema-lock.ts
  • packages/rt-client/src/settings/schema.ts
  • packages/rt-client/src/settings/write.ts
  • packages/rt-client/src/settings/zod-source.ts
  • packages/rt-client/test/index-surface.test.ts
  • packages/settings-kit/README.md
  • packages/settings-kit/package.json
  • packages/settings-kit/src/__tests__/server.test.ts
  • packages/settings-kit/src/react.ts
  • packages/settings-kit/src/server.ts
  • skills/rt-release/SKILL.md
  • website/docs/reference/settings/index.mdx
  • website/docs/reference/settings/migrate.mdx
  • website/docs/reference/settings/schema/diff.mdx
  • website/docs/reference/settings/schema/index.mdx

Comment @coderabbitai help to get the list of available commands.

m4ttheweric and others added 2 commits September 25, 2026 19:46
Two removed keys of the same shape as one added key no longer both draft
a rename onto that added key. Every rename draft now carries a confirm
note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cope older-name findings

check.ts and resolve.ts's invalid branches dropped the migration error
when the authored old-shape value also failed the type check; both now
report the migration error first. checkSection now applies the same
scope and repoScoped allow-list planStoreMigrations uses before labeling
an older name diverged, stale or leftover, leaving invalid and
nonconforming untouched. renderExplainRow now shows the store-name
suffix and older-name lines on an invalid row too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
m4ttheweric and others added 8 commits September 25, 2026 19:49
null, an array or a string under $migrated cannot take a property edit underneath it (jsonc-parser's modify throws adding an index to it). The first write of a bumped key over one now replaces $migrated wholesale with a fresh object holding the new baselines, in the same write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/prune's scope-not-allowed message was copied from /unset and kept its
wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bare --force, or one immediately followed by another flag, silently
dropped instead of erroring, so --force --yes never swallowed --yes as
a key but also never told the caller why nothing was forced. Both are
now a usage error. A --force key that matches no older store name in
the plan now warns instead of silently doing nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "in" operator sees Object.prototype names (constructor, toString,
hasOwnProperty), so renameProperty, deleteProperty, setDefault and the
path walker could act on an inherited member that was never an own
property of the stored value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The schema-lock row's release-notes bump list only matched keys present
under the same name in both locks, so a key that was renamed and bumped
in the same change (present in the tag lock only under its old name)
never appeared. It now also lists a heir's bump against the old key's
storeVersion, as "old -> heir oldVersion -> newVersion".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-ref cases

Three settingsSchemaDiff tests called real git against this checkout
(HEAD, a nonexistent branch, a nonexistent tag) instead of a fake,
the way the neighboring git-failure tests already do. None of them
need a real git checkout to pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…omment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@m4ttheweric
m4ttheweric merged commit 34ebec8 into main Sep 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant