Skip to content

feat: the package upgrade plan — compare the installed and incoming versions, an install dry run, os package install --dry-run - #30983

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-89x8tb
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-89x8tb

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#1 · os-elon-musk · session_016WV4cyPqWLpc3AxaZrs98n

Refs #30884 (PR 1 of 2: widening only. PR 2 carries Fixes #30884 and the Q3 refusal.)

Clause-②: yes (widening) — new exports from @objectstack/metadata-core (comparePackageVersions and its types, typeChangeCategory, isEmptyPackageUpgradePlan, upgradePlanSeedDatasets, seedModeOverwritesMatchedRows), a new planUpgrade method on objectql's manifest service, a new dryRun key on the POST /api/v1/marketplace/install-local body, a new --dry-run flag on os package install, and new os diff entries (declared and suspected renames, seed datasets). Nothing a request did before answers differently.
Metadata-protocol: none

Claim: 6686492425 (re-posted with this branch) · Branch: claude/project-thread-89x8tb · Scope note: 6685166393

维护者速读

  • 改了什么:升级软件包前可以先看「会变什么」。平台比较已安装版本和新版本,列出增删的对象和字段、字段类型变化(带漂移等级)、用 renamedFrom 声明的改名、没声明却像改名的「疑似改名」,以及种子数据集的增删改和各自的模式。入口有三个:安装接口带 dryRun: true 只返回预览、什么都不写;os package install --dry-run 打印预览;os diff 改用同一个比较。
  • 为什么改:今天升级时字段改名会变成一个新的空列,旧数据留在旧列里看不到;改类型也是静默的(卡 Package upgrade plan: one framework comparison of installed vs new version (fields, types, renames, seeds) as a dry run; declared renamedFrom is planned, an undeclared rename or type change is refused unless confirmed (epic cloud#3652 Q3) #30884,史诗 cloud#3652 Q3)。预览还读当前环境:每个受影响的列有多少行数据,以及 upsert 种子重放会覆盖多少行客户改过的数据。
  • 风险与回滚:小。这一版只加不收,以前能装的包照样能装;Q3 的默认拒绝在 PR 2。注意一点:不认识 dryRun 的旧运行时会直接安装,CLI 会认出来并报错退出,但那次安装已经发生。回滚即撤 PR。
  • 先看:packages/metadata-core/src/package-upgrade-plan.ts(比较本身),packages/objectql/src/package-upgrade-environment.ts(行数和「被覆盖的客户编辑」怎么数),install-local 的 2a′ 步(干跑在哪一步回答、为什么在任何写入之前)。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/package-upgrade-environment.test.ts,预期 7 条全绿,其中「a seeded upsert dataset with one row edited reports that row」断言三行种子里只有客户改过的 won 被报出。
  • 需要你做的:无,席位落地。

What changes

@objectstack/metadata-core: the comparison (package-upgrade-plan.ts, new)

comparePackageVersions(installed, incoming) takes two flat package bodies (objects, data) and returns:

  • objects added and removed, by name;
  • fields added and removed within an object both versions declare, each with required and stored;
  • type changes, each with typeChangeCategory's drift word (safe when the installed field had no stored column, destructive when the new one has none, needs_confirm when both are stored — the column keeps its old type under the additive sync);
  • declared renames: a new key whose renamedFrom names an installed key the new version no longer declares. The pair is listed as a rename, not as a removal and an addition. A declared rename whose type changes is also a type change, judged on its own (scope note 6685166393), carrying renamedFrom;
  • suspected renames: within one object, a stored field removed while stored fields are added with nothing linking them. One finding per removed field, every unlinked added stored field a candidate. Both keys stay listed as removed and added;
  • seed datasets added, removed and changed (records, mode, columns, env), each with its mode (the spec default ignore when none is declared), keyed by object plus a non-default match key and locales.

It is pure and decides nothing. The drift vocabulary is restated as UpgradeDriftCategory, because this package cannot depend on a driver; a type-level pin in the CLI's tests holds it equal to driver-sql's DriftCategory. Also exported: upgradePlanSeedDatasets (so the environment half pairs datasets by the plan's own keys) and seedModeOverwritesMatchedRows (upsert, update).

@objectstack/objectql: the environment half (package-upgrade-environment.ts, new; planUpgrade on the manifest service in plugin.ts)

planUpgrade(installed, incoming) folds each side with core's resolveStackCollection, runs the comparison, and adds two read-only readings, with the system context, across every organization:

  • rows holding a value ($ne: null) in each stored column the upgrade removes, renames (declared or suspected) or retypes (not a safe retype), and the rows of each object it removes;
  • for each upsert or update dataset both versions declare, the rows the replay matches by externalId, and of those the rows a customer edited that the replay overwrites: some column the replay writes holds a value other than the installed seed value and other than the incoming one. Values are compared as the replay compares them (temporal storage form, blank-as-null form). Credential, file, reference, computed and expression columns, applyOn: 'create' columns and the match key are never compared. Up to 20 edited keys are named.

A reading that fails is null and is logged, never 0.

@objectstack/cloud-connection: the dry run (marketplace-install-local-plugin.ts)

  • Step 0′: dryRun must be a boolean (400 INVALID_REQUEST otherwise). A dry run of an unmanaged install is refused 400, since that branch writes and a copy follows no upgrade.
  • Before the customization-pair branch: a dry run of a pair is refused 400.
  • Step 2a′: after every refusal that says the package cannot install at all, and before the D5e ceremony (2b), the access gate (2c) and the first write (3), dryRun: true answers 200 with { dryRun, manifestId, version, installedVersion, plan, environment, message }. The installed side is the ledger entry, converted forward as the incoming one is. A manifest service with no planUpgrade answers 503 SERVICE_UNAVAILABLE, never an empty plan. A ledger entry that does not parse is reported (installedRecordUnreadable).

@objectstack/cli

  • os diff builds its object and field entries from comparePackageVersions. Every shape it printed before prints byte for byte as before (pinned before the move). New: a renamedFrom rename prints as one modified entry (renamed from X, declared by renamedFrom) instead of a removal plus an addition (renamedFrom has not shipped yet: changeset 30880-field-renamed-from is pending); a suspected rename prints a modified entry on the removed key beside its removal and addition; seed datasets print under data, after every other category, with their mode.
  • os package install --dry-run sends dryRun: true and prints the plan, each finding with the environment's reading beside it. A runtime that answers without dryRun (one that predates the key, and so installed) is printed as a failure, exit 1.

Why

Card #30884 (epic objectstack-ai/cloud#3652, Phase 2 "change preview before install"; rulings Q3 and D3). This PR is the preview. The refusal ruled in Q3 is PR 2.

Re-measured on 616311c1, the card's "Today" items 1–6 still hold (diff.ts:162, :223-224; migrate/plan.ts:62, :137-139; schema-drift.ts:6-9, :135; install-local :85-97, :454, :3456, ledger :1319, install.ts:174, :307; upgrade-impact-report.ts:4-17; seed.zod.ts:193 and the 18.seed-mode-default-create-only migration). Item 7 no longer holds: renamedFrom landed with #30934 (field.zod.ts:2610, object.zod.ts's refuseDishonestRenamedFrom).

Tests

The card's Acceptance bullets this PR carries, each with its test. The other three (the 409 with its token, the declared-rename refusal, the ledger code) belong to PR 2.

Acceptance bullet Test
a field removed and one added of the same type in one object, no renamedFrom → one suspected rename metadata-core package-upgrade-plan.test.ts › renames › "a field removed and one added of the same type in one object, no renamedFrom → one suspected rename"
the same pair with renamedFrom: ['old'] → one declared rename and no suspected rename same file › renames › "the same pair with renamedFrom: ['company'] on the new field → one declared rename and no suspected rename"
a type change carries its drift category same file › type changes › "a type change carries its drift category"
an upsert dataset change and an ignore dataset change are both listed with their modes same file › seed datasets › "an upsert dataset change and an ignore dataset change are both listed with their modes"
os diff output is unchanged for its existing fixtures cli diff-upgrade-plan.test.ts › "every shape, in order, with each entry's key order" (pinned on main before the move, then held)
dryRun: true writes nothing (ledger file and registry unchanged) cloud-connection marketplace-install-local-upgrade-plan.test.ts › "a re-install: the installed ledger body against the incoming one; registry, schema and ledger untouched" (ledger bytes compared file by file; register and syncSchemas never called)
environment half: a seeded upsert dataset, one row edited, reports that row objectql package-upgrade-environment.test.ts › "a seeded upsert dataset with one row edited reports that row" (real ObjectQL engine; of three seeded rows only the edited won is named)
a declared rename whose type changes is also a type change (scope note 6685166393) package-upgrade-plan.test.ts › "a declared rename whose type changes is ALSO a type change, judged on its own"; diff-upgrade-plan.test.ts › "a declared rename that changes type prints the type change too"

New tests: 42 (metadata-core 17, objectql 7, cloud-connection 6, cli 7 + 5).

Ablations, on head 3100b841, each through scripts/ablation-replace.mjs (anchor hit ×1, the mutation proven on disk by blob hash, restored to the HEAD blob with git diff HEAD empty):

Ablation Mutation Result
suspected-rename detection const storedAdded = added.filter(…) → const storedAdded: string[] = [] 3 failed / 14 passed (17): the suspected-rename case, the "renamedFrom naming a key the installed version does not have declares nothing" case, the two-candidates case
type judgment on a declared rename the type-change test limited to target === key (a field that kept its key) 1 failed / 16 passed (17): "a declared rename whose type changes is ALSO a type change, judged on its own"
dryRun write guard step 2a′ (if (dryRun) return this.answerUpgradeDryRun(…)) removed 3 failed / 3 passed (6): the re-install, the not-installed and the 503 cases; with the guard gone the request installs

Acceptance notes

  • Two readings go beyond the card's letter. The edited-row count covers every overwriting dataset both versions declare, changed or not, and update as well as upsert. An unchanged upsert dataset writes the same edits back, and update overwrites a matched row like upsert. Each entry says whether the plan lists it as changed.
  • The dry run answers before the confirmation ceremonies. It does not say whether the D5e or access check would stop the real install. The plan is what an installer reads before deciding, so it is answered either way.
  • An older runtime ignores dryRun and installs. The CLI detects this from the answer and exits 1, but by then the install has happened. No guard exists before the send.
  • The value comparison restates the seed loader's seedValueEquals. That function is private to @objectstack/metadata-protocol; its temporal and blank rules come from the same @objectstack/core functions here. Sinking the comparison to one shared home would be a follow-up. carrier: none (noted, not filed).
  • Not counted: a replace dataset (it deletes every row on replay); a dataset's locale or env filter (the count may include a dataset a locale filter would skip, which over-reports, never under-reports).
  • Docs. content/docs/deployment/cli.mdx has no row for --dry-run yet. That page is outside this claim's file surface; the seat can card it.
  • planPackageUpgrade is not exported from @objectstack/objectql's entry. A host reaches it through ctx.getService('manifest').planUpgrade, the service method the card names.

Verification

Head 3100b841, base 616311c1. Every test run below went through scripts/pm/os-verify-lock.sh and ended VERDICT command-exit 0.

Run Result
pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2 24 files, 928 tests passed
pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 560 files, 11207 tests passed (on 3dba4119; 3100b841 changes only package-upgrade-environment.test.ts, 7/7 on head)
pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2 73 files, 923 tests passed
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 391 files, 5494 tests passed
… --project integration test/package-install-local- 8 files, 83 tests passed (every install-local integration file)
… --project integration test/package-owned-command-readers.test.ts 1 file, 8 tests passed
typecheck: metadata-core, cloud-connection, objectql (on head), cli exit 0 each; the test-layer debt ledgers held unchanged (cli 3 files / 28 errors, objectql 39 / 233). cli needs NODE_OPTIONS=--max-old-space-size=8192; at the default heap it runs out of memory before any verdict
pnpm lint exit 0

Gates. node scripts/pm/dispatch-gates.mjs --commands derives 66 families from this diff. All 66 ran with their exit codes recorded before any pipe, and --ran reconciles: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED". Four needed a second run, each for a reason outside the diff, and each then exited 0:

  • check:objectql-double-limit exited 1 on the first run: the probe drives every find double in the objectql tests, and my driver double's failure switch threw under it. Fixed in 3100b841 (the failure case now stubs engine.find); the gate then reports "none new".
  • check-plugin-teardown-shape --self-test exited 3 on a shallow clone; fetching the commit its self-test names (621a4876) fixed it.
  • check:dual-build-cjs-loads and check:i18n-coverage exited 3 for want of dist/; after a full build (pnpm turbo run build --filter=!@objectstack/docs, exit 0) both exited 0.

check:nul-bytes exits 0. A self-scan for control bytes over the changed files finds none (the environment half spells its key separator as an escape).

Today, re-measured on 616311c1: see Why. Items 1–6 hold; item 7 does not.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec untouched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request (PR 1 of the claim's two).
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

…ith the incoming one

The package upgrade plan lists objects and fields added and removed, type
changes with their drift category, renames a field declares with
`renamedFrom`, suspected renames nothing declares, and seed datasets added,
removed and changed with their modes. A declared rename whose type changes is
also a type change, judged on its own.

Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
Co-authored-by: Claude <noreply@anthropic.com>
…ade plan

`diffStacks` lifts the command's comparison out of `run()` unchanged, and the
new test pins its output byte for byte (entry order and each entry's key
order) over every shape it reports today, plus a type-level pin that the
upgrade plan's three drift words equal driver-sql's `DriftCategory`.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
…pgrade plan

`os diff` now builds its object and field entries from
`comparePackageVersions` (`@objectstack/metadata-core`), the comparison the
install door's dry run and the `manifest` service read, so the CLI and an
install cannot disagree about one pair of versions. The output for every
shape it reported before is unchanged (pinned before the move). New with the
plan: a declared `renamedFrom` rename, a suspected rename on the removed key,
and seed datasets added, removed and changed, each with its mode.

`seedModeOverwritesMatchedRows` names the two modes whose replay writes over
a matched row, for the CLI's wording and the environment half's count.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
…ironment knows

The `manifest` service gains `planUpgrade(installed, incoming)`: the package
upgrade plan (`comparePackageVersions`, each side folded with core's
`resolveStackCollection`) plus two read-only readings, across every
organization with the system context:

- rows holding a value in each stored column the upgrade removes, renames
  (declared or suspected) or retypes, and rows of each object it removes;
- for each `upsert`/`update` dataset both versions declare, the rows a
  customer edited that the seed replay overwrites: a column the replay
  writes whose stored value differs from the installed seed value and from
  the incoming one, compared as the replay compares (temporal and blank
  storage forms; credential, file, reference, expression and
  `applyOn: 'create'` columns never compared).

A failed reading answers null, never zero. metadata-core exports the plan's
dataset reader (`upgradePlanSeedDatasets`) so both halves pair datasets by
one key.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
… and writes nothing

`POST /api/v1/marketplace/install-local` with `dryRun: true` answers 200
with the `manifest` service's `planUpgrade` of the installed version (its
ledger entry, converted forward) against the incoming one, after every
refusal that says the package cannot install at all and before the first
confirmation ceremony or write. A `dryRun` that is not a boolean, a dry run
of an `unmanaged` install and one of a customization pair are refused 400;
a runtime whose `manifest` service answers no `planUpgrade` answers 503.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
`--dry-run` sends `dryRun: true` and prints the runtime's plan, each
finding with the environment's reading beside it. A runtime that ignored
the key and installed is printed as a failure, exit 1.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
…driver double

`check:objectql-double-limit` drives every `find` double it finds with its
own rows; the in-memory driver's per-object failure switch threw under that
probe and was left unjudged. The failure case now rejects `engine.find`
itself, and the driver double is plain.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyPqWLpc3AxaZrs98n
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

42 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 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 40 pages)
  • 25 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 — 54 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 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 81faed8b9569db4470bcdff29fec07fb7006bcdc — the merge of head 3100b841e32027af5f3d910cba6c6b70c4ca1b19 into base 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404, 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 81faed8b9569db4470bcdff29fec07fb7006bcdc && git checkout 81faed8b9569db4470bcdff29fec07fb7006bcdc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404 3100b841e32027af5f3d910cba6c6b70c4ca1b19 && git checkout -B drift-repro 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404 && git merge --no-ff 3100b841e32027af5f3d910cba6c6b70c4ca1b19

node scripts/docs-audit/affected-docs.mjs --json 1aeeafd9d371be1fd5afa637c4f0ae6b6f4c1404

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 3100b841e32027af5f3d910cba6c6b70c4ca1b19
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/metadata-core/src/package-upgrade-plan.ts, packages/objectql/src/package-upgrade-environment.ts, packages/objectql/src/plugin.ts, packages/cli/src/commands/diff.ts, packages/cli/src/commands/package/install.ts, .changeset/30884-package-upgrade-plan.md + packages/drivers/driver-sql/src/schema-drift.ts, packages/spec/src/data/search-fields.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/api/package-lifecycle.zod.ts, packages/cloud-connection/src/local-manifest-source.ts, packages/objectql/src/engine.ts, packages/cli/tsconfig.test.json

PR 1 of card #30884, the widening half. The Q3 refusal is PR 2 and is not judged here. Head 3100b841 is still the PR head. Base 616311c1, merge base the same; main is 17 commits ahead and none of those commits touches any of the 13 changed files (measured with git diff --name-only over those paths: empty).

① Derived judgments

Must-fix: none.

Should-fix (does not block this PR):

  • S1. packages/cli/src/commands/package/install.ts:374, :457-465. --dry-run sends dryRun: true and nothing else. A runtime that predates the key reads the body as before (body: any, no closed key set, see ② below) and installs. The command detects this only from the answer and exits 1 after the install has happened. The PR body and the handoff both say so. The flag's own sentence is "installs nothing", and against any runtime from before this release that sentence is false. No pre-send probe exists in install.ts (only the auth call at :341). Recommend a card: a pre-send probe of the runtime (or a refusal to send dryRun to a runtime whose answer shape cannot be known), so the flag can never install.
  • S2. packages/objectql/src/package-upgrade-environment.ts:317. isOverwrittenEdit skips a column the installed seed did not write (if (!(column in installed)) continue;). A customer value in a column the installed seed left unset, which the incoming seed now writes, is a customer edit the replay erases, and it is not counted. This under-reports, never over-reports. The card's acceptance case (one edited row of three) passes because the edit is in a column both seeds write. Recommend counting that case too: when installed lacks the column, treat the seeded value as absent and count the row when the stored value is non-null and differs from the incoming value.

Confirmed, by file and line:

Gate 1, dryRun writes nothing.

  • marketplace-install-local-plugin.ts:1784 runs requireInstallCapability first, before the body is read (:1790). :1815-1842 is step 0′: dryRun must be absent or a boolean, else 400 INVALID_REQUEST; dryRun with installMode: 'unmanaged' is 400. :2272-2286 refuses a dry run of a customization pair. :2382-2389 is step 2a′: if (dryRun) return this.answerUpgradeDryRun(...), after the conflict check (2) and the held-datasource check (2a), before the D5e ceremony (2b), the access gate (2c) and the first write (3).
  • Between step 0′ and 2a′ every operation is a read: the cloud fetch (:1891-1940), payload parse, id parse (1b), handshake (1c), install-mode judgment (1e), unrunnableCode (1d, :2199), judgeWithheldMaps (1d′, :2215), the conflict check (2), findHeldDatasourceName (2a). The unmanaged branch (:2227) and the pair branch (:2272) both write, and both are refused for a dry run before they run.
  • answerUpgradeDryRun (:4919-4961) reads the ledger (LocalManifestSource.read, local-manifest-source.ts:398-407: existsSync + readFileSync, no write), converts both bodies forward and calls planUpgrade. convertPackageBodyForward (engine.ts, engine-package-registration.ts) is a pure conversion with one in-memory "already warned" memo for its log line; no file, registry, seed, metadata, activation-ledger, audit or cache write. A manifest service with no planUpgrade answers 503 SERVICE_UNAVAILABLE, never an empty plan (:4926-4935).
  • Body key validation: the body is any from readJsonRequestBody (:1790-1792). There is no closed key set on this route and never was: an unknown key is ignored, not refused, before and after this PR. dryRun is validated by its own typeof check at :1823. The only key whose handling changed is dryRun itself: a non-boolean dryRun, ignored before, is now 400.
  • dryRun with other keys: with confirmAccessUpgrade, the token is ignored because the dry run answers at 2a′, before the access gate at 2c (the PR says so). With packageId, the manifest is fetched from the cloud and then planned. With installMode: 'managed' or absent, the plan runs; with 'unmanaged', 400. With manifest inline, the plan runs against the inline body.
  • Test marketplace-install-local-upgrade-plan.test.ts:127-152 pins register and syncSchemas never called and the ledger directory byte for byte; :166-173 is the control (the same body without dryRun installs and planUpgrade is not called); :175-184 the 503; :188-205 the two 400s.

Gate 2, the comparison against Expected 1.

  • Added and removed objects: package-upgrade-plan.ts:316-317. Added and removed fields with required and stored: :340-351, describeField at :181.
  • Type changes carry a category: :366-372, typeChangeCategory at :202-208. The category is derived from the storage fact (isVirtualSearchField, search-fields.ts:159), not from a live column: installed field computed → safe; new field computed → destructive (the driver's drop_column op is destructive, schema-drift.ts:464); both stored → needs_confirm (the driver's manual_column_type_change is needs_confirm, schema-drift.ts:1257-1258, and the comment at :1141-1157 says the gate reads category only). A pure function cannot see the physical column, so a retype the live driver would call destructive (narrowing) is needs_confirm here. PR 2 decides what confirms; both words are above safe.
  • Declared renames via renamedFrom: :329-338. A new key whose renamedFrom names an installed key the new version no longer declares. The source is not listed as removed (:342) and the target not as added (:348). The first matching renamedFrom entry wins; refuseDishonestRenamedFrom in spec already refuses an old key two fields claim.
  • A declared rename whose type changes is ALSO a type change, at any category: :353-373. The type-change test at :366 is from !== to with no category condition, and target !== key only adds renamedFrom to the finding. Test package-upgrade-plan.test.ts:78-92 pins the needs_confirm case (text → number on a declared rename), which is exactly the case scope note 6685166393 names. The scope note holds.
  • Suspected renames: :375-389. Within one object, each removed stored field with every unlinked added stored field as a candidate. Computed fields on either side are not candidates (they have no column); the card's sentence does not say "stored", but a computed field is no rename hazard, so this is a faithful reading. Both keys stay listed as removed and added.
  • Seed datasets with their mode: compareSeeds at :273-294; the default mode comes from SeedSchema.shape.mode.parse(undefined) at :212 (spec default ignore), not a copy. Changed means records, mode, columns or env. Test :149-194.
  • Type-equality pin: packages/cli/test/diff-upgrade-plan.test.ts:26-27, Equal<UpgradeDriftCategory, DriftCategory> assigned true. It is real: packages/cli/tsconfig.test.json:179-184 includes test/**/*, pnpm --filter @objectstack/cli typecheck runs tsc --noEmit && pnpm check:test-typecheck (packages/cli/package.json:31), and the check-run "Type Check · debt ledger" is green on this head. A word added to or dropped from either union makes this line a new error outside the debt ledger. The runtime expect(driftWordsAgree).toBe(true) at :65 is decoration; the pin is the tsc line.

Gate 3, os diff unchanged.

  • On main there is no output fixture for os diff (packages/cli/test/: commands.test.ts and normalized-call-sites.test.ts reference the command's registration, diff-usage-error-stream.e2e.test.ts its usage error). Commit 60c95956d0 adds diff-upgrade-plan.test.ts with a byte-for-byte pin (JSON.stringify, entry and key order) over removed/added objects, required/optional field additions, field removal, type change, label change and every name-level category, and lifts the comparison into diffStacks unchanged. Commit 3714e769bd moves diffStacks onto the plan. git diff 60c95956d0 3714e769bd -- packages/cli/test/diff-upgrade-plan.test.ts shows only an appended second describe; the pinned block (:62-93) did not move. No expectation was rewritten.
  • Read against the old code: entry shapes and order are the same (diff.ts:88-134 on the head, :89-158 on the base). getNames (:34-41) and the plan's objectsByName both take the last declaration of a name. Type detection is the same (oldType && newType && oldType !== newType versus typeOf requiring a non-empty string).
  • Two output changes exist and are stated in the PR body and changeset, not silent: a renamedFrom pair prints one modified entry instead of a removal plus an addition; a stored field removed beside a stored field added in one object prints an extra modified entry (suspected rename to …), and seed datasets print under data last. The pinned fixture holds neither pattern, so "unchanged for its existing fixtures" is true of the fixtures and the PR says what is new.

Gate 4, the environment half.

  • Read-only: package-upgrade-environment.ts:195-196 (engine.count with $ne: null), :155 (engine.count per removed object), :365-366 (engine.find with $in over the first match-key column). No write call in the module. find carries no default limit (engine-find.ts:312), so every matched row is read; the read is bounded by the dataset's own key values.
  • Scoping: every read passes { context: { isSystem: true } } (:122), with no organization. The counts are environment-wide across every organization, on purpose (:28-29): a package install here is environment-wide, one ledger per environment, and the caller is gated by manage_metadata (marketplace-install-local-plugin.ts:399), an environment operator. So yes, a dry run counts every organization's rows, and that is what the install would touch. The method takes no organization argument; see ③ for the hosted door.
  • A failed reading is null: reading() at :160-170 returns null and calls warn once with the object and the error. rows starts null (:180); matchedRows and editedRows start null (:344) and are set only after a successful find (:386-387). Test :127-135 pins null and the warning.
  • No cell values in the plan: the answer carries counts and, per overwriting dataset, up to 20 editedKeys (:383). A key is the natural key of a row that matched an incoming seed record (:375-376), so its text equals a key the package itself ships. No other row value leaves the function.
  • Edited-row comparison: isOverwrittenEdit at :313-327. A row counts when some column the replay writes holds a value that is neither the installed seed's value nor the incoming one, in storage form (blankStorageForm, temporalStorageForm), skipping id, the match key, applyOn: 'create' columns, computed, masked, file and reference columns, and expression values. A row the installed version did not seed is matched but not counted (:380). Correct for the card's case (test :76-96: three seeded rows, the relabelled lost is an upgrade, the edited won counts). The one gap is S2 above.
  • planUpgrade wiring: plugin.ts:1039-1043 passes ql, ql.registry.getObject and ctx.logger.warn. Test :183-204 runs it through a kernel with a multi-package artifact.

Gate 5, who may call.

  • requireInstallCapability (:1784) runs before the body is parsed, so before dryRun is read: anonymous → 401, a held session → 403, no manage_metadata → 403 FORBIDDEN (:5954-5977). The dry run has exactly the install's gate. The counts are disclosed only to a principal who could install, and they are environment-wide aggregates, not another tenant's rows. The dry run does not run the D5e ceremony (2b) or the access gate (2c), so a dry run can say "plan" for an install the real call would then refuse; the PR states this. That ordering discloses nothing beyond the plan.

Gate 7, tests and ablations, read not run.

  • Suspected-rename ablation (storedAdded forced empty): package-upgrade-plan.test.ts:53 (one suspected rename), :93 (the unlinked renamedFrom case expects a suspected rename), :111 (two candidates) go red; the rest hold. 3/17, as reported.
  • Declared-rename type ablation (type change limited to target === key): :78 goes red; nothing else asserts a type change on a renamed key. 1/17, as reported.
  • Dry-run guard ablation (step 2a′ removed): marketplace-install-local-upgrade-plan.test.ts:127 (register called, ledger moves), :154 (ledger entry appears), :175 (installs instead of 503) go red; the control, the non-boolean and the unmanaged cases still pass because step 0′ stays. 3/6, as reported.
  • Every card acceptance bullet this PR carries has a named test, and I found each one in the file at the line the PR names.

Gate 8, check-runs on 3100b841, latest run per name: 23 green (among them Governed Surface Queue Guard, Check Changeset, PR-body guards, Lint & Repo Gates, Lint · ESLint, Lint · PM dispatch-gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Dogfood Regression Gate, Test UI, Live E2E UI); 10 in progress (Build Core, Test Core 1/8 to 8/8, Type Check · workspace); 13 skipped (UI, docs, opt-in harnesses); none red. mergeable: true, mergeable_state: blocked: the lag of 17 commits is not a conflict (no overlap on any changed file); blocked is the pending required checks and the review this record supplies. Landing waits for the ten to finish green.

② Semver level

  • Changeset .changeset/30884-package-upgrade-plan.md: patch on @objectstack/metadata-core, @objectstack/objectql, @objectstack/cloud-connection, @objectstack/cli. Correct by AGENTS.md's rule: Metadata-protocol: none takes patch; a non-protocol widening ships patch.
  • Clause-②: yes (widening) is right. New exports from metadata-core (index.ts:164), a new planUpgrade method on the manifest service, a new dryRun body key, a new --dry-run flag, new os diff entries. The PR body and the changeset carry the same two declaration lines. "Nothing a request did before answers differently" holds with one hairline: a non-boolean dryRun, ignored before, is now 400. No client sent that key, so this is not a narrowing anyone can meet.
  • Metadata-protocol: none is right. The PR reads renamedFrom (landed with feat(spec): a field declares the key(s) it was renamed from — renamedFrom on FieldSchema #30934) and adds nothing an author can write.
  • The install-local body is not declared in packages/spec. packages/spec/src/api/package-lifecycle.zod.ts:391-403 declares the import body of the unmanaged import door, not this route; POST /api/v1/marketplace/install-local reads body: any with per-key checks in the handler. dryRun is accepted by its own typeof check (:1823). No spec file is owed. The two codes the dry run answers (INVALID_REQUEST, SERVICE_UNAVAILABLE) are standard catalog codes this plugin already uses; no ledger hunk is owed in PR 1.
  • Served tier: the recogniser (record-recognisers.mjs --template --paths <13 paths>) finds no published-schema or governed path and maps the changeset's Clause-②: yes (widening) line to the declaration face, TIER_DEFAULT. contract-review.md says a Clause-②: yes-only diff owes TIER_DEFAULT and the tier is exact, not a floor, so this record is served at TIER_DEFAULT. The review itself was run at the depth the claim (6686492425) asked for; the judgments do not change with the tier.

③ Boundary flags

  • The dev's open_questions are all PR 2 (ledger code name, the declared-rename refusal shape, what the verdict reads, where the gate sits). None bears on PR 1. On the third, the dev's option A (verdict from the pure comparison, readings attached when planUpgrade exists) is consistent with this PR's 503 for a dry run without planUpgrade: a preview that cannot run is refused, a verdict must not hinge on a reading. Carry that distinction into PR 2.
  • The older-runtime hazard (S1) is a CLI contract flag for the seat: file a card, or fold a pre-send probe into PR 2.
  • planUpgrade(installed, incoming) takes no organization and reads with the system context across every organization. Cloud's hosted install door (sibling card 01c, repo:cloud) must call it per environment, never per organization within a shared runtime, or it will show one organization another's counts. Name this in 01c's claim.
  • content/docs/deployment/cli.mdx has no row for --dry-run; outside this claim's file surface, noted by the dev, not filed. The seat cards it.
  • The dry run emits the forward-conversion warning for the incoming body once and marks it warned in memory, so a real install in the same process will not print it a second time. Benign; stated so the log is not read as a missing conversion.
  • PR author user.login is os-elon-musk, not objectstack-fleet[bot]: the PR was created off-relay by the seat's account (dev report, api_writes). Read before landing, as the review rules ask.
  • Head 3100b841 is unchanged from the dev report and the handoff. Local verification in the PR body (gates, suites, typecheck) was not re-run here; the check-runs are the measure.

④ Design judgment

  • The comparison's home (metadata-core, a package cli, objectql and cloud-connection already depend on, so no new edge) holds against the card: one function, read by os diff, the door and the service. The three drift words are restated and pinned by type to the driver's, which is the right shape for a package that cannot depend on a driver.
  • The scope note is honoured in the code, not only in a test: the type-change rule reads no category and no rename.
  • Step 2a′ is placed where the card asks: after every refusal that says the package cannot install, before any ceremony and before the first write. Answering before the ceremonies is the right trade for a preview; the PR says what it does not say.
  • The environment half restates the seed loader's private seedValueEquals through the same @objectstack/core functions. Two homes for one comparison is a drift risk; the dev noted the follow-up. Not blocking.
  • S1 is the one place where the shipped sentence ("installs nothing") can be false. It is disclosed and reversible, and the fix sits on the CLI side; it does not block this PR.

Implemented-by: claude/project-thread-89x8tb
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T15:32Z · #30884

Head-sha: 3100b84

  • Contract review: TIER_DEFAULT record 6688583190 on this head, PASS, no must-fix. The diff touches no published schema and no governed rule text; the only face is the Clause-②: yes (widening) declaration, which the record confirms (a non-boolean dryRun, ignored before, is now 400; no client sent that key).
  • Inside claim 6686492425 (branch for PR 1 of Package upgrade plan: one framework comparison of installed vs new version (fields, types, renames, seeds) as a dry run; declared renamedFrom is planned, an undeclared rename or type change is refused unless confirmed (epic cloud#3652 Q3) #30884), Refs #30884: the pure comparison in @objectstack/metadata-core, planUpgrade on objectql's manifest service, dryRun: true on POST /api/v1/marketplace/install-local answered at step 2a′ before any ceremony or write, os package install --dry-run, and os diff reading the same comparison. The Q3 refusal is PR 2 and is not in this PR.
  • Read by the seat:
    • The dry run answers after the admission gate and every "cannot install" refusal, before the D5e ceremony, the access gate and the first write; the door test pins register and syncSchemas never called and the ledger bytes unchanged.
    • A declared rename whose type changes is also a type change at any category, as scope note 6685166393 asked.
    • UpgradeDriftCategory is pinned type-equal to driver-sql's DriftCategory by a tsc line under check:test-typecheck, not only by the runtime assert.
    • os diff's output pin was added before the move and did not change across it.
  • Should-fix, not blocking (from the record): S1, --dry-run against a runtime older than the key installs, and the CLI only notices afterwards (the PR body says so); S2, an overwritten customer value in a column the installed seed did not write is not counted (under-reports only). Both go into the card's PR 2 handoff for the next claim.
  • Gates: on this head 31 success, 13 skipped, 3 still running, 0 failure at the time of this record. Locally: dev report 6688229380. Auto-merge waits for the rest to finish green.
  • Changesets: patch on metadata-core, objectql, cloud-connection and cli, Metadata-protocol: none: right.
  • Landing: ready, then auto-merge, through the relay. The card stays open for PR 2.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 15:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 15:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 8847e09 Nov 6, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-89x8tb branch November 6, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants