Repository navigation
feat(cli): os migrate unmapped-columns reads a retired field's columns, keyed by record id, for conversion before the destructive drop - #21643
Conversation
…d field's columns Reads the columns os migrate plan reports as unmapped_column for one object, through the SQL driver the plan's differ ran on, and emits them keyed by record id. Read-only boot, the plan's own composition. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Retired columns emitted keyed by record id, the same column set os migrate plan reports, empty work for an object with none, OBJECT_NOT_FOUND for an unknown name, a refused partial read, and a byte-identical database. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…osters The absent-database roster (empty work on a missing table, the control reads a retired column), the bootSchemaStack caller table, and the --json stdout purity family. Re-exported beside account-issuer. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…or a retired field The os migrate table and example block, a subsection with the three-step route and the door's answers, the queries page's conversion sentence, and the callout pointer that sits above its sentence, not below. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
check:objectql-double-limit could not seat the double: it read only the id seek. It now implements equality and $gt, refuses any other operator, applies limit by presence after the filter, and projects only when asked. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c23fe3ee320dc8104a950885ab370795ecf032c4 && git checkout c23fe3ee320dc8104a950885ab370795ecf032c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1cbe165bfc0c733cc286bb282863660d9c871c17 242cbc64f2175c445de007c7e6d601d3020041c9 && git checkout -B drift-repro 1cbe165bfc0c733cc286bb282863660d9c871c17 && git merge --no-ff 242cbc64f2175c445de007c7e6d601d3020041c9
node scripts/docs-audit/affected-docs.mjs --json 1cbe165bfc0c733cc286bb282863660d9c871c17
|
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…ry as stored Binary bytes, a bigint and a non-finite number reach a JSON document as a stand-in a conversion would write as the value. The read now refuses them in both faces, exit 1, naming the column and the record id, and emits no record; a Date passes as its ISO 8601 text. No column the platform creates for a field type answers with one, measured on SQLite and PostgreSQL. The --json refusal path passes exit 1 to emitJson, as the family does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Contract reviewServed-tier: Inputs: card #21573 (body, claim, both os-dev reports, the REWORK and the ACCEPT), PR #21643 (body, file list, the net diff against ① Derived judgmentsEach accept-set or public-surface change the diff implies, judged right or wrong.
Nothing judged wrong. ② Semver level
Clause-②: yes (widening). The declaration matches the diff. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…nd an unknown --object is refused (objectstack-ai#21662) Fixes objectstack-ai#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 objectstack-ai#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` (objectstack-ai#21649, objectstack-ai#21632, objectstack-ai#21648, objectstack-ai#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>
Fixes #21573
Clause-②: yes (widening)
os migrate unmapped-columns --object NAMEreads the values of the columnsos migrate planreports asunmapped_columnfor one object, and emits them keyed by record id. It is the operator-only, read-only route for converting a retired field's values. Since the read narrowing (#21571) and the write narrowing (#21613), no runtime door serves those columns. They stay in the table untilos migrate apply --allow-destructivedrops them. The door runs under the operator's own database credentials, through the SQL driver the plan's differ ran on. No REST route, API flag, engine verb or driver changes.Measured first (at BASE
3222c57404)A1. No existing door reads an unmapped column's values (hypothesis confirmed)
os migratefamily. 13 modules sit underpackages/cli/src/commands/migrate/.planandapplyname the column only (kind,table,column,actual,op).account-issuerreads one fixed column of one platform table through the driver. It reports collision groups, not values.migration-plansregistry holds one plan,metadata.recorded-by-sentinel-to-null. A plan's rows load throughstep.load(engine), which is the narrowed engine verbs.git grepforgetDriverForObjectanddriver.findunderpackages/cli/src):account-issuer,secret orphans,secret rewrapand the secret-reference union. Each reads declared columns of fixed platform tables.os data queryandos data getread through REST.So this is a new subcommand, and
Clause-②: yes (widening)stands as the claim declared it.A2. One column set (reused and pinned)
stack.driver.detectManagedDrift()the plan calls, on the plan's own boot:deferSchemaDdl,readOnlyProbeandcomposeHostStack. It keeps theunmapped_columnfindings for the object's table (unmappedColumnsOf). It writes no second diff.id,created_atandupdated_at, and any column ending in the hash-shadow suffix.os migrate plan --json'sunmapped_columnfindings for the table, and that the shadow is in neither.actual).A3. Drivers with no drift report: the plan's answers, reused
os migrate plan{ error: 'no_sql_driver', changes: [] }, exit 0{ error: 'no_sql_driver', object, columns: [], records: [] }, exit 0setDeferredDdl(true)is refused on that face)boot_failed, exit 1)A4. The read carrier (confirmed on SQLite and PostgreSQL 16, with no driver or engine change)
A throwaway probe created a table with six typed fields, re-registered it with them retired, and added a blob column and a hash-shadow column by hand.
detectManagedDrift()reported the seven undeclared columns and skipped the shadow.driver.find(object, { fields: ['id', ...columns] })returned exactlyidplus those columns, onbetter-sqlite3and on PostgreSQL 16.The read goes through the driver, never through the engine's verbs.
A5. Values as stored
The driver's output pass decodes declared fields only, so an unmapped column comes back exactly as the database client returns it. The door adds no codec.
Patch round (the seat's REWORK, answer B). A value JSON cannot carry as stored is refused, not emitted. That covers binary bytes (a
Bufferor anyArrayBufferView), abigint, and a non-finite number (NaN,Infinity,-Infinity). The refusal applies in both faces, with exit 1; it names the column and the record id, says to read that column with the database's own client, and emits no record (unrepresentableKind). Each of these would reach JSON as a stand-in: an object shaped{ type, data }, a thrown serialisation, or anull. A conversion would write that stand-in as the value. ⛔ No codec.Measured first, before pinning. A throwaway probe created the column the platform creates for each retired field type, re-registered the table with the fields retired, and read it through the door's projection, classified with the door's own predicate. None of the platform-created columns lands in the refused set, on either dialect (refused count 0 of 24 values on each):
texttextfloatnumericfloatnumericfloatnumericboolean1/0booleantrue/falsetextjsondatedatetimetime without time zonedatetimetimestamp with time zoneDate, emitted as its ISO 8601 textblobbyteaThe platform creates no binary column for any field type. The column emitter has no binary arm, and only the SQLite table rebuild preserves a binary column that already exists. So the refusal fires only on a column added by hand. A
Datepasses: it serialises to unambiguous ISO 8601 text. The real CLI was run by hand on PostgreSQL over the probe table: exit 0, 12 columns, theDateas ISO text. Over abyteacolumn it answered exit 1, one document, naming the column and the record.A6. Family conventions
--database-url(envOS_DATABASE_URL),--max-recordsand--jsonuseaccount-issuer's declarations. Reaching--max-recordsrefuses. There is also a required--object, the family's spelling for an object filter.schema-migrate.one-shot-family.integration.test.ts: the database stays byte-identical, no SQLite file is created, and no key material is written.stack.tableAbsentbefore the differ, says on stderr that the table is not there yet, and answers empty work. It is entered in the absent-database roster, with a control row that holds a retired column.isExitSignalas its first statement.test/exit-signal.pin.test.tsdiscovers the command structurally.--json. It prints one document. The door is entered in the stdout purity family.--objectfilters do not refuse an unknown name:value-shapes,summary-nulls,files-to-referencesandduplicatesfilter it out. An empty answer from this door is what an operator acts on before dropping columns. So a name the deployment does not declare refuses with the platform's own envelope (objectNotFoundError:OBJECT_NOT_FOUND), not with empty work. What the sibling behaviour reaches is in the report's out-of-scope findings.The name
The command is
unmapped-columnsbecause the plan's finding kind isunmapped_column, so the finding names the command that reads it. This follows the family's noun-phrase style (multi-value-columns,summary-nulls,value-shapes). The finding's message says "orphaned", but no flag or JSON key uses that word.Changes
packages/cli/src/commands/migrate/unmapped-columns.ts(new):unmappedColumnsOf, a filter over the plan's findings;readUnmappedColumnValues, a keyset walk overdriver.find(via the sharedkeysetWalk), keyed by id, that refuses a partial read;unrepresentableKind(patch round), the predicate the read refuses a value JSON cannot carry as stored by;planis (refuseWhenHostConfigUnloadable, thenexitOneShotCommand). Patch round: the--jsonrefusal path now handsemitJsonexit 1, as the family does. The exit status was already 1.packages/cli/src/index.ts:MigrateUnmappedColumnsCommandis re-exported besideMigrateAccountIssuerCommand. oclif discovers commands by file pattern, sopackage.jsonneeds no change (measured:oclif.commandsispatternover./dist/commands/**).unmapped-columns.test.ts(unit) andunmapped-columns.integration.test.ts(the public door), plus one roster entry each in:data-commands.absent-database.integration.test.ts(with its control row);schema-migrate.one-shot-family.integration.test.ts;test/json-stdout-purity.e2e.test.ts.content/docs/deployment/cli.mdx: a table row, an example line, a short subsection with the three-step route and the door's answers, and "the callout below" corrected to "above" (the callout sits above its sentence).content/docs/data-modeling/queries.mdx: the conversion sentence now names the door as the route after retirement.content/docs/upgrading.mdxis untouched. It states no conversion route (grep for conversion, retire, orphan, unmapped and allow-destructive hits only protocol conversions and the apply line)..changeset/21573-migrate-unmapped-columns.md:@objectstack/climinor,Clause-②: yes (widening).The two landed changesets,
packages/objectql,packages/spec, the drivers'src/and every runtime door are untouched. The patch round mergedorigin/main(1cbe165bfc, 5 commits, none on this diff's paths) with a merge commit. There was no rebase and no force-push.Pins
os migrate plan --json's for the table.OBJECT_NOT_FOUND, exit 1, one document.--max-records 2over three rows: refused, exit 1, with norecordskey.unmapped_columnfindings of the named table, in the differ's order. A type mismatch, an orphaned index over the same column and another table's orphan are all left out.idplus exactly the columns, keys records by id, and seeks past one page. It decodes nothing: a JSON-looking string, a0/1, aDate, a parsed json object and a string pass through as the same instances, and theDateserialises to its ISO 8601 text.ArrayBufferView, abigint,NaN,Infinityand-Infinity. A control asserts the predicate answersnullfornull, strings, finite numbers, booleans, aDate, an object and an array.os migrate apply --allow-destructive#21571's and A write response still serves ORPHANED columns no metadata declares: after the read narrowing, PATCH /api/v1/data/OBJECT/ID answers 200 with a retired field's column inrecord#21613's declared-field pins ran unchanged and green (below). The engine's doors still never return these columns.Reverse verification (implementation committed first, at
d5b349d4de)node scripts/ablation-replace.mjsreplaced the column selection's predicate with a kind that never matches. The anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob changed from9d976e14toddaa4be0.src/: oclif runs in development mode, anddist/held no build of the new file while the pins ran it.OBJECT_NOT_FOUND, byte-identity, and the read walk's own unit cases.git checkout HEAD -- PATH. Blob9d976e14equals the HEAD blob,git diff HEADis empty, andgit status --porcelainis clean. The anchor counts 1 and the mutation counts 0 on disk.Patch-round leg: the refusal predicate (the refusal committed first, at
242cbc64f2).ablation-replacerejected the leg before the command started (exit 1, lock held 0 s). The file was restored: blob35c1a6c6equals HEAD.if (ArrayBuffer.isView(value)) return 'binary bytes';, became an unconditionalreturn null. The anchor went from 1 to 0, the replacement from 0 to 1, and the blob changed from35c1a6c6to25e968b9.Date, a parsed json object, a string), the predicate'snullcontrol, every retired-column case, empty work,OBJECT_NOT_FOUND, the cap refusal and byte-identity.35c1a6c6equals HEAD,git diff HEADis empty,git status --porcelainis clean, and the anchor counts 1 and the marker 0 on disk.Local verification (final head
242cbc64f2, after mergingorigin/mainat1cbe165bfc)@objectstack/cliunit project in full: 256 files, 3765 passed. That is the round-one 3758 plus the seven new unit cases.@objectstack/clitypecheck: exit 0, withcheck:test-typecheckholding its existing ledger (3 files, none of them new).os migrateintegration pins, on real built packages (a repo build of the merged tree, VERDICT 0): 4 files, 134 passed, 1 skipped.json-stdout-purity.e2e.test.tsunderOS_TEST_TIERS=nightly, 50 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 95 commands at242cbc64f2, the same 95 as round one. All 95 were run there and each exited 0.--ranreconciles: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.PREREQUISITE NOT METgates read packages outside the CLI closure and exit 0 after a repo build, and the unit double holds the caller's bound forcheck:objectql-double-limit.check:cli-command-ids, exit 0.pnpm lint(eslint . --no-inline-configover the whole repo): exit 0 at242cbc64f2, with no error or warning printed.origin/maingained two commits (0c50b5dfe7,15fe567c9c: a CI workflow and spec citation re-anchors). Neither touches this diff's paths, so they were not merged again; CI runs on the merge ref.Acceptance notes
Generated by Claude Code