Skip to content

Commit 83cf2d3

Browse files
os-zhuangclaude
andauthored
feat(migrate,metadata-protocol): os migrate meta --stored rewrites sys_metadata rows in place (#4327) (#4464)
* feat(migrate,metadata-protocol): os migrate meta --stored rewrites sys_metadata rows in place (#4327) #4317 closed the correctness gap from the read side: every stored-row rehydration seam replays the full ADR-0087 conversion chain, retired entries included, so a row written under any past protocol is served canonical forever. The rows themselves stayed legacy — the chain re-lowers them on every load and each logs a conversion notice per process. Until now the only things that rewrote such a row were a Studio re-save and duplicatePackage. `os migrate meta --stored` walks sys_metadata (active + draft, all orgs), replays the same applyConversionsToStoredItem pass, and re-saves each changed body through saveMetaItem — so a rewritten row gets a sys_metadata_history entry, a fresh checksum and the mutation projectors, exactly like an author's save. The history row's source is `migrate-stored`, distinguishing an upgrade from an edit. parentVersion is the row's own checksum, so a concurrent writer produces a 409 the report names rather than a clobber. Preview is the default and --apply the only writing mode, matching its two siblings and #3617's "a dry run changes nothing"; an apply run refuses to start while another process holds the SQLite database. Nothing gates on this having run (#3855) and no sys_migration flag is recorded — a flag would advertise enforcement that does not exist. What a run buys is hygiene plus an assertable verdict: nothing left to do exits 0, work remaining exits 1. Three carve-outs are reported rather than counted as done: flow rows (their seam is AutomationEngine.registerFlow, which holds the executor registry the node-type conflict guard needs), types with no repository write path (agent), and rows that still fail the current schema after conversion. An empty scan says it attests nothing rather than reading as a pass. Also: protocol.migrateStoredMetadata() returns the same structured report an admin route would render, and saveMetaItem takes an optional `source` for its history/audit rows — server-stated, never request-derived. Closes #4327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * docs(adr-0087): name the follow-up that gives flow rows the same finish line (#4454) The addendum said flows were "tracked separately" without saying where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * docs(releases): name the stored-metadata pass on the v17 page (#4327) The v17 entry for #3903 described the read-path guarantee and stopped there, and the upgrade checklist listed the two per-deployment migrations without this one. Both now point at `os migrate meta --stored`, marked optional — it opens no gate, unlike its two neighbours in that list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec975f1 commit 83cf2d3

12 files changed

Lines changed: 1431 additions & 25 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(migrate,metadata-protocol): `os migrate meta --stored` rewrites sys_metadata rows so the read-path chain has a finish line (#4327)
7+
8+
#4317 closed the correctness gap from the read side: every stored-row
9+
rehydration seam replays the full ADR-0087 conversion chain, retired entries
10+
included, so a row written under any past protocol is *served* canonical
11+
forever. What it deliberately did not do is make the rows themselves canonical.
12+
A pre-17 row keeps its legacy bytes, the chain re-lowers it on every load, and
13+
each affected row logs one conversion notice per process — deduped, but back
14+
every boot. Until now the only things that ever rewrote such a row were a Studio
15+
re-save and `duplicatePackage`.
16+
17+
**`os migrate meta --stored`** is the pass that ends it for a deployment that
18+
runs it. It walks `sys_metadata``active` and `draft`, every organization —
19+
replays the same `applyConversionsToStoredItem` chain, and re-saves each changed
20+
body through the normal write path, so a rewritten row gets a
21+
`sys_metadata_history` entry, a fresh checksum and the mutation projectors,
22+
exactly like an author's save. The history row's `source` is `migrate-stored`,
23+
so a later diff distinguishes an upgrade from somebody's edit.
24+
25+
```bash
26+
os migrate meta --stored # preview: per-row report, writes nothing
27+
os migrate meta --stored --apply # rewrite the rows (prompts)
28+
os migrate meta --stored --apply --yes --json # CI / scripts
29+
os migrate meta --stored --type view # restrict to a type (repeatable)
30+
```
31+
32+
**Preview is the default and `--apply` is the only writing mode** — the house
33+
rule its siblings already keep (#3617's "a dry run changes nothing"), and it
34+
applies with more force here because what moves is metadata: every affected
35+
row's checksum and a history entry per row. An apply run also refuses to start
36+
while another process holds the SQLite database, for the same reason
37+
`os migrate files-to-references --apply` does.
38+
39+
**Nothing gates on this having run.** #3855's conclusion stands — an
40+
operator-run migration cannot be relied upon, so the read path remains the
41+
guarantee for every deployment, and no `sys_migration` flag is recorded (a flag
42+
would advertise enforcement that does not exist). What a run buys is hygiene —
43+
rows stop carrying pre-protocol dialects, so diffs, exports and history are
44+
clean going forward, and the recurring notices go quiet — plus one thing that
45+
was previously unobtainable: **an operator can assert it.** A run with nothing
46+
left to do exits `0`, a deployment with rows still on an old dialect exits `1`,
47+
so "my metadata is on protocol N" becomes a CI check rather than a belief.
48+
49+
Three things the pass declines, and reports rather than counting as done:
50+
`flow` rows (their seam is `AutomationEngine.registerFlow`, which holds the
51+
executor registry the node-type conflict guard needs), types with no repository
52+
write path (`agent` — rewriting there would record no history and force a draft
53+
live), and rows that still fail the current schema after conversion (a genuine
54+
contract violation the write path is right to refuse; it keeps reading through
55+
the chain and stays fixable in Studio).
56+
57+
Also new, and usable without the CLI: `protocol.migrateStoredMetadata()` returns
58+
the same structured report an admin route would render, and `saveMetaItem`
59+
accepts an optional `source` for the history/audit rows. `source` is not
60+
request-derived — the REST layer builds its save request field by field and
61+
never forwards a client-supplied value, so provenance stays something the server
62+
states rather than something a caller claims.

content/docs/deployment/cli.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ written.
589589
| `os migrate plan` | Warns and continues — a plan writes nothing either way |
590590
| `os migrate apply` | **Refuses** (exit 1, `error: database_busy` under `--json`). Stop the other process, or pass `--force` |
591591
| `os migrate files-to-references --apply` | **Refuses** likewise — it rewrites rows, so a concurrent writer is at least as dangerous |
592+
| `os migrate meta --stored --apply` | **Refuses** likewise — it rewrites `sys_metadata` rows, and a live process saving metadata is exactly the collision |
592593

593594
The check applies to SQLite only: Postgres and MySQL take their own server-side
594595
locks. Only same-user processes are visible without elevated privileges, and a
@@ -642,6 +643,7 @@ where the data lives.
642643
|---------|-------------|
643644
| `os migrate files-to-references` | Convert legacy file-field values to `sys_file` references, verify the ownership ledger, and record the deployment's migration flag |
644645
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
646+
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
645647

646648
```bash
647649
os migrate files-to-references # Dry run: full report, writes nothing
@@ -774,6 +776,64 @@ closed gate logs that it is enforcing, and an app that declares neither class
774776
of field says nothing at all. So a running deployment always tells you the
775777
state of its own data — which is the question `os migrate meta` cannot answer.
776778

779+
#### `os migrate meta --stored`
780+
781+
The two commands above are about **application data**. This one is about the
782+
**metadata itself**, at rest: the `sys_metadata` rows Studio and the runtime
783+
authoring APIs write.
784+
785+
Those rows already *read* correctly whatever protocol they were written under —
786+
every rehydration seam replays the full conversion chain, so a body from an
787+
older major is served in today's canonical shape and always will be. What the
788+
rows do not do is *change*: they keep their original bytes, the chain re-lowers
789+
them on every load, and each one logs a conversion notice once per boot. This
790+
command ends that for the deployment that runs it.
791+
792+
```bash
793+
os migrate meta --stored # Preview: per-row report, writes nothing
794+
os migrate meta --stored --apply # Rewrite the rows (prompts)
795+
os migrate meta --stored --apply --yes --json # CI / scripts
796+
os migrate meta --stored --type view --type object # Restrict to a type (repeatable)
797+
```
798+
799+
It walks `active` and `draft` rows across every organization (archived rows are
800+
a record of what *was* and are never read), replays the same chain the read path
801+
does, and re-saves each changed body through the normal write path — so a
802+
rewritten row gets a `sys_metadata_history` entry, a fresh checksum, and the
803+
mutation projectors, exactly like an author's save. The history entry's source
804+
is `migrate-stored`, so a later diff shows which changes were an upgrade and
805+
which were somebody's edit.
806+
807+
Three things it deliberately declines, and names in the report rather than
808+
counting as done:
809+
810+
| Not rewritten | Why |
811+
| :--- | :--- |
812+
| `flow` rows | Flow-node conversions carry a conflict guard that needs the automation engine's live executor registry; flows canonicalize at their own seam when the engine loads them |
813+
| Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path |
814+
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
815+
816+
<Callout type="warn">
817+
`--apply` is the only writing mode, and it rewrites **metadata** — each affected
818+
row's checksum moves and each gets a history entry. Preview first. Like the
819+
other row-rewriting migration, an apply run refuses to start while another
820+
process holds the SQLite database (`--force` overrides).
821+
</Callout>
822+
823+
**Nothing gates on this having run.** The read path is the guarantee, for every
824+
deployment, whether or not anyone runs this — an operator-run migration is not
825+
something the platform can depend on. What running it buys is hygiene (cleaner
826+
diffs, exports and history from here on, and the recurring boot notices go
827+
quiet) plus one thing that was previously unobtainable: **you can assert it.**
828+
A run with nothing left to do exits `0`; a deployment with rows still carrying
829+
an old dialect exits `1`. So "my metadata is on protocol N" becomes a check
830+
rather than a belief.
831+
832+
Note the division of labour with the default mode: `os migrate meta --from N`
833+
rewrites an **author's source** and reads no database; `--stored` rewrites **one
834+
deployment's rows** and reads no config. Same chain, opposite ends of the
835+
contract — which is why the two modes are mutually exclusive.
836+
777837
### Scaffolding
778838

779839
| Command | Alias | Description |

content/docs/releases/v17.mdx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1716,7 +1716,12 @@ platform capabilities an administrator gains, then the Console delta.
17161716
source at every read seam (including `registerFlow` rehydration) — a stored
17171717
action with the removed `execute` dispatches via `target` again; boot
17181718
hydration validates each row post-conversion and diagnoses invalid ones
1719-
with `[metadata_spec_invalid]` instead of shrugging.
1719+
with `[metadata_spec_invalid]` instead of shrugging. **The rows themselves
1720+
can now be brought forward too (#4327):** `os migrate meta --stored`
1721+
replays the chain over `sys_metadata` and rewrites what still carries a
1722+
pre-protocol shape, through the normal write path. Optional — the read path
1723+
is the guarantee either way — but it is what makes the conversion pass a
1724+
no-op on your data instead of a permanent shim.
17201725
- **Studio's metadata forms tell the truth (#3786).** Four of seventeen forms
17211726
had drifted from their schemas, so controls saved nothing: the Object →
17221727
Capabilities toggles bound a key the schema does not declare (all seven
@@ -2021,6 +2026,12 @@ covers are folded into the list below rather than left to the changelog.)
20212026
fix what it reports before `--apply`. It converts nothing — the values it
20222027
names are application data — and a scan that was truncated or could not read
20232028
an object fails the gate even at zero violations.
2029+
- **Stored metadata (optional):** run `os migrate meta --stored` to see which
2030+
`sys_metadata` rows still carry a pre-17 shape, and `--apply` to rewrite
2031+
them. Unlike the two above this opens no gate and nothing depends on it —
2032+
those rows already read canonically, forever. It stops them re-converting on
2033+
every load, keeps diffs and exports clean going forward, and gives you an
2034+
exit code to assert on: nothing left to do exits `0`.
20242035
- **Datasources:** verify every declared datasource connects in every
20252036
environment — a bound datasource that cannot connect now fails the boot
20262037
instead of failing every later query.

docs/adr/0087-metadata-protocol-upgrade-contract.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,39 @@ diverged (#3903). This addendum extends the contract to data at rest:
421421
at boot would unhook live tables and make the row unfixable in Studio
422422
(availability over purity for data at rest; the same verdict reaches Studio
423423
as `_diagnostics` on every read).
424+
425+
## Addendum (2026-08-01) — the stored chain gets a finish line (#4327)
426+
427+
The addendum above makes a legacy row read canonical *forever*, which is the
428+
correctness guarantee — and, read literally, also a promise that the chain runs
429+
on that row forever. `os migrate meta --stored`
430+
(`ObjectStackProtocolImplementation.migrateStoredMetadata`) lets a deployment
431+
end that for itself: it walks `sys_metadata` (active + draft, all orgs), replays
432+
the same `applyConversionsToStoredItem` pass, and re-saves each changed body
433+
through `saveMetaItem` with `source: 'migrate-stored'` — history row, checksum,
434+
mutation projectors and all. Preview is the default; `--apply` is the only
435+
writing mode.
436+
437+
- **Not load-bearing, and no flag.** #3855's conclusion stands: an operator-run
438+
migration cannot be relied on, so the read path — not this — remains the
439+
guarantee, and nothing gates on it having run. Deliberately no `sys_migration`
440+
row either: unlike ADR-0104's two gates, a flag here would advertise
441+
enforcement that does not exist. The verifiable statement operators wanted is
442+
the **re-run** — a second pass reporting every row canonical exits 0, so "my
443+
metadata is on protocol N" is a check rather than a belief.
444+
- **The write path's gate is not bypassed.** A body that still fails the current
445+
schema after conversion is refused (422) and reported, exactly as the bullet
446+
above describes for reads: it is a genuine contract violation, and the pass
447+
has no more standing to persist it than an author does. It keeps reading
448+
through the chain and stays fixable in Studio.
449+
- **The version layer stays verbatim.** `sys_metadata_history` is appended to,
450+
never rewritten. Canonicalizing a past version's body would break the
451+
checksum↔body pairing this contract depends on — the migration is a new
452+
commit, not a rewrite of history.
453+
- **What the pass does not cover, it names.** Flows (their seam is
454+
`AutomationEngine.registerFlow`, which holds the executor registry the
455+
conflict guard needs) and types with no repository write path are reported as
456+
`skipped` with the reason, never counted as done. Giving flows the same finish
457+
line needs a canonicalization entry point on the automation engine — tracked
458+
as #4454, and worth doing precisely because the graduated flow-node
459+
conversions are where the most stored dialect lives.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@objectstack/lint": "workspace:*",
5656
"@objectstack/mcp": "workspace:*",
5757
"@objectstack/metadata": "workspace:*",
58+
"@objectstack/metadata-protocol": "workspace:*",
5859
"@objectstack/objectql": "workspace:^",
5960
"@objectstack/observability": "workspace:^",
6061
"@objectstack/platform-objects": "workspace:*",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4327 — the flag surface where `os migrate meta`'s two modes meet.
5+
*
6+
* One command, two subjects: `--from N` replays the ADR-0087 chain over an
7+
* **author's source** (a config file, no database in reach), `--stored` replays
8+
* it over **one deployment's** `sys_metadata` rows (a database, no config in
9+
* reach). Everything below pins a boundary that a plausible-looking oclif
10+
* declaration gets wrong.
11+
*/
12+
import { describe, expect, it } from 'vitest';
13+
import MigrateMeta, { storedOnlyFlagsIn } from './meta.js';
14+
15+
describe('storedOnlyFlagsIn (#4327)', () => {
16+
it('reports the stored-only flags the operator typed', () => {
17+
expect(storedOnlyFlagsIn(['--from', '16', '--apply'])).toEqual(['apply']);
18+
expect(storedOnlyFlagsIn(['--from=16', '--type=view', '--force'])).toEqual(['force', 'type']);
19+
expect(storedOnlyFlagsIn(['--from', '16', '-y'])).toEqual(['yes']);
20+
});
21+
22+
it('says nothing about a run that typed none of them', () => {
23+
expect(storedOnlyFlagsIn(['--from', '16', '--step', '--json'])).toEqual([]);
24+
expect(storedOnlyFlagsIn([])).toEqual([]);
25+
});
26+
27+
it('never double-reports --yes given both spellings', () => {
28+
expect(storedOnlyFlagsIn(['--stored', '--yes', '-y'])).toEqual(['yes']);
29+
});
30+
31+
it('reads argv, not the environment — an exported OS_DATABASE_URL is not a typed flag', () => {
32+
// The trap that made `dependsOn` unusable: oclif fills `--database-url`
33+
// from `OS_DATABASE_URL`, so a merely-exported env var would have counted
34+
// as "provided" and broken `os migrate meta --from N` for anyone who has
35+
// one set. Provenance comes from argv precisely so it cannot.
36+
expect(storedOnlyFlagsIn(['--from', '16'])).toEqual([]);
37+
expect(storedOnlyFlagsIn(['--stored', '--database-url', 'sqlite://x.db'])).toEqual(['database-url']);
38+
});
39+
});
40+
41+
describe('MigrateMeta flag declarations (#4327)', () => {
42+
const flags = MigrateMeta.flags as Record<string, any>;
43+
44+
it('does not declare --from required — that would reject every --stored run', () => {
45+
expect(flags.from.required).not.toBe(true);
46+
});
47+
48+
it('makes the authored-chain flags exclusive with --stored', () => {
49+
// `--from` names a major an author wrote against; a stored row carries its
50+
// own history and gets the full chain regardless. Accepting both would
51+
// imply the stored pass honours a range it does not have.
52+
for (const name of ['from', 'to', 'step', 'out']) {
53+
expect(flags[name].exclusive).toContain('stored');
54+
}
55+
});
56+
57+
it('leaves the stored-only flags free of dependsOn', () => {
58+
// Enforced by `storedOnlyFlagsIn` instead: see the env-var case above.
59+
for (const name of ['apply', 'yes', 'force', 'type', 'database-url']) {
60+
expect(flags[name].dependsOn).toBeUndefined();
61+
}
62+
});
63+
64+
it('defaults --apply off — preview is the only mode a bare run can have', () => {
65+
expect(flags.apply.default).toBe(false);
66+
expect(flags.stored.default).toBe(false);
67+
});
68+
});

0 commit comments

Comments
 (0)