Skip to content

feat(cli): os migrate unmapped-columns reads a retired field's columns, keyed by record id, for conversion before the destructive drop - #21643

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21573-migrate-unmapped-read
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21573-migrate-unmapped-read

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21573

Clause-②: yes (widening)

os migrate unmapped-columns --object NAME reads the values of the columns os migrate plan reports as unmapped_column for 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 until os migrate apply --allow-destructive drops 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)

  • The os migrate family. 13 modules sit under packages/cli/src/commands/migrate/.
    • plan and apply name the column only (kind, table, column, actual, op).
    • account-issuer reads one fixed column of one platform table through the driver. It reports collision groups, not values.
    • Every other module reads declared fields.
  • Data-migration plans. The migration-plans registry holds one plan, metadata.recorded-by-sentinel-to-null. A plan's rows load through step.load(engine), which is the narrowed engine verbs.
  • Direct driver readers in the CLI (git grep for getDriverForObject and driver.find under packages/cli/src): account-issuer, secret orphans, secret rewrap and the secret-reference union. Each reads declared columns of fixed platform tables.
  • os data query and os data get read 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)

  • Same differ, same boot. The door calls the same stack.driver.detectManagedDrift() the plan calls, on the plan's own boot: deferSchemaDdl, readOnlyProbe and composeHostStack. It keeps the unmapped_column findings for the object's table (unmappedColumnsOf). It writes no second diff.
  • The differ's exclusions hold unchanged. These are the driver's id, created_at and updated_at, and any column ending in the hash-shadow suffix.
  • Pinned on SQLite. The integration pin adds a hash-shadow column to its fixture. It asserts the door's column list equals os migrate plan --json's unmapped_column findings for the table, and that the shadow is in neither.
  • Measured by hand on PostgreSQL 16. On a temporary server I started and stopped, the door's list was identical to the plan's (seven columns, same order, same actual).

A3. Drivers with no drift report: the plan's answers, reused

case os migrate plan this door
no SQL driver { error: 'no_sql_driver', changes: [] }, exit 0 { error: 'no_sql_driver', object, columns: [], records: [] }, exit 0
Turso remote the read-only boot refuses (setDeferredDdl(true) is refused on that face) the same boot, so the same refusal (boot_failed, exit 1)
an object outside the set the plan diffs (federated, bound to no driver, or bound to another datasource) reports nothing; its coverage pass calls that UNMEASURED refused, exit 1, in the coverage pass's own words, not empty work

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 exactly id plus those columns, on better-sqlite3 and on PostgreSQL 16.
  • A projection naming a column the table lacks returns the whole row. This is the driver's unknown-column recovery, and the row includes declared fields and the shadow. So the door never passes a driver row through. It picks the reported columns, and it refuses a row that is missing one.

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 Buffer or any ArrayBufferView), a bigint, 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 a null. 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):

retired field type SQLite column SQLite value PostgreSQL 16 column PostgreSQL value
text text string text string
number float number numeric string at the column's scale
currency float number numeric string at the column's scale
percent float number numeric string at the column's scale
boolean boolean 1 / 0 boolean true / false
json (object and array) text the stored text json parsed by the client
date date string date string
time time string time without time zone string
datetime datetime the stored text timestamp with time zone a Date, emitted as its ISO 8601 text
a binary column added by hand blob Buffer: refused bytea Buffer: refused

The 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 Date passes: it serialises to unambiguous ISO 8601 text. The real CLI was run by hand on PostgreSQL over the probe table: exit 0, 12 columns, the Date as ISO text. Over a bytea column it answered exit 1, one document, naming the column and the record.

A6. Family conventions

  • Flags. --database-url (env OS_DATABASE_URL), --max-records and --json use account-issuer's declarations. Reaching --max-records refuses. There is also a required --object, the family's spelling for an object filter.
  • Read-only boot. It is the plan's boot. The door is entered in schema-migrate.one-shot-family.integration.test.ts: the database stays byte-identical, no SQLite file is created, and no key material is written.
  • No database yet. The door asks stack.tableAbsent before 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.
  • Exit signal. The catch rethrows isExitSignal as its first statement. test/exit-signal.pin.test.ts discovers the command structurally.
  • --json. It prints one document. The door is entered in the stdout purity family.
  • Unknown object name. The family's existing --object filters do not refuse an unknown name: value-shapes, summary-nulls, files-to-references and duplicates filter 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-columns because the plan's finding kind is unmapped_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 over driver.find (via the shared keysetWalk), keyed by id, that refuses a partial read;
    • unrepresentableKind (patch round), the predicate the read refuses a value JSON cannot carry as stored by;
    • the command, wrapped the way plan is (refuseWhenHostConfigUnloadable, then exitOneShotCommand). Patch round: the --json refusal path now hands emitJson exit 1, as the family does. The exit status was already 1.
  • packages/cli/src/index.ts: MigrateUnmappedColumnsCommand is re-exported beside MigrateAccountIssuerCommand. oclif discovers commands by file pattern, so package.json needs no change (measured: oclif.commands is pattern over ./dist/commands/**).
  • Tests: unmapped-columns.test.ts (unit) and unmapped-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.
  • Docs:
    • 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.mdx is 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/cli minor, Clause-②: yes (widening).

The two landed changesets, packages/objectql, packages/spec, the drivers' src/ and every runtime door are untouched. The patch round merged origin/main (1cbe165bfc, 5 commits, none on this diff's paths) with a merge commit. There was no rebase and no force-push.

Pins

  • Integration (the CLI spawned against SQLite). Release one writes three contacts while two text fields are declared. Release two retires both. A hash-shadow column is added by hand.
    • The door emits the two retired columns for all three records, keyed by id. A record written with the fields empty is emitted with two NULLs.
    • No declared field, built-in column or shadow appears in the output.
    • The column list equals os migrate plan --json's for the table.
    • The human face lists the columns and the records.
    • The database is byte-identical after the door ran (schema and every row, read on a connection of the test's own).
    • An object with none: empty work, exit 0, on both faces.
    • An undeclared name: OBJECT_NOT_FOUND, exit 1, one document.
    • --max-records 2 over three rows: refused, exit 1, with no records key.
    • Patch round: a BLOB column added by hand to a third object, holding three bytes, is refused in both faces with exit 1. The refusal names the column and the record id, emits no record, and neither face carries the Buffer stand-in.
  • Unit.
    • The filter keeps only unmapped_column findings 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.
    • The read asks for id plus exactly the columns, keys records by id, and seeks past one page. It decodes nothing: a JSON-looking string, a 0/1, a Date, a parsed json object and a string pass through as the same instances, and the Date serialises to its ISO 8601 text.
    • It refuses on the cap, on a row missing a column (the whole-row shape) and on a non-array answer. A table holding exactly the cap is read without refusing.
    • Patch round: one refusal case per class, each with the bad value on the second row and each naming the column and the record id. The cases are a Buffer, another ArrayBufferView, a bigint, NaN, Infinity and -Infinity. A control asserts the predicate answers null for null, strings, finite numbers, booleans, a Date, an object and an array.
  • Absent-database roster. On a project with no database: empty work, exit 0, on both faces, saying the table is not there yet. On the booted control: the retired column and its value are read.
  • Runtime-door control. An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, until 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 in record #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)

  • The mutation. node scripts/ablation-replace.mjs replaced 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 from 9d976e14 to ddaa4be0.
  • No rebuild was needed. The spawned CLI loads this command from src/: oclif runs in development mode, and dist/ held no build of the new file while the pins ran it.
  • Result: 6 red, 13 green.
    • Red: both column-selection unit cases, plus four integration cases (the retired columns' emission, the one-column-set comparison with the plan, the human face, and the cap refusal, which now reads nothing).
    • Green: the no-retired-columns control (empty work), OBJECT_NOT_FOUND, byte-identity, and the read walk's own unit cases.
  • Restore. The tool restored with git checkout HEAD -- PATH. Blob 9d976e14 equals the HEAD blob, git diff HEAD is empty, and git status --porcelain is clean. The anchor counts 1 and the mutation counts 0 on disk.

Patch-round leg: the refusal predicate (the refusal committed first, at 242cbc64f2).

  • A void first attempt. It is recorded here because it never measured anything. Its replacement text contained the anchor, so the anchor count read 1 to 1 and ablation-replace rejected the leg before the command started (exit 1, lock held 0 s). The file was restored: blob 35c1a6c6 equals HEAD.
  • The mutation that ran. The predicate's first check, if (ArrayBuffer.isView(value)) return 'binary bytes';, became an unconditional return null. The anchor went from 1 to 0, the replacement from 0 to 1, and the blob changed from 35c1a6c6 to 25e968b9.
  • Result: 7 red, 20 green.
    • Red: the six per-class unit refusals and the public-door bytes refusal.
    • Green: the pass-through control (a Date, a parsed json object, a string), the predicate's null control, every retired-column case, empty work, OBJECT_NOT_FOUND, the cap refusal and byte-identity.
  • Restore. Blob 35c1a6c6 equals HEAD, git diff HEAD is empty, git status --porcelain is clean, and the anchor counts 1 and the marker 0 on disk.

Local verification (final head 242cbc64f2, after merging origin/main at 1cbe165bfc)

  • @objectstack/cli unit project in full: 256 files, 3765 passed. That is the round-one 3758 plus the seven new unit cases.
  • @objectstack/cli typecheck: exit 0, with check:test-typecheck holding its existing ledger (3 files, none of them new).
  • The four os migrate integration pins, on real built packages (a repo build of the merged tree, VERDICT 0): 4 files, 134 passed, 1 skipped.
    • the new pin: 9 passed;
    • the absent-database roster: 39 passed;
    • the one-shot family: 78 passed;
    • the read-only preview: 8 passed, plus its live-PostgreSQL cell as a named skip. That cell ran 12 of 12 against my temporary server in round one; the server is stopped now.
  • Nightly tier: json-stdout-purity.e2e.test.ts under OS_TEST_TIERS=nightly, 50 passed.
  • Runtime-door control. Unchanged since round one, and the patch touches no runtime path: rest 14 passed, objectql 48 passed (round one).
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 95 commands at 242cbc64f2, the same 95 as round one. All 95 were run there and each exited 0. --ran reconciles: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.
    • Round one's notes still hold: the three PREREQUISITE NOT MET gates read packages outside the CLI closure and exit 0 after a repo build, and the unit double holds the caller's bound for check:objectql-double-limit.
    • Added beyond the derived list: check:cli-command-ids, exit 0.
  • Full pnpm lint (eslint . --no-inline-config over the whole repo): exit 0 at 242cbc64f2, with no error or warning printed.
  • After the final push, origin/main gained 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

  • A composition edge. If the composed boot's coverage pass sees the driver refuse registration for the very object named, the plan reports nothing for it and so does this door. The door's membership check (the object is declared, not federated, and bound to the plan's driver) cannot see that case without reading driver-private state. The plan's own notes report such a refusal. Carrier: none.
  • Unscoped by design. No tenant scope is passed, so the read covers every organization's rows. The docs and the changeset say so.
  • The text face prints values. It lists each record's values, as the JSON face does. Both run only under the operator's credentials.

Generated by Claude Code

claude added 5 commits October 3, 2026 22:19
…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/data-modeling/queries.mdx (via os migrate unmapped-columns (command, read off packages/cli/src/commands/migrate/unmapped-columns.ts))
  • content/docs/deployment/backup-restore.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/deployment/cli.mdx (via OS_DATABASE_URL (literal, a string literal in flags), unmapped_column (literal, a string literal in unmappedColumnsOf), os migrate unmapped-columns (command, read off packages/cli/src/commands/migrate/unmapped-columns.ts))
  • content/docs/deployment/environment-variables.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/deployment/production-readiness.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/deployment/self-hosting.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/deployment/single-project-mode.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/getting-started/your-first-project.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
  • content/docs/plugins/index.mdx (via OS_DATABASE_URL (literal, a string literal in flags))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/src/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1cbe165bfc0c733cc286bb282863660d9c871c17 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c23fe3ee320dc8104a950885ab370795ecf032c4 — the merge of head 242cbc64f2175c445de007c7e6d601d3020041c9 into base 1cbe165bfc0c733cc286bb282863660d9c871c17, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1cbe165bfc0c733cc286bb282863660d9c871c17 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 3, 2026 23:10
…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 242cbc64f2175c445de007c7e6d601d3020041c9
Local-runs: none

Inputs: card #21573 (body, claim, both os-dev reports, the REWORK and the ACCEPT), PR #21643 (body, file list, the net diff against main at the merge base 1cbe165bfc, read as git objects), and the check-runs on this head. Every check-run on the head is completed; every gate-carrying one, the seven required contexts included, concluded success. The only skipped runs are the opt-in packed-tarball smoke, the Console Pin Gate (no pin moved) and the second invocations of Auto Label and Check PR Size. Head repo equals base repo; 1,114 changed lines; no governed-surface path in the file list, and the Governed Surface Queue Guard agrees.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged right or wrong.

  • A new oclif command id, migrate unmapped-columns (packages/cli/src/commands/migrate/unmapped-columns.ts), discovered by oclif.commands pattern over ./dist/commands, so no manifest edit. RIGHT.
  • Its flag accept set: --database-url (env OS_DATABASE_URL), --object (required), --max-records (integer; reaching it refuses), --json. The three shared flags are account-issuer's declarations; --object is the family's spelling for an object filter, required because the door reads one object per run. No --apply, --yes or --allow-destructive: it has no writing mode. No new env var. RIGHT.
  • A new named export MigrateUnmappedColumnsCommand from @objectstack/cli beside MigrateAccountIssuerCommand, the family convention. RIGHT.
  • Reachability. The file list holds no path under packages/rest, packages/runtime, packages/adapters, packages/objectql, packages/spec or any driver src/. No REST route, API flag, per-request option, engine verb or driver contract is added or changed. The door is an oclif command run under the credentials --database-url / OS_DATABASE_URL / the project database carry. RIGHT, and it is the card's security posture.
  • Read-only. The boot is the plan's boot (deferSchemaDdl, readOnlyProbe, composeHostStack), whose contract in bootSchemaStack is no DDL, no seed, no database file brought into existence. The only driver calls are detectManagedDrift() (introspection: hasTable then detectTableDrift) and find. The one-shot family roster enters the command with write: [], and the public-door pin reads the schema and every row on its own connection before and after and asserts byte identity. RIGHT.
  • One column set. unmappedColumnsOf is a filter over the plan's own detectManagedDrift() findings: kind === 'unmapped_column' and table === StorageNameMapping.resolveTableName({ name }), which is the key the driver's managedObjectFields and tableAbsent use. The differ's orphan pass skips BUILTIN_COLUMNS (id, created_at, updated_at), the hash-shadow suffix, and every expected column (declared fields and the registry-injected tenant, owner and audit columns), so a declared or driver-owned column cannot enter the set. The row loop copies only the reported columns into values, so even the driver's whole-row recovery cannot leak a declared value, and a row missing a reported column refuses. The public-door pin compares the door's columns with os migrate plan --json's findings for the same table on the same database, with a hand-added hash shadow in the table and in neither answer. RIGHT. id is read as the record key, which the card asks for.
  • Completeness of the read. find is called with no options, and applyTenantScope returns the builder unchanged when options.tenantId is absent; the driver's find adds no other implicit row filter. So the walk covers every organization's rows, which the PR body, changeset and docs all state. A cap that stops the walk, a non-array answer, a row missing a column and an unrepresentable value each refuse with no records key. RIGHT.
  • Refused value classes: any ArrayBufferView, a bigint, a non-finite number; a Date passes as ISO 8601 text. This is the REWORK's ruling, implemented exactly, with a unit case per class on the second row, a pass-through control, a predicate control and a public-door BLOB case in both faces. The platform-created column types were measured on both dialects before pinning, 0 of 24 refused per dialect. RIGHT. Residual the dev disclosed and I accept: an invalid Date (serialises to null) is outside the ruled set, and no measured driver hands one back for a column the database itself typed; a PostgreSQL infinite timestamp arrives as a non-finite number and is refused.
  • Membership check (federated, bound to no driver, bound to another datasource: refused, exit 1) mirrors measureComposedCoverage's own judgement, identity-compared against the plan's driver. RIGHT.
  • OBJECT_NOT_FOUND for an undeclared name, with the platform's objectNotFoundError envelope, where the sibling --object filters drop an unknown name silently. Right for this door: its empty answer is what an operator acts on before a destructive drop. The sibling behaviour is filed as [finding] os migrate value-shapes --object … --apply records the DEPLOYMENT-level flag from a scan of only the named objects; a misspelled name scans nothing and still records "verified" #21644, not folded in. RIGHT.
  • Exit and JSON contract: found or empty work or absent table, exit 0, one document { database, object, table, columns: [{ column, actual }], count, records: [{ id, values }], duration }; no_sql_driver, exit 0, the plan's answer; boot_failed, OBJECT_NOT_FOUND, outside-the-plan, partial, missing column, unrepresentable, non-array: exit 1, one document, no records key. The --json refusal path hands emitJson exit 1 as the family does. The catch rethrows the exit signal first. RIGHT. Nit, not blocking: the cli.mdx bullet lists the document as { object, table, columns, count, records }, leaving database and duration out; the changeset lists the full shape.
  • Absent database: tableAbsent is asked before the differ, empty work on both faces with a stderr line; entered in the absent-database roster with a control row, and in the stdout-purity family on that face. RIGHT.
  • Docs: the os migrate table row, the example line and the subsection in cli.mdx; the conversion sentence in queries.mdx naming the door as the route after retirement; upgrading.mdx left alone, measured to state no route. The one-word fix below to above is correct: the memory-driver callout sits above the sentence that cites it (lines 506 to 518 against 522 at this head). The anchor spelling follows the page's existing cli#os-… links to backticked headings. RIGHT.
  • Nothing a release page or a CHANGELOG needed: content/docs/releases/** and packages/*/CHANGELOG.md untouched, as the guardrails require. RIGHT.

Nothing judged wrong.

② Semver level

  • The diff publishes one thing: a new subcommand and its barrel export in @objectstack/cli, a released package at version 17.6.0. Nothing that ran before changes; no key, export or flag is removed or renamed. That is a widening of the public surface.
  • Changeset .changeset/21573-migrate-unmapped-columns.md: '@objectstack/cli': minor. Correct level. Its body carries the Clause-②: yes (widening) line, the one the gate reads; the PR body carries the same line. Not breaking, so no ADR-0087 disposition marker is owed. Check Changeset concluded success.
  • No other released package is in the diff (@objectstack/types's keysetWalk and @objectstack/core's objectNotFoundError are imported, not changed), so one changeset is the right count.

Clause-②: yes (widening). The declaration matches the diff.

③ Boundary flags

  • Round-one open_questions[0] (A5: a binary value cannot be emitted as stored): the seat answered B in the REWORK; the patch round implements it, pins it (unit per class, controls, public-door BLOB case), ablates the predicate (7 red, 20 green, blob-proven restore, the void first attempt disclosed) and states it in the changeset and cli.mdx. ANSWERED; the implementation matches the ruling.
  • Patch-round open_questions: empty. Nothing outstanding.
  • Dev flag, PR acceptance note 1 (composition edge): if the driver refused schema registration for the very object named, the plan reports nothing for it and so does this door, as empty work. The seat accepted it with no carrier. I judge that right: os migrate apply --allow-destructive reconciles the same findings and skips the same table, so no column is dropped and no value is lost in that edge, and the plan's own notes print the refusal. Noted, not escalated: the door never prints composition.notes where plan prints them in both faces, so an operator reading only this door's text face does not see the refusal note; a follow-up could print them, and it is not a defect of this card's scope.
  • Dev flag, acceptance note 2 (unscoped by design): stated in the PR body, the changeset and cli.mdx; confirmed in the driver (applyTenantScope is a no-op without a tenant option). ANSWERED.
  • Dev flag, acceptance note 3 (the text face prints values): both faces run only under the operator's credentials. ANSWERED.
  • Round-one deviations (the OBJECT_NOT_FOUND envelope, the required --object, the PostgreSQL leg as a hand measurement, no main merge while no path overlapped): each dispositioned in the REWORK. ANSWERED.
  • Patch-round deviations: the dev left the replacement PR body for the seat, which applied it (the body read on GitHub carries the patch-round content); the refused set follows the ruling exactly, with the bare ArrayBuffer and invalid Date boundary stated; the extra enumeration clauses and the extra integration case are accepted in the ACCEPT; origin/main gained two commits after the final push (a CI workflow and spec citation re-anchors), neither on this diff's paths, and the merge queue rebuilds on current main regardless; the temporary PostgreSQL server was stopped and its directory removed. ANSWERED.
  • Out-of-scope findings: the narrowed --apply deployment-flag defect is filed as [finding] os migrate value-shapes --object … --apply records the DEPLOYMENT-level flag from a scan of only the named objects; a misspelled name scans nothing and still records "verified" #21644 by the seat; the coverage-pass note is accepted with no carrier. ANSWERED.
  • Security posture, as the brief asks: CLI-only under operator credentials with no runtime route, API flag or per-request option in the diff; read-only; column set exactly the plan's unmapped_column findings. All three hold on this head.

Implemented-by: claude/issue-21573-migrate-unmapped-read
Reviewed-by: session_016GiHYRmLSNWTfbX9gVQkpz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 00:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 00:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 759dbe9 Oct 4, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21573-migrate-unmapped-read branch October 4, 2026 00:28
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants