Skip to content

Commit 417443e

Browse files
fix(cli): a narrowed os migrate --apply records no deployment flag, and an unknown --object is refused (#21662)
Fixes #21644 Clause-②: no A deployment-level flag is now written only by a full-scope run. `os migrate value-shapes` and `os migrate files-to-references` narrowed by `--object` apply their fixes, record no deployment flag, and say so. A full-scope `--apply` records the flag exactly as before. Across the family (`value-shapes`, `files-to-references`, `summary-nulls`, `duplicates`), an `--object` name the deployment does not declare is refused with `OBJECT_NOT_FOUND` before anything is read or written. The refusal names the unknown name and the declared objects. This follows triage ruling `5974774596`. `--apply --object` is not refused. ## Measured first (base `759dbe9ed3`) ### A1. The reach, at the public door (hypothesis confirmed) A throwaway SQLite project held three objects, one of them `os21644_site` with a `location` field. A served-shape boot seeded one clean row per object. Then one off-shape value was written past the write path: the site's `geo` stored as `{latitude, longitude}`. The fresh-datastore attestation had recorded both ADR-0104 flags as verified at birth, so the flag table was emptied first. - `os migrate value-shapes --json` exited 1, with `gatePassed: false` and `blocking: 1`. - `os migrate value-shapes --object os21644_sitee --apply --yes --json` (misspelled) exited **0**. It answered `gatePassed: true` with `scannedObjects: []`, and the `adr-0104-value-shapes` row read **verified** (`verified_at` set, `blocking: 0`). ### A2. The census, one row per command | command | `--object` | `--apply` records a deployment flag | where it is written (base) | unknown `--object` on base (measured) | | --- | --- | --- | --- | --- | | `value-shapes` | repeatable | `adr-0104-value-shapes` | the CLI: `recordDataMigrationRun` at `value-shapes.ts:221` | exit 0, `scannedObjects: []`; with `--apply`, the flag is recorded **verified** | | `files-to-references` | repeatable | `adr-0104-file-references`, then the column step's `columns_moved_at` | the producer: `runFilesToReferencesMigration` at `files-to-references-migration.ts:120`; the column stamp is `recordFileColumnMove` in the CLI (`files-to-references.ts:472`) | exit 0, both scans' `scannedObjects: []`; with `--apply`, the flag is recorded **verified**, **and the column step moved `os21644_product.image` and stamped `columns_moved_at`** | | `summary-nulls` | repeatable | none (its header: "No deployment flag, deliberately") | none | exit 0, `fields: []`, on a dry run and on `--apply` | | `duplicates` | single | none: no `--apply`, and it writes nothing | none | exit 0, `scanned: []`, `filter: { object: 'os21644_sitee' }` | A correctly spelled narrowed `files-to-references --apply` on base also recorded the flag verified and moved the column. Every scan draws its default candidates from the same registry: `options.objects ?? Object.keys(engine.getConfigs())` in `scanValueShapes`, `backfillFileReferences`, `verifyFileReferences` and `backfillSummaryNulls`, and `stack.allObjects()` for `collectScanTargets`. Each keeps only the candidates it covers, which is where an undeclared name was dropped. ### A3. The narrowed run - **CLI-recorded flag (`value-shapes`).** The flag write is skipped on a narrowed run, whether the run passes or fails. `--json` carries `flag: null` and `filter: { objects }` (`null` on a full-scope run, the shape `duplicates` already keeps). Both faces print one sentence: the run was narrowed, no deployment flag was recorded, and the command that records one is the same command without `--object`. - **Producer-recorded flag (`files-to-references`).** `runFilesToReferencesMigration` skips the write when it is given `objects`. This is the declared `service-storage` path only. Its `flag` result is `null` on a narrowed run. The CLI prints the same sentence and carries `filter`. - **The column step (`files-to-references`) does not run on a narrowed run.** The census row above is why. The step retypes every single-value media column in the database on the authority of the gate, and a narrowed gate vouches only for the named objects. Its stamp also requires a verified flag, which a narrowed run no longer records. Left running, a narrowed `--apply` would move columns and then fail to record the move. It now returns a stated skip, `narrowed_run`, and the human face says why. - **What "narrowed" means.** Any `--object` narrows, even a list that names every declared object. The flag is earned by the one spelling that means "every object", which is a run without `--object`. Treating a full list as full scope would need a second definition of "the whole deployment", checked against the registry of the moment, and that registry changes with the composition between two runs. The operator also gets one unambiguous prescription. - **Deviation from the dispatch wording ("skips it when `objects` is non-empty").** The producer treats **any** `objects` as narrowed, `[]` included. A scan handed `[]` walks nothing (`[] ?? …` is `[]`). A non-empty test would therefore record a verified flag over an empty scan, the card's own defect at the producer's API. A unit pin holds this. - The prompts and closing lines that promised a flag on a narrowed run now say it records none. ⛔ `--apply --object` is not refused, and the full-scope write is unchanged. ### A4. Unknown `--object` - **Checked against the registry the command's own boot resolved, before the scan.** For `value-shapes`, `files-to-references` and `summary-nulls` that registry is `Object.keys(engine.getConfigs())`. For `duplicates` it is the names of `stack.allObjects()`. These are the same sets the scans draw from, so the refusal and the scan judge one population. There is no `packages/objectql` edit and no scanner edit. - **The refusal is #21643's.** It is `objectNotFoundError` from `@objectstack/core`: `code: 'OBJECT_NOT_FOUND'`, `status: 404`, and `object` naming the first unknown name. Its message names every unknown name and the declared objects, sorted. There is no new error code. `value-shapes`, `files-to-references` and `summary-nulls` answer `{ error, code }`, as `unmapped-columns` does. `duplicates` keeps its own error shape, `{ error: 'report_failed', detail, code }`: its catch now passes `errorCodeFields` through. - **The list is the declared set, not the covered subset.** Computing the covered subset for `value-shapes` needs `isScannableValueShapeField`, which `@objectstack/objectql` does not export, and that package is fenced. The declared set is also exactly the accept set. A declared object the command has nothing to check on is accepted, because an empty answer about a real object is true. On the fixture boot the list is 12 names, platform objects included. - **Clause-②: no stands as the claim declared it.** A misspelled name moves from exit 0 to exit 1, which is the ruled correction of a wrong answer. Every declared name and `--apply --object` are still accepted. ## Changes - `packages/cli/src/utils/migrate-object-scope.ts` (new): `refuseUndeclaredObjects`, `isNarrowedRun` and `narrowedFlagNote`, shared by the four commands. - `packages/cli/src/commands/migrate/value-shapes.ts`: refuses an unknown name, skips the flag on a narrowed run, adds `filter`, and adjusts the narrowed prompt and closing lines. - `packages/cli/src/commands/migrate/files-to-references.ts`: refuses an unknown name, adds the `narrowed_run` column-step skip, adds `filter`, and adjusts the narrowed prompt and closing lines. - `packages/cli/src/commands/migrate/summary-nulls.ts` and `duplicates.ts`: refuse an unknown name. `duplicates`' error document carries the error's `code`. - `packages/services/service-storage/src/files-to-references-migration.ts`: skips the flag write when given `objects`. - `content/docs/deployment/cli.mdx`: one paragraph under "Data migrations" (`--object` narrows, an unknown name is refused, only a full-scope run records a flag), and the two `--object` example comments. - `.changeset/21644-narrowed-apply-flag.md`: `@objectstack/cli` patch and `@objectstack/service-storage` patch, `Clause-②: no`. `packages/objectql`, `packages/platform-objects`, `packages/spec`, every other `service-storage` path, and `content/docs/releases/` are untouched. ## Pins - **`object-scope.integration.test.ts`** spawns the CLI against SQLite, one database copy per run, and is one enumeration over the census (`FAMILY`). - A narrowed `--apply` (`value-shapes`, `files-to-references`): exit 0, `flag: null`, `filter: { objects }`, no flag row, and the note on stderr naming the full-scope command. - A full-scope `--apply`: the flag recorded verified, in the document and in the row. - `summary-nulls` and `duplicates`: no flag row, narrowed or not, as before. - A narrowed `--apply` after an earned flag leaves that row byte-equal. - `files-to-references` narrowed: `columnMove: null` and `columnsMovedAt: null`. Its full-scope control moves `os21644_product.image` and stamps it. - Unknown `--object`, on all four: exit 1 and `OBJECT_NOT_FOUND`, naming the name and the declared objects. The one document is the refusal and no report, no flag row is written, and the app rows are unchanged. The human face exits 1 and names it. - The measured repro. Control: the full-scope scan sees `blocking: 1` and exits 1. The misspelled `--object --apply` exits 1 with `OBJECT_NOT_FOUND`, and the flag stays unrecorded. Spelled right, the narrowed run finds the value, exits 1, and still records no flag. - **`migrate-object-scope.test.ts`** (unit): the envelope (`code`, `status`, `object`), every unknown name named once, the declared list sorted, the empty-registry message, the accepted cases, and what `isNarrowedRun` treats as narrowed (an empty list and a full list both narrow). - **`files-to-references-migration.test.ts`** (`service-storage`, beside the producer): - a narrowed apply converts and records no flag; - a narrowed failing apply records nothing; - a narrowed apply leaves an earned flag row equal; - `objects: []` records nothing. ## Reverse verification (implementation committed first; all three legs re-run at the final head `fe988c20f0`) Each leg ran through `node scripts/ablation-replace.mjs` in wrap mode, under a script trap that restores from `HEAD`. The spawned CLI loads its commands from `src/` through `bin/run-dev.js`. In the first round `packages/cli/dist` did not exist. In the final round it held a build of the unmutated source, and legs 1a and 2 still went red, which shows the spawned CLI read the mutated `src/`. The `service-storage` unit pin imports the producer from `src/`. Neither needed a rebuild. - **Leg 1a, the narrowed-run skip in the CLI** (`value-shapes.ts`): - The anchor `if (apply && !narrowed) {` became `if (apply) {`: anchor 1 to 0, replacement 0 to 1, blob `9f241dc2` to `d3a5c236`. - **3 red, 17 green.** Red: the `value-shapes` narrowed pin, the earned-flag-unchanged pin, and the spelled-right repro. Green: both full-scope controls (the column-step control among them), the `files-to-references` narrowed pin (its skip is the producer's), and every unknown-name pin. - Restored: blob `9f241dc2` equals `HEAD`, and `git diff HEAD` is empty. - **Leg 1b, the narrowed-run skip in the producer** (`files-to-references-migration.ts`): - The same anchor and replacement: anchor 1 to 0, blob `1aa9fea2` to `d4bb5002`. - **4 red, 6 green.** Red: all four narrowed pins (passing, failing, earned-flag-unchanged, and `objects: []`). Green: the six original pins, the full-scope apply among them. - Restored: blob `1aa9fea2` equals `HEAD`. - **A first round is recorded here because one of its readings was vacuous.** At `80e5eda6ea` this leg read 3 red and 7 green: the earned-flag-unchanged pin stayed green under the mutation, because the fake engine's rewrite landed in the same millisecond as the earned row. The pin now dates the earned row in the past (`fe988c20f0`), and the re-run is the reading above. - **Leg 2, the unknown-name refusal** (`migrate-object-scope.ts`): - The anchor `if (unknown.length === 0) return;` became `if (unknown.length >= 0) return;`: anchor 1 to 0, replacement 0 to 1, blob `b78a88b4` to `9286a990`. - **Unit: 3 red, 3 green.** Red: the three refusal cases. Green: the accepted cases and the two `isNarrowedRun` cases. - **Integration: 10 red, 10 green.** Red: all eight unknown-name pins (two per command, on all four), the human face, and the misspelled repro. Green: every narrowed and full-scope pin, and the repro's control. - Restored: blob `b78a88b4` equals `HEAD`. - In the first round, the `duplicates` "refused before anything was read" pin stayed green under this mutation: it asserted only on a key that report never carries. It now asserts that the one document is the refusal, which reds on all four commands. After all legs, `git diff HEAD` was empty and `git status --porcelain` was clean. ## Local verification (final head `fe988c20f0`, on base `759dbe9ed3`) `origin/main` was `759dbe9ed3` for the whole verification. Just before this PR opened, it gained four commits, `f40bb3217f` to `1a230548cf` (#21649, #21632, #21648, #21650). None of them touches this diff's paths (`packages/spec`, `metadata-protocol`, `service-automation`, `lint`, skills and docs references), so they were not merged in. CI runs on the merge ref. - **Builds.** The CLI's dependency closure (`turbo run build --filter=@objectstack/cli^...`) gave VERDICT 0. `@objectstack/service-storage` was rebuilt after the producer change (exit 0), and `@objectstack/cli` was built (exit 0). A repo build for the gate prerequisites gave VERDICT 0 (turbo: 72 tasks, 71 cached). - **`@objectstack/cli` typecheck** (`tsc --noEmit` plus `check:test-typecheck`): exit 0 at `80e5eda6ea`. No CLI file changed after that commit. - **`@objectstack/cli` unit project in full** at `80e5eda6ea`: - 255 of 257 files passed, with 3742 tests passed and 29 skipped (the two files below). - The other two files, `test/published-subpath-{console,hook-body}.pin.test.ts`, refused before testing because `packages/cli` was not built (their own prerequisite message). After the CLI build, both passed: 2 files, 29 tests. - **`@objectstack/service-storage`**: typecheck exit 0, and the full suite at `fe988c20f0` passed 41 files and 633 tests. - **`os migrate` integration pins on built packages**, at `80e5eda6ea`: - this PR's pin plus the absent-database roster: 2 files, 59 passed; - the one-shot family plus `duplicates.integration`: 2 files, 79 passed. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 97 commands at `fe988c20f0`. - All 97 ran there, and each exited 0. - `--ran` with exit codes: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN. - An earlier round at `80e5eda6ea` had three gates answer `PREREQUISITE NOT MET` (exit 3): `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. They read packages outside the CLI closure. The repo build cleared them. - **Full `pnpm lint`** (`eslint . --no-inline-config` over the whole repo): exit 0 at `fe988c20f0`, with nothing printed. - **No exported symbol was renamed or moved,** so the liveness-ledger anchor check had nothing to read. ## Acceptance notes - **A narrowed run's counterexample is not recorded.** The ruling says a narrowed `--apply` records no flag, so it records none even when it finds a violation. Such a counterexample is deployment-level evidence, since one off-shape value disproves "every value is on shape". The operator still gets exit 1 and the findings, and the next full-scope run closes the gate. This is an observation, not a filing. Carrier: none. - **The declared list includes platform objects.** It is 12 names on the fixture's lean boot, and a deployment that composes more plugins prints more. A long list in an error message is the price of naming the exact accept set. Carrier: none. - **A narrowed `value-shapes --apply` still takes the plain (DDL-performing) boot** even though it now writes nothing. That is unchanged, and the boot paragraph in the docs still describes it truthfully. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98eb3b9 commit 417443e

11 files changed

Lines changed: 830 additions & 27 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-storage': patch
4+
---
5+
6+
`os migrate value-shapes` and `os migrate files-to-references` record the deployment-level ADR-0104 flag only from a run over every object, and every command in the `os migrate` data-migration family refuses an `--object` name the deployment does not declare (#21644).
7+
8+
Clause-②: no
9+
10+
- **A narrowed `--apply` records no deployment flag.** The flag attests the stored data of every object and turns strict enforcement on, but a run narrowed by `--object` reads only the named objects. Such a run still applies its fixes: `files-to-references` converts the named objects' values. It records no flag, whether it passes or fails, and leaves a flag that an earlier full-scope run recorded exactly as it was. Its output says why and names the run that records the flag: the same command without `--object`. The `--json` document carries `filter: { objects }`, which is `null` on a full-scope run, so a narrowed run is never mistaken for a full one. Any `--object` narrows, even a list that names every object. A full-scope `--apply` records the flag as before.
11+
- **`runFilesToReferencesMigration`** (`@objectstack/service-storage`) skips the flag write when it is given `objects`. That includes `[]`, which walks nothing. Its `flag` result is `null` on a narrowed run.
12+
- **The column step of `files-to-references` does not run on a narrowed run.** It retypes every single-value media column in the database on the authority of the gate, and a narrowed gate vouches only for the named objects. Before this change, a narrowed `--apply` or a misspelled one moved those columns and stamped `columns_moved_at`.
13+
- **An unknown `--object` is an error.** This applies to `value-shapes`, `files-to-references`, `summary-nulls` and `duplicates`. A name the booted registry does not declare exits 1 with `OBJECT_NOT_FOUND`, and the error names that name and the declared objects. The check runs before anything is read or written. Until now, such a name was filtered out of the scan without a word, so a typo scanned nothing and read as a clean run. `duplicates` reports the refusal as `{ error: 'report_failed', detail, code }`. A declared object that the command has nothing to check on is still accepted.

‎content/docs/deployment/cli.mdx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,11 +1132,25 @@ report no secret and no file. A read the command cannot avoid and that fails for
11321132
other reason still refuses and exits 1. Point `--database-url` at the deployment's
11331133
database, or boot the deployment once first, to see what it holds.
11341134
1135+
**`--object` narrows a run, and only a run over every object records a flag.**
1136+
`files-to-references`, `value-shapes`, `summary-nulls` and `duplicates` take
1137+
`--object` to restrict the run to the objects you name. `duplicates` takes one name,
1138+
and the others are repeatable. A name your deployment does not declare is refused
1139+
with `OBJECT_NOT_FOUND` and exit 1 before anything is read or written. The error
1140+
names the unknown name and the declared objects, so a misspelling is never answered
1141+
as a clean run over nothing. A narrowed `--apply` applies its fixes to the named
1142+
objects. `files-to-references` and `value-shapes` then record **no** deployment flag,
1143+
because the flag is a claim about every object's stored data and a narrowed run read
1144+
only some. A narrowed `files-to-references` run does not move the media columns
1145+
either. The output says so, a flag that an earlier full run recorded is left as it
1146+
was, and `--json` carries `filter: { objects }`. Any `--object` narrows, even a list
1147+
that names every object, so run the command without `--object` to record the flag.
1148+
11351149
```bash
11361150
os migrate files-to-references # Dry run: full report, writes nothing
11371151
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)
11381152
os migrate files-to-references --apply --yes --json # CI / scripts
1139-
os migrate files-to-references --object product # Restrict to one object (repeatable)
1153+
os migrate files-to-references --object product # Restrict to one object (repeatable); records no flag
11401154
```
11411155
11421156
A `file` / `image` / `avatar` / `video` / `audio` field value is an opaque
@@ -1189,7 +1203,7 @@ The same gate for the **non-media** value classes — references (`lookup`,
11891203
os migrate value-shapes # Scan: full report, writes nothing
11901204
os migrate value-shapes --apply # Scan, then record the flag if clean (prompts)
11911205
os migrate value-shapes --apply --yes --json # CI / scripts
1192-
os migrate value-shapes --object contact # Restrict to one object (repeatable)
1206+
os migrate value-shapes --object contact # Restrict to one object (repeatable); records no flag
11931207
```
11941208
11951209
**This one converts nothing.** Its sibling rewrites legacy file values because

‎packages/cli/src/commands/migrate/duplicates.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Command, Flags } from '@oclif/core';
4-
import { emitJson, isExitSignal } from '../../utils/format.js';
4+
import { emitJson, errorCodeFields, isExitSignal } from '../../utils/format.js';
55
import { bootSchemaStack } from '../../utils/schema-migrate.js';
6+
import { refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
67
// The `objectql` slot's contract (#4251) — read the registry through it rather
78
// than erasing the lookup to `any`, so a rename breaks this at compile time
89
// instead of silently reporting zero objects.
@@ -874,7 +875,9 @@ export default class MigrateDuplicates extends Command {
874875
env: 'OS_DATABASE_URL',
875876
}),
876877
object: Flags.string({
877-
description: 'Restrict the scan to one object (recorded in the report, so a narrowed run cannot be mistaken for a full one)',
878+
description:
879+
'Restrict the scan to one object (recorded in the report, so a narrowed run cannot be mistaken for a full ' +
880+
'one). A name the deployment does not declare is refused',
878881
}),
879882
};
880883

@@ -906,6 +909,18 @@ export default class MigrateDuplicates extends Command {
906909
}
907910

908911
try {
912+
// [#21644] `collectScanTargets` keeps only the objects it would probe, so
913+
// a name this registry does not declare would be dropped without a word
914+
// and the report would read "no duplicates" over a scan of nothing.
915+
// Refused before any probe, against the set the scan draws from.
916+
refuseUndeclaredObjects(
917+
flags.object === undefined ? undefined : [flags.object],
918+
stack
919+
.allObjects()
920+
.map((o) => (o as { name?: unknown } | null)?.name)
921+
.filter((name): name is string => typeof name === 'string'),
922+
);
923+
909924
const {
910925
resolveSeedTenancyExec,
911926
normalizeRows,
@@ -973,7 +988,11 @@ export default class MigrateDuplicates extends Command {
973988
await emitJson(report);
974989
} catch (error: unknown) {
975990
if (isExitSignal(error)) throw error;
976-
await emitJson({ error: 'report_failed', detail: messageOf(error) }, 1, { compact: true });
991+
await emitJson(
992+
{ error: 'report_failed', detail: messageOf(error), ...errorCodeFields(error) },
993+
1,
994+
{ compact: true },
995+
);
977996
} finally {
978997
await stack.shutdown();
979998
}

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

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { bootSchemaStack } from '../../utils/schema-migrate.js';
2121
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
2222
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
2323
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
24+
import { isNarrowedRun, narrowedFlagNote, refuseUndeclaredObjects } from '../../utils/migrate-object-scope.js';
2425
import {
2526
describeFileColumnMoveRefusal,
2627
runFileColumnMove,
@@ -41,6 +42,7 @@ import type { MediaColumnMoveScan, SqlDialectName } from '@objectstack/driver-sq
4142
interface ColumnStepOutcome {
4243
skipped:
4344
| 'gate_not_passed'
45+
| 'narrowed_run'
4446
| 'no_sql_driver'
4547
| 'no_sql_seam'
4648
| 'driver_cannot_plan'
@@ -96,11 +98,19 @@ async function confirm(question: string): Promise<boolean> {
9698
* Dry run by default, and a dry run writes NOTHING — not conversions, not the
9799
* flag. Not run / not passed → files keep being retained forever: storage
98100
* cost, zero data loss.
101+
*
102+
* [#21644] Only a run over every object records the flag or moves the
103+
* columns. A run narrowed by `--object` converts the named objects' values and
104+
* stops there: the producer records no flag for it, and the column step, which
105+
* retypes every media column in the database on the strength of the gate,
106+
* does not run. A name the deployment does not declare is refused
107+
* (`OBJECT_NOT_FOUND`) rather than scanned as nothing.
99108
*/
100109
export default class MigrateFilesToReferences extends Command {
101110
static override description =
102111
'Migrate legacy file-field values to sys_file references and verify the ownership ledger (ADR-0104). ' +
103-
'Dry-run by default; --apply also records the deployment-level migration flag when the self-check passes.';
112+
'Dry-run by default; --apply also records the deployment-level migration flag when the self-check of every ' +
113+
'object passes.';
104114

105115
static override examples = [
106116
'$ os migrate files-to-references',
@@ -117,7 +127,8 @@ export default class MigrateFilesToReferences extends Command {
117127
}),
118128
apply: Flags.boolean({
119129
description:
120-
'Write the conversions and record the deployment migration flag (default is a read-only dry run)',
130+
'Write the conversions and record the deployment migration flag (default is a read-only dry run). ' +
131+
'Only a run without --object records the flag',
121132
default: false,
122133
}),
123134
yes: Flags.boolean({ char: 'y', description: 'Skip the --apply confirmation prompt', default: false }),
@@ -126,7 +137,9 @@ export default class MigrateFilesToReferences extends Command {
126137
default: false,
127138
}),
128139
object: Flags.string({
129-
description: 'Restrict to this object (repeatable; default: every object with a file field)',
140+
description:
141+
'Restrict to this object (repeatable; default: every object with a file field). A narrowed run converts ' +
142+
'but records no deployment flag and moves no column, and a name the deployment does not declare is refused',
130143
multiple: true,
131144
}),
132145
'max-records': Flags.integer({
@@ -144,6 +157,7 @@ export default class MigrateFilesToReferences extends Command {
144157
const { flags } = await this.parse(MigrateFilesToReferences);
145158
const timer = createTimer();
146159
const apply = flags.apply;
160+
const narrowed = isNarrowedRun(flags.object);
147161

148162
if (!flags.json) {
149163
printHeader('Migrate · files-to-references');
@@ -197,7 +211,12 @@ export default class MigrateFilesToReferences extends Command {
197211
return;
198212
}
199213
const ok = await confirm(
200-
chalk.bold('\nConvert legacy file values and record the migration flag on this database? [y/N] '),
214+
chalk.bold(
215+
narrowed
216+
? '\nConvert legacy file values of the named object(s) on this database? ' +
217+
'A run narrowed by --object records no deployment flag. [y/N] '
218+
: '\nConvert legacy file values and record the migration flag on this database? [y/N] ',
219+
),
201220
);
202221
if (!ok) {
203222
printInfo('Aborted — no changes made.');
@@ -248,6 +267,13 @@ export default class MigrateFilesToReferences extends Command {
248267
'Run "os build" in your project root first (the migration reads dist/objectstack.json), then re-run.',
249268
);
250269
}
270+
// [#21644] Before anything is converted: the scan keeps only the
271+
// candidates it covers, so a name this registry does not declare would
272+
// be dropped without a word, and the run would read as clean.
273+
refuseUndeclaredObjects(flags.object, loadedObjects);
274+
const narrowedNote = isNarrowedRun(flags.object)
275+
? narrowedFlagNote('files-to-references', flags.object, apply)
276+
: null;
251277
const getStorage = () => {
252278
try {
253279
// Canonical slot since #9683 (service-storage also registers the
@@ -293,13 +319,18 @@ export default class MigrateFilesToReferences extends Command {
293319
engine,
294320
apply,
295321
gatePassed: result.gatePassed,
322+
narrowed,
296323
json: flags.json,
297324
});
298325

299326
if (flags.json) {
327+
if (narrowedNote) logger.info(narrowedNote);
300328
await emitJson({
301329
database: stack.dbLabel,
302330
apply,
331+
// [#21644] Recorded in the document, so a narrowed run cannot be
332+
// mistaken for a full one (the shape `os migrate duplicates` keeps).
333+
filter: narrowed ? { objects: flags.object } : null,
303334
backfill: {
304335
scannedObjects: result.backfill.scannedObjects,
305336
scannedRecords: result.backfill.scannedRecords,
@@ -341,7 +372,23 @@ export default class MigrateFilesToReferences extends Command {
341372
console.log(formatFileReferenceReport(result.verify));
342373
console.log('');
343374

344-
if (result.gatePassed) {
375+
if (narrowedNote) {
376+
// [#21644] Every sentence below would promise the flag or the
377+
// enforcement it turns on, and a narrowed run records neither.
378+
if (!result.gatePassed) {
379+
for (const failure of result.gateFailures) printError(`Gate not passed: ${failure}`);
380+
printWarning('Fix the records listed above, then re-run.');
381+
} else if (apply) {
382+
printSuccess('Self-check passed over the named object(s); their conversions are written.');
383+
} else if (result.backfill.converted > 0) {
384+
printInfo(
385+
`Dry run only — ${result.backfill.converted} value(s) would be converted. Re-run with --apply to convert.`,
386+
);
387+
} else {
388+
printInfo('Data in the named object(s) is already in reference form.');
389+
}
390+
printInfo(narrowedNote);
391+
} else if (result.gatePassed) {
345392
if (apply) {
346393
printSuccess(
347394
'Self-check passed — deployment flag recorded (adr-0104-file-references). ' +
@@ -398,15 +445,23 @@ export default class MigrateFilesToReferences extends Command {
398445
engine: unknown;
399446
apply: boolean;
400447
gatePassed: boolean;
448+
narrowed: boolean;
401449
json: boolean;
402450
}): Promise<ColumnStepOutcome> {
403-
const { stack, apply, gatePassed, json } = args;
451+
const { stack, apply, gatePassed, narrowed, json } = args;
404452

405453
if (!gatePassed) {
406454
// ⛔ The ruling's "abort unless backfill + verify report zero blocking".
407455
// Not an error of this step's own — the gate already reported why.
408456
return { skipped: 'gate_not_passed', failed: false, stampedAt: null, report: null };
409457
}
458+
if (narrowed) {
459+
// [#21644] ⛔ The move retypes EVERY single-value media column in the
460+
// database, and a narrowed gate vouched for the named objects only. Its
461+
// stamp also requires the verified flag a narrowed run does not record,
462+
// so running it here would move columns it then could not record.
463+
return { skipped: 'narrowed_run', failed: false, stampedAt: null, report: null };
464+
}
410465
if (!stack.driver || typeof stack.driver.planMediaColumnMove !== 'function') {
411466
return { skipped: 'no_sql_driver', failed: false, stampedAt: null, report: null };
412467
}
@@ -503,7 +558,12 @@ export default class MigrateFilesToReferences extends Command {
503558
/** The human-mode half of {@link runColumnStep}. JSON mode reports the same facts. */
504559
private renderColumnStep(outcome: ColumnStepOutcome): void {
505560
if (outcome.skipped === 'gate_not_passed' || outcome.report === null) {
506-
if (outcome.skipped === 'no_sql_driver') {
561+
if (outcome.skipped === 'narrowed_run') {
562+
printInfo(
563+
'Column step: not run — it moves every media column in the database, so only a run without ' +
564+
'--object authorises it.',
565+
);
566+
} else if (outcome.skipped === 'no_sql_driver') {
507567
printInfo(
508568
'Column step: not applicable — the ADR-0104 file-family column move is a SQL-driver step ' +
509569
'and no SQL driver is active here.',

0 commit comments

Comments
 (0)