Skip to content

fix(cli): os migrate meta lists and writes a conversion the load already applies, on a stack the schema accepts - #22351

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22256-migrate-meta-load-conversions
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22256-migrate-meta-load-conversions

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #22256
Clause-②: no
Release: the composed half stays open on #22256. That half is a conversion the load still applies inside a composeStacks package body. Its carrier is the domain:cli seat, which takes it to the domain:spec seat with the H4 measurement below. It needs a spec seam or a second copy of composition's rule.

os migrate meta now lists and writes a conversion the load still applies, such as datasources[].driver: 'mongo', on a one-package stack the current schema ACCEPTS. Every change is in packages/cli. There is no packages/spec change, and nothing here reads or writes the producer's provenance key.

What changed

  1. The shim keeps the argument of an accepted defineStack call (src/utils/config.ts). __tolerant gains a keep hook. It runs after a successful call, outside the try. The defineStack wrap passes __keepAuthored, which defines one non-enumerable property on the returned stack, holding the argument. Its key is a CLI-owned Symbol.for key, @objectstack/cli:authored-source/accepted-argument. A refused call is unchanged: it hands its argument through raw, and __recordStack records it for composeStacks. A call takes exactly one of the two arms.
  2. loadConfig({ authoredSource: true }) starts the default export from that argument (authoredArgumentOf). It reads the argument off mod.default BEFORE 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, so defineStack(defineStack({ … })) answers the inner literal. stackProvenance and stackConversions are still read off the built stack. A load without authoredSource, which is every other command, is untouched.
  3. meta.ts: no code change, one comment. Both chain call sites (:1159–:1165, and the --write re-run at :1343–:1344) already run normalizeStackInput(config, { convert: false }) over loadConfig(…, { 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.
  4. Changeset: @objectstack/cli patch, Clause-②: no. It keeps PR fix(cli): os migrate meta runs on a composeStacks project 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.stringify and a schema parse all leave it behind.

Why loadConfig substitutes, and meta.ts does not re-merge

In the order's route, loadConfig exposes the record and meta.ts starts from it at its two call sites. meta.ts would then have to redo loadConfig's named-export merge over the authored argument, which is a second copy of that rule. Substituting before the merge keeps one merge. The --write planner 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/main 3599fef1 with the built CLI (node packages/cli/bin/run.js), against temp projects that link this worktree's built @objectstack/spec.

  • H1, reproduced. The stack is a one-package stack the schema accepts, with datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }]. migrate meta objectstack.config.ts --from 16 --write --json gave exit 0, "applied": [] and "write": {"status": "written", "files": [], "written": [], "manual": [], "unexplained": []}. The source kept driver: '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.
    • A first probe with 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.
  • H2, confirmed by instrument. loadConfig(…, { authoredSource: true }).config.datasources[0].driver was 'mongodb', and so was the chain input after normalizeStackInput(…, { convert: false }). stackConversions named datasource-driver-mongo-to-mongodb. On success the shim's __tolerant returned the real result. Where driver is converted: buildDefinedStack (packages/spec/src/stack.zod.ts) calls normalizeStackInput with conversions on, in both modes. That calls applyConversions, which runs datasourceDriverMongoToMongodb (packages/spec/src/conversions/registry.ts). No other define* helper runs the pass.
  • H3, fixed as above. After the change, the same H1 run gave applied = [datasource-driver-mongo-to-mongodb @ datasources[0].driver, mongo → mongodb], write.files = [{ objectstack.config.ts, sites: 1 }], verification.ok: true and schemaValid: true. The source line now reads driver: 'mongodb'. A later os validate printed no converted at load line, and a re-run applied nothing.
    • Nothing to convert is unchanged, measured on the corpus. All four example apps are ACCEPTED by defineStack and carry no load-path conversion, so the fix changes each one's chain input. I ran migrate meta --from 16 --json and --from 17 --json on app-crm, app-multi-package, app-showcase and app-todo. The eight --json documents, minus duration, are byte-identical by md5 before and after. I also ran --write --from 16 on a throwaway copy of each app. The --json outcome and the written bytes are identical by md5: app-showcase writes 3 sites in src/automation/flows/index.ts and app-todo 1 site in src/flows/task.flow.ts, both before and after.
    • The --out snapshot 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 as manifest.scope, manifest.defaultDatasource, flows[].version and hooks[].runAs, and objects[].actions from defineStack's action merge. Or the parse transformed a value: hooks[].condition and flows[].edges[].condition. Nothing appears that the author did not write. The composed app's snapshot is unchanged. The changeset says so.
  • H4, measured. NOT fixed here. The project is composeStacks([ServiceStack, AppStack], { manifest: 'preserve' }), with the service body carrying the accepted mongo datasource. Before AND after this change, --from 16 --write --json gives exit 0, applied: [], write.files: [] and schemaValid: true. The sources keep their md5, and a later load prints the notice once. With a refused spelling added to the same body (time default '10:00Z'), applied lists only time-default-utc-suffix-dropped @ packages[0].manifest.objects[0].fields.starts_at.defaultValue, because the strict: false re-production converts the driver.
    • Why it stops. The chain's per-body run reads packages[i].manifest. composeStacks assembles that body from the stack the input's defineStack call RETURNED (assemblePackageBody: { ...stack.manifest }, then each package-owned key). To start that run from the authored input, packages/cli needs two things it does not have as values:

      1. The mapping from entry to input (preservePackageEntries: an input with packages contributes its own entries, and an input without manifest contributes none).
      2. The body assembly itself. The codemod's packageBodyKeys gives the key SET, and its composedBody step 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 composeStacks refuses the authored inputs at its step 0 (STACK_PROVENANCE_MISSING). Marking them would take the spec's provenance key, or defineStack, which converts. So this half needs a packages/spec seam, for example a defineStack mode that skips the D2 pass, or an exported body assembler. It goes back to the seat.

  • H5, holds. An accepted call is kept (__keepAuthored), and a refused call is handed through (__recordStack). They are the two arms of one try. The composed re-production calls the REAL defineStack (__specRoot.defineStack), not the wrap, so it keeps nothing. A composed artifact carries no record, so loadConfig uses 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, unit tier

The 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 unit lists the file, and --project integration does not. 5 tests:

  • The fix. The accepted mongo stack. The dry run lists datasource-driver-mongo-to-mongodb @ datasources[0].driver. --write writes 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's stackConversions goes from naming the conversion to []. The reload is read through stackConversions, not the stderr notice, because defineStack prints that notice once per process.
  • Where the argument is read. With export const onEnable beside the default, the authored-source load gives driver: 'mongo', namedExports: ['onEnable'], onEnable merged, stackProvenance: true and stackConversions naming the conversion. The strict load still gives 'mongodb'.
  • Defined twice. defineStack(defineStack({ … })) lists the conversion exactly once.
  • Control. An already-canonical source gives applied: [] and write = { status: 'written', files: [], written: [], manual: [], unexplained: [] }, and every byte is unchanged. Its applied, todos, absentTodos and schemaValid equal those of the chain over the stack defineStack built, which is where the chain started before this change.
  • Mixed (H5). One refused spelling (time default '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: blob 0488d7e55d38 = HEAD, and git diff HEAD empty. The suite imports src directly, so no dist/ is involved. The direction was stated before each run.

  1. The converted hand-through restored in the loader (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) } ].
  2. The shim no longer keeps the argument (the defineStack wrap passes only __recordStack). The same 3 red, 2 green.
  3. The record followed one level only. Predicted 1 red. Got 1 red (defined twice) and 4 green.

Local verification at 038ef735d

  • CLI unit tier: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 gave Test Files 269 passed (269) and Tests 3956 passed (3956).
  • Typecheck: pnpm --filter @objectstack/cli typecheck (tsc --noEmit, then check:test-typecheck) exited 0, ending check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held, the pre-existing ledger. It ran at 93f524998. The only later commit is the changeset, so the TypeScript tree is the same.
  • Integration tier: declared to CI, because this diff touches no integration-tier file and no spawn entry. As a targeted check, I ran the two integration files that drive migrate meta over an authored source, test/migrate-meta-default-range.test.ts and test/migrate-meta-engine-guidance.test.ts, with --project integration: Test Files 2 passed (2), Tests 10 passed | 1 skipped (11).
  • Gates: 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-loads first exited 3 (PREREQUISITE NOT MET: 8 packages outside the closures I had built had no dist/). After building those 8 it passed (106/66/712/1 entries/packages/cjsFiles/probes). dispatch-gates --ran reports 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint: pnpm lint (eslint . --no-inline-config, the whole repo) exited 0 with no findings.
  • Base: the branch is on 3599fef1. main has since moved 6 commits, and none touches packages/cli, the spec's stack or provenance modules, or PR fix(cli): os migrate meta runs on a composeStacks project and migrates its package bodies #22326's changeset. I did not merge it; CI's merge ref covers the join.

Acceptance notes

  • PR fix(cli): os migrate meta runs on a composeStacks project 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 --write paragraph. Nothing in it goes stale, because it makes no claim about load-path conversions. No docs edit.
  • Named exports, in this one load. 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.
  • A stack spread from a built stack (defineStack({ ...base, … })) still reaches the chain with base's converted values. The spread drops the record, as it drops the provenance mark, and --write refuses that site as spread anyway.
  • A one-input composeStacks([x]) returns x itself. Measured: it now lists datasource-driver-mongo-to-mongodb @ datasources[0].driver, and --write lists that site as helper under the codemod's existing composed-key rule.

Round 2 (REWORK 6068295985), written by the domain:cli seat

  • Merged main with a merge commit, daa27b7c7 (parents 038ef735d and 35afb1587). There was no rebase and no force-push, and no path conflicted.
  • PR fix(cli): os migrate meta runs on a composeStacks project 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.
  • The open question (the --out snapshot) is ruled A. It is kept as shipped and declared in this PR's changeset.
  • Re-verified by the dev at 2f8e479a3, after a full turbo run build:
    • CLI unit tier: 269 files, 3956 tests passed. Typecheck exit 0. pnpm lint exit 0.
    • The same 65 gates re-derived over the final diff: 64 exit 0, and check-empty-changeset exits 1 on the foreign-changeset rule only. dispatch-gates --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
  • The docs-drift advisory (6068211309), re-derived at 2f8e479a3 with node scripts/docs-audit/affected-docs.mjs --json 35afb158. It lists 16 hand-written pages and 9 release-owned ones (read-only).
    • The two pages that describe os migrate meta (content/docs/upgrading.mdx and content/docs/deployment/cli.mdx) make no claim this PR changes. The --out row says "the migrated stack as a JSON snapshot", which still holds. cli.mdx:1354 is about --stored, which this PR does not touch.
    • The other 14 name os migrate meta as a remedy, which stays true.
    • So no docs edit is owed.

Generated by Claude Code

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

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 8 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 35afb15878054ea1ac72a2a29b2271712f0bfa9d.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 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 — 28 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 35afb15878054ea1ac72a2a29b2271712f0bfa9d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 91967e944ff13524307df50fab35bb1d636a3b9a — the merge of head 2f8e479a39d79405f7c3b4e31a4a4e5430410f00 into base 35afb15878054ea1ac72a2a29b2271712f0bfa9d, 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 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

⚠️ 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 35afb15878054ea1ac72a2a29b2271712f0bfa9d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 8, 2026 20:20
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Deliberate changeset correction, confirmed by the domain:cli seat (#6024 · session_01RWZbGvPFcRKvUqASZtunCU · 2026-10-08T21:20Z).

This PR edits .changeset/22289-migrate-meta-composed-project.md, a pending changeset PR #22326 added. It removes one clause, ", the same as for an input the current schema accepts", which this PR makes false. Both changesets ship in the same @objectstack/cli release.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 21:33
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 21:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 1cb0edb Oct 8, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22256-migrate-meta-load-conversions branch October 8, 2026 21:56
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants