Skip to content

Commit 6057357

Browse files
os-steveclaude
andauthored
feat(spec)!: a multi-package artifact carries its metadata once, in packages[] (#14512) (#19666)
Fixes #14512 Clause-②: yes (narrowing) The emitter half of ADR-0130 D4 option B, ruled on #14512 (maintainer, decision batch #23, 2026-09-03, comment 5528589044): **readers first, emitter last**. `composeStacks(…, { manifest: 'preserve' })` stops emitting the flattened top-level collections for a multi-package artifact, so each definition is serialized once, under the package that owns it. ## The measurement the dispatch ordered first, and which fork it selected Triage's 置信缺口 asked whether the collections inside a `packages[i]` body have the **same shape** as the flattened top level, because if they differ, option B is a shape adaptation and the ruling's cost estimate has to be re-ordered. Measured on `origin/main` `80ca0b1c88`, three ways: 1. **Declaration identity — 35/35 keys are the SAME object.** `ObjectStackDefinitionSchema.shape` ∩ `AssembledPackageBodySchema.shape` is 35 keys, and for every one of them the two shapes hold the *identical schema object* (`top[k] === body[k]`), because `assembledPackageBodyShape()` copies the entries of `STACK_DEFINITION_COLLECTIONS_SHAPE` that the stack schema also spreads. There is one declaration per collection, not two that happen to agree. 2. **The key sets differ only outside the collections.** The body additionally carries 20 manifest identity fields (`id`, `name`, `version`, `type`, `namespace`, `scope`, `engines`, …). Eleven top-level keys have no body expression and are the artifact envelope: `api`, `devHint`, `devLogins`, `devPlugins`, `i18n`, `manifest`, `onEnable`, `packages`, `plugins`, `runtimeModule`, `server`. Those eleven are exactly what this change keeps at the top level. 3. **Data level.** On `examples/app-multi-package` the top-level collections were an identical multiset to the union of the bodies (`apps` 1 = 1, `objects` 2 = 2). On the two-package collection zoo (`packages/cli/test/fixtures/option-b-collection-zoo.ts`), all 11 populated families identical, 0 differing. ⇒ **Same shape. Option B is "iterate somewhere else", and the emitter change proceeds.** Two content facts measured beside the shape, both about `objects` and both already known to the ruling (5521308880): under `objectConflict: 'merge'` the top level carries a merged object no body carries, and a standalone action bound to a *sibling package's* object is merged into the top-level object only. Those are composition-time derivations of the flattened copy, not a different shape of any collection — and they are the double source of truth the ruling refused to keep. ## The mechanical re-verification of the reader program `5557596712`'s all-clear is a card-state reading and says so. The program's own criterion is #15004's acceptance probe, run here rather than inferred: `packages/cli/test/option-b-reader-acceptance.pin.test.ts`, which boots a two-package collection zoo through every load boundary in both shapes. - Run on `origin/main` `80ca0b1c88` **before any edit**: `Test Files 1 passed / Tests 9 passed`. All **38 probe rows present on both legs, 0 lost**, `OPTION_B_LOSSES` empty, five boundaries represented. No still-red subsystem, so no seventh reader card is owed. - Re-run with this PR's emitter landed: the same 9 pass, 38 rows, 0 lost. ## What a top-level-only reader now sees, and the size `examples/app-multi-package`, built with `os build` on both sides of the change (the spec source flipped to the base commit, spec rebuilt, artifact rebuilt, then restored and rebuilt — restore verified by blob hash and an empty `git diff HEAD`): | | top-level keys | `dist/objectstack.json` | |:--|:--|--:| | before | `apps, manifest, objects, packages` | 8,223 B | | after | `manifest, packages` | 5,328 B (−35.2%) | A consumer that only ever read the top level sees **no collections at all** for a multi-package artifact — the change is breaking for it, which is what `Clause-②: yes` and the changeset's `**BREAKING**` banner declare. Every reader this repository ships was converted first and reads through `packages[]`; an artifact built *before* this change still carries both halves and still loads, because D4's read-both rule is untouched. `os build`'s own summary is unchanged (`Data: 2 Objects 5 Fields · UI: 1 Apps`) and its three author-time findings are the same three, in a different order (the union rule input is derived from package order now). One behaviour that moves for the better, measured on the probe's compiled-artifact leg: a seed dataset declared once reached the runtime's shared seed registry **twice** on the additive artifact (the body copy is stamped `_packageId`/`_provenance` by registration before the collection resolver compares it, so the top-level copy's identity claim misses) and reaches it **once** now. ## The three conditions that keep this a copy-removal `packagesCarryEveryInputCollection` and `packagesReproduceComposedCollections` strip only when all three hold, and each is pinned: - **Two or more package entries** — ADR-0130 D7 as a condition rather than a promise: a single-package artifact is byte-identical to before. - **Every input's collections attributed to a body** — an input with no `manifest` has no package to own its collections (`manifest` is optional on the stack schema), and an input that carries `packages` contributes those entries untouched, so its own collections belong to no body. Either shape keeps today's additive output, which every reader still reads. Neither is refused: a composition that was legal before stays legal. - **The bodies reproduce the composed collections** — ⭐ added after the at-tier review measured the first two insufficient. Conditions 1 and 2 read the INPUT stacks; what the strip deletes is the **COMPOSED** result, and those differ wherever composition reconciled something. `packagesReproduceComposedCollections` asks the question the strip actually depends on — does the union of the bodies reproduce the flattened collections, item for item — as multiset equality over a stable serialization per package-owned key, mirroring `@objectstack/runtime`'s own `stableIdentity` so producer and reader cannot disagree. Deliberately **not** array-order equality: a `packages[]` artifact registers in topological order, and requiring order would keep the copy where nothing is lost. Two shapes fail it and keep the additive output: an `objectConflict: 'merge'`/`'override'` collision, where the flattened half is the **reconciled** copy; and a standalone action bound by `mergeActionsIntoObjects` onto an object a **sibling** package owns, where no name collides at all and the flattened half still is not a copy. The strip also moved to after `mergeActionsIntoObjects`, so what it tests is what that step produced rather than an earlier draft. ## Verification - **Ablation (reverse verification), with the MUTATION named because the count depends on it.** Replacing the `packagesReproduceComposedCollections` conjunct with `true` and re-running: the five changed spec files go **`3 failed | 103 passed (106)`** — the failures are exactly the three new cases (merge, override, sibling-binding) — and the runtime consumer pin goes **`1 failed | 14 passed`**, failing with `expected [ …(2) ] to have a length of 1 but got 2`. Baselines on the same files are `106 passed` and `15 passed`, both exit 0. Ablating conditions 2 and 3 together instead gives `6 failed | 100 passed`. ⚠️ Earlier revisions of this body quoted `2 failed | 34 passed` and then `5 failed | 98 passed`; **neither reproduces against the mutation and file set named here**, because each was taken over a different pair of the two. The criterion is load-bearing either way — that is what the ablation establishes — but an ablation count is exactly the kind of claim that must not be inherited, so this body now carries the mutation, the file set and the baseline alongside the number. Tree restored and verified clean after each leg. - **Tests** (exit codes captured before any pipe, each through `scripts/pm/os-verify-lock.sh`, whose `VERDICT` line is the reading): `@objectstack/spec` 511 files / 14,934 tests; `@objectstack/cli` unit tier 222 files / 3,141 tests; `@objectstack/metadata` 53/792; `@objectstack/objectql` 303/5,050; `@objectstack/runtime` 272/3,799; `@objectstack/plugin-security` 117/2,250; `@objectstack/plugin-dev` 8/76; `@objectstack/verify` 15/116; `@objectstack/core` 51/1,321; `@objectstack/dogfood` `multi-package-artifact.dogfood.test.ts` 9/9 — all green. `typecheck` green for spec, cli, metadata, plugin-security, plugin-dev. - **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (120 families, from the merge-base change set, not a hand-written list) and reconciled with `--ran`: **120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN**, every family recorded with its own exit code. Two needed a prerequisite build first (`check:dual-build-cjs-loads`, `check:i18n-coverage` — exit 3, not a finding) and one needed `@objectstack/client-react` built (`check:skill-examples`); all three are green after building what they name. `pnpm --filter @objectstack/spec check:generated` — all 15 generated artifacts up to date, nothing to regenerate. `node scripts/check-changeset-no-major.mjs --base origin/main` exit 0; `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0, reading this changeset as `[BREAKING+clause-②-narrowing]`. - **Lint, narrowed and declared.** `pnpm exec eslint --no-inline-config --format json` over the 13 lintable changed files: exit 0, 13 files, 0 errors, 1 warning that is `File ignored because no matching configuration was supplied` on the `.mdx` page and is identical on the base revision. The narrowing is sound because this repository runs one `eslint.config.mjs` which **never enables type-aware linting** (no `parserOptions.project`, no typed `@typescript-eslint` rules) for any file — stated in that file's own header at the `QUERY_OPTIONS_TEST_GLOBS` block — so no edit in these files can move the verdict on a file they do not contain. The repo-wide sweep is CI's. ## Landing ⚠️ **This diff touches a governed surface: `docs/adr/0130-release-artifact-as-co-ownership-boundary.md` (Tier H under Prime Directive #14).** The addendum is part of what the ruling ordered for the emitter half, and Prime Directive #13 forbids changing behaviour in ADR-governed territory without the record moving with it, so the two are here together. ⛔ No seat merges, queues or arms auto-merge on this PR: Tier H lifts only for an authorized approval. A contract review at the review tier is owed as well and is the seat's to dispatch against this head — ⛔ not self-reviewed here. ## Acceptance notes - `packages/runtime/src/artifact-collections.ts` says `#14877` will publish the package-owned key set as an export, at which point three private derivations (that module, `packages/cli/src/utils/stack-collections.ts` and the `packageOwnedCollectionKeys()` added here) read one symbol instead. Noted, not filed here — it is an open card, and nothing in this diff makes it more urgent. - The `os build` author-time findings arrive in a different ORDER after this change (same three findings, same text). No card: the union rule input is unordered by contract and nothing pins the order. ## 维护者速读(草稿) **改了什么** —— 多包产物(一个 `objectstack.json` 里装 N 个包)以前把每条元数据写两遍:顶层扁平一份、`packages[]` 每个包体里再一份。现在只写一份,写在拥有它的包里。单包产物一个字节都没动。 **为什么改** —— 两份副本没有任何东西负责对账,合并/覆盖策略下它们已被实测为内容不同,消费者看到哪一份取决于它走了哪个读取方;体积也白涨了 35%(示例实测 8,223 → 5,328 字节)。维护者 2026-09-03 已裁定方向(读取方先改、发射端最后改),读取方六张卡全部落地,本 PR 是最后一步。 **风险与代价(含回滚)** —— 风险面是"某个读取方没被改到",症状是静默的:不报错,集合直接为空。为此本 PR 先跑了 #15004 的验收探针(38 个读取点、两种产物形态、五个装载边界),落地前后都是 0 丢失。已经构建好、躺在客户磁盘上的旧产物不受影响 —— ADR-0130 D4 的"两种形态都读"规则没动。回滚 = 还原 `packages/spec/src/stack.zod.ts` 的这一步(一个 `if` 加两个辅助函数),产物立刻回到两份副本的旧形态,不需要迁移。 **席位意见** —— **你要做的** —— 这个 PR 动了 `docs/adr/0130-*.md`(Tier H 受管面),按 Prime Directive #14 只有您的批准能让它落地;ADR 附录记的就是您 2026-09-03 那次裁定的执行结果。请看两处:①附录里"D4 的哪一半变了、哪一半没变"的表述是否准确;②「两种组合保持旧形态」这条兜底是否是您要的(没有 manifest 的输入、自带 `packages` 又自带集合的输入,都不剥离,也都不拒绝)。 <sub>Body rewritten by the dispatching seat `domain:spec#4` (`session_01AmH9bKvGoLjiY86Q4Z3og2`) after the at-tier review's FAIL and the dev's rework — the three edits the dev named and, per protocol, did not apply to a body it wrote at creation.</sub> --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5afe38 commit 6057357

16 files changed

Lines changed: 800 additions & 110 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`composeStacks(…, { manifest: 'preserve' })` emits each definition **once**, in the body of the package that owns it: a multi-package release artifact no longer carries a flattened copy of its collections at the top level (#14512, ADR-0130 D4's 2026-09-22 addendum).
6+
7+
Measured on `examples/app-multi-package` (two packages, three definitions): `dist/objectstack.json` goes from 8,223 to 5,328 bytes, −35%, and the ratio does not improve with size — it was one extra copy of everything a package owns. The artifact's own `manifest`, `packages`, `plugins`, `devPlugins`, `devLogins`, `api`, `server`, `i18n`, `runtimeModule`, `onEnable` and `devHint` are untouched at the top level; the 35 package-owned collections are what moves.
8+
9+
**BREAKING** for anything that read a compiled multi-package artifact's top-level collections directly. Every reader the platform ships was converted first — the ruling's order was readers first, emitter last — and this lands only with #15004's acceptance probe green on both shapes, which boots a two-package collection zoo through all five load boundaries and fails if any subsystem sees an empty collection.
10+
11+
- **No authored metadata changes, and no artifact on disk is invalidated.** The authoring shape is identical: N `defineStack` packages plus a project config composing them with `manifest: 'preserve'`. ADR-0130 D4's read-both rule is untouched — `packages` present ⇒ iterate it, `packages` absent ⇒ `manifest` as a single-element list — so an artifact built before this change, which carries both halves, loads exactly as it did.
12+
- **A single-package artifact keeps today's shape byte for byte** (ADR-0130 D7): the emitter strips nothing below two package entries.
13+
- **The flattened half is dropped only where it is a COPY — three conditions, and a composition failing any one of them keeps today's additive shape**: (1) two or more package entries; (2) every input's collections attributed to a body — an input declaring no `manifest` has no package that could own its collections, and an input already carrying `packages` contributes those entries untouched; (3) the bodies reproduce the flattened collections item for item. Condition 3 is what `objectConflict: 'merge'` / `'override'` and a standalone action bound onto a SIBLING package's object fail: composition RECONCILES those into a top-level definition no body carries, so the flattened half is not a copy of anything and stays. Nothing here is refused — a composition that was legal before stays legal.
14+
- **Why the copy goes rather than being compressed**: one definition was serialized twice with nothing keeping the copies equal. Where they differ today the flattened one is the reconciled copy and the platform's reader prefers it deterministically, which is exactly why this change removes the second copy only where the first one is redundant. One measured consequence on the compiled path: a seed dataset declared once reached the runtime's shared seed registry twice, and now reaches it once.
15+
16+
Clause-②: yes (narrowing)
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves: no spec key, export or config field is removed or renamed, and no stored metadata representation changes, so `objectstack migrate meta` has nothing to rewrite. What changed is the SHAPE A COMPILER EMITS, and every reader of it in this repository was converted by the reader half of the same ruling (#15005, #15006, #15007, #15229, #15232) before this landed. -->

‎content/docs/getting-started/examples.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,10 @@ export default defineStack({
411411

412412
`os build` compiles that project into **one** `dist/objectstack.json` carrying a
413413
`packages[]` list — one entry per package, each holding that package's own
414-
manifest fields and the metadata it owns. `os dev` boots the same shape straight
415-
from source, and `GET /api/v1/packages` on a running instance lists every
416-
package in the artifact.
414+
manifest fields and the metadata it owns. Each definition is written **once**,
415+
in the body of the package that owns it, beside the artifact's own `manifest`.
416+
`os dev` boots the same shape straight from source, and `GET /api/v1/packages`
417+
on a running instance lists every package in the artifact.
417418

418419
The rules worth knowing before you split a product this way:
419420

‎docs/adr/0130-release-artifact-as-co-ownership-boundary.md‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
([#14487](https://github.com/objectstack-ai/objectstack/issues/14487)): the permission matrix
66
§1.3(a) measures is **not** part of this boundary's payoff — permission sets stay whole in the
77
`type: app` package.
8+
**D4's emitted shape amended by the 2026-09-22 addendum**
9+
([#14512](https://github.com/objectstack-ai/objectstack/issues/14512)): a multi-package artifact
10+
carries its metadata **once**, in `packages[]` — the flattened copy is no longer emitted beside
11+
it. D4's read-both rule and D7 are unchanged, so every artifact already on disk still loads.
812
**Deciders**: ObjectStack maintainer, 2026-09-01, live PM chat, verbatim and untranslated:
913
「立 ADR 起草卡派发」and 「14122 作为epic 任务集中跟踪」— approving the proposal in
1014
[#14122](https://github.com/objectstack-ai/objectstack/issues/14122) into the ADR-drafting lane
@@ -246,6 +250,11 @@ legibly.
246250

247251
### D4 — Artifact schema: `packages: [...]` is additive, and both shapes are read
248252

253+
⚠️ **Amended 2026-09-22 (#14512) — see the addendum at the foot of this record.** "Additive" below
254+
describes the SCHEMA (the key is optional and `manifest` is retained) and the LOAD path (both
255+
shapes are read), and both still hold. What no longer holds is the EMITTED shape: a multi-package
256+
`composeStacks(…, { manifest: 'preserve' })` output carries its collections in `packages[]` only.
257+
249258
`ObjectStackDefinitionSchema` gains an **optional** `packages` key carrying an array of
250259
manifests. `manifest` (singular, `packages/spec/src/stack.zod.ts`) is **retained**, and the load path reads
251260
both:
@@ -636,3 +645,101 @@ rediscovered:
636645
all-objects matrix at the environment-admin door. After a split the app package owns the sets
637646
but no objects, so where a *packaged* cross-module set's grants are authored is a UI question
638647
this record does not answer and #14488 inherits.
648+
649+
---
650+
651+
## Addendum (2026-09-22, #14512) — a multi-package artifact carries its metadata ONCE, in `packages[]`
652+
653+
**Provenance.** Maintainer ruling, 2026-09-03, live PM chat with the director seat (decision batch
654+
#23), recorded on
655+
[#14512](https://github.com/objectstack-ai/objectstack/issues/14512#issuecomment-5528589044).
656+
Verbatim reply: 「同意」. It re-scopes the 2026-09-02 ruling of the same direction
657+
([#14512 comment 5518059994](https://github.com/objectstack-ai/objectstack/issues/14512#issuecomment-5518059994),
658+
also 「同意」) whose cost estimate — "an iteration change, not a shape adaptation" — a reader
659+
enumeration falsified: the reader surface was at least fourteen sites across three packages, and
660+
the one-fold option was measured dead. The ruling's own words for what stands:「B stands,
661+
re-scoped as a program: readers first, emitter last; the release is not held」.
662+
663+
### What D4 said, and what this changes
664+
665+
D4 above declares `packages` **additive**: the load path reads both shapes, and
666+
`composeStacks(…, { manifest: 'preserve' })` emitted the flattened collections **and** a package
667+
list carrying the same definitions a second time. That second half is what changes. A
668+
multi-package artifact now carries each object, view, flow, permission set and every other
669+
package-owned collection **once**, in the body of the package that owns it.
670+
671+
**⛔ D4's read-both rule is untouched.** `packages` present → iterate it; `packages` absent →
672+
`manifest` as a single-element list. An artifact built before this change carries both halves and
673+
loads exactly as it did — which is why this is a producer change and not a format migration, and
674+
why no artifact on disk anywhere is invalidated by it.
675+
676+
**D7 is untouched and is stated as a condition rather than trusted.** A single-package artifact
677+
keeps today's shape byte for byte: the emitter strips nothing unless the artifact carries **two or
678+
more** package entries.
679+
680+
### Why the copy goes, rather than being compressed
681+
682+
- **Size.** The reserved `{ ref, integrity }` external-segment position exists in D4 because
683+
artifact size is a real constraint at 2.6 MB for a single package. Emitting every definition
684+
twice moves in the opposite direction, and it did so in the same format that reserved the
685+
position. Measured on `examples/app-multi-package` (two packages, three definitions):
686+
8,223 → 5,328 bytes, −35%.
687+
- **One source of truth.** Two copies of one definition, and nothing that keeps them equal once
688+
they are written. They are measured to differ whenever composition merges or overrides an object,
689+
and which copy a consumer sees is decided by the reader it goes through rather than by the
690+
artifact.
691+
692+
⚠️ **Stated precisely, because the imprecise version is load-bearing in the wrong direction**
693+
(PR #19666 review, 2026-09-22): where the two halves differ today, the flattened copy is the
694+
RECONCILED one and the platform's own reader deterministically prefers it —
695+
`resolveArtifactCollections` claims by name from the top level first, so a merged object is
696+
answered once, merged, and its two unmerged halves are dropped. The divergence this record
697+
objects to is therefore not "the reader picks arbitrarily"; it is that **one definition is
698+
serialized twice with nothing reconciling the copies**, which is a standing invitation for a
699+
producer, a hand-edit or a future reader to disagree with that preference. It also bounds what
700+
the emitter may do: dropping the flattened half is only ever legitimate where it is a COPY, and
701+
where composition reconciled something it is not.
702+
703+
### The order this landed in, which is the decision's substance
704+
705+
**Readers first, emitter last.** Every reader of a top-level collection gained a `packages[]` path
706+
while the artifact stayed additive (#15004's acceptance probe, #15005 `@objectstack/runtime`,
707+
#15006 `@objectstack/cli`, #15007 `@objectstack/plugin-security`, #15229 `@objectstack/verify`,
708+
#15232 `@objectstack/plugin-dev`, and the `plugins` / `devPlugins` contract ruling #15219). The
709+
emitter is the last step, and the acceptance probe — which boots a two-package collection zoo
710+
through every load boundary in **both** shapes and fails if any subsystem sees an empty collection
711+
— is the mechanical criterion for calling the program done, ⛔ never a card-state reading of the
712+
blocker list.
713+
714+
The failure mode that order exists to contain is a reader nobody enumerated, and it is SILENT:
715+
nothing throws, the collection is simply absent. ⛔ A missed reader is a new reader card, never a
716+
fallback bolted onto the emitter.
717+
718+
### What is NOT decided here
719+
720+
- ⛔ **Not a partly flattened artifact.** The emitter flips whole-artifact. A shape carrying some
721+
collections flattened and others only in bodies is a new permanent shape and was refused by name
722+
in the same ruling (option D).
723+
- ⛔ **Not the four envelope keys.** `packages`, `plugins`, `devPlugins` and `devLogins` are not
724+
package-owned collections (#15219, #17556): they stay at the artifact's top level, where
725+
`composeStacks` still concatenates them and the host reads them.
726+
- **Three conditions hold the emitter to a copy-removal, and a composition failing any one of them
727+
keeps the additive shape** — by construction rather than by choice, since stripping there would
728+
delete metadata rather than a copy of it:
729+
730+
1. **two or more package entries** (D7 above);
731+
2. **every input's collections attributed to a body** — an input declaring no `manifest` has no
732+
package that could own its collections (`manifest` is optional on the stack schema), and an
733+
input that already carries `packages` contributes those entries untouched, so its own
734+
collections are attributed to no body either;
735+
3. **the bodies reproduce the flattened collections, item for item.** Composition is not always a
736+
concatenation: `objectConflict: 'merge'` / `'override'` RECONCILE two packages' same-named
737+
objects into one, and `mergeActionsIntoObjects` binds a standalone action onto an object a
738+
SIBLING package owns. In both cases the flattened half carries a definition no body carries,
739+
so it is not a copy. Condition 2 cannot see this — it reads the INPUT stacks, while what the
740+
strip deletes is the COMPOSED result. Measured on a two-package `merge` composition: the
741+
registration-path reader answers one reconciled object with the flattened half present, and
742+
two conflicting partial objects with it gone (PR #19666 review, 2026-09-22).
743+
744+
Every one of those shapes stays readable by D4's read-both rule, and none is refused: a
745+
composition that was legal before this change stays legal.

‎examples/app-multi-package/objectstack.config.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,31 @@ import ordersStack from './src/packages/orders/index.js';
2020
*
2121
* ## What `preserve` produces
2222
*
23-
* The composed stack the platform has always produced — every collection
24-
* flattened to the top level — PLUS `packages[]`, one entry per input stack,
25-
* each carrying that package ASSEMBLED (its manifest fields with the
26-
* collections it owns written over them). The flattened top level is what the
27-
* metadata service reads; `packages[]` is what `ObjectQL.registerApp`
28-
* registers, package by package, in dependency-topological order — which is
29-
* where per-package ownership comes from. Without the list, a two-package
30-
* artifact would install two package records owning nothing at all.
23+
* `packages[]`, one entry per input stack, each carrying that package ASSEMBLED
24+
* (its manifest fields with the collections it owns written over them) — and
25+
* the artifact's own `manifest`, which is its identity. Each definition is
26+
* serialized ONCE, under the package that owns it: a multi-package artifact
27+
* carries no flattened copy of its collections at the top level (#14512,
28+
* ADR-0130 D4's 2026-09-22 addendum). This project is that shape — its two
29+
* packages own different objects, so the package bodies carry everything the
30+
* flattened half would have.
31+
*
32+
* ⚠️ A composition that RECONCILES something keeps the flattened half instead,
33+
* because there it is not a copy: `objectConflict: 'merge'` / `'override'` fold
34+
* two packages' same-named objects into one, and a standalone action binds onto
35+
* an object a sibling package owns. Neither is refused and both stay readable —
36+
* the emitter drops the flattened collections only when the bodies reproduce
37+
* them item for item.
38+
*
39+
* `packages[]` is what the metadata service's artifact door reads and what
40+
* `ObjectQL.registerApp` registers, package by package, in
41+
* dependency-topological order — which is where per-package ownership comes
42+
* from. Without the list, a two-package artifact would install two package
43+
* records owning nothing at all.
44+
*
45+
* An artifact built BEFORE that change carries both halves, and every reader
46+
* still resolves either (D4's read-both rule), so upgrading the compiler does
47+
* not invalidate an artifact already on disk.
3148
*
3249
* ## Why the module is listed FIRST
3350
*

‎packages/cli/src/utils/permission-set-name-collisions.test.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,28 @@ describe('#18024 — `os build` reports a permission-set name another package in
226226
expect(await findPermissionSetNameCollisions(parsed)).toEqual([]);
227227
});
228228

229-
it('reports the collision ONCE, not once per surface the composed artifact carries it on', async () => {
230-
// `manifest: 'preserve'` is ADDITIVE: `permissions` composes by `concat`,
231-
// so on an artifact the top-level collection is the union of every
232-
// package's sets with their per-package provenance flattened away. A walk
233-
// that read both levels would report each collision twice — the second time
234-
// attributed to whichever manifest composition picked.
229+
it('reports the collision ONCE, not once per surface an artifact carries it on', async () => {
230+
// A multi-package artifact built before #14512's emitter half carries BOTH
231+
// surfaces: `permissions` composes by `concat`, so its top-level collection
232+
// is the union of every package's sets with their per-package provenance
233+
// flattened away, beside the bodies that still carry them. Such artifacts
234+
// are on disk and D4's read-both rule still loads them, so the hazard this
235+
// case exists for is live: a walk reading both levels reports each
236+
// collision twice — the second time attributed to whichever manifest
237+
// composition picked.
235238
const parsed = parsedArtifact([ordersStack([ordersSet(SHARED_NAME)]), coreStack([coreSet(SHARED_NAME)])]);
236-
expect((parsed.permissions as unknown[]).length, 'the top level really does carry both sets').toBe(2);
239+
expect(parsed.permissions, 'today the composer emits ONE surface (#14512)').toBeUndefined();
237240
expect(await findPermissionSetNameCollisions(parsed)).toHaveLength(1);
241+
242+
// The legacy shape, synthesized: the flattened union written back beside
243+
// the bodies, which is exactly what this walk must not double-count.
244+
const legacy = {
245+
...parsed,
246+
permissions: (parsed.packages as Array<{ manifest: { permissions?: unknown[] } }>)
247+
.flatMap((entry) => entry.manifest.permissions ?? []),
248+
};
249+
expect((legacy.permissions as unknown[]).length, 'the legacy top level really does carry both sets').toBe(2);
250+
expect(await findPermissionSetNameCollisions(legacy)).toHaveLength(1);
238251
});
239252

240253
it('a set whose name no OTHER package here declares is not a finding — the cross-artifact case stays supported', async () => {

0 commit comments

Comments
 (0)