|
5 | 5 | ([#14487](https://github.com/objectstack-ai/objectstack/issues/14487)): the permission matrix |
6 | 6 | §1.3(a) measures is **not** part of this boundary's payoff — permission sets stay whole in the |
7 | 7 | `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. |
8 | 12 | **Deciders**: ObjectStack maintainer, 2026-09-01, live PM chat, verbatim and untranslated: |
9 | 13 | 「立 ADR 起草卡派发」and 「14122 作为epic 任务集中跟踪」— approving the proposal in |
10 | 14 | [#14122](https://github.com/objectstack-ai/objectstack/issues/14122) into the ADR-drafting lane |
@@ -246,6 +250,11 @@ legibly. |
246 | 250 |
|
247 | 251 | ### D4 — Artifact schema: `packages: [...]` is additive, and both shapes are read |
248 | 252 |
|
| 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 | + |
249 | 258 | `ObjectStackDefinitionSchema` gains an **optional** `packages` key carrying an array of |
250 | 259 | manifests. `manifest` (singular, `packages/spec/src/stack.zod.ts`) is **retained**, and the load path reads |
251 | 260 | both: |
@@ -636,3 +645,101 @@ rediscovered: |
636 | 645 | all-objects matrix at the environment-admin door. After a split the app package owns the sets |
637 | 646 | but no objects, so where a *packaged* cross-module set's grants are authored is a UI question |
638 | 647 | 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. |
0 commit comments