Skip to content

Commit 7832233

Browse files
committed
feat(migrate): os migrate value-shapes — the per-deployment gate for reference and structured-JSON value shapes (#3438)
ADR-0104 D1's second evidence gate. Media value shapes already enforce once a deployment verifies its file migration (#3681); the reference and structured-JSON classes now get a gate of their own rather than borrowing that one — the file flag attests that file values were migrated and reconciled and says nothing about whether a `lookup` id or a `location` payload is well formed. - spec: `VALUE_SHAPES_MIGRATION_ID` beside its file sibling. - objectql: `scanValueShapes` walks every stored value of the covered classes against the write path's own predicate — `valueShapeViolation` / `isScannableValueShapeField` are exported from record-validator and imported by the scan, so the two cannot drift into disagreeing about "malformed". A test pins that property directly. - objectql: `validateRecord` gains `valueShapeStrict` beside `mediaValueShapeStrict`, and the engine reads the second flag through the same memoized seam. The two flag reads are refactored into one id-parameterised helper so "every way of not knowing answers false" is written once. - cli: `os migrate value-shapes`, dry-run by default. No backfill — a malformed `location` is application data whose correct value only its author knows, so the run reports and prescribes. With nothing to convert, `--apply`'s only write is the flag row, which preserves #3617's invariant trivially. - New escape hatch `OS_ALLOW_LAX_VALUE_SHAPES`, same precedence as its media sibling: opt-out beats all-class opt-in beats the flag. The scanner deliberately does NOT record the flag: readers of a migration flag use the spec contract and only writers depend on platform-objects, so having the engine write one would invert the layering. The CLI composes the two. A truncated or partially-unreadable scan fails the gate even at zero violations — "none in the part we read" is not the claim the flag makes. Verified: objectql 1272, spec 7167, cli 621, service-storage 252 green; all ten spec artifact gates pass (api-surface regenerated for the new exports); ESLint clean on every changed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zgA8CQMJeJbFEjnbib1Uv
1 parent ac1cc8c commit 7832233

11 files changed

Lines changed: 945 additions & 78 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/cli": minor
5+
---
6+
7+
feat(migrate): `os migrate value-shapes` — the per-deployment gate for reference and structured-JSON value shapes (#3438)
8+
9+
The second of ADR-0104 D1's two evidence gates. Media value shapes already
10+
enforce once a deployment has verified its file migration (#3681); the
11+
reference (`lookup` / `master_detail` / `user` / `tree`) and structured-JSON
12+
(`location` / `address` / `composite` / `repeater` / `record` / `vector`)
13+
classes now get a gate of their own.
14+
15+
```bash
16+
os migrate value-shapes # scan: reports, writes nothing
17+
os migrate value-shapes --apply # scan + record the deployment flag when clean
18+
```
19+
20+
The run walks every stored value of those classes against
21+
`valueSchemaFor(field, 'stored')` — the same predicate the write path enforces,
22+
imported rather than re-derived — and, at zero violations, records
23+
`sys_migration { id: 'adr-0104-value-shapes', verified_at, blocking: 0 }`.
24+
Strict enforcement of these classes reads **that row**, never the platform
25+
version, so upgrading changes nothing until a deployment produces its own
26+
evidence.
27+
28+
**There is no backfill, deliberately.** The file migration converts legacy
29+
values because the platform narrowed that storage form and owes the conversion.
30+
A malformed `location` is application data whose correct value only its author
31+
knows, so this run reports and prescribes — naming the object, field, type,
32+
count, offending record ids and the parse issue — and the operator fixes and
33+
re-runs. With nothing to convert, `--apply`'s only write is the flag row, which
34+
keeps the #3617 invariant trivially: a dry run changes nothing, and whether a
35+
run changed this deployment's posture never depends on what it found.
36+
37+
**A separate flag from the file migration**, because it attests a separate
38+
fact. That flag says file values were migrated and their ownership reconciled;
39+
it says nothing about whether a `lookup` id or a `location` payload is well
40+
formed. Gating these classes on it would be borrowing evidence for a fact it
41+
does not cover.
42+
43+
- New escape hatch **`OS_ALLOW_LAX_VALUE_SHAPES=1`** returns a verified
44+
deployment to warnings, with the same precedence as its media sibling: the
45+
opt-out beats `OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, which beats the flag.
46+
Wrongly staying lenient costs a warning; wrongly enforcing stops a working
47+
app from writing.
48+
- `@objectstack/spec/system` exports `VALUE_SHAPES_MIGRATION_ID`.
49+
- `@objectstack/objectql` exports `scanValueShapes`, `valueShapeScanPassed`
50+
and `formatValueShapeScanReport`. The scanner is read-only and does **not**
51+
record the flag: readers of a migration flag use the spec contract, only
52+
writers depend on `@objectstack/platform-objects`, so the composition lives
53+
with the CLI command rather than inverting the engine's dependencies.
54+
- `validateRecord` gains `valueShapeStrict`, the sibling of
55+
`mediaValueShapeStrict`. Both default to `false`: a caller that cannot say
56+
stays lenient, so nothing starts rejecting merely because the evidence was
57+
unavailable.
58+
59+
**Nothing changes for an existing deployment until it runs the command.** A
60+
scan that is truncated, or that cannot read an object, fails the gate even with
61+
zero violations found — "none in the part we read" is not the claim the flag
62+
makes.

packages/cli/src/commands/migrate/files-to-references.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { bootSchemaStack } from '../../utils/schema-migrate.js';
1818
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
1919
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
20-
import { resolveStorageCapabilityArg } from '../serve.js';
20+
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
2121

2222
async function confirm(question: string): Promise<boolean> {
2323
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -30,36 +30,6 @@ async function confirm(question: string): Promise<boolean> {
3030
}
3131
}
3232

33-
/**
34-
* The settings + storage service plugins, so the booted kernel carries
35-
* `sys_file`/`sys_migration` and the deployment's REAL storage adapter.
36-
* Settings first: the storage plugin re-resolves its adapter from persisted
37-
* settings when a settings service is present, which is how an S3-configured
38-
* deployment's backfill uploads land in S3 rather than on this machine.
39-
* Storage config goes through the SAME resolver `os serve` uses
40-
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
41-
* the server would. It did not, and that mattered here more than anywhere: this
42-
* command reconciles what records claim against what storage actually holds, so
43-
* a root that disagrees with the server's reconciles against the wrong tree.
44-
* It built `{ driver: 'local', root }` — keys `StorageServicePluginOptions` does
45-
* not declare — so the adapter silently used the plugin's own `./storage`
46-
* default while the server (since framework#4096) writes under
47-
* `.objectstack/data/uploads`.
48-
*/
49-
async function buildDataMigrationPlugins(): Promise<unknown[]> {
50-
const plugins: unknown[] = [];
51-
try {
52-
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
53-
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
54-
} catch {
55-
// optional — without it, constructor/env-driven storage config still applies
56-
}
57-
const { StorageServicePlugin } = await import('@objectstack/service-storage');
58-
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
59-
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
60-
return plugins;
61-
}
62-
6333
/**
6434
* `os migrate files-to-references` — the ADR-0104 D3 data migration, with its
6535
* self-check gate (#3617).
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import { createInterface } from 'node:readline';
6+
import {
7+
printHeader,
8+
printSuccess,
9+
printWarning,
10+
printError,
11+
printInfo,
12+
printStep,
13+
createTimer,
14+
emitJson,
15+
} from '../../utils/format.js';
16+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
17+
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
18+
19+
async function confirm(question: string): Promise<boolean> {
20+
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
21+
const rl = createInterface({ input: process.stdin, output: process.stdout });
22+
try {
23+
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
24+
return /^y(es)?$/i.test(answer.trim());
25+
} finally {
26+
rl.close();
27+
}
28+
}
29+
30+
/**
31+
* `os migrate value-shapes` — the ADR-0104 D1 non-media value-shape gate
32+
* (#3438), the sibling of `os migrate files-to-references` (#3617).
33+
*
34+
* Scans every stored reference (`lookup` / `master_detail` / `user` / `tree`)
35+
* and structured-JSON (`location` / `address` / `composite` / `repeater` /
36+
* `record` / `vector`) value against the contract the write path enforces, and
37+
* — on an `--apply` run finding zero violations — records the deployment-level
38+
* `adr-0104-value-shapes` flag. That flag, never the platform version, is what
39+
* turns strict enforcement of those classes on for THIS deployment.
40+
*
41+
* ## No backfill, deliberately
42+
*
43+
* Unlike its sibling this run rewrites nothing. The file migration converts
44+
* legacy values because the platform narrowed that storage form and therefore
45+
* owes the conversion; a malformed `location` is application data whose correct
46+
* value only its author knows. So this reports and prescribes, and the operator
47+
* fixes and re-runs until it is green.
48+
*
49+
* The happy consequence: with nothing to convert, `--apply`'s only write is the
50+
* flag row, so #3617's invariant is trivially preserved — a dry run changes
51+
* nothing, and whether a run changed this deployment's posture never depends on
52+
* what the run found.
53+
*
54+
* ## Why it is not gated on the file migration's flag
55+
*
56+
* That flag attests that file values were migrated and their ownership
57+
* reconciled. It says nothing about whether a `lookup` id or a `location`
58+
* payload is well formed. Reusing it here would be borrowing evidence for a
59+
* fact it does not cover — see the ADR's 2026-07-27 amendment.
60+
*/
61+
export default class MigrateValueShapes extends Command {
62+
static override description =
63+
'Scan stored reference and structured-JSON field values against the ADR-0104 value contract. ' +
64+
'Read-only; --apply records the deployment-level migration flag when the scan finds zero violations.';
65+
66+
static override examples = [
67+
'$ os migrate value-shapes',
68+
'$ os migrate value-shapes --apply',
69+
'$ os migrate value-shapes --apply --yes --json',
70+
'$ os migrate value-shapes --object contact --object account',
71+
];
72+
73+
static override flags = {
74+
'database-url': Flags.string({
75+
description: 'Database URL to scan (defaults to $OS_DATABASE_URL / the project DB)',
76+
env: 'OS_DATABASE_URL',
77+
}),
78+
apply: Flags.boolean({
79+
description:
80+
'Record the deployment migration flag when the scan passes (the scan itself is always read-only)',
81+
default: false,
82+
}),
83+
yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }),
84+
object: Flags.string({
85+
description: 'Restrict to this object (repeatable; default: every object with a covered field)',
86+
multiple: true,
87+
}),
88+
'max-records': Flags.integer({
89+
description:
90+
'Safety bound on records scanned per object — exceeding it truncates the scan and fails the gate',
91+
}),
92+
json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to apply)' }),
93+
};
94+
95+
async run(): Promise<void> {
96+
const { flags } = await this.parse(MigrateValueShapes);
97+
const timer = createTimer();
98+
const apply = flags.apply;
99+
100+
if (!flags.json) printHeader('Migrate · value-shapes');
101+
102+
// No occupancy gate, unlike the file migration: that command rewrites rows,
103+
// so a second writer on the same SQLite file is a real hazard. This one only
104+
// reads. A concurrent writer can still move the counts under us, which the
105+
// scan reports honestly rather than pretending to have frozen the database.
106+
107+
if (apply && !flags.yes) {
108+
if (flags.json || !process.stdin.isTTY) {
109+
if (flags.json) {
110+
await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true });
111+
this.exit(1);
112+
return;
113+
}
114+
printWarning(
115+
'Apply mode records this deployment\'s migration flag, which turns on strict value-shape ' +
116+
'enforcement. Re-run with --yes to confirm, or run without --apply to preview.',
117+
);
118+
this.exit(1);
119+
return;
120+
}
121+
const ok = await confirm(
122+
chalk.bold('\nRecord the value-shape migration flag on this database if the scan passes? [y/N] '),
123+
);
124+
if (!ok) {
125+
printInfo('Aborted — nothing recorded.');
126+
return;
127+
}
128+
}
129+
130+
if (!flags.json) {
131+
printStep(apply ? 'Booting data stack (APPLY mode)…' : 'Booting data stack (scan only)…');
132+
}
133+
134+
let stack;
135+
try {
136+
stack = await bootSchemaStack({
137+
databaseUrl: flags['database-url'],
138+
extraPlugins: await buildDataMigrationPlugins(),
139+
});
140+
} catch (error: any) {
141+
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
142+
printError(error.message || String(error));
143+
this.exit(1);
144+
return;
145+
}
146+
147+
try {
148+
const engine: any = stack.kernel.getService('objectql');
149+
if (typeof engine?.getObject !== 'function') {
150+
throw new Error('No ObjectQL engine on this stack — cannot scan.');
151+
}
152+
// An empty scan is indistinguishable from a clean one, and this command's
153+
// verdict is what authorises strict enforcement — so refuse to run when no
154+
// app metadata is loaded (missing artifact / wrong directory) rather than
155+
// "verify" a database the scan never actually looked at.
156+
const loadedObjects: string[] =
157+
typeof engine.getConfigs === 'function' ? Object.keys(engine.getConfigs()) : [];
158+
if (!loadedObjects.some((name) => !name.startsWith('sys_'))) {
159+
throw new Error(
160+
'No app objects are loaded, so the scan would examine nothing. ' +
161+
'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.',
162+
);
163+
}
164+
165+
const { scanValueShapes, valueShapeScanPassed, formatValueShapeScanReport } =
166+
await import('@objectstack/objectql');
167+
168+
// In JSON mode keep stdout parseable — route scan warnings to stderr.
169+
const logger = flags.json
170+
? { info: (m: string) => console.error(m), warn: (m: string) => console.error(m) }
171+
: { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) };
172+
173+
const report = await scanValueShapes(engine, logger, {
174+
objects: flags.object,
175+
maxRecordsPerObject: flags['max-records'],
176+
});
177+
const passed = valueShapeScanPassed(report);
178+
179+
// The flag write is the ONLY write this command makes, and only on an
180+
// apply run that passed. A failing apply run still records — deliberately:
181+
// it stamps `blocking` and clears `verified_at`, so a deployment whose data
182+
// has regressed closes its own gate rather than coasting on an old pass.
183+
let flag: unknown = null;
184+
if (apply) {
185+
const { recordDataMigrationRun } = await import('@objectstack/platform-objects/system');
186+
const { VALUE_SHAPES_MIGRATION_ID } = await import('@objectstack/spec/system');
187+
flag = await recordDataMigrationRun(engine, {
188+
migrationId: VALUE_SHAPES_MIGRATION_ID,
189+
passed,
190+
blocking: report.blocking,
191+
advisory: 0,
192+
applied: true,
193+
// Passed as an OBJECT — `recordDataMigrationRun` serialises it.
194+
details: {
195+
scannedObjects: report.scannedObjects.length,
196+
scannedRecords: report.scannedRecords,
197+
fields: report.findings.length,
198+
truncated: report.truncated,
199+
unreadableObjects: report.unreadableObjects,
200+
},
201+
});
202+
if (typeof engine.invalidateDataMigrationFlags === 'function') {
203+
engine.invalidateDataMigrationFlags();
204+
}
205+
}
206+
207+
if (flags.json) {
208+
await emitJson({
209+
database: stack.dbLabel,
210+
apply,
211+
scan: report,
212+
gatePassed: passed,
213+
flag,
214+
duration: timer.elapsed(),
215+
});
216+
if (!passed) this.exit(1);
217+
return;
218+
}
219+
220+
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
221+
console.log('');
222+
console.log(formatValueShapeScanReport(report).join('\n'));
223+
console.log('');
224+
225+
if (passed && apply) {
226+
printSuccess(
227+
`Scan clean — recorded the deployment flag. Reference and structured-JSON value shapes ` +
228+
`are now enforced on this deployment (${timer.elapsed()}).`,
229+
);
230+
} else if (passed) {
231+
printSuccess(`Scan clean (${timer.elapsed()}). Re-run with --apply to record the flag and enforce.`);
232+
} else if (apply) {
233+
printError('Scan found violations — the flag records the failure and the gate stays closed.');
234+
printInfo('Fix the values named above and re-run; nothing is converted for you.');
235+
this.exit(1);
236+
} else {
237+
printError('Scan found violations — fix them, then re-run with --apply.');
238+
this.exit(1);
239+
}
240+
} catch (error: any) {
241+
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
242+
printError(error.message || String(error));
243+
this.exit(1);
244+
} finally {
245+
await stack.shutdown();
246+
}
247+
}
248+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { resolveStorageCapabilityArg } from '../commands/serve.js';
4+
5+
/**
6+
* The service plugins a gated data migration boots with, so the kernel carries
7+
* `sys_migration` (the deployment-level flag ledger) — and, for the file
8+
* migration, `sys_file` plus the deployment's REAL storage adapter.
9+
*
10+
* Settings first: the storage plugin re-resolves its adapter from persisted
11+
* settings when a settings service is present, which is how an S3-configured
12+
* deployment's backfill uploads land in S3 rather than on this machine.
13+
* Storage config goes through the SAME resolver `os serve` uses
14+
* (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where
15+
* the server would. It did not, and that mattered more here than anywhere: the
16+
* file migration reconciles what records claim against what storage actually
17+
* holds, so a root that disagrees with the server's reconciles against the
18+
* wrong tree.
19+
*
20+
* ⚠️ `sys_migration` is registered by the STORAGE plugin (its first consumer),
21+
* which is why a migration with nothing to do with files still boots it. That
22+
* coupling is not this module's to fix — the ledger is platform infrastructure
23+
* and should not be owned by an optional service — but until it moves, every
24+
* gated migration boots the same set so they all find the same ledger. Tracked
25+
* separately; `os serve` auto-wires storage, so a served deployment always has
26+
* it registered and the runtime gates can read their flags.
27+
*/
28+
export async function buildDataMigrationPlugins(): Promise<unknown[]> {
29+
const plugins: unknown[] = [];
30+
try {
31+
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
32+
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
33+
} catch {
34+
// optional — without it, constructor/env-driven storage config still applies
35+
}
36+
const { StorageServicePlugin } = await import('@objectstack/service-storage');
37+
const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT);
38+
plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false }));
39+
return plugins;
40+
}

0 commit comments

Comments
 (0)