Skip to content

fix(cli): os migrate meta converts objects built with ObjectSchema.create and the other strict authoring factories - #20801

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20696-migrate-strict-factories
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20696-migrate-strict-factories

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20696

Clause-②: no

What was wrong

os migrate meta loads the config through the authored-source shim in packages/cli/src/utils/config.ts. The shim swaps each @objectstack/spec entrypoint for a module that wraps its helpers as try-real-then-authored, so a retired key reaches the conversion chain instead of stopping the load. It wrapped only the exports matching DEFINE_HELPER_RE (the define* helpers). ObjectSchema.create(...) parses when it is called, inside defineStack's argument, so it threw before the wrapped defineStack ever ran.

Reproduced on main at 91e8fa194 before any edit:

  • Repro. The card's defineStack({ objects: [ObjectSchema.create({ ..., tenancy: { enabled: true, organizationField: 'organization_id' } })] }), with ObjectSchema imported from @objectstack/spec/data. os migrate meta --from 17 --json exits 1 with {"error":"[\n {\n \"code\": \"unrecognized_keys\", ... (the raw ZodError array). The tombstone inside that array says Run os migrate meta --from 17.
  • Control. The same object as a plain literal exits 0: applied holds object-tenancy-organization-field-removed at objects[0].tenancy.organizationField, and schemaValid is true.

What changed

Everything below is in packages/cli/src/utils/config.ts, as the triage direction asks. packages/spec is untouched: ObjectSchema.create stays strict everywhere, and the shim is installed only by os migrate meta.

  • One written list. STRICT_AUTHORING_FACTORIES names the five factories below as owner export → member, plus the entrypoint each was measured on.

    factory home
    ObjectSchema.create @objectstack/spec/data
    App.create @objectstack/spec/ui
    Dashboard.create @objectstack/spec/ui
    Report.create @objectstack/spec/ui
    Action.create @objectstack/spec/ui

    The shim wraps an entry on every entrypoint whose owner export carries that member. Unlisted factories are not wrapped. DEFINE_HELPER_RE is unchanged, and no second pattern is added.

  • How a factory is wrapped. The wrap happens in place, on a Proxy over the real owner. Only the listed member gets the try-real-then-authored wrap; every other member (parse, safeParse, shape, ...) passes through untouched. It has to be a Proxy rather than a copy because ObjectSchema is a lazy-schema Proxy whose ownKeys trap throws. Reflect.ownKeys(ObjectSchema) answers TypeError: 'ownKeys' on proxy: trap result did not include 'prototype', so Object.keys(ObjectSchema) cannot see create. For the same reason, the shim reads the owner by property and never enumerates it.

  • The refused-artifact line on stderr is rendered. While the config loads, the shim prints one stderr line per artifact the current schema refused. A raw ZodError on that line (its message is the issues as a JSON array) is now printed through the project's own formatZodError, as a ObjectSchema.create validation failed (1 issue): block with one ✗ path: message line per issue. Errors that already carry prose, such as defineStack's StackSchemaInvalidError or ObjectSchema.create's own unknown-key and system-data refusals, keep their message. The template is shared, so define* helpers that throw a raw ZodError, such as defineAgent, get the same rendering. This is the same defect class inside the same generated template: the card's third pin forbids a raw ZodError array, and without this change the fixed repro would still print one, only on stderr instead of in the exit payload.

Where the list lives, and how it was measured

The list lives on the cli side. It does not have to be exported by packages/spec: the shim is its only reader, and the pin holds it to the spec surface from packages/cli. So there is no needs_decision.

Measured at 5f3c0f648 over all 19 JS entrypoints in @objectstack/spec's exports (the two .json entries excluded), reading each export's create by property:

  • 18 distinct exported values carry a create member, under 31 export names (each identity factory is exported a second time as its *Schema).
  • 5 are strict (throw on a refused input): the five in the list. Positive control: ObjectSchema.create is found at @objectstack/spec/data.
  • 13 are identity ((config) => config): ApiDocumentationConfig, ApiEndpoint, ApiTestCollection, BatchTask, MiddlewareConfig, OpenApiSpec, QueueConfig, RestApiConfig, RestApiPluginConfig, RestApiRouteRegistration, RestServerConfig, Task, WorkerConfig. They refuse nothing, so there is nothing to tolerate.
  • Siblings. An eager-mode (OS_EAGER_SCHEMAS=1) sweep of every function member of every exported value checked the other members: Field.* (32 builders), SCIM.*, RLS.*, OData.*, StorageNameMapping.resolveTableName. None of them validates.
  • Top-level exports. The only non-define* top-level functions whose own source calls .parse( are connectorFetchOptions, describeHighPrivilegeBits and utcInstantMs. They are runtime helpers, not authoring factories.
  • Producers. git grep on this tree counts 35 ObjectSchema.create( sites and 3 App.create( sites under examples/. Dashboard.create, Report.create and Action.create have 0 sites in examples/, but they are wrapped anyway because the triage asked for every strict factory from one list.

Pins

packages/cli/test/migrate-meta-strict-factories.test.ts is unit tier: in-process over MigrateMeta.run and loadConfig, against a temp project that links the real @objectstack/spec. It spawns no process and boots no kernel. It has five cases:

  1. The card's repro migrates. It exits cleanly, applied holds the tenancy conversion at objects[0].tenancy.organizationField, and schemaValid is true. The stderr line names ObjectSchema.create() and carries no raw ZodError array.
  2. The plain-literal control is unchanged, and the factory spelling now produces the same applied list as the literal.
  3. An unrelated strict error surfaces as a refusal. The fixture adds an unknown field type next to the retired key. The retired key is still converted and schemaValid is false. The human report reads does not yet pass schema validation — 1 refusal left after the chain and lists ✗ objects.0.fields.stage.type: .... No stream (stdout or stderr, --json or human) contains a raw ZodError array.
  4. Every listed factory is tolerated through the shim, and only through it. The fixture is generated from the list, and each refused call comes back exactly as authored, announced once by name. ObjectSchema.safeParse still works through the Proxy. Loading the same source without authoredSource still rejects.
  5. The list stays honest in both directions. Every entry is live at its home and throws on a refused input. Every create that spec exports and the list does not name returns its argument by identity. A new strict factory in spec therefore turns this case red and names itself.

Reverse verification

I committed the fix first, then ran each mutation with scripts/ablation-replace.mjs. It checks that the anchor hits exactly once, that the file's hash on disk changes, and that it is restored afterwards (hash equal to HEAD, empty git diff HEAD). The subject is src through relative imports, so no dist rebuild was involved. Predicted direction for both: red.

  • A: ObjectSchema entry deleted from the list (at 5f3c0f648). Cases 1, 2, 3 and 5 are red; case 4 is green.
    • Cases 1 and 3 fail on expected 1 to be undefined, meaning exit 1 at load.
    • Case 2 fails on no `applied` in the --json payload: {"error":"[\n {\n \"code\": \"unrecognized_keys\", ..., which is the original defect.
    • Case 5 fails on add a strict `create` to STRICT_AUTHORING_FACTORIES ..., naming the unlisted strict factory.
    • Case 4 staying green is correct: its population is the list itself, so removing an entry removes it from that case, and case 5 is the one that catches the removal.
  • B: the ZodError rendering disabled (error.name === 'ZodError' changed to 'AblatedZodError', at 888e0c78d). Cases 1 and 3 are red on expected '[authored-source] ObjectSchema.create…' not to match /"code":\s*"/. Cases 2, 4 and 5 are green, as predicted.

Verification

  • cli unit tier (pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2) at 5f3c0f648: Test Files 237 passed (237), Tests 3370 passed (3370). The integration tier is declared to CI: this diff touches no integration-tier file and no spawn or boot entry point.
  • cli typecheck (pnpm --filter @objectstack/cli typecheck, which is tsc --noEmit plus check:test-typecheck) at 5f3c0f648: exit 0. The test layer passes (OK ... 3 file(s) / 28 error(s) / 6 pinned signature(s) held, all pre-existing debt), and tsconfig.test.json --listFilesOnly includes the new pin file.
  • pnpm lint (full repo, eslint . --no-inline-config) at 5f3c0f648: exit 0 in 183s. The last commit, 3d877c6d4, is a docblock-only change (it corrects the measured counts). At that head, a targeted eslint --no-inline-config --format json over the two touched TS files reports both linted with 0 errors and 0 warnings. The changeset .md is outside eslint's population.
  • Gates. dispatch-gates --commands derives 63 families from this diff: the 56 in the dispatch list, plus 7 that the changeset adds (check-adr-0087-registration ×2, check-empty-changeset ×2, release-rehearsal-clone --self-test, check:objectui-changeset, check:pm-changeset-deadline-census). All 63 exited 0 at 5f3c0f648. --ran reconciliation reports: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, since every entry recorded an exit code). They were re-run at the final head 3d877c6d4; see the report comment on the card.
  • Branch. It carries one merge of origin/main (085ca6bc1). After the merge I rebuilt @objectstack/spec and the six closure packages that the merge changed, plus @objectstack/cli.

Acceptance notes

  • Checks that only a factory makes are not part of schemaValid. Some checks run only when a factory is called and are not part of the stack schema: ObjectSchema.create's refusal of a managedBy: 'system-data' object that grants no create, edit or delete, its referenceVia sibling check, and its refusal of an explicit required: false on a controlled_by_parent master-detail reference. A source that trips one of them now migrates with that refusal on the stderr line and schemaValid: true. os validate still refuses it (exit 1).
    • Measured: system-data with every write closed, plus the retired tenancy key, gives applied: [object-tenancy-organization-field-removed], schemaValid: true, the refusal prose on stderr, and os validate exit 1.
    • Precedent: defineStack has behaved this way under the existing shim. Its call-time namespace-prefix check throws StackNamespacePrefixInvalidError while ObjectStackDefinitionSchema.safeParse of the same stack succeeds.
    • Reading: schemaValid is documented as whether the migrated stack parses under the installed schema, and that is what it reports. The changeset states this boundary.
  • Lazy-schema proxies cannot be enumerated. Reflect.ownKeys on a lazy-schema proxy (ObjectSchema, for one) throws, because the trap omits the target function's non-configurable prototype. I found no public door that reaches it, so this is an observation, not a filed finding. Carrier: none.
  • Sibling cards. [finding][devx] os migrate meta --from 17 buries a project's real findings under 240 generic protocol-18 notices, and marks default-flip conversions as "Applied" when the right action is usually no edit #20620 (the verdict-first report) has a changeset already on main, and this diff does not touch that report code. For [finding] the conversions defineStack applies still miss two door paths after PR #20579: os lint --json never reads the record, and a defineStack that converts then refuses drops them from --json #20583 (os lint --json reads the conversion record), nothing here changes the record that tool reads. The only premise this moves for either card is that os migrate meta now opens stacks whose objects are built with a strict factory.

Generated by Claude Code

…nly define*

The authored-source shim wrapped only `define*` exports, so an
`ObjectSchema.create(...)` carrying a retired key threw at the call, inside
the config load, before the migration chain could convert it.

The shim now also wraps every strict authoring factory from one written list
(`STRICT_AUTHORING_FACTORIES`: ObjectSchema/App/Dashboard/Report/Action
`.create`), in place on the owner through a Proxy, and renders a swallowed raw
ZodError through the project's `formatZodError` instead of its JSON array.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
The card's repro migrates, the plain-literal control is unchanged, an
unrelated strict error surfaces as a refusal and never as a raw ZodError
array, every listed factory is tolerated through the shim only, and the list
is held to the spec surface in both directions.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…dently of the list

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…alues, 31 names, 5 strict, 13 identity)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 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 — 25 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 96e724475c476d018c2b6d13dcd117ade14d0aa0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94c4f829f64960e3869ab2c67bed93f038c9905f — the merge of head 3d877c6d452377a6e0c69092285a0adf8fcfaf04 into base 96e724475c476d018c2b6d13dcd117ade14d0aa0, 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 94c4f829f64960e3869ab2c67bed93f038c9905f && git checkout 94c4f829f64960e3869ab2c67bed93f038c9905f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96e724475c476d018c2b6d13dcd117ade14d0aa0 3d877c6d452377a6e0c69092285a0adf8fcfaf04 && git checkout -B drift-repro 96e724475c476d018c2b6d13dcd117ade14d0aa0 && git merge --no-ff 3d877c6d452377a6e0c69092285a0adf8fcfaf04

node scripts/docs-audit/affected-docs.mjs --json 96e724475c476d018c2b6d13dcd117ade14d0aa0

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3d877c6d452377a6e0c69092285a0adf8fcfaf04
Local-runs: none

Inputs read:

Seat spot checks on adoption:

  • git diff --stat: 3 files, +567 / −27;
  • DEFINE_HELPER_RE is unchanged (/^define[A-Z]/, config.ts:276), and STRICT_AUTHORING_FACTORIES is a frozen list (:333);
  • @objectstack/cli's exports map is ., ./console, ./hook-body and ./package.json.

① Derived judgments

  • (a) Five strict factories, every other create an identity: RIGHT. At the merge-base, packages/spec/src exports 18 values with a function member create.
    • Five parse at the call: ObjectSchema.create (data/object.zod.ts), and Action.create, App.create, Dashboard.create and Report.create (ui/*.zod.ts).
    • Thirteen return their argument unchanged: the api/* and system/* config factories.
    • The one other create: hit (api/contract.zod.ts) is an object literal.
    • The root entry exports none of the five owners, so no spelling escapes the list.
    • Pin case 5 re-derives this population from @objectstack/spec's exports map in both directions, and is green in Test Core. The changeset's population claim is accurate.
  • (b) The shim's reach: RIGHT. authoredSourcePlugin is module-private and installed only under options.authoredSource. Its only caller is commands/migrate/meta.ts, and the diff adds none. validate and build load without it, and the wrap exists only inside the generated shim module. "os validate, os build and every other command still refuse the retired key at load" is accurate, and pin case 4 holds the no-shim rejection.
  • (c) The stderr rendering: RIGHT, and no machine-read output moves.
    • Only the shared prelude's console.warn string changes: a raw validation error renders through the project's own formatZodError, and other errors keep their message.
    • The --json payload is built in meta.ts, which is not in the diff, so its keys are byte-for-byte the merge-base's.
    • The define* reach follows from triage's third pin ("not a raw ZodError array"), and it is declared in the body, in the changeset and in the deviations.
  • (d) No packages/spec edit and no second heuristic: RIGHT. The file list has no path under packages/spec. DEFINE_HELPER_RE stays the only pattern, and the addition is a written list read by property, never by enumeration or name matching. The in-place Proxy wrap is justified by the lazy-schema proxy's ownKeys trap.
  • Other points.
    • STRICT_AUTHORING_FACTORIES is exported from src/utils/config.ts, which the package's exports map cannot reach, so no published surface is added.
    • "A run whose migrated stack does not parse still exits 0" matches meta.ts.
    • The commits carry model-free trailers.
    • The head differs from 5f3c0f648 by a docblock-only hunk.

② Semver level

  • patch for @objectstack/cli is right: a bug fix, with no published export and no --json key added.
  • Clause-②: no in the claim, the PR body and the changeset is right. Loading factory-built sources restores the codemod's declared input class; it is not a widening of a published payload.
  • Check Changeset is green.

③ Boundary flags

  • Deviations: all five are answered, and none bears on the diff: the pre-PR merge, the readings at the docblock-only parent, the define* stderr reach, the ablation-A fixture fix and re-run, and the model-free trailers.
  • Out-of-scope 1 (Reflect.ownKeys on a lazy-schema proxy throws): confirmed in source. No public door was measured reaching it, so it has no class (a) reach. It is noted with no carrier, which is correct.
  • Out-of-scope 2 (checks only the factory makes fall outside schemaValid under the shim): this matches schemaValid's documented meaning. The refusal still prints, and os validate still exits 1. The changeset states the boundary.
  • Nothing needs escalation.

Implemented-by: claude/issue-20696-migrate-strict-factories
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 07:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit d2b188f Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20696-migrate-strict-factories branch September 30, 2026 08:10
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/l tests tooling

Projects

None yet

2 participants