Repository navigation
fix(cli): os migrate meta lists and writes a conversion the load already applies, on a stack the schema accepts - #22351
Conversation
…call's argument
The authored-source shim now keeps, beside the stack an accepted
defineStack call returns, the argument the call was given, under a
CLI-owned non-enumerable key. loadConfig({ authoredSource: true })
starts the default export from that argument before the named-export
merge, so the chain no longer sees a stack the load-time conversion
pass already converted.
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com>
…th conversion A driver: 'mongo' source the schema accepts is listed, written and stops converting at load; the authored argument is read before the named-export merge; a twice-defined stack converts once; a canonical source writes nothing and keeps its summary; a stack with one refused and one load-path spelling applies each conversion once. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
…ath conversions Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 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 91967e944ff13524307df50fab35bb1d636a3b9a && git checkout 91967e944ff13524307df50fab35bb1d636a3b9a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 35afb15878054ea1ac72a2a29b2271712f0bfa9d 2f8e479a39d79405f7c3b4e31a4a4e5430410f00 && git checkout -B drift-repro 35afb15878054ea1ac72a2a29b2271712f0bfa9d && git merge --no-ff 2f8e479a39d79405f7c3b4e31a4a4e5430410f00
node scripts/docs-audit/affected-docs.mjs --json 35afb15878054ea1ac72a2a29b2271712f0bfa9d
|
Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
PR 22326's pending changeset ended its "Known limit" bullet by likening the composed case to an input the current schema accepts. A one-package accepted input's load-path conversion is now listed and written, so the clause is removed; the rest of the bullet is unchanged. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
|
Deliberate changeset correction, confirmed by the This PR edits
|
… behind its cookie (objectstack-ai#22367) Part of objectstack-ai#22258 Clause-②: yes (widening) Release: the domain:services half of this card stays open, carried by domain:services. Nine in-process readers there (plugin-auth, plugin-webhooks, plugin-sharing, service-storage, service-settings, service-datasource; table H5 below) still renew a cookie session without re-issuing its cookie. ## What this changes An in-process `auth.api.getSession` read renews a session past better-auth's `updateAge` and stages the renewed cookie on a response the door never sends. The browser's cookie then dies before its session: a split session, a dead cookie beside a live bearer. One rule now covers every in-process reader in this lane. It lives in one helper, `inProcessSessionReadInput(headers)` in `@objectstack/types`, and is decided by what the request carries: - **A session cookie** (a browser, including the console, which sends its cookie beside its bearer): the read passes `query: { disableRefresh: true }`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. - **No session cookie** (a bearer-only client): read exactly as before, renewal included. No cookie exists to fall behind, and the bearer is the session token, which renewal does not change. The rule only ever adds `disableRefresh`. It sets no cookie, forwards none, and never changes which session a request resolves to. **Applied at all ten readers in this lane.** The census on `b7e01fbbd` found six. Four more use the optional-chained spelling `api?.getSession?.(`, which the `.getSession(` regex did not match: | package | reader (line on this branch) | in the PM census | |---|---|---| | rest | `rest-server.ts:3037` `computeExecCtx` getter | yes | | rest | `rest-server.ts:3224` auth-gate re-read | yes | | runtime | `http-dispatcher.ts:1362` `enforceAuthGate` | yes | | runtime | `http-dispatcher.ts:1442` `enforceProjectMembership` | **no** | | runtime | `security/resolve-session-principal.ts:57` (rate limiter, concrete route mounts) | yes | | runtime | `security/resolve-execution-context.ts:165` (dispatcher scope, MCP door) | **no** | | plugin-hono-server | `current-user-endpoints.ts:412` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2624` `resolveActiveOrgId` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2794` `resolveInstallPrincipal` getter | **no** | | cloud-connection | `cloud-connection-plugin.ts:209` session bridge | **no** | The four extra readers are fixed in place under the bounded in-place-fix rule: the same defect class, the same one-line call, no other claim on those files, and the same packages and gates. Their doors split measurably: the MCP door and `/i18n/locales` go through `resolve-execution-context` (H1, ablation 2). This adds two files to the claim's file surface: `packages/runtime/src/security/resolve-execution-context.ts` and `packages/cloud-connection/src/cloud-connection-plugin.ts`. **Where the helper lives, and why there.** The claim suggested a helper in `packages/runtime`. That cannot serve all ten readers: `rest` cannot import `runtime` (runtime depends on rest, so it would be a cycle), and `plugin-hono-server` does not depend on runtime. `@objectstack/types` is in this lane and is already a dependency of all four reader packages, which is the same "one home, no new edge" reasoning that file's barrel records for its other shared rules. ## Why `Part of`, and why `Clause-②: yes` - **`Part of`.** Triage's done-when reads "No framework door extends a session without forwarding its cookie". After this PR, every door in this lane holds (H1). The nine `domain:services` readers still split a session through public doors (H5), so the card stays open for them. - **`Clause-②: yes (widening)`, not the claim's `no`.** The claim's gloss on `no` was "No accepted input, export or published shape changes", written before the helper's home was chosen. The one shared helper has to be an export of a published package, so `@objectstack/types` gains three named exports: `inProcessSessionReadInput`, `carriesSessionCookie` and the `InProcessSessionReadInput` type. That is an additive widening of a published package's public surface, which the `Check Changeset` "WHICH LEVEL" ruling grades at least `minor`. The changeset is therefore `minor` for `@objectstack/types` and `patch` for `rest`, `runtime`, `plugin-hono-server` and `cloud-connection`. Raised with the seat in the dev report; no accepted input and no wire shape changes. ## H1: reproduced through public doors, then measured on the fix The probe is a fresh `pnpm dev:crm -- --fresh` stack, run on `b7e01fbbd` and again on this branch, with better-auth 1.7.3 as pinned. `expiresIn` (604800 s) is read from the fresh `sys_session` row; the pin reads both values off the running instance's `sessionConfig`, and `updateAge` is 86400 s. Each row ages the signed-in session in `sys_session` to `now + expiresIn − updateAge − 60 s`, sends one request, and reads `expires_at` back. | door | by | before: Δ `expires_at` · session `Set-Cookie` | after | |---|---|---|---| | `GET /api/v1/auth/get-session` (control) | cookie | +86460 s · `Max-Age=604800` | +86460 s · `Max-Age=604800` | | `GET /api/v1/data/sys_user` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/data/sys_user` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/auth/me/permissions` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/auth/me/permissions` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/meta/object` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/i18n/locales` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/packages` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/marketplace/install-local` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/mcp` (answers 406) | cookie | +86460 s · none | **0 · none** | Every door's bearer row is unchanged by the fix (+86460 s, no cookie); only the first two doors are shown here. ## H3: who holds only a bearer These clients would lose renewal under a blanket `disableRefresh`. Measured by reading where each one sends its credential and when it reaches `get-session`: - **`@objectstack/client`** sends `Authorization: Bearer` from its stored token on every request (`fetch`). It calls `get-session` only on an explicit `auth.me()` or `refreshToken()`. Outside a browser it holds no cookie jar, so it is bearer-only. - **The CLI** is bearer-only. It reaches `get-session` only at `os login` and `os cloud whoami`. Its data commands (`datasource list-tables`, `validate`, `introspect`, `package publish`, `plugin publish`) carry a bearer. - **MCP:** OAuth access tokens are verified separately and are not better-auth sessions, so they are unaffected. API keys are unaffected too. A session bearer presented there is bearer-only. - **objectui console:** NOT bearer-only. Its fetches send the cookie (`credentials: 'include'`) beside the stored bearer, and it calls `get-session` on mount and on every re-resolution (`AuthProvider.loadSession`). **Before:** every bearer-only data read past `updateAge` renewed, +86460 s on every door above. A blanket `disableRefresh` would end that, and a CLI or SDK session would die `expiresIn` (7 days) after sign-in however active. **After:** bearer-only reads still renew, +86460 s on every door (measured above, and pinned). ## H4: the rule, chosen on that measurement - **Forward the renewed `Set-Cookie` from every door: measured and not taken.** - Only three of the ten readers have a response in hand: the Hono `me/*` endpoints and two cloud-connection routes, all on a Hono `c`. - REST's `computeExecCtx(environmentId, req)` has no response object and is cached per request across many routes. - The dispatcher is transport-neutral and returns `{ status, body }`. A forward there would need a header channel through every dispatcher result and every adapter's `sendResult`. - The rate limiter reads before the route. - A request can pass two or three in-process reads (REST: 2, dispatcher: up to 3), and only the first renews. - A forward would still need this same cookie test, so that no cookie is ever set on a request that sent none. - So forwarding cannot be one rule for all ten. - **Taken: the PM's lean, unchanged.** A cookie request reads with `disableRefresh`; a bearer-only request reads as before. better-auth applies the same rule to its own reads that cannot write a cookie (React Server Components, `dist/integrations/next-js.mjs:62-69`). ## H5: the `domain:services` readers, measured and not edited Measured on this branch's build, so a remaining renewal belongs to the services reader and not to a lane reader that ran on the same request. Line numbers are at `b7e01fbbd`. | reader | door | by cookie | by bearer | |---|---|---|---| | plugin-auth `auth-plugin.ts:2464` | `POST /api/v1/auth/admin/oauth2/toggle-disabled` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2527` (`gateAdmin`, every admin route behind it) | `POST /api/v1/auth/admin/sso/register` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2594` | `POST /api/v1/auth/admin/unlock-user` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2912` | `POST /api/v1/auth/admin/has-permission` | +86460 s · no cookie (**split**) | +86460 s | | plugin-webhooks `webhook-outbox-plugin.ts:482` | `POST /api/v1/webhooks/redeliver` (showcase stack) | +86460 s · no cookie (**split**) | +86460 s | | service-storage `storage-service-plugin.ts:843` | `GET /api/v1/storage/upload/chunked/:id/progress` | +86460 s · no cookie (**split**) | +86460 s | | plugin-sharing `sharing-plugin.ts:940` (not in the PM census) | `DELETE /api/v1/share-links/:id` | +86460 s · no cookie (**split**) | +86460 s | | service-settings `settings-service-plugin.ts:299` (not in the PM census) | `GET /api/settings` | +86460 s · no cookie (**split**) | +86460 s | | service-datasource `admin-routes.ts:212` (not in the PM census) | `GET /api/v1/datasources/drivers` | +86460 s · no cookie (**split**) | +86460 s | **The fix there is the same rule:** `api.getSession(inProcessSessionReadInput(headers))`. Five of the six packages already depend on `@objectstack/types`; `plugin-webhooks` would gain that one dependency. ## Pins, and the tier each runs in All pins run in each package's `local` vitest project (CI: `Test Core`). The runtime pin boots an in-process `ObjectKernel`, with no spawned process and no driver socket. - `packages/types/src/in-process-session-read.test.ts`: the cookie test across every spelling better-auth writes (default and custom `cookiePrefix`, `__Secure-`). Also: other cookies, empty values, plain header records, and bearer-only. The input builder passes the same headers object through and adds `query` only for a cookie. - `packages/runtime/src/in-process-session-renewal.pin.test.ts`, against real better-auth: - It reads `expiresIn` and `updateAge` off the running instance. A precondition proves the fixture renews: a bare in-process read without the rule moves `expires_at`. - Three doors, each by cookie and by bearer: `GET /data/:object` (rest), `GET /auth/me/permissions` (hono) and `GET /i18n/locales` (dispatcher `resolveExecutionContext`). - Pinned for each: a cookie request leaves cookie and session expiry aligned (no renewal, no cookie). A bearer-only request still renews to `now + expiresIn`, and no cookie is set on its response. - The rate limiter's reader (`resolveSessionPrincipalId`) by cookie and by bearer. After the limiter's read, `get-session` still renews AND re-issues the cookie. - Control: `get-session` renews and re-issues with `Max-Age = expiresIn`. - `packages/rest/src/in-process-session-read.pin.test.ts`, `packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts`, `packages/cloud-connection/src/in-process-session-read.pin.test.ts`: every remaining reader hands better-auth the rule's input. The cases are cookie, cookie plus bearer, and bearer-only. Covered: REST's getter and gate re-read; the Hono resolver; the cloud-connection session bridge, `resolveActiveOrgId` and `resolveInstallPrincipal`. - `packages/rest/src/execctx-authz-input-seam-reachability.test.ts`: its source-text pin on the auth-gate re-read now reads the new argument. Its intent is unchanged: still the raw, throwing api call. ## Ablation, run twice: drop the rule from one reader Both runs used `scripts/ablation-replace.mjs` in wrap mode, inside a script with an absolute-path restore trap. In both suites the mutated reader resolves from `src` (rest: a relative import; runtime: the `@objectstack/rest` alias and a relative import), so no `dist` leg applies. 1. **rest `computeExecCtx` getter** reverted to `api.getSession({ headers: h })`. The anchor went 1 → 0 and the blob `89fae0b5e5f5` → `677bb44cb288`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /data/:object — by cookie` went red: "the session renewed (+86460 s) but its cookie was not re-issued". - rest pin: **2 failed | 1 passed** (both cookie cases red; bearer-only green). - Restored: blob == HEAD `89fae0b5e5f5`, and `git diff HEAD` is empty. 2. **runtime `resolve-execution-context` getter** reverted the same way. The blob went `c570e6e4cd84` → `2e86b8e71527`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /i18n/locales — by cookie` went red. - Restored: blob == HEAD `c570e6e4cd84`, and the diff is empty. ## Verification All at HEAD `8200f5778`, the last commit on this branch: - **Unit tests** (`pnpm --filter PKG test`, each package's `local` project): - `types`: 25 files, 749 passed. - `rest`: 264 files, 4954 passed, 326 skipped. - `runtime`: 341 files, 4787 passed, 19 skipped. - `plugin-hono-server`: 28 files, 329 passed. - `cloud-connection`: 42 files, 514 passed. - `test:repo`: `types` 11, `rest` 191 (1 skipped), `runtime` 751, all passed. - **Typecheck** for the five packages: 41 of 41 turbo tasks green. Each test layer compiles; runtime is at its existing ledger (27 files, 190 errors), unchanged. - **Gates**: the 67 commands that `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff. That is the order's 61 plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All exit 0. The `--ran` reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint**: the full `pnpm lint` (`eslint . --no-inline-config`) exits 0. - **Not merged with `origin/main`**, which is two commits ahead (objectstack-ai#22351 cli, objectstack-ai#22352 plugin-security and plugin-auth). Neither touches a file here, and CI tests the merge ref. ## Acceptance notes - **Residue the server cannot see.** Some browser requests carry the bearer without the cookie (a cross-origin fetch without credentials, or a blocked cookie). Those read as bearer-only and still renew in-process. If the same browser sends that session's cookie on other requests, that cookie can still fall behind. The rule decides from what each request carries. - **A behaviour change for a tab that never calls `get-session`.** Such a tab now signs out at the session's real expiry, instead of keeping a live bearer beside a dead cookie. The console calls `get-session` on mount and on each re-resolution. - **Declaration drift, for `domain:spec` (not edited here).** `AuthSessionApi` in `packages/spec/src/contracts/auth-service.ts` declares `getSession`'s input as `{ headers }`. Its doc says every reader "calls exactly `getSession({ headers })`", which is no longer true. The helper declares the wider slice it passes (`query.disableRefresh`) itself; that type-checks because the extra key reaches a non-fresh object. Carrier: the `domain:spec` seat. - **Vendor behaviour, unchanged here.** better-auth's own `get-session` re-issues the session cookie on a bearer-only request too (measured: `Max-Age=604800` with a bearer). That route is better-auth's, and this PR does not touch it. - **Overlap.** Open PR objectstack-ai#22357 edits `packages/runtime/src/http-dispatcher.ts` in two comment hunks (around lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362 and 1420-1442). - **Unread.** The cloud measurement the card cites (`objectstack-ai/cloud` issue 2699's report and PR 2708) was not readable from this session. H1 re-measured the defect independently on this repo's doors. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… inside composed package bodies (objectstack-ai#22393) Fixes objectstack-ai#22256 Clause-②: no (a seam off the public entry: an internal `Symbol.for`-keyed parameter, undeclared on `DefineStackOptions` and not exported; the seat's answer A in its review on objectstack-ai#22256) The composed half of objectstack-ai#22256 (the one-package half landed in PR objectstack-ai#22351). Inside a `composeStacks([…], { manifest: 'preserve' })` project, `os migrate meta` now lists a conversion the load already applies inside a package body (for example `datasources[].driver: 'mongo'`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused and produced again in `strict: false` mode. ## The seam (H2), and why it is off the public entry `composeStacks` assembles each `packages[i].manifest` body from the stack its input's producer RETURNED, and `defineStack` runs the load-time ADR-0087 D2 conversion pass in both modes. No existing switch skips that pass for a `defineStack` call (no option, env or global; `applyConversions` has none either), so a `packages/spec` change is required. Candidates measured: | | carrier | api-surface rows | export-origins | package `exports` | `DefineStackOptions` type | verdict | |---|---|---|---|---|---|---| | C1 | an internal parameter: a spec-owned `Symbol.for` key read off `defineStack`'s options, honoured only with `strict: false`, that skips the D2 pass | +0 | +0 | +0 | unchanged | **chosen** | | C2 | a symbol-keyed `composeStacks` option assembling bodies from per-input authored sources | +0 | +0 | +0 | unchanged | rejected: bodies would be authored while the flattened half stays converted and parsed, so the option-B strip comparison fails on parse defaults and the artifact shape changes even on canonical projects | | C3 | a declared `DefineStackOptions` member (a `convert: false` twin of `NormalizeStackInputOptions.convert`) | +0 (rows are `name (kind)` only) | +0 | +0 | widened | public option, `Clause-②: yes` | | C4 | a new export (`preservePackageEntries` / `assemblePackageBody`, or a `composeAuthoredStacks`) | +1 | +1 | entry | n/a | public export, `Clause-②: yes` | | C5 | CLI-only: fill the marked empty object `composeStacks([])` returns with authored content | n/a | n/a | n/a | n/a | rejected: it would attach a producer's mark to content no producer judged (ruling B) | | C6 | CLI-only: rebuild the bodies in `packages/cli` | n/a | n/a | n/a | n/a | refused by the `domain:cli` seat's cut | C1 is the conversion-free twin of the `strict: false` re-produce the shim already performed for refused inputs (PR objectstack-ai#22326), so the semantic delta is exactly "the D2 pass is skipped". A strict call ignores the key, so it can never let an old spelling reach the strict parse unconverted; the output is marked as every non-strict output is. The key is documented beside `defineStack` (`AUTHORED_INPUT_OPTION`, `packages/spec/src/stack.zod.ts`) and is neither declared on `DefineStackOptions` nor exported. `check:api-surface` and `check:export-origins` stay green with no regenerated artifact. ## The consumer (H3) `packages/cli/src/utils/config.ts`, the authored-source shim's `composeStacks` wrap: every input whose `defineStack` call the shim saw is produced again from what the author wrote, through the real `defineStack` in `strict: false` mode with the key. A refused input is its recorded hand-through (as before); an accepted one is the argument PR objectstack-ai#22351 keeps under `AUTHORED_ARGUMENT_KEY`, followed to the innermost literal. Inputs the shim never saw a `defineStack` call build (a nested composition, a plain object) reach the real `composeStacks` untouched, so `STACK_PROVENANCE_MISSING` for an unwrapped input is unchanged. Composition then runs its own rule over the authored inputs. No composition logic is copied into `packages/cli`. `meta.ts` and the `--write` planner are unchanged in code (comment only): the planner already traces `packages[i].manifest.KEY` to input i's literal (PR objectstack-ai#22326). Measured: the H4 site `packages[0].manifest.datasources[0].driver` is written into `src/service.stack.ts`, the input's own module, and nothing else moves. ## Measurements **H1 on `origin/main` `16096e8d7` (BASE), built CLI, composed fixture** (`composeStacks([ServiceStack, AppStack], { manifest: 'preserve' })`, the service body carrying `driver: 'mongo'`): | case | `applied` | `write.files` | source | `os validate` reload | |---|---|---|---|---| | A: accepted input | `[]` | `[]` | keeps `'mongo'` | prints `converted at load … datasource-driver-mongo-to-mongodb` | | B: refused input (`'10:00Z'`), re-produced `strict: false` | only `time-default-utc-suffix-dropped` | `src/service.stack.ts` 1 site | keeps `'mongo'` | prints `converted at load` | | C: canonical | `[]` | `[]` | unchanged | — | **After the fix** (same fixtures, `--from 16`): | case | `applied` | `write.files` | verification | re-run | `converted at load` lines on reload | |---|---|---|---|---|---| | A | `datasource-driver-mongo-to-mongodb @ packages[0].manifest.datasources[0].driver` | `src/service.stack.ts` 1 site | ok | `applied []` | 0 | | B | mongo then time, both under `packages[0].manifest…` | `src/service.stack.ts` 2 sites | ok | `applied []` | 0 | | C | `[]` | `[]` | — | `[]` | 0 | **Corpus control, BASE vs fix** (both trees built, the four example apps, `app-multi-package` being the composed one; `--from 16` and `--from 17`; `--json` dry and `--write` on same-depth copies; `duration` removed): 16 of 16 documents byte-identical by md5, the written-file sets identical, and the written bytes identical (`app-showcase` `src/automation/flows/index.ts` `2aec23aad9d3`, `app-todo` `src/flows/task.flow.ts` `4ffb6d9aa6ce`, in both trees). **The `--out` snapshot of a composed project** moves, as ruling A (REWORK `6068295985`) already accepted for one-package stacks: on `app-multi-package` at `--from 17`, 53 differences, every one a key the schema's parse fills in that exists only on BASE (`externalId`, `hidden`, `multiple`, `readonly`, `searchable`, `sortable`, `unique`, `required`, `scope`, `defaultDatasource`, `datasource`, `isSystem`, `priority`, `deleteBehavior`, `active`, `isDefault`, `expanded`). No value changed and nothing was added. The changeset states it. ## Pins - `packages/spec/src/stack-authored-input.test.ts` (3): the key keeps the authored spelling, the output is marked, and no conversion is recorded, while the control without it converts; a strict call ignores it; `composeStacks` over such inputs assembles the body from the authored spelling, and over plain non-strict inputs from the converted one. - `packages/cli/test/migrate-meta-composed-load-conversions.test.ts` (5, `unit` tier, in-process): the H4 accepted case listed, written into the input's file and nothing else, a clean re-run, and a strict reload with an empty `stackConversions`; the authored-source load's body carries `'mongo'` while every other load's carries `'mongodb'`; an input defined twice is listed once; the refused input re-produced by the shim lists and writes both conversions; the canonical control writes nothing and its `--json` summary equals the one the built composition gives. - PR objectstack-ai#22326's 8 composed pins and PR objectstack-ai#22351's 5 one-package pins stay green beside them. ## Ablations Both committed first, mutated through `scripts/ablation-replace.mjs` (anchor hit once, mutation proven on disk, restore proven: blob equal to HEAD and `git diff HEAD` empty, whole-tree `git status --porcelain` empty). 1. **The seam** (dist leg, because the CLI suite resolves `@objectstack/spec` through its `exports`): deleted `convert: strict || !asksForAuthoredInput(options),` from `buildDefinedStack`. Presence reading on the pristine build: the marker in 4 built files. After the spec rebuild, `ablation-dist-preflight --absent`: absent from all 232 built files, tree reading "mutate leg". Predicted spec 2 red / 1 green and the new CLI file 4 red / 1 green (control green), the 13 neighbouring pins green; got exactly that (`Tests 2 failed | 1 passed (3)`; `Tests 4 failed | 14 passed (18)`). Restore leg: rebuild, marker present in 4 built files, tree clean, `3 passed (3)` and `18 passed (18)`. 2. **The accepted arm of the shim** (no dist leg: the CLI suite imports `src`): made `__composable` hand an accepted input through as built. Predicted 3 red (the three accepted-input pins) / 15 green; got `Tests 3 failed | 15 passed (18)`. ## Deliberate correction of two pending changesets This change makes the "Known limit" bullet of two pending, unreleased changesets false, and all three ship in the same `@objectstack/cli` release: `.changeset/22289-migrate-meta-composed-project.md` (PR objectstack-ai#22326) and `.changeset/22256-migrate-meta-load-path-conversions.md` (PR objectstack-ai#22351). Each loses exactly that one bullet; nothing else in either file moves. `check-empty-changeset`'s foreign-changeset rule is therefore red by design, in its DELIBERATE CORRECTION class (precedent PR objectstack-ai#21683 and PR objectstack-ai#22351); it asks for the correction to be confirmed on the PR, not restored. ## Tests and gates All at `a0d0a6253` (this branch merged with `origin/main` `117d34de3`), after rebuilding the CLI closure: - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` (the whole unit tier): `Test Files 271 passed (271)`, `Tests 3973 passed (3973)`. The integration tier is declared to CI: the diff touches no integration-tier file and no spawn entry. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` (the package's `test` script): `Test Files 627 passed (627)`, `Tests 18745 passed | 1 todo (18746)`. - `pnpm --filter @objectstack/spec --filter @objectstack/cli run typecheck`: exit 0. Both `check:test-typecheck` ledgers hold unchanged (spec 52 files / 246 errors / 135 signatures; cli 3 / 28 / 6), so both new test files compile clean. - eslint, narrowed, as a measurement rather than a skip. Population: the 5 changed `.ts` files, all inside eslint's linted set (no "file ignored" notice in the JSON output). Count from `--format json`: 5 files, 0 errors, 0 warnings. Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move the verdict of any untouched file. The full `pnpm lint` is CI's. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date, tree clean. `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `a0d0a6253` derives 90 commands from the change set (8 paths against merge base `117d34de3`). All 90 ran with their exit codes recorded, and `--ran` answers `90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED`. - 89 exit 0, among them `check:api-surface` ("public API surface + factory signatures unchanged"), `check:export-origins` ("5363 exports across 19 entry points resolve exactly as recorded"), `check:dual-source-exports`, `check:exported-any`, `check:docs`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check-changeset-no-major` and `check-adr-0087-registration`. - One exit 1, by design: `check-empty-changeset --base origin/main`, the foreign-changeset rule on the two corrected changesets above. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages without `dist/`). After those 8 were built it exited 0, and that is the code recorded. ## Acceptance notes - The 22289 changeset's "What changed" bullet and the `stack-provenance.ts` module header described the conversion as applied in both modes. The text-only round `9abe8861b` (seat review `6072731732`) made both exact. The bullet now says a refused input "is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (objectstack-ai#22256) before it is handed to `composeStacks`", and the header names the one exception. - A single-input `composeStacks([x])` under `os migrate meta` now hands the chain `x` produced again as authored (normalised, bound actions merged) instead of the raw argument. Only the `--out` snapshot of such a project can differ, and only when it has bound standalone actions. This edge's `--out` now differs from a one-package stack's snapshot shape, which keeps bound actions unmerged (contract review `6073322979`, finding 6). Carrier: the `domain:cli` seat, at the next change to `--out`. - C5 above rests on an observation, not a filed defect: a built stack is an ordinary mutable object, so the mark never covered later mutation; `composeStacks([])` is one instance of that. - Docs: no hand-written page under `content/docs` describes the composed limit (searched), and no `skills/**` text states it. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #22256
Clause-②: no
Release: the composed half stays open on #22256. That half is a conversion the load still applies inside a
composeStackspackage body. Its carrier is thedomain:cliseat, which takes it to thedomain:specseat with the H4 measurement below. It needs a spec seam or a second copy of composition's rule.os migrate metanow lists and writes a conversion the load still applies, such asdatasources[].driver: 'mongo', on a one-package stack the current schema ACCEPTS. Every change is inpackages/cli. There is nopackages/specchange, and nothing here reads or writes the producer's provenance key.What changed
defineStackcall (src/utils/config.ts).__tolerantgains akeephook. It runs after a successful call, outside thetry. ThedefineStackwrap passes__keepAuthored, which defines one non-enumerable property on the returned stack, holding the argument. Its key is a CLI-ownedSymbol.forkey,@objectstack/cli:authored-source/accepted-argument. A refused call is unchanged: it hands its argument through raw, and__recordStackrecords it forcomposeStacks. A call takes exactly one of the two arms.loadConfig({ authoredSource: true })starts the default export from that argument (authoredArgumentOf). It reads the argument offmod.defaultBEFORE the named-export merge, which is where the provenance mark and the conversion record are already read: the merge's spread drops non-enumerable properties. It follows the record to the end, sodefineStack(defineStack({ … }))answers the inner literal.stackProvenanceandstackConversionsare still read off the built stack. A load withoutauthoredSource, which is every other command, is untouched.meta.ts: no code change, one comment. Both chain call sites (:1159–:1165, and the--writere-run at:1343–:1344) already runnormalizeStackInput(config, { convert: false })overloadConfig(…, { authoredSource: true }).config, and that value is now the stack as written. The comment says where it comes from, and that the composed path is not covered.@objectstack/clipatch,Clause-②: no. It keeps PR fix(cli):os migrate metaruns on acomposeStacksproject and migrates its package bodies #22326's "Known limit" true, and restates it for both kinds of composed input.The carrier (H3): a property on the stack, not a WeakMap in the shim's record module
The record has to cross from the bundled shim to
loadConfig. The only values that cross are the config module's exports, and the default export IS the stack the call returned. A property on it crosses with it, with no global state and no lifetime beyond the value's. The hand-through WeakMap (PR #22326) lives in a module bundled inside the load, and nothing outside the load can reach it; reaching it would take a process-global. The property is non-enumerable, like the producer's own mark, so a spread,JSON.stringifyand a schema parse all leave it behind.Why
loadConfigsubstitutes, andmeta.tsdoes not re-mergeIn the order's route,
loadConfigexposes the record andmeta.tsstarts from it at its two call sites.meta.tswould then have to redoloadConfig's named-export merge over the authored argument, which is a second copy of that rule. Substituting before the merge keeps one merge. The--writeplanner also gets the authored value, so its literal match (subsetOf(literal, loaded)) compares the literal with what the author wrote.PM readings, measured
All on
origin/main3599fef1with the built CLI (node packages/cli/bin/run.js), against temp projects that link this worktree's built@objectstack/spec.datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }].migrate meta objectstack.config.ts --from 16 --write --jsongave exit 0,"applied": []and"write": {"status": "written", "files": [], "written": [], "manual": [], "unexplained": []}. The source keptdriver: 'mongo'. A later load (os validate) printed:defineStack: datasources[0].driver: 'mongo' → 'mongodb' (converted at load; conversion 'datasource-driver-mongo-to-mongodb', retires in protocol 18). Update the source to the canonical shape — the conversion stops running then.config: {}was REFUSED, because a mongo datasource needs a connection target. It took the raw hand-back, and the conversion was listed and written. Only an accepted stack shows the defect.loadConfig(…, { authoredSource: true }).config.datasources[0].driverwas'mongodb', and so was the chain input afternormalizeStackInput(…, { convert: false }).stackConversionsnameddatasource-driver-mongo-to-mongodb. On success the shim's__tolerantreturned the real result. Wheredriveris converted:buildDefinedStack(packages/spec/src/stack.zod.ts) callsnormalizeStackInputwith conversions on, in both modes. That callsapplyConversions, which runsdatasourceDriverMongoToMongodb(packages/spec/src/conversions/registry.ts). No otherdefine*helper runs the pass.applied=[datasource-driver-mongo-to-mongodb @ datasources[0].driver, mongo → mongodb],write.files=[{ objectstack.config.ts, sites: 1 }],verification.ok: trueandschemaValid: true. The source line now readsdriver: 'mongodb'. A lateros validateprinted noconverted at loadline, and a re-run applied nothing.defineStackand carry no load-path conversion, so the fix changes each one's chain input. I ranmigrate meta --from 16 --jsonand--from 17 --jsononapp-crm,app-multi-package,app-showcaseandapp-todo. The eight--jsondocuments, minusduration, are byte-identical by md5 before and after. I also ran--write --from 16on a throwaway copy of each app. The--jsonoutcome and the written bytes are identical by md5:app-showcasewrites 3 sites insrc/automation/flows/index.tsandapp-todo1 site insrc/flows/task.flow.ts, both before and after.--outsnapshot changes for an accepted stack. It is now the authored stack plus the chain's edits, as it already was for a refused stack. Measured on the three one-package apps, every difference is one of two kinds. Either a key is present only BEFORE: parse defaults such asmanifest.scope,manifest.defaultDatasource,flows[].versionandhooks[].runAs, andobjects[].actionsfromdefineStack's action merge. Or the parse transformed a value:hooks[].conditionandflows[].edges[].condition. Nothing appears that the author did not write. The composed app's snapshot is unchanged. The changeset says so.composeStacks([ServiceStack, AppStack], { manifest: 'preserve' }), with the service body carrying the accepted mongo datasource. Before AND after this change,--from 16 --write --jsongives exit 0,applied: [],write.files: []andschemaValid: true. The sources keep their md5, and a later load prints the notice once. With a refused spelling added to the same body (timedefault'10:00Z'),appliedlists onlytime-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue, because thestrict: falsere-production converts the driver.Why it stops. The chain's per-body run reads
packages[i].manifest.composeStacksassembles that body from the stack the input'sdefineStackcall RETURNED (assemblePackageBody:{ ...stack.manifest }, then each package-owned key). To start that run from the authored input,packages/clineeds two things it does not have as values:preservePackageEntries: an input withpackagescontributes its own entries, and an input withoutmanifestcontributes none).packageBodyKeysgives the key SET, and itscomposedBodystep states the assembly over SYNTAX nodes. Neither produces a value.Writing either as a value is a second copy of composition's rule. The real
composeStacksrefuses the authored inputs at its step 0 (STACK_PROVENANCE_MISSING). Marking them would take the spec's provenance key, ordefineStack, which converts. So this half needs apackages/specseam, for example adefineStackmode that skips the D2 pass, or an exported body assembler. It goes back to the seat.__keepAuthored), and a refused call is handed through (__recordStack). They are the two arms of onetry. The composed re-production calls the REALdefineStack(__specRoot.defineStack), not the wrap, so it keeps nothing. A composed artifact carries no record, soloadConfiguses it as before. The pin is a stack carrying one refused spelling and one load-path spelling: each conversion is applied once, and both are written.Pins —
test/migrate-meta-load-conversions.test.ts,unittierThe tests run in-process over
MigrateMeta.run, against temp projects that link the real spec. No process is spawned and no kernel is booted.vitest list --project unitlists the file, and--project integrationdoes not. 5 tests:datasource-driver-mongo-to-mongodb @ datasources[0].driver.--writewrites it:write.files=[{ objectstack.config.ts, sites: 1 }],manual: [],unexplained: [], and the bytes are the source with'mongo'→'mongodb'and nothing else. The re-run applies nothing. The strict load'sstackConversionsgoes from naming the conversion to[]. The reload is read throughstackConversions, not the stderr notice, becausedefineStackprints that notice once per process.export const onEnablebeside the default, the authored-source load givesdriver: 'mongo',namedExports: ['onEnable'],onEnablemerged,stackProvenance: trueandstackConversionsnaming the conversion. The strict load still gives'mongodb'.defineStack(defineStack({ … }))lists the conversion exactly once.applied: []andwrite={ status: 'written', files: [], written: [], manual: [], unexplained: [] }, and every byte is unchanged. Itsapplied,todos,absentTodosandschemaValidequal those of the chain over the stackdefineStackbuilt, which is where the chain started before this change.timedefault'10:00Z') and one load-path spelling (the valid mongo datasource).applied=[mongo, time], each once.write.files=[{ objectstack.config.ts, sites: 2 }], and both edits are written. The re-run applies nothing, and the strict load accepts and converts nothing.PR #22326's pins (
test/migrate-meta-composed.test.ts, 8 tests, including both one-package controls) stay green.Ablations
Each ablation went through
scripts/ablation-replace.mjs. The anchor hit once, the mutation was proven on disk, and the restore was proven: blob0488d7e55d38= HEAD, andgit diff HEADempty. The suite importssrcdirectly, so nodist/is involved. The direction was stated before each run.authoredBase=baseConfig). Predicted 3 red, 2 green. Got 3 red (the fix, where the argument is read, defined twice) and 2 green (control, mixed). The fix pin's first red:expected [] to deeply equal [ { …(4) } ].defineStackwrap passes only__recordStack). The same 3 red, 2 green.Local verification at
038ef735dpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2gaveTest Files 269 passed (269)andTests 3956 passed (3956).pnpm --filter @objectstack/cli typecheck(tsc --noEmit, thencheck:test-typecheck) exited 0, endingcheck:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held, the pre-existing ledger. It ran at93f524998. The only later commit is the changeset, so the TypeScript tree is the same.migrate metaover an authored source,test/migrate-meta-default-range.test.tsandtest/migrate-meta-engine-guidance.test.ts, with--project integration:Test Files 2 passed (2),Tests 10 passed | 1 skipped (11).node scripts/pm/dispatch-gates.mjs --commands, re-derived over this diff, gives the same 65 commands the order listed. All 65 exited 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 packages outside the closures I had built had nodist/). After building those 8 it passed (106/66/712/1entries/packages/cjsFiles/probes).dispatch-gates --ranreports65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(eslint . --no-inline-config, the whole repo) exited 0 with no findings.3599fef1.mainhas since moved 6 commits, and none touchespackages/cli, the spec's stack or provenance modules, or PR fix(cli):os migrate metaruns on acomposeStacksproject and migrates its package bodies #22326's changeset. I did not merge it; CI's merge ref covers the join.Acceptance notes
os migrate metaruns on acomposeStacksproject and migrates its package bodies #22326's "Known limit" sentence ends "…, the same as for an input the current schema accepts." Inside a composed project, which is its context, it stays true. Read as the one-package path, this PR makes the comparison stale. This PR's changeset restates the limit for both kinds of composed input. I did not edit the other changeset: it is outside the claimed file surface.content/docs/upgrading.mdx: I read the--writeparagraph. Nothing in it goes stale, because it makes no claim about load-path conversions. No docs edit.migrate meta's merge now judges whether a named export is shadowed against the authored argument, not the built stack. A top-level key that the parse defaults and that the author also exports would now merge instead of being reported as shadowed. Measured on the corpus, no TOP-LEVEL key differs between the two; every difference is nested.defineStack({ ...base, … })) still reaches the chain withbase's converted values. The spread drops the record, as it drops the provenance mark, and--writerefuses that site asspreadanyway.composeStacks([x])returnsxitself. Measured: it now listsdatasource-driver-mongo-to-mongodb @ datasources[0].driver, and--writelists that site ashelperunder the codemod's existing composed-key rule.Round 2 (REWORK
6068295985), written by thedomain:cliseatmainwith a merge commit,daa27b7c7(parents038ef735dand35afb1587). There was no rebase and no force-push, and no path conflicted.os migrate metaruns on acomposeStacksproject and migrates its package bodies #22326's pending changeset, corrected (2f8e479a3). In.changeset/22289-migrate-meta-composed-project.md, the "Known limit" bullet's closing clause ", the same as for an input the current schema accepts" is removed. Nothing else in that file moves.@objectstack/clirelease.check-empty-changeset's foreign-changeset rule is red by design. The seat confirms that in a comment on this PR.os migrate metaruns on acomposeStacksproject and migrates its package bodies #22326's "Known limit" sentence) no longer applies.--outsnapshot) is ruled A. It is kept as shipped and declared in this PR's changeset.2f8e479a3, after a fullturbo run build:pnpm lintexit 0.check-empty-changesetexits 1 on the foreign-changeset rule only.dispatch-gates --ran:65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.6068211309), re-derived at2f8e479a3withnode scripts/docs-audit/affected-docs.mjs --json 35afb158. It lists 16 hand-written pages and 9 release-owned ones (read-only).os migrate meta(content/docs/upgrading.mdxandcontent/docs/deployment/cli.mdx) make no claim this PR changes. The--outrow says "the migrated stack as a JSON snapshot", which still holds.cli.mdx:1354is about--stored, which this PR does not touch.os migrate metaas a remedy, which stays true.Generated by Claude Code