diff --git a/.changeset/spec-compose-key-dispositions-export.md b/.changeset/spec-compose-key-dispositions-export.md new file mode 100644 index 0000000000..dcf6c878b1 --- /dev/null +++ b/.changeset/spec-compose-key-dispositions-export.md @@ -0,0 +1,44 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): export `COMPOSE_KEY_DISPOSITIONS` and `STACK_DEFINITION_KEYS` — the artifact envelope's top-level key set and each key's composition rule, derivable from one source instead of hand-copied per consumer (#14877) + +`minor`, additive: two new named exports and two new exported types on the +root entry; nothing renamed, narrowed or removed. Every existing import keeps +compiling and every behaviour of `composeStacks` is unchanged — the table it +reads is the same object, now frozen and public. + +- `COMPOSE_KEY_DISPOSITIONS` — a frozen, read-only record from every top-level + key `ObjectStackDefinitionSchema` declares (`manifest`, `packages`, + `requires`, `objects`, … `onEnable`) to its composition rule: `'concat'` + (an array collection, concatenated in stack order), `'single'` (identical + declarations pass through, differing ones refuse naming the key), + `'manifest'` (picked by the `manifest` option), `'objects'` (the + `objectConflict` strategy) or `'functions'` (merged by handler name). + Literal-typed, so `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition, + not the union. +- `STACK_DEFINITION_KEYS` — the top-level key set, derived from that table by + `Object.keys` (never a second literal), frozen. +- `StackDefinitionKey` and `ComposeDisposition` — the key union and the + disposition union, for a consumer that types its own seam against them. + +Why: the collection half of this key set was already derivable downstream +(`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`), but the non-collection keys — +`manifest`, `requires`, `packages`, and whatever comes next — had to be +hand-copied by every consumer that walks an artifact's top level, and that +copy drifted silently twice: objectstack-ai/cloud#897 (`roles` → `positions` +dropped every hosted `positions[]`) and objectstack-ai/cloud#1888 (`packages[]` +dropped by an artifact merge, recreating downstream the duplicate-ownership +state #14599 had repaired at the door). A seam that derives its key set from +`STACK_DEFINITION_KEYS` — and asks `COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` +whether a key may be concatenated across artifacts — picks up the next key +(#14865's `grantedPermissions`) the day the schema declares it, with no edit of +its own. + +Pinned (`compose-key-dispositions-export.pin.test.ts`): the exported key set +equals `ObjectStackDefinitionSchema`'s declared top-level key set in both +directions, the view is frozen, every value is a declared disposition, every +`'concat'` key is what `composeStacks` concatenates and every `'single'` key is +what it passes through or refuses, and the key list is `Object.keys` of the +table. diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 15801e4e24..73f514598a 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -332,6 +332,14 @@ blocks first — make them identical, or declare `i18n` in only one of the stacks. Composition never picks a locale set for you: each stack's `translations` bundles are written against the locales that stack declares. +Every top-level key's composition rule is exported: `COMPOSE_KEY_DISPOSITIONS` +maps each key `ObjectStackDefinitionSchema` declares to its rule (`concat`, +`single`, `manifest`, `objects`, `functions`) and `STACK_DEFINITION_KEYS` is +the key set derived from it — a downstream seam that walks an artifact's top +level (a hosted publish, an artifact merge) derives the key set and each key's +disposition from those two exports instead of copying a list, so a key added +to the schema reaches it without an edit. + ### Short Names Are Canonical Each app declares a `namespace` in its manifest, but **the short object name is what you use everywhere** — in `engine.find()`, hooks, formulas, lookups, REST URLs, and physical tables. The namespace is internal metadata used only for package provenance and cross-package disambiguation. diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 785cc37061..0dd41d5a57 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -35,12 +35,14 @@ "Book (type)", "BuiltinIdentityName (type)", "BuiltinMembershipRole (type)", + "COMPOSE_KEY_DISPOSITIONS (const)", "CONVERSIONS_BY_MAJOR (const)", "CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE (const)", "CapabilityClassification (interface)", "CapabilityEdition (type)", "CapabilityProviderStatus (type)", + "ComposeDisposition (type)", "ComposeStacksOptions (type)", "ComposeStacksOptionsParsed (type)", "ComposeStacksOptionsSchema (const)", @@ -124,6 +126,7 @@ "PredicateSchema (const)", "RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR (const)", + "STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS (const)", "SemanticMigration (interface)", @@ -138,6 +141,7 @@ "SpecSurfaceAddSchema (const)", "SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema (const)", + "StackDefinitionKey (type)", "StateNodeConfig (type)", "StoredConversionOptions (type)", "SurfaceDiff (interface)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 9042946abc..75f8348356 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -35,12 +35,14 @@ "Book": "src/system/book.zod.ts#Book (type)", "BuiltinIdentityName": "src/identity/eval-user.zod.ts#BuiltinIdentityName (type)", "BuiltinMembershipRole": "src/identity/membership-role.ts#BuiltinMembershipRole (type)", + "COMPOSE_KEY_DISPOSITIONS": "src/stack.zod.ts#COMPOSE_KEY_DISPOSITIONS (const)", "CONVERSIONS_BY_MAJOR": "src/conversions/registry.ts#CONVERSIONS_BY_MAJOR (const)", "CONVERSION_CONFLICT_CODE": "src/conversions/types.ts#CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE": "src/conversions/types.ts#CONVERSION_NOTICE_CODE (const)", "CapabilityClassification": "src/kernel/platform-capabilities.ts#CapabilityClassification (interface)", "CapabilityEdition": "src/kernel/platform-capabilities.ts#CapabilityEdition (type)", "CapabilityProviderStatus": "src/kernel/platform-capabilities.ts#CapabilityProviderStatus (type)", + "ComposeDisposition": "src/stack.zod.ts#ComposeDisposition (type)", "ComposeStacksOptions": "src/stack.zod.ts#ComposeStacksOptions (type)", "ComposeStacksOptionsParsed": "src/stack.zod.ts#ComposeStacksOptionsParsed (type)", "ComposeStacksOptionsSchema": "src/stack.zod.ts#ComposeStacksOptionsSchema (const)", @@ -124,6 +126,7 @@ "PredicateSchema": "src/shared/expression.zod.ts#PredicateSchema (const)", "RETIRED_DEFS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_DEFS_BY_MAJOR (const)", "RETIRED_KEYS_BY_MAJOR": "src/migrations/registry.ts#RETIRED_KEYS_BY_MAJOR (const)", + "STACK_DEFINITION_KEYS": "src/stack.zod.ts#STACK_DEFINITION_KEYS (const)", "STACK_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#STACK_KEY_GUIDANCE (const)", "STACK_RUNTIME_MEMBERS": "src/data/authoring-key-lint.ts#STACK_RUNTIME_MEMBERS (const)", "SemanticMigration": "src/migrations/types.ts#SemanticMigration (interface)", @@ -138,6 +141,7 @@ "SpecSurfaceAddSchema": "src/migrations/spec-changes.ts#SpecSurfaceAddSchema (const)", "SpecSurfaceRemove": "src/migrations/spec-changes.ts#SpecSurfaceRemove (type)", "SpecSurfaceRemoveSchema": "src/migrations/spec-changes.ts#SpecSurfaceRemoveSchema (const)", + "StackDefinitionKey": "src/stack.zod.ts#StackDefinitionKey (type)", "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", "StoredConversionOptions": "src/conversions/stored.ts#StoredConversionOptions (type)", "SurfaceDiff": "src/migrations/spec-changes.ts#SurfaceDiff (interface)", diff --git a/packages/spec/src/compose-key-dispositions-export.pin.test.ts b/packages/spec/src/compose-key-dispositions-export.pin.test.ts new file mode 100644 index 0000000000..8fecd7fa59 --- /dev/null +++ b/packages/spec/src/compose-key-dispositions-export.pin.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14877 — the compose-key disposition table is a PUBLIC, FROZEN export that + * cannot drift from `ObjectStackDefinitionSchema`. + * + * `COMPOSE_KEY_DISPOSITIONS` is the one place that enumerates every top-level + * key of the artifact envelope together with its composition rule. Until + * #14877 it was module-private, so a downstream seam that walks an artifact's + * top level (a hosted publish, an artifact merge, an assembler) could derive + * the COLLECTION half of that key set from `PLURAL_TO_SINGULAR` / + * `METADATA_ALIASES` and had to hand-copy the rest — and the copy drifted + * silently twice (cloud#897, cloud#1888). The maintainer's ruling + * (2026-09-04, decision batch #38 item 3): export a read-only view of the + * table plus the derived key set — derived from the same table, never a + * second literal. + * + * What this file pins, each a way the export could go quietly wrong: + * + * 1. the exported key set and the schema's declared key set are EQUAL, in + * both directions — a key added to either side without the other reds + * here naming the key. The `satisfies Record` on + * the table is the compile-time half; this is the runtime half, read off + * the schema's ACTUAL shape rather than the TS type derived from it; + * 2. the view is frozen — a consumer cannot widen or retarget the contract + * by assignment (existing key or new key), and the derived key list + * cannot be pushed to; + * 3. every value is one of the declared dispositions — the runtime table and + * the `ComposeDisposition` union name the same vocabulary, and the table + * is assignable to the ruling's declared shape; + * 4. the dispositions are the composer's RULES, not labels: every `'concat'` + * key is what `composeStacks` concatenates, every `'single'` key is what + * it passes through when identical and refuses when different — so the + * `'concat'` subset a consumer reads off the export is exactly the + * module-private `CONCAT_ARRAY_FIELDS` the composer walks; + * 5. the derived key list is DERIVED — same members, same order as + * `Object.keys` of the table — and both symbols reach the package surface + * (`./index`) as the SAME objects, not copies. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + COMPOSE_KEY_DISPOSITIONS, + STACK_DEFINITION_KEYS, + ObjectStackDefinitionSchema, + composeStacks, + defineStack, + type ComposeDisposition, + type StackDefinitionKey, + type ObjectStackDefinition, +} from './stack.zod'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** A hand-built stack: `strict: false` so no element schema runs — the sweep is about keys, not values. */ +function raw(overrides: Record): ObjectStackDefinition { + return defineStack(overrides as never, { strict: false }); +} + +const manifestA = { id: 'com.example.base', name: 'base', version: '1.0.0', type: 'app' as const }; +const manifestB = { id: 'com.example.addon', name: 'addon', version: '1.0.0', type: 'app' as const }; + +/** The schema's declared top-level keys, read off its shape — the same reading the #8687 accept-side sweep uses. */ +function schemaTopLevelKeys(): string[] { + const shape = (ObjectStackDefinitionSchema as unknown as { shape: Record }).shape; + return Object.keys(shape); +} + +const sorted = (keys: readonly string[]): string[] => [...keys].sort(); + +/** + * The disposition vocabulary, stated ONCE as a total record over the exported + * union: tsc reds this literal when `ComposeDisposition` gains a member this + * object does not name (missing key) or loses one it still names (excess + * property), so the runtime check below cannot fall behind the type. + */ +const DECLARED_DISPOSITIONS: Record = { + concat: true, + single: true, + manifest: true, + objects: true, + functions: true, +}; + +const keysWith = (disposition: ComposeDisposition): StackDefinitionKey[] => + STACK_DEFINITION_KEYS.filter((key) => COMPOSE_KEY_DISPOSITIONS[key] === disposition); + +let warnSpy: ReturnType; + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +// ─── 1. Key-set parity with the schema, both directions ───────────── + +describe('#14877 pin 1 — the exported key set equals the schema\'s declared top-level key set', () => { + it('COMPOSE_KEY_DISPOSITIONS names every key the schema declares, and no other', () => { + const schemaKeys = schemaTopLevelKeys(); + // Non-vacuity: the schema really is the 40+-key envelope, not an empty shape agreeing with anything. + expect(schemaKeys.length).toBeGreaterThan(40); + + const tableKeys = Object.keys(COMPOSE_KEY_DISPOSITIONS); + // Sorted equality is set equality in BOTH directions once neither side repeats a key. + expect(new Set(tableKeys).size).toBe(tableKeys.length); + expect(new Set(schemaKeys).size).toBe(schemaKeys.length); + expect(sorted(tableKeys)).toEqual(sorted(schemaKeys)); + }); + + it('names the drift in each direction, not just "arrays differ"', () => { + const schemaKeys = new Set(schemaTopLevelKeys()); + const tableKeys = new Set(Object.keys(COMPOSE_KEY_DISPOSITIONS)); + const onlyInSchema = [...schemaKeys].filter((key) => !tableKeys.has(key)); + const onlyInTable = [...tableKeys].filter((key) => !schemaKeys.has(key)); + expect(onlyInSchema, 'declared on ObjectStackDefinitionSchema but absent from COMPOSE_KEY_DISPOSITIONS').toEqual([]); + expect(onlyInTable, 'in COMPOSE_KEY_DISPOSITIONS but not declared on ObjectStackDefinitionSchema').toEqual([]); + }); + + it('STACK_DEFINITION_KEYS is that same key set', () => { + expect(sorted(STACK_DEFINITION_KEYS)).toEqual(sorted(schemaTopLevelKeys())); + }); + + it('the envelope-only keys the incidents were about are in the set', () => { + // The five members cloud's hand-copied list had grown to by cloud#1888, + // each present here without anyone having listed them. + for (const key of ['manifest', 'packages', 'requires', 'positions', 'data', 'datasets']) { + expect(STACK_DEFINITION_KEYS, `${key} must be a declared top-level key`).toContain(key); + } + }); +}); + +// ─── 2. Frozen ────────────────────────────────────────────────────── + +describe('#14877 pin 2 — the view is frozen', () => { + it('COMPOSE_KEY_DISPOSITIONS is frozen and refuses assignment to an existing key', () => { + expect(Object.isFrozen(COMPOSE_KEY_DISPOSITIONS)).toBe(true); + const mutable = COMPOSE_KEY_DISPOSITIONS as unknown as Record; + // ESM modules are strict-mode code, so a write to a frozen object THROWS + // rather than being silently ignored — the loud direction. + expect(() => { mutable.packages = 'single'; }).toThrow(TypeError); + expect(COMPOSE_KEY_DISPOSITIONS.packages).toBe('concat'); + }); + + it('refuses a NEW key too — a consumer cannot widen the contract from outside', () => { + const mutable = COMPOSE_KEY_DISPOSITIONS as unknown as Record; + expect(() => { mutable.grantedPermissions = 'concat'; }).toThrow(TypeError); + expect('grantedPermissions' in COMPOSE_KEY_DISPOSITIONS).toBe(false); + expect(() => { delete mutable.manifest; }).toThrow(TypeError); + expect(COMPOSE_KEY_DISPOSITIONS.manifest).toBe('manifest'); + }); + + it('STACK_DEFINITION_KEYS is frozen and cannot be pushed to', () => { + expect(Object.isFrozen(STACK_DEFINITION_KEYS)).toBe(true); + const mutable = STACK_DEFINITION_KEYS as unknown as string[]; + expect(() => { mutable.push('grantedPermissions'); }).toThrow(TypeError); + expect(STACK_DEFINITION_KEYS).not.toContain('grantedPermissions'); + }); +}); + +// ─── 3. Every value is a declared disposition ─────────────────────── + +describe('#14877 pin 3 — every value is one of the declared dispositions', () => { + it('no key carries a disposition outside the ComposeDisposition vocabulary', () => { + const vocabulary = new Set(Object.keys(DECLARED_DISPOSITIONS)); + for (const [key, disposition] of Object.entries(COMPOSE_KEY_DISPOSITIONS)) { + expect(vocabulary.has(disposition), `'${key}' carries disposition '${disposition}'`).toBe(true); + } + }); + + it('every declared disposition is used by at least one key (the vocabulary carries no dead word)', () => { + for (const disposition of Object.keys(DECLARED_DISPOSITIONS) as ComposeDisposition[]) { + expect(keysWith(disposition).length, `no key composes as '${disposition}'`).toBeGreaterThan(0); + } + }); + + it('is assignable to the ruled shape — a read-only record from the key union to the disposition union', () => { + // The ruling's declared type. Literal typing on the export is a SUBTYPE of + // it (each key's disposition rather than the union), which is what keeps + // the module's own derivations mechanical; this line is the compile-time + // proof the widening direction still holds. + const view: Readonly> = COMPOSE_KEY_DISPOSITIONS; + expect(Object.keys(view).length).toBe(STACK_DEFINITION_KEYS.length); + }); +}); + +// ─── 4. The dispositions are the composer's rules ─────────────────── + +describe('#14877 pin 4 — each disposition is what composeStacks actually does', () => { + it("every 'concat' key concatenates in stack order — the export's concat subset IS the composer's", () => { + const concatKeys = keysWith('concat'); + // Non-vacuity: the concat family is the bulk of the table. + expect(concatKeys.length).toBeGreaterThan(30); + expect(concatKeys).toContain('packages'); + expect(concatKeys).toContain('requires'); + + // Elements are named objects with distinct names per side: composition + // walks some collections' contents after the concat pass (a cross-stack + // `actions` name collision is refused), so the marker must be a legal, + // non-colliding element for every key, not a bare string. + const element = (key: string, side: 'a' | 'b') => ({ name: `${key}_${side}` }); + const a = raw({ manifest: manifestA, ...Object.fromEntries(concatKeys.map((key) => [key, [element(key, 'a')]])) }); + const b = raw({ manifest: manifestB, ...Object.fromEntries(concatKeys.map((key) => [key, [element(key, 'b')]])) }); + const composed = composeStacks([a, b]) as unknown as Record; + + for (const key of concatKeys) { + expect(composed[key], `'${key}' must concatenate in stack order`).toEqual([element(key, 'a'), element(key, 'b')]); + } + // Zero warnings: every concat key hit a DECLARED rule, not the + // undeclared-key default that happens to concatenate arrays too. + expect(warnSpy.mock.calls.map((c: readonly unknown[]) => String(c[0]))).toEqual([]); + }); + + it("every 'single' key passes through when identical and refuses, naming the key, when different", () => { + const singleKeys = keysWith('single'); + expect(singleKeys.length).toBeGreaterThan(3); + expect(singleKeys).toContain('api'); + expect(singleKeys).toContain('i18n'); + + const sharedHook = () => {}; + const valueFor = (key: string, variant: 'same' | 'other'): unknown => { + if (key === 'runtimeModule') return variant === 'same' ? './rt.mjs' : './other.mjs'; + if (key === 'onEnable') return variant === 'same' ? sharedHook : () => {}; + return { probe: variant === 'same' ? key : `${key}:other` }; + }; + + // Identical → pass-through, for every single key at once. + const same = Object.fromEntries(singleKeys.map((key) => [key, valueFor(key, 'same')])); + const composed = composeStacks([raw({ manifest: manifestA, ...same }), raw({ manifest: manifestB, ...same })]) as unknown as Record; + for (const key of singleKeys) { + expect(composed[key], `'${key}' must pass through when identical`).toEqual(same[key]); + } + expect(warnSpy.mock.calls.map((c: readonly unknown[]) => String(c[0]))).toEqual([]); + + // Different → refused, naming the key — one key at a time so the message is attributable. + for (const key of singleKeys) { + const a = raw({ manifest: manifestA, [key]: valueFor(key, 'same') }); + const b = raw({ manifest: manifestB, [key]: valueFor(key, 'other') }); + expect(() => composeStacks([a, b]), `'${key}' must refuse differing values`).toThrow(`top-level key '${key}'`); + } + }); + + it("the three bespoke dispositions name their own strategies: 'manifest' picks, 'objects' merges by name, 'functions' merges by handler", () => { + expect(keysWith('manifest')).toEqual(['manifest']); + expect(keysWith('objects')).toEqual(['objects']); + expect(keysWith('functions')).toEqual(['functions']); + + const a = raw({ manifest: manifestA, objects: [{ name: 'task', fields: { a: {} } }], functions: { one: () => 1 } }); + const b = raw({ manifest: manifestB, objects: [{ name: 'task', fields: { b: {} } }], functions: { two: () => 2 } }); + // manifest: picked by the option (default 'last'), never concatenated. + expect(composeStacks([a, b], { objectConflict: 'override' }).manifest?.id).toBe(manifestB.id); + expect(composeStacks([a, b], { manifest: 'first', objectConflict: 'override' }).manifest?.id).toBe(manifestA.id); + // objects: the conflict strategy decides — error by default, one object under override. + expect(() => composeStacks([a, b])).toThrow(/object 'task'/); + expect(composeStacks([a, b], { objectConflict: 'override' }).objects).toHaveLength(1); + // functions: merged by handler name. + const composed = composeStacks([a, b], { objectConflict: 'override' }) as unknown as { functions: Record }; + expect(Object.keys(composed.functions).sort()).toEqual(['one', 'two']); + }); +}); + +// ─── 5. Derived, and reaching the surface as the same objects ─────── + +describe('#14877 pin 5 — STACK_DEFINITION_KEYS is derived, and both symbols reach the package surface', () => { + it('STACK_DEFINITION_KEYS is Object.keys of the table — same members, same order, no second literal', () => { + expect([...STACK_DEFINITION_KEYS]).toEqual(Object.keys(COMPOSE_KEY_DISPOSITIONS)); + }); + + it('the root entry re-exports the SAME objects, not copies', async () => { + const surface = await import('./index'); + expect(surface.COMPOSE_KEY_DISPOSITIONS).toBe(COMPOSE_KEY_DISPOSITIONS); + expect(surface.STACK_DEFINITION_KEYS).toBe(STACK_DEFINITION_KEYS); + // Anti-vacuity: the namespace probed is the real root surface. + expect(typeof surface.composeStacks).toBe('function'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index dc669dcb9b..65657ebf2e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -811,21 +811,64 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { /** - * How {@link composeStacks} treats one top-level key (#5005). - * - * - `'concat'` — array collection; concatenated in stack order. - * - `'single'` — one scalar/object value; identical declarations pass - * through, differing ones are a composition ERROR. - * - `'manifest'` — chosen by the `manifest` option. - * - `'objects'` — merged by the `objectConflict` strategy. - * - `'functions'` — named-handler collection; merged by name. - * @internal + * How {@link composeStacks} treats one top-level key of the artifact envelope + * (#5005) — the value half of {@link COMPOSE_KEY_DISPOSITIONS}. + * + * - `'concat'` — an array collection; the inputs' arrays are concatenated + * in stack order. Every metadata collection (`objects` aside) + * and the envelope's `packages` / `requires` lists compose + * this way, so a downstream seam that merges N artifacts may + * concatenate a `'concat'` key without reading its element + * type. + * - `'single'` — one scalar/object configuration value; identical + * declarations pass through, differing ones are a + * composition ERROR naming the key and both stacks (never + * last-wins, never deep-merge). + * - `'manifest'` — the singular package identity; picked by the `manifest` + * option (`'first'` / `'last'` / index, or folded into + * `packages` under `'preserve'`). + * - `'objects'` — merged per object name by the `objectConflict` strategy. + * - `'functions'` — a named-handler map; merged by handler name, a duplicate + * name is an ERROR. */ -type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functions'; +export type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functions'; + +/** + * Every top-level key {@link ObjectStackDefinitionSchema} declares — the + * artifact envelope's key set: the two envelope-only keys (`manifest`, + * `packages`) plus every member of the collections shape. The key half of + * {@link COMPOSE_KEY_DISPOSITIONS}; {@link STACK_DEFINITION_KEYS} is its + * runtime value. + */ +export type StackDefinitionKey = 'manifest' | 'packages' | keyof typeof STACK_DEFINITION_COLLECTIONS_SHAPE; /** * The composition rule for EVERY top-level key of `ObjectStackDefinition` - * (#5005). + * (#5005) — and, since #14877, THE exported source of the artifact envelope's + * top-level key set. + * + * ## Exported, frozen: derive from it, never copy it (#14877) + * + * A downstream seam that walks an artifact's top level — a hosted publish, an + * artifact merge, an assembler — needs two answers: *which keys exist* and + * *what composing each one means*. The collection half of the first answer + * was already derivable (`PLURAL_TO_SINGULAR`, `METADATA_ALIASES`); the rest + * (`manifest`, `requires`, `packages`, …) had to be hand-copied per consumer, + * and the copy drifted silently twice (cloud#897: `roles` → `positions` dropped + * every hosted `positions[]`; cloud#1888: an artifact merge dropped + * `packages[]`). So this table is public: read its keys for the key set + * ({@link STACK_DEFINITION_KEYS} is that derivation, done once), and index it + * for the rule (`COMPOSE_KEY_DISPOSITIONS[key] === 'concat'` is the question + * "may I concatenate this across artifacts?"). A key added to the schema + * reaches every deriving consumer the day it lands — a copy would not. + * + * It is `Object.freeze`d: the contract is read-only, and a consumer cannot + * widen or retarget it by assignment. It is literal-typed (`as const`), so + * `(typeof COMPOSE_KEY_DISPOSITIONS)[K]` is K's disposition rather than the + * union — the derivations below depend on that. The pin + * `compose-key-dispositions-export.pin.test.ts` holds this key set equal to + * `ObjectStackDefinitionSchema`'s shape in both directions, so the table cannot + * drift from the schema without a red test naming the key. * * ## Why a total table and not a list * @@ -867,12 +910,8 @@ type ComposeDisposition = 'concat' | 'single' | 'manifest' | 'objects' | 'functi * `translations` bundles each stack ships are written against its own * `supportedLocales`, so overriding one stack's declaration leaves the other * stack's bundles addressing locales the composed app no longer admits. - * - * @internal */ -type StackDefinitionKey = 'manifest' | 'packages' | keyof typeof STACK_DEFINITION_COLLECTIONS_SHAPE; - -const COMPOSE_KEY_DISPOSITIONS = { +export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({ // ── Bespoke strategies (unchanged by #5005) ── manifest: 'manifest', objects: 'objects', @@ -952,14 +991,29 @@ const COMPOSE_KEY_DISPOSITIONS = { onEnable: 'single', // #5051: the last key still on last-wins; aligned here, see the note above. i18n: 'single', -} as const satisfies Record; +} as const satisfies Record); + +/** + * The artifact envelope's top-level key set — every key + * {@link ObjectStackDefinitionSchema} declares — as a frozen list (#14877). + * + * DERIVED from {@link COMPOSE_KEY_DISPOSITIONS} by `Object.keys`, never a + * second literal: the table is total over the schema's declared keys, so this + * list is too, and a consumer that iterates it sees a new top-level key the + * day the schema declares one. Read it where a seam needs "the keys an + * artifact may carry" without caring what composing each one means; index the + * table where it does. + */ +export const STACK_DEFINITION_KEYS: readonly StackDefinitionKey[] = Object.freeze( + Object.keys(COMPOSE_KEY_DISPOSITIONS) as StackDefinitionKey[], +); /** * All array fields on `ObjectStackDefinition` that are simply concatenated. * Derived from {@link COMPOSE_KEY_DISPOSITIONS} so the two cannot drift. * @internal */ -const CONCAT_ARRAY_FIELDS = (Object.keys(COMPOSE_KEY_DISPOSITIONS) as (keyof ObjectStackDefinition)[]) +const CONCAT_ARRAY_FIELDS = STACK_DEFINITION_KEYS .filter((key) => COMPOSE_KEY_DISPOSITIONS[key] === 'concat');