From 054800148f60301c5b91d6b35f6b422b6d174b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:21:47 +0000 Subject: [PATCH 1/7] wip: artifact-scoped cross-reference resolution in defineStack/composeStacks Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- .../spec/src/stack-artifact-crossref.test.ts | 297 +++++++++++++++++ packages/spec/src/stack.zod.ts | 300 +++++++++++++++--- 2 files changed, 549 insertions(+), 48 deletions(-) create mode 100644 packages/spec/src/stack-artifact-crossref.test.ts diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts new file mode 100644 index 0000000000..75d3fd18c3 --- /dev/null +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #18202 — the cross-reference gate resolves the two ARTIFACT-SCOPED classes + * against the ARTIFACT, and keeps refusing everything else against the stack. + * + * ## The defect + * + * ADR-0130 D1 lets one release artifact carry N packages co-owning one + * namespace; its 2026-09-02 addendum (#14487) rules that **permission sets stay + * whole in the `type: app` package**. Those two records cannot both hold once + * the app package owns objects of its own: `defineStack` validates + * cross-references PER STACK, so an app-owned permission set granting on an + * object a MODULE owns is refused — measured downstream on + * `objectstack-ai/hotcrm` `claude/issue-1907-sales-app-service-module` + * (`be11c07`) as "18 grants across 7 sets". `data[].object` is refused the same + * way, which is why that branch had to move its seed rows into the module. + * + * The addendum's own measurement (hotcrm#1449) never saw it because it measured + * an app package declaring NO objects — exactly the case + * `validateCrossReferences` early-returns on (`objectNames.size === 0`). The + * `ObjectLessPackage` block at the bottom pins that leniency as untouched. + * + * ## What is fixed, and what deliberately is not + * + * `DefineStackOptions.artifactObjects` widens `permissions[].objects` and + * `data[].object` — and NOTHING else. `hooks[].object` and an app's own + * `navigation` `objectName` stay refused even when the name is listed, because + * ADR-0130 §1.5 measured both refusals and recorded them as the SHAPE of the + * seam: *"navigation crosses only through contributions, and the split must + * follow hook ownership."* Those two blocks below are the fence on that. + * + * ## The refusal MOVED; it did not disappear + * + * A name `artifactObjects` claims and no package in the artifact defines is + * refused by `composeStacks` — the ARTIFACT pass — with the same + * `STACK_CROSS_REFERENCE_INVALID` envelope, the same 422, and a per-finding + * message byte-identical to the per-stack pass's. Only the HEADER differs, + * because only the pass differs. `ArtifactPass` is that fixture, and it is an + * acceptance criterion of #18202 rather than a nicety. + * + * ## Fixture shape + * + * The two-package shape measured downstream, reproduced at its smallest: the + * `type: app` package owns `crm_account` and carries the permission set; the + * `type: module` package owns `crm_case` and declares the dependency edge. Note + * the edge runs MODULE → APP — the app cannot declare its own modules as + * dependencies without inverting ADR-0116's topological order — which is why + * the resolution scope is the ARTIFACT and not the referencing package's + * declared dependency closure. + */ +import { describe, it, expect } from 'vitest'; +import { composeStacks, defineStack } from './stack.zod'; + +/** The `type: app` package — owns objects AND every permission set. */ +const appManifest = { + id: 'app.objectstack.hotcrm', + name: 'HotCRM', + version: '3.1.0', + type: 'app' as const, + namespace: 'crm', +}; + +/** + * The `type: module` package. `dependencies` names the APP, which is the only + * direction ADR-0116's topological order admits: the module registers after the + * package it extends. + */ +const moduleManifest = { + id: 'app.objectstack.hotcrm.service', + name: 'HotCRM Support', + version: '3.1.0', + type: 'module' as const, + namespace: 'crm', + dependencies: { 'app.objectstack.hotcrm': '^3.1.0' }, +}; + +/** Owned by the app package. */ +const account = { name: 'crm_account', label: 'Account', fields: { title: { type: 'text' as const } } }; +/** Owned by the module package — the object every cross-package reference names. */ +const supportCase = { name: 'crm_case', label: 'Case', fields: { subject: { type: 'text' as const } } }; + +/** The name NO package in the artifact defines. */ +const NOWHERE = 'crm_nowhere'; + +type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] }; + +/** The thrown value, or `null` when the call is accepted. */ +function refusalOf(run: () => unknown): Envelope | null { + try { + run(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const anyStack = (config: Record) => config as unknown as Parameters[0]; + +/** The module package, always identical. */ +const serviceStack = () => defineStack(anyStack({ manifest: moduleManifest, objects: [supportCase] })); + +/** The app package: owns `crm_account`, grants on `crm_case`, seeds `crm_case`. */ +const appConfig = (grantObject: string, seedObject: string) => anyStack({ + manifest: appManifest, + objects: [account], + permissions: [ + { + name: 'sales_rep', + label: 'Sales Rep', + objects: { crm_account: { allowRead: true }, [grantObject]: { allowRead: true } }, + }, + ], + data: [{ object: seedObject, records: [] }], +}); + +const GRANT_ON_CASE = "Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects."; +const SEED_ON_CASE = "Seed data references object 'crm_case' which is not defined in objects."; +const GRANT_ON_NOWHERE = `Permission 'sales_rep' grants on object '${NOWHERE}' which is not defined in objects.`; +const SEED_ON_NOWHERE = `Seed data references object '${NOWHERE}' which is not defined in objects.`; + +describe('#18202 — the per-stack pass, without the opt-in', () => { + it('REFUSES an app-owned grant and seed on a module-owned object — the reported defect', () => { + const refused = refusalOf(() => defineStack(appConfig('crm_case', 'crm_case'))); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toContain(GRANT_ON_CASE); + expect(refused?.issues).toContain(SEED_ON_CASE); + }); + + it('keeps refusing a name nothing anywhere defines — unchanged', () => { + const refused = refusalOf(() => defineStack(appConfig(NOWHERE, NOWHERE))); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + expect(refused?.issues).toContain(SEED_ON_NOWHERE); + }); +}); + +describe('#18202 — `artifactObjects` widens exactly the two ARTIFACT-SCOPED classes', () => { + it('ACCEPTS the app package once the artifact’s other objects are declared', () => { + const service = serviceStack(); + const accepted = refusalOf(() => + defineStack(appConfig('crm_case', 'crm_case'), { + artifactObjects: (service.objects ?? []).map((o) => o.name), + }), + ); + expect(accepted).toBeNull(); + }); + + it('composes both packages into ONE artifact carrying TWO manifests', () => { + const service = serviceStack(); + const app = defineStack(appConfig('crm_case', 'crm_case'), { artifactObjects: ['crm_case'] }); + const artifact = composeStacks([service, app], { manifest: 'preserve' }); + + expect((artifact as { packages?: unknown[] }).packages).toHaveLength(2); + expect((artifact.objects ?? []).map((o) => o.name).sort()).toEqual(['crm_account', 'crm_case']); + }); + + it('does NOT widen `hooks[].object` — ADR-0130 §1.5, the split follows hook ownership', () => { + const refused = refusalOf(() => + defineStack( + anyStack({ + manifest: appManifest, + objects: [account], + hooks: [{ name: 'case_hook', object: 'crm_case', events: ['afterInsert'], handler: 'noop' }], + }), + { artifactObjects: ['crm_case'] }, + ), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain( + "Hook 'case_hook' references object 'crm_case' which is not defined in objects.", + ); + }); + + it('does NOT widen an app’s own navigation — ADR-0130 §1.5, navigation crosses through contributions', () => { + const refused = refusalOf(() => + defineStack( + anyStack({ + manifest: appManifest, + objects: [account], + apps: [ + { + name: 'crm_enterprise', + label: 'CRM', + navigation: [{ id: 'nav_case', type: 'object', label: 'Cases', objectName: 'crm_case' }], + }, + ], + }), + { artifactObjects: ['crm_case'] }, + ), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain( + "App 'crm_enterprise' navigation references object 'crm_case' which is not defined in objects.", + ); + }); +}); + +describe('#18202 — the ARTIFACT pass: the refusal MOVED, it did not disappear', () => { + /** The opt-in is a promise about a composition; this is the composition calling it in. */ + const composeClaiming = (name: string) => { + const service = serviceStack(); + const app = defineStack(appConfig(name, name), { artifactObjects: [name] }); + return refusalOf(() => composeStacks([service, app], { manifest: 'preserve' })); + }; + + it('REFUSES a grant naming an object NO package in the artifact defines', () => { + const refused = composeClaiming(NOWHERE); + expect(refused).toBeInstanceOf(Error); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + }); + + it('REFUSES the seed-data twin in the same aggregate', () => { + const refused = composeClaiming(NOWHERE); + expect(refused?.issues).toContain(SEED_ON_NOWHERE); + }); + + it('carries the SAME ADR-0112 envelope as the per-stack pass', () => { + const refused = composeClaiming(NOWHERE); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + }); + + it('NAMES the pass in the header, and only the header differs', () => { + const refused = composeClaiming(NOWHERE); + expect(refused?.message).toContain('composeStacks artifact cross-reference validation failed'); + // The per-finding line is byte-identical to the per-stack pass's, so a + // reader greps one string whichever pass refused them. + expect(refused?.message).toContain(GRANT_ON_NOWHERE); + }); + + it('ACCEPTS the artifact when the claimed object IS defined by a sibling — the control', () => { + const service = serviceStack(); + const app = defineStack(appConfig('crm_case', 'crm_case'), { artifactObjects: ['crm_case'] }); + expect(refusalOf(() => composeStacks([service, app], { manifest: 'preserve' }))).toBeNull(); + }); +}); + +describe('#18202 — #14122 §6 compatibility: a stack that does not opt in is untouched', () => { + it('a single-package stack still refuses its own dangling grant', () => { + const refused = refusalOf(() => + defineStack(anyStack({ + manifest: appManifest, + objects: [account], + permissions: [{ name: 'sales_rep', label: 'Sales Rep', objects: { [NOWHERE]: { allowRead: true } } }], + })), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + }); + + it('a single-package stack granting on its OWN object is accepted', () => { + const accepted = refusalOf(() => + defineStack(anyStack({ + manifest: appManifest, + objects: [account], + permissions: [{ name: 'sales_rep', label: 'Sales Rep', objects: { crm_account: { allowRead: true } } }], + data: [{ object: 'crm_account', records: [] }], + })), + ); + expect(accepted).toBeNull(); + }); + + it('composing two ordinary single-package stacks still throws nothing new', () => { + const a = defineStack(anyStack({ + manifest: appManifest, + objects: [account], + permissions: [{ name: 'sales_rep', label: 'Sales Rep', objects: { crm_account: { allowRead: true } } }], + })); + const b = serviceStack(); + expect(refusalOf(() => composeStacks([b, a], { manifest: 'preserve' }))).toBeNull(); + }); +}); + +describe('#18202 — the object-less leniency the ARTIFACT pass inherits verbatim', () => { + /** + * hotcrm#1449's shape: the app package declares NO objects, so + * `validateCrossReferences` early-returns and the ARTIFACT pass skips it for + * the same reason — its references may be served by a plugin that is not in + * this composition at all. Pinned as UNCHANGED, in both directions. + */ + const objectLessApp = (grantObject: string) => defineStack(anyStack({ + manifest: { ...appManifest, namespace: undefined }, + permissions: [{ name: 'sales_rep', label: 'Sales Rep', objects: { [grantObject]: { allowRead: true } } }], + })); + + it('accepts an object-less package granting on a sibling’s object, composed', () => { + expect(refusalOf(() => composeStacks([serviceStack(), objectLessApp('crm_case')], { manifest: 'preserve' }))) + .toBeNull(); + }); + + it('still accepts an object-less package granting on a name nobody defines — unchanged leniency', () => { + expect(refusalOf(() => composeStacks([serviceStack(), objectLessApp(NOWHERE)], { manifest: 'preserve' }))) + .toBeNull(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index be30488ca2..ee39357422 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1441,6 +1441,61 @@ export interface DefineStackOptions { * @default true */ strict?: boolean; + + /** + * Object names provided by the OTHER packages of the same release artifact + * (ADR-0130 D1 — "the release artifact is the co-ownership boundary"). + * + * `defineStack` sees exactly ONE stack, so "defined in objects" and "defined + * in the artifact" are the same question for a single-package app and two + * different questions for a package that co-owns a namespace with its + * siblings. This option is how the second question is asked: the + * ARTIFACT-SCOPED reference classes — `permissions[].objects` and + * `data[].object` — resolve against this stack's own objects PLUS these + * names. + * + * ⚠️ It widens exactly those two classes and nothing else. `hooks[].object` + * and an app's own `navigation` `objectName` stay refused against the + * stack's own objects even when the name appears here, because ADR-0130 §1.5 + * measured both of those refusals and recorded them as the SHAPE of the seam: + * *"navigation crosses only through contributions, and the split must follow + * hook ownership."* Widening them would delete a decision, not honour one. + * + * ## Why the two classes cross + * + * ADR-0130's 2026-09-02 addendum (#14487) rules that **permission sets stay + * whole in the `type: app` package** — a set is authored per ROLE, so no + * module owns one, and ADR-0086 D3 gives a set exactly one owning package. + * Once the app package also owns objects of its own, its sets necessarily + * grant on objects owned by its modules. Seed rows are the same shape: data + * placed into a co-owned object, not a claim on its definition. + * + * ## Why an OPTION and not a metadata key + * + * The addendum is explicit that **no `module` or grouping key is added to a + * permission set**, so the opt-in cannot live on the authored item the way + * an app nav item's `requiresObject` does. It is a property of the + * COMPOSITION this stack is built for, not of the stack's own metadata, and + * it therefore leaves `ObjectStackDefinitionSchema` — and every artifact + * already built from it — untouched. + * + * ## The claim is verified, not trusted + * + * A name listed here that no package in the artifact actually defines is + * refused by {@link composeStacks}, with the same `STACK_CROSS_REFERENCE_INVALID` + * envelope and the same per-finding message. Omitting the option keeps + * today's behaviour byte-for-byte. + * + * @example + * ```ts + * const service = defineStack(serviceConfig); // owns crm_case + * const app = defineStack(appConfig, { // owns crm_account … + * artifactObjects: service.objects?.map((o) => o.name), // … grants on crm_case + * }); + * export default composeStacks([service, app], { manifest: 'preserve' }); + * ``` + */ + artifactObjects?: readonly string[]; } /** @@ -1838,18 +1893,35 @@ abstract class StackRefusalError extends Error { * ADR-0112 envelope exists to remove, and one that five message-substring pins * had already come to depend on. * - * ⭐ Why the code names the rule FAMILY and not one item class: there is - * exactly ONE raise site. {@link validateCrossReferences} returns every - * finding as a `string[]` and `defineStack` throws the whole set at once, so a - * single refusal can carry findings from several classes together — a - * per-class code would have to pick one of several true answers. The classes - * stay machine-readable in {@link StackCrossReferenceError.issues}, one entry - * per finding, which is the structured form of what was previously only + * ⭐ Why the code names the rule FAMILY and not one item class: a raise site + * throws an AGGREGATE. {@link validateCrossReferences} returns every finding as + * a `string[]` and `defineStack` throws the whole set at once, so a single + * refusal can carry findings from several classes together — a per-class code + * would have to pick one of several true answers. The classes stay + * machine-readable in {@link StackCrossReferenceError.issues}, one entry per + * finding, which is the structured form of what was previously only * newline-joined prose. The family is also WIDER than "undefined object": the * same aggregate carries the duplicate-action-key, global-`update`-action and * mapping `javascript`-transform findings, so a * `…_UNDEFINED_OBJECT` spelling would be false for those. * + * ⭐ There are TWO raise sites since #18202, one per PASS, and they share this + * code deliberately — same rule family, same finding text, two resolution + * scopes: + * + * - `defineStack` — the PER-STACK pass. Header: + * `defineStack cross-reference validation failed (N issues):`. + * - `composeStacks` — the ARTIFACT pass, which re-raises the two + * ARTIFACT-SCOPED rules ({@link collectSeedDataObjectErrors}, + * {@link collectPermissionGrantObjectErrors}) over the composed object set, + * so a reference that {@link DefineStackOptions.artifactObjects} let past the + * per-stack pass is still refused when NO package in the artifact defines it. + * Header: `composeStacks artifact cross-reference validation failed (N issues):`. + * + * The HEADER names the pass, which is what a reader needs to know; the `code` + * names the rule family, which is what a machine matches on. Splitting the code + * per pass would make the pass — not the defect — the machine-readable half. + * * `status: 422` matches both precedents for this defect class * (`ObjectOwnershipConflictError`, `NamespaceConflictError` in * `packages/objectql/src/registry.ts`) — an unprocessable authored entity, not @@ -2002,11 +2074,87 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { } } +/** + * Seed data → object references (#18202, ARTIFACT-SCOPED — see + * {@link DefineStackOptions.artifactObjects}). + * + * Platform objects are runtime-provided seed targets — see + * {@link isPlatformObjectName}. + * + * `resolvable` is the stack's own object names for a single-package stack, and + * those plus the rest of the artifact's for a package that co-owns a namespace. + * The MESSAGE is identical either way, which is what lets {@link composeStacks} + * re-raise this rule over the artifact without inventing a second dialect for + * the same finding. + */ +function collectSeedDataObjectErrors( + config: ObjectStackDefinition, + resolvable: ReadonlySet, +): string[] { + const errors: string[] = []; + if (!config.data) return errors; + for (const dataset of config.data) { + if (dataset.object && !resolvable.has(dataset.object) && !isPlatformObjectName(dataset.object)) { + errors.push( + `Seed data references object '${dataset.object}' which is not defined in objects.`, + ); + } + } + return errors; +} + +/** + * Permission-set / profile object grants → object references (#18202, + * ARTIFACT-SCOPED — see {@link DefineStackOptions.artifactObjects}). + * + * A grant keyed by an object that isn't declared (e.g. a short `lead` instead + * of the namespaced `crm_lead`) silently applies to NOTHING: the authenticated + * path may namespace-resolve it, but the anonymous / explicit-permission-set + * path does not — so the grant is simply lost (e.g. a public Web-to-Lead INSERT + * is denied for "roles []"). Fail loudly at build time. + * (`validateNamespacePrefix`'s doc already assumes this check lives here.) + * Platform objects are legitimate grant targets (e.g. a delegated-admin set + * carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here. + * + * `resolvable` carries the same two readings as its seed-data sibling above. + */ +function collectPermissionGrantObjectErrors( + config: ObjectStackDefinition, + resolvable: ReadonlySet, +): string[] { + const errors: string[] = []; + if (!config.permissions) return errors; + for (const perm of config.permissions) { + const grants = (perm as { objects?: Record }).objects; + if (!grants || typeof grants !== 'object') continue; + for (const objName of Object.keys(grants)) { + if (!resolvable.has(objName) && !isPlatformObjectName(objName)) { + errors.push( + `Permission '${(perm as { name?: string }).name ?? '(unnamed)'}' grants on object ` + + `'${objName}' which is not defined in objects.`, + ); + } + } + } + return errors; +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). - */ -function validateCrossReferences(config: ObjectStackDefinition): string[] { + * + * `artifactObjects` (#18202) widens the resolution scope of the two + * ARTIFACT-SCOPED classes ONLY — see {@link DefineStackOptions.artifactObjects} + * for which two, why those two, and why the rest of this function keeps + * resolving against the stack's own objects alone. Omitted (every + * single-package stack, which is every stack that does not opt in) the two + * scopes are the same Set and this function behaves exactly as it did before + * the option existed. + */ +function validateCrossReferences( + config: ObjectStackDefinition, + artifactObjects?: ReadonlySet, +): string[] { const errors: string[] = []; const objectNames = collectObjectNames(config); @@ -2020,6 +2168,14 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { if (objectNames.size === 0) return errors; + // The resolution scope of the two ARTIFACT-SCOPED classes. Identical to + // `objectNames` unless the caller declared sibling packages, so a stack that + // does not opt in cannot observe that this line exists. + const artifactScope: ReadonlySet = + artifactObjects && artifactObjects.size > 0 + ? new Set([...objectNames, ...artifactObjects]) + : objectNames; + // Validate hook → object references if (config.hooks) { for (const hook of config.hooks) { @@ -2059,21 +2215,8 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } - // Validate seed data → object references (platform objects are runtime- - // provided seed targets — see isPlatformObjectName). - if (config.data) { - for (const dataset of config.data) { - if ( - dataset.object && - !objectNames.has(dataset.object) && - !isPlatformObjectName(dataset.object) - ) { - errors.push( - `Seed data references object '${dataset.object}' which is not defined in objects.`, - ); - } - } - } + // Validate seed data → object references. ARTIFACT-SCOPED (#18202). + errors.push(...collectSeedDataObjectErrors(config, artifactScope)); // Validate mapping → object references + executable-transform gate (#2611). // A mapping whose targetObject doesn't exist can never be applied by the @@ -2100,29 +2243,8 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } // Validate permission-set / profile object grants → object references. - // A grant keyed by an object that isn't declared (e.g. a short `lead` instead - // of the namespaced `crm_lead`) silently applies to NOTHING: the - // authenticated path may namespace-resolve it, but the anonymous / - // explicit-permission-set path does not — so the grant is simply lost (e.g. a - // public Web-to-Lead INSERT is denied for "roles []"). Fail loudly at build - // time. (`validateNamespacePrefix`'s doc already assumes this check lives here.) - // Platform objects are legitimate grant targets (e.g. a delegated-admin set - // carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here. - if (config.permissions) { - for (const perm of config.permissions) { - const grants = (perm as { objects?: Record }).objects; - if (grants && typeof grants === 'object') { - for (const objName of Object.keys(grants)) { - if (!objectNames.has(objName) && !isPlatformObjectName(objName)) { - errors.push( - `Permission '${(perm as { name?: string }).name ?? '(unnamed)'}' grants on object ` + - `'${objName}' which is not defined in objects.`, - ); - } - } - } - } - } + // ARTIFACT-SCOPED (#18202) — the rule's own doc carries why it fails loudly. + errors.push(...collectPermissionGrantObjectErrors(config, artifactScope)); // Validate app navigation → object/dashboard/page/report references if (config.apps) { @@ -2815,7 +2937,12 @@ export function defineStack( throw new StackCapabilityUnknownError(`${header}\n\n${lines.join('\n')}`, capErrors); } - const crossRefErrors = validateCrossReferences(data); + const crossRefErrors = validateCrossReferences( + data, + options?.artifactObjects && options.artifactObjects.length > 0 + ? new Set(options.artifactObjects) + : undefined, + ); if (crossRefErrors.length > 0) { const header = `defineStack cross-reference validation failed (${crossRefErrors.length} issue${crossRefErrors.length === 1 ? '' : 's'}):`; const lines = crossRefErrors.map((e) => ` ✗ ${e}`); @@ -3611,6 +3738,65 @@ function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody return body as AssembledPackageBody; } +/** + * The ARTIFACT pass of the cross-reference gate (#18202, ADR-0130 D1). + * + * ## What it discharges + * + * {@link DefineStackOptions.artifactObjects} lets a package's + * `permissions[].objects` and `data[].object` name an object one of its SIBLING + * packages owns — the shape ADR-0130's 2026-09-02 addendum (#14487) makes + * mandatory once the `type: app` package, which keeps every permission set + * whole, also owns objects of its own. That option is a PROMISE made at + * `defineStack` time about a composition that has not happened yet. This + * function is where the promise is redeemed: the composed object set is the + * artifact, so a name no package in it defines is refused here, loudly, with + * the same envelope and the same per-finding message the per-stack pass uses. + * + * ## Why it re-checks inputs that never opted in (and why that is free) + * + * It does not read the option — the option is not recorded on the returned + * stack, and deliberately so: recording it would put a composition-time concern + * on `ObjectStackDefinitionSchema`, i.e. on every artifact already built. It + * instead re-runs the two rules for every input, which is a NO-OP for an input + * that did not opt in: that input's references already resolved against its own + * objects, and its own objects are a subset of the composed set. Nothing that + * composes cleanly today can newly fail here. + * + * ## The one leniency it inherits verbatim + * + * An input declaring NO objects is skipped, exactly as + * {@link validateCrossReferences}'s `objectNames.size === 0` early return skips + * it — that stack's references may be served by a plugin that is not in this + * composition at all, and this pass is not the place to reopen that question. + * It is also why hotcrm#1449's measurement (an app package declaring no + * objects) never saw the defect #18202 reports. + * + * ## Known boundary + * + * A stack that opts in and is then NEVER composed has no artifact to be + * checked against, and its claim stands unverified — the same shape as + * `strict: false`, and for the same reason: the author asserted something only + * a composition can confirm. `os build` composes; a stack that does not is not + * a package of an artifact. + */ +function collectArtifactCrossReferenceErrors( + stacks: readonly ObjectStackDefinition[], + composedObjects: readonly { name: string }[] | undefined, +): string[] { + const artifactObjectNames = new Set(); + for (const obj of composedObjects ?? []) artifactObjectNames.add(obj.name); + if (artifactObjectNames.size === 0) return []; + + const errors: string[] = []; + for (const stack of stacks) { + if (collectObjectNames(stack).size === 0) continue; + errors.push(...collectSeedDataObjectErrors(stack, artifactObjectNames)); + errors.push(...collectPermissionGrantObjectErrors(stack, artifactObjectNames)); + } + return errors; +} + /** * Declaratively compose multiple stack definitions into a single unified stack. * @@ -3731,6 +3917,24 @@ export function composeStacks( if (preserved.length > 0) composed.packages = preserved; } + // 3b. The ARTIFACT pass of the cross-reference gate (#18202). AFTER the + // collections are concatenated, because it reads the composed + // `permissions` / `data` of each INPUT against the composed `objects`; + // BEFORE anything else can throw on a stack whose references do not + // resolve in the first place. See + // {@link collectArtifactCrossReferenceErrors} for why re-checking every + // input cannot newly refuse a composition that passes today. + const artifactCrossRefErrors = collectArtifactCrossReferenceErrors( + stacks, + objects as readonly { name: string }[] | undefined, + ); + if (artifactCrossRefErrors.length > 0) { + const count = artifactCrossRefErrors.length; + const header = `composeStacks artifact cross-reference validation failed (${count} issue${count === 1 ? '' : 's'}):`; + const lines = artifactCrossRefErrors.map((e) => ` ✗ ${e}`); + throw new StackCrossReferenceError(`${header}\n\n${lines.join('\n')}`, artifactCrossRefErrors); + } + // 4. Named handler functions — merged by name (#5005). const functions = composeFunctions(stacks); if (functions.declared) { From 990ba7cfa0c74668ca7ffc366673162a44b00d55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 15:29:30 +0000 Subject: [PATCH 2/7] fix(spec): resolve permission and seed object references against the artifact Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- .changeset/artifact-scoped-cross-reference.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/artifact-scoped-cross-reference.md diff --git a/.changeset/artifact-scoped-cross-reference.md b/.changeset/artifact-scoped-cross-reference.md new file mode 100644 index 0000000000..51742f8f42 --- /dev/null +++ b/.changeset/artifact-scoped-cross-reference.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +--- + +`defineStack`: a package of a multi-package release artifact can now grant permissions on, and seed data into, an object one of its SIBLING packages owns. + +**FROM** — every `permissions[].objects` key and every `data[].object` had to name an object the same stack declares. In an ADR-0130 artifact this made two accepted records contradict each other: the 2026-09-02 addendum keeps every permission set whole in the `type: app` package, so as soon as that package also owns objects of its own, its sets were refused for granting on its modules' objects (`Permission 'sales_rep' grants on object 'crm_case' which is not defined in objects.`). The only escapes were `strict: false` for the whole package or splitting the sets per package, which contradicts the addendum. + +**TO** — pass the artifact's other object names to `defineStack` and those two reference classes resolve against the artifact instead of the one stack: + +```ts +const service = defineStack(serviceConfig); // owns crm_case +const app = defineStack(appConfig, { // owns crm_account, grants on crm_case + artifactObjects: service.objects?.map((o) => o.name), +}); +export default composeStacks([service, app], { manifest: 'preserve' }); +``` + +Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` stay refused against the stack's own objects even when the name is listed, because ADR-0130 §1.5 records both refusals as the shape of the package seam. A stack that does not pass `artifactObjects` — every single-package app — validates exactly as before. + +The refusal moved rather than disappearing: `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. From 6942581443b8235dd66af4383e93c9b695c8a85c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:13:00 +0000 Subject: [PATCH 3/7] docs(getting-started): the multi-package rules name the artifact-scoped classes Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- content/docs/getting-started/examples.mdx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 73f514598a..f495715944 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -432,6 +432,15 @@ The rules worth knowing before you split a product this way: may reference an object another package owns. An app's own `navigation` may not point outside its package — inject into another package's app with `navigationContributions` instead. +- **Permission sets and seed data cross the boundary; declare what they reach.** + A permission set is authored per *role*, not per module, so the `type: 'app'` + package keeps every set whole and grants across the whole artifact — and seed + rows are the same shape. Both resolve against the artifact rather than the one + stack, but only once you hand `defineStack` the sibling object names: + `defineStack(appConfig, { artifactObjects: ordersStack.objects?.map((o) => o.name) })`. + `composeStacks` then refuses any name no package in the artifact defines, so a + typo is still caught — one pass later, and by name. Hooks are the deliberate + exception: a hook still belongs to the package that owns its object. - **One artifact, one version.** Everything inside ships, installs and upgrades together; you cannot hot-fix one module on its own. A module that needs its own release cadence belongs in its own artifact. From b00723791c1e290f2f46e9c7f4ec6b206ae799eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:28:40 +0000 Subject: [PATCH 4/7] docs(getting-started): say position, not the ADR-0090 D3 reserved word Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- content/docs/getting-started/examples.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index f495715944..9fb6bbea86 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -433,9 +433,9 @@ The rules worth knowing before you split a product this way: not point outside its package — inject into another package's app with `navigationContributions` instead. - **Permission sets and seed data cross the boundary; declare what they reach.** - A permission set is authored per *role*, not per module, so the `type: 'app'` - package keeps every set whole and grants across the whole artifact — and seed - rows are the same shape. Both resolve against the artifact rather than the one + A permission set is authored for a **position**, not for a module, so the + `type: 'app'` package keeps every set whole and grants across the whole + artifact — and seed rows are the same shape. Both resolve against the artifact rather than the one stack, but only once you hand `defineStack` the sibling object names: `defineStack(appConfig, { artifactObjects: ordersStack.objects?.map((o) => o.name) })`. `composeStacks` then refuses any name no package in the artifact defines, so a From b63b526edac45104fe48300d20f8fb146d8e9103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 01:41:13 +0000 Subject: [PATCH 5/7] fix(spec): guard the artifact cross-reference pass and declare what it narrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact pass added for #18202 ran the two artifact-scoped collectors over every composed input, including inputs the strict parse never saw. Three corrections, all inside this PR's own surface: - `collectSeedDataObjectErrors` and `collectPermissionGrantObjectErrors` now guard their own shapes the way `composeStacks`'s step-3 concat pass does. A non-array `permissions` / `data` is announced through `warnMalformedCollectionKey` (deduplicated per key, so the two passes speak once) and skipped; a non-object entry, and a non-string `object`, carry no reference for the rule to resolve and are skipped. Before this, an unparsed input that `origin/main` composes with a warning raised a bare `TypeError` with no `code` and no `status` — a crash outside the ADR-0112 envelope, in the file whose refusal discipline is that envelope. - The compatibility claim is replaced by the invariant that actually holds. An input that passed the strict `defineStack` parse cannot newly fail at composition; an input that BYPASSED it (`strict: false`, a hand-built stack object) is checked for these two rules at composition for the first time and a dangling reference in it is now refused. That narrowing is declared in the docstring, the changeset and the fixtures instead of being claimed away. - `composeStacks` returns `stacks[0]` untouched, so the artifact pass never runs for a single input. The option docstring and the docs bullet now say "in a composition of two or more packages" rather than stating the guarantee unqualified. Fixtures pin all three: the refusals on unparsed inputs, the malformed-shape guards, and the one-input boundary with its two-package contrast. Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- .changeset/artifact-scoped-cross-reference.md | 6 +- content/docs/getting-started/examples.mdx | 9 +- .../spec/src/stack-artifact-crossref.test.ts | 199 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 119 ++++++++--- 4 files changed, 303 insertions(+), 30 deletions(-) diff --git a/.changeset/artifact-scoped-cross-reference.md b/.changeset/artifact-scoped-cross-reference.md index 51742f8f42..d4d74eb9e0 100644 --- a/.changeset/artifact-scoped-cross-reference.md +++ b/.changeset/artifact-scoped-cross-reference.md @@ -16,6 +16,8 @@ const app = defineStack(appConfig, { // owns crm_account, export default composeStacks([service, app], { manifest: 'preserve' }); ``` -Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` stay refused against the stack's own objects even when the name is listed, because ADR-0130 §1.5 records both refusals as the shape of the package seam. A stack that does not pass `artifactObjects` — every single-package app — validates exactly as before. +Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` stay refused against the stack's own objects even when the name is listed, because ADR-0130 §1.5 records both refusals as the shape of the package seam. -The refusal moved rather than disappearing: `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. +The refusal moved rather than disappearing: in a composition of **two or more** packages, `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. `composeStacks` returns a single input untouched, so a one-package composition does not re-check the claim. + +**What that changes about which inputs `composeStacks` accepts.** `defineStack` itself is unchanged for a stack that does not pass `artifactObjects` — every single-package app validates exactly as before. `composeStacks` is not: it applies the two artifact-scoped rules to **every** input carrying objects, not only the ones that opted in. For an input that passed the strict `defineStack` parse that is a no-op, so such an input cannot newly fail. For an input that **bypassed** the strict parse it is not: `defineStack(config, { strict: false })` returns before cross-reference validation runs, and a hand-built stack object never enters it, so these two rules have never been applied to it. Such an input carrying a dangling `permissions[].objects` key or `data[].object` is now refused at composition where it previously composed with only a warning. If you compose unparsed stacks, that is the one behavioural change to expect; a malformed `permissions` / `data` on such an input is still skipped with the existing non-array warning rather than raising. diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx index 9fb6bbea86..2ddf882afb 100644 --- a/content/docs/getting-started/examples.mdx +++ b/content/docs/getting-started/examples.mdx @@ -438,9 +438,12 @@ The rules worth knowing before you split a product this way: artifact — and seed rows are the same shape. Both resolve against the artifact rather than the one stack, but only once you hand `defineStack` the sibling object names: `defineStack(appConfig, { artifactObjects: ordersStack.objects?.map((o) => o.name) })`. - `composeStacks` then refuses any name no package in the artifact defines, so a - typo is still caught — one pass later, and by name. Hooks are the deliberate - exception: a hook still belongs to the package that owns its object. + **In a composition of two or more packages**, `composeStacks` then refuses any + name no package in the artifact defines, so a typo is still caught — one pass + later, and by name. The qualifier is real: `composeStacks` returns a single + stack untouched, so composing one package on its own does not check the claim. + Hooks are the deliberate exception: a hook still belongs to the package that + owns its object. - **One artifact, one version.** Everything inside ships, installs and upgrades together; you cannot hot-fix one module on its own. A module that needs its own release cadence belongs in its own artifact. diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts index 75d3fd18c3..7dc60a8946 100644 --- a/packages/spec/src/stack-artifact-crossref.test.ts +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -39,6 +39,26 @@ * because only the pass differs. `ArtifactPass` is that fixture, and it is an * acceptance criterion of #18202 rather than a nicety. * + * ## What the artifact pass DOES newly refuse, and what it must never do + * + * The compatibility statement that holds is not "nothing can newly fail". It + * is two statements: + * + * - an input that passed the strict `defineStack` parse cannot newly fail at + * composition — its references already resolved against its own objects, a + * subset of the composed set; + * - an input that BYPASSED the strict parse (`strict: false`, a hand-built + * stack object) is checked for these two rules at composition for the FIRST + * time, and a dangling reference in it is refused where it previously + * composed. + * + * The second is a narrowing, it is deliberate, and the three blocks at the + * bottom of this file pin it as declared behaviour: the refusals themselves, + * the shape guards that keep an unparsed malformed collection a warning rather + * than a bare `TypeError` outside the ADR-0112 envelope, and the one-input + * boundary that makes "in a composition of two or more packages" the only + * correct way to state the guarantee to an author. + * * ## Fixture shape * * The two-package shape measured downstream, reproduced at its smallest: the @@ -295,3 +315,182 @@ describe('#18202 — the object-less leniency the ARTIFACT pass inherits verbati .toBeNull(); }); }); + +/** + * The ARTIFACT pass runs over EVERY input, not only the ones that opted in — + * and for an input that never went through the strict `defineStack` parse that + * is not a no-op. `defineStack(config, { strict: false })` returns before + * `validateCrossReferences` runs at all, and a hand-built stack object never + * enters it, so these two rules have never been applied to such an input. The + * artifact pass is the first place they are. + * + * That is a real narrowing of what `composeStacks` accepts, in the Prime + * Directive #12 direction. It is pinned here as DECLARED behaviour so the next + * reader meets it as a decision rather than as a regression: the changeset says + * it, `collectArtifactCrossReferenceErrors`'s docstring says it, and these + * fixtures hold it. + */ +describe('#18202 — an input that bypassed the strict parse IS checked at composition', () => { + /** The app package as an unparsed stack: `strict: false` skips every validation. */ + const unparsedApp = (grantObject: string, seedObject: string) => + defineStack(appConfig(grantObject, seedObject), { strict: false }); + + /** The same config as a hand-built object — it never enters `defineStack` at all. */ + const handBuiltApp = (grantObject: string, seedObject: string) => + appConfig(grantObject, seedObject) as unknown as ReturnType; + + it('`strict: false` alone still composes — the parse it skipped is not reinstated here', () => { + // The control for the two refusals below: same construction, a name the + // artifact DOES define. If this went red the refusals would prove nothing. + expect( + refusalOf(() => composeStacks([serviceStack(), unparsedApp('crm_case', 'crm_case')], { manifest: 'preserve' })), + ).toBeNull(); + }); + + it('REFUSES a `strict: false` input whose grant names an object NO package defines', () => { + const refused = refusalOf(() => + composeStacks([serviceStack(), unparsedApp(NOWHERE, 'crm_case')], { manifest: 'preserve' }), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + }); + + it('REFUSES the seed-data twin on a `strict: false` input', () => { + const refused = refusalOf(() => + composeStacks([serviceStack(), unparsedApp('crm_case', NOWHERE)], { manifest: 'preserve' }), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(SEED_ON_NOWHERE); + }); + + it('REFUSES a hand-built stack object on the same two rules', () => { + const refused = refusalOf(() => + composeStacks([serviceStack(), handBuiltApp(NOWHERE, NOWHERE)], { manifest: 'preserve' }), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + expect(refused?.issues).toContain(SEED_ON_NOWHERE); + }); + + it('leaves every OTHER rule un-applied to an unparsed input — only these two cross', () => { + // A hook on an object nobody defines is refused by the PER-STACK pass only. + // The artifact pass re-raises the two ARTIFACT-SCOPED rules and nothing + // else, so an unparsed input carrying a dangling hook still composes. + const unparsedHook = defineStack( + anyStack({ + manifest: appManifest, + objects: [account], + hooks: [{ name: 'nowhere_hook', object: NOWHERE, events: ['afterInsert'], handler: 'noop' }], + }), + { strict: false }, + ); + expect(refusalOf(() => composeStacks([serviceStack(), unparsedHook], { manifest: 'preserve' }))).toBeNull(); + }); +}); + +/** + * The shape guard the two collectors carry (#18202 rework). + * + * Because the artifact pass reads `permissions` / `data` off inputs the strict + * parse never saw, those keys can be a non-array, and an entry can be `null` or + * a scalar. `composeStacks`'s step-3 concat pass already refuses to drop such a + * key without a word (#5005); the two collectors must not turn the same input + * into a bare `TypeError` with no `code` and no `status`, which is exactly what + * this pass did before the guards existed. Every case below composes on + * `origin/main`, so a throw here is a regression, not a stricter contract. + * + * ⚠️ `warnMalformedCollectionKey` deduplicates per key for the lifetime of the + * module, so each key is asserted in exactly ONE test and the count assertion + * (`toBe(1)`) is what proves the two passes do not both speak. + */ +describe('#18202 — a malformed collection on an unparsed input is skipped, never a bare TypeError', () => { + /** Collect `console.warn` for one call, restoring the real one afterwards. */ + function warningsDuring(run: () => unknown): { warnings: string[]; thrown: Envelope | null } { + const warnings: string[] = []; + const real = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + try { + return { warnings, thrown: refusalOf(run) }; + } finally { + console.warn = real; + } + } + + const malformed = (overrides: Record) => + anyStack({ manifest: appManifest, objects: [account], ...overrides }) as unknown as ReturnType; + + const composeWith = (stack: ReturnType) => () => + composeStacks([serviceStack(), stack], { manifest: 'preserve' }); + + it('a non-array `permissions` composes, and the key is warned about exactly once', () => { + const { warnings, thrown } = warningsDuring(composeWith(malformed({ permissions: 'not-an-array' }))); + expect(thrown).toBeNull(); + expect(warnings.filter((w) => w.includes("top-level key 'permissions'"))).toHaveLength(1); + }); + + it('a non-array `data` composes, and the key is warned about exactly once', () => { + const { warnings, thrown } = warningsDuring(composeWith(malformed({ data: 42 }))); + expect(thrown).toBeNull(); + expect(warnings.filter((w) => w.includes("top-level key 'data'"))).toHaveLength(1); + }); + + it('a null entry inside `permissions` is skipped, not dereferenced', () => { + expect(refusalOf(composeWith(malformed({ permissions: [null] })))).toBeNull(); + }); + + it('a null entry inside `data` is skipped, not dereferenced', () => { + expect(refusalOf(composeWith(malformed({ data: [null] })))).toBeNull(); + }); + + it('a scalar entry, and a non-string `object`, carry no reference for the rule to resolve', () => { + expect( + refusalOf(composeWith(malformed({ data: ['crm_case', { object: 7 }], permissions: ['sales_rep'] }))), + ).toBeNull(); + }); + + it('a malformed `objects` grant map is skipped while the rest of the set is still read', () => { + const refused = refusalOf( + composeWith( + malformed({ + permissions: [ + { name: 'broken', label: 'Broken', objects: null }, + { name: 'sales_rep', label: 'Sales Rep', objects: { [NOWHERE]: { allowRead: true } } }, + ], + }), + ), + ); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + }); +}); + +/** + * The declared boundary of the artifact pass (#18202 rework). + * + * `composeStacks` returns `stacks[0]` untouched for a single input, so a + * one-package composition never reaches the pass at all. That is why every + * reader-facing statement of the guarantee — the option's docstring and the + * multi-package docs bullet — says "in a composition of two or more packages". + * This block is the fence on that qualifier: if the early return is ever + * removed, the qualifier becomes wrong and these tests say so. + */ +describe('#18202 — a composition of ONE package never reaches the artifact pass', () => { + const claiming = () => defineStack(appConfig(NOWHERE, NOWHERE), { artifactObjects: [NOWHERE] }); + + it('accepts a one-input composition whose claim names an object nothing defines', () => { + expect(refusalOf(() => composeStacks([claiming()], { manifest: 'preserve' }))).toBeNull(); + }); + + it('accepts it with no options either — the early return precedes the option parse', () => { + expect(refusalOf(() => composeStacks([claiming()]))).toBeNull(); + }); + + it('and REFUSES the identical claim as soon as a second package joins — the contrast', () => { + const refused = refusalOf(() => composeStacks([serviceStack(), claiming()], { manifest: 'preserve' })); + expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refused?.issues).toContain(GRANT_ON_NOWHERE); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ee39357422..690c80dd44 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1479,12 +1479,20 @@ export interface DefineStackOptions { * it therefore leaves `ObjectStackDefinitionSchema` — and every artifact * already built from it — untouched. * - * ## The claim is verified, not trusted + * ## The claim is verified, not trusted — in a composition of two or more packages * * A name listed here that no package in the artifact actually defines is - * refused by {@link composeStacks}, with the same `STACK_CROSS_REFERENCE_INVALID` - * envelope and the same per-finding message. Omitting the option keeps - * today's behaviour byte-for-byte. + * refused by {@link composeStacks} **in a composition of two or more + * packages**, with the same `STACK_CROSS_REFERENCE_INVALID` envelope and the + * same per-finding message. The qualifier is load-bearing: `composeStacks` + * returns a single input untouched, so a one-package composition — like a + * stack that is never composed — leaves the claim unverified. See + * {@link collectArtifactCrossReferenceErrors}'s known boundary. + * + * Omitting the option keeps `defineStack`'s own behaviour byte-for-byte. It + * does not exempt the stack from the artifact pass, which re-runs the two + * rules over every input: a no-op for anything that passed the strict parse, + * and the first application of those rules to anything that bypassed it. * * @example * ```ts @@ -2086,17 +2094,39 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { * The MESSAGE is identical either way, which is what lets {@link composeStacks} * re-raise this rule over the artifact without inventing a second dialect for * the same finding. + * + * ## Why this guards shapes the strict parse already rejects + * + * Since #18202 this runs from {@link composeStacks} too, and composition + * accepts inputs the strict parse never saw (`strict: false`, a hand-built + * stack object). So `data` here can be a non-array, and an entry can be `null` + * or a scalar. Those shapes are SKIPPED, never dereferenced: a rule whose job + * is to resolve object references must not turn a malformed collection into a + * bare `TypeError` with no `code` and no `status` — the refusal discipline of + * this file is the ADR-0112 envelope. A non-array `data` gets the same word + * {@link composeStacks}'s concat pass gives it ({@link warnMalformedCollectionKey}, + * #5005, deduplicated per key so the two passes speak once); an entry that is + * not an object, or whose `object` is not a string, carries no object + * reference for this rule to resolve and is simply not this rule's finding. */ function collectSeedDataObjectErrors( config: ObjectStackDefinition, resolvable: ReadonlySet, ): string[] { const errors: string[] = []; - if (!config.data) return errors; - for (const dataset of config.data) { - if (dataset.object && !resolvable.has(dataset.object) && !isPlatformObjectName(dataset.object)) { + const datasets: unknown = (config as { data?: unknown }).data; + if (datasets === undefined || datasets === null) return errors; + if (!Array.isArray(datasets)) { + warnMalformedCollectionKey('data'); + return errors; + } + for (const dataset of datasets) { + if (!dataset || typeof dataset !== 'object') continue; + const objectName: unknown = (dataset as { object?: unknown }).object; + if (typeof objectName !== 'string' || objectName.length === 0) continue; + if (!resolvable.has(objectName) && !isPlatformObjectName(objectName)) { errors.push( - `Seed data references object '${dataset.object}' which is not defined in objects.`, + `Seed data references object '${objectName}' which is not defined in objects.`, ); } } @@ -2116,15 +2146,27 @@ function collectSeedDataObjectErrors( * Platform objects are legitimate grant targets (e.g. a delegated-admin set * carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here. * - * `resolvable` carries the same two readings as its seed-data sibling above. + * `resolvable` carries the same two readings as its seed-data sibling above, + * and so does its shape guard: a non-array `permissions` is announced through + * {@link warnMalformedCollectionKey} and skipped, and a `permissions` entry + * that is not an object is skipped, because this rule reads + * `permissions[].objects` and an unparsed input may carry neither. Same reason + * as the sibling — a malformed collection must not become a bare `TypeError` + * in the pass whose refusals are ADR-0112 envelopes. */ function collectPermissionGrantObjectErrors( config: ObjectStackDefinition, resolvable: ReadonlySet, ): string[] { const errors: string[] = []; - if (!config.permissions) return errors; - for (const perm of config.permissions) { + const permissions: unknown = (config as { permissions?: unknown }).permissions; + if (permissions === undefined || permissions === null) return errors; + if (!Array.isArray(permissions)) { + warnMalformedCollectionKey('permissions'); + return errors; + } + for (const perm of permissions) { + if (!perm || typeof perm !== 'object') continue; const grants = (perm as { objects?: Record }).objects; if (!grants || typeof grants !== 'object') continue; for (const objName of Object.keys(grants)) { @@ -3753,15 +3795,35 @@ function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody * artifact, so a name no package in it defines is refused here, loudly, with * the same envelope and the same per-finding message the per-stack pass uses. * - * ## Why it re-checks inputs that never opted in (and why that is free) + * ## Why it re-checks inputs that never opted in, and what that changes * * It does not read the option — the option is not recorded on the returned * stack, and deliberately so: recording it would put a composition-time concern * on `ObjectStackDefinitionSchema`, i.e. on every artifact already built. It - * instead re-runs the two rules for every input, which is a NO-OP for an input - * that did not opt in: that input's references already resolved against its own - * objects, and its own objects are a subset of the composed set. Nothing that - * composes cleanly today can newly fail here. + * instead re-runs the two rules for every input. The invariant that buys is + * narrower than "nothing can newly fail", and the narrower statement is the + * true one: + * + * - **An input that passed the strict `defineStack` parse cannot newly fail + * here.** Its references already resolved against its own objects, and its + * own objects are a subset of the composed set — so re-running the two rules + * over a superset is a no-op. + * - **An input that BYPASSED the strict parse is checked for these two rules + * here for the first time.** `defineStack(config, { strict: false })` returns + * before {@link validateCrossReferences} runs at all, and a hand-built stack + * object never enters it — so such an input has never had these two rules + * applied, and a dangling `permissions[].objects` key or `data[].object` in + * it is refused at composition where it previously composed. + * + * The second bullet is a real narrowing of what {@link composeStacks} accepts, + * and it is DECLARED rather than incidental: it is the Prime Directive #12 + * direction (reject off-spec input at the producer, loudly), the changeset + * states it, and `stack-artifact-crossref.test.ts` pins it as behaviour rather + * than leaving it to be rediscovered as a regression. What it is NOT is a + * licence to crash: the two collectors guard their own shapes, so an unparsed + * input carrying a malformed `permissions` / `data` is warned about and skipped + * — the same treatment {@link composeStacks}'s concat pass gives it — instead + * of raising a bare `TypeError` outside the ADR-0112 envelope. * * ## The one leniency it inherits verbatim * @@ -3772,13 +3834,17 @@ function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody * It is also why hotcrm#1449's measurement (an app package declaring no * objects) never saw the defect #18202 reports. * - * ## Known boundary + * ## Known boundary — this pass runs only for TWO OR MORE packages * - * A stack that opts in and is then NEVER composed has no artifact to be - * checked against, and its claim stands unverified — the same shape as - * `strict: false`, and for the same reason: the author asserted something only - * a composition can confirm. `os build` composes; a stack that does not is not - * a package of an artifact. + * {@link composeStacks} returns `stacks[0]` untouched for a single input, so a + * one-package composition never reaches this pass. Together with a stack that + * is never composed at all, that is the population whose `artifactObjects` + * claim stands unverified — the same shape as `strict: false`, and for the same + * reason: the author asserted something only a composition can confirm. So the + * guarantee to state to authors is "a name no package in the artifact defines + * is refused **in a composition of two or more packages**", never the + * unqualified form. `os build` composes; a stack that does not is not a package + * of an artifact. */ function collectArtifactCrossReferenceErrors( stacks: readonly ObjectStackDefinition[], @@ -3921,9 +3987,12 @@ export function composeStacks( // collections are concatenated, because it reads the composed // `permissions` / `data` of each INPUT against the composed `objects`; // BEFORE anything else can throw on a stack whose references do not - // resolve in the first place. See - // {@link collectArtifactCrossReferenceErrors} for why re-checking every - // input cannot newly refuse a composition that passes today. + // resolve in the first place. Never reached for a single input — the + // `stacks.length === 1` early return above is the declared boundary. See + // {@link collectArtifactCrossReferenceErrors} for which inputs this can + // and cannot newly refuse: an input that passed the strict parse cannot + // newly fail, an input that bypassed it is checked here for the first + // time. const artifactCrossRefErrors = collectArtifactCrossReferenceErrors( stacks, objects as readonly { name: string }[] | undefined, From 0c21631cd36bc79ba5f82a44ce46eb198b24962c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 01:43:59 +0000 Subject: [PATCH 6/7] test(spec): pin the non-array `permissions` guard on a value that is not iterable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ablation leg measured the previous fixture as GREEN under the mutation: a string value iterates its own characters, so removing the `Array.isArray` guard changed nothing for it. Map format — the shape a hand-built stack most plausibly carries for a collection key — is not iterable, so the fixture now fails without the guard and the ablation reads red for all six malformed cases. Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- packages/spec/src/stack-artifact-crossref.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts index 7dc60a8946..27f6cfd802 100644 --- a/packages/spec/src/stack-artifact-crossref.test.ts +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -426,7 +426,12 @@ describe('#18202 — a malformed collection on an unparsed input is skipped, nev composeStacks([serviceStack(), stack], { manifest: 'preserve' }); it('a non-array `permissions` composes, and the key is warned about exactly once', () => { - const { warnings, thrown } = warningsDuring(composeWith(malformed({ permissions: 'not-an-array' }))); + // Map format, which `permissions` does not support — the shape a + // hand-built stack most plausibly carries. It is NOT iterable, which is + // what makes this the case that distinguishes the guard: a string value + // would iterate its characters and never throw either way. + const mapShaped = { sales_rep: { label: 'Sales Rep', objects: { [NOWHERE]: { allowRead: true } } } }; + const { warnings, thrown } = warningsDuring(composeWith(malformed({ permissions: mapShaped }))); expect(thrown).toBeNull(); expect(warnings.filter((w) => w.includes("top-level key 'permissions'"))).toHaveLength(1); }); From 19db79b67b29d2d5468acb00e907cfb3cfa491a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:57:49 +0000 Subject: [PATCH 7/7] =?UTF-8?q?docs(spec):=20state=20the=20prior=20behavio?= =?UTF-8?q?ur=20truthfully=20=E2=80=94=20no=20diagnostic,=20not=20a=20warn?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset said an input the artifact pass newly refuses "previously composed with only a warning". Measured on main, that class of input produces no diagnostic at all: `composeStacks` calls `validateCrossReferences` zero times and `console.warn` zero times, and its one `warnMalformedCollectionKey` site fires on `declared.length !== arrays.length` — a collection key that is not an array. The newly-refused inputs carry well-formed arrays with a dangling reference, so that condition never holds. The clause conflated two populations and handed an upgrading reader a false self-test ("we never saw a warning, so this is not us") in text that ships verbatim into CHANGELOG.md. It now reads "where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference". Same stroke, the precision the review asked for: the no-op half of the invariant holds for an input that passed the strict parse AND did not opt in. An opted-in input also passed that parse but resolved against its own objects plus the names it listed, and checking a listed name against the real artifact is what this pass is for — so it can fail here by design. The qualifier is added in the changeset, in the `collectArtifactCrossReferenceErrors` docstring and in the fixture file's header, which carried the same sentence. Text only: no schema, no rule, no fixture and no docs page changes, and the changeset level stays `minor`. Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W Co-authored-by: Claude --- .changeset/artifact-scoped-cross-reference.md | 2 +- packages/spec/src/stack-artifact-crossref.test.ts | 11 +++++++---- packages/spec/src/stack.zod.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.changeset/artifact-scoped-cross-reference.md b/.changeset/artifact-scoped-cross-reference.md index d4d74eb9e0..c00cf9f3af 100644 --- a/.changeset/artifact-scoped-cross-reference.md +++ b/.changeset/artifact-scoped-cross-reference.md @@ -20,4 +20,4 @@ Nothing else widens. `hooks[].object` and an app's own `navigation` `objectName` The refusal moved rather than disappearing: in a composition of **two or more** packages, `composeStacks` now re-checks those two classes over the composed artifact, so a name `artifactObjects` claims and no package in the artifact defines is refused there, with the same `STACK_CROSS_REFERENCE_INVALID` code, the same `422`, and the same per-finding message. Only the header differs, naming the pass that refused it. `composeStacks` returns a single input untouched, so a one-package composition does not re-check the claim. -**What that changes about which inputs `composeStacks` accepts.** `defineStack` itself is unchanged for a stack that does not pass `artifactObjects` — every single-package app validates exactly as before. `composeStacks` is not: it applies the two artifact-scoped rules to **every** input carrying objects, not only the ones that opted in. For an input that passed the strict `defineStack` parse that is a no-op, so such an input cannot newly fail. For an input that **bypassed** the strict parse it is not: `defineStack(config, { strict: false })` returns before cross-reference validation runs, and a hand-built stack object never enters it, so these two rules have never been applied to it. Such an input carrying a dangling `permissions[].objects` key or `data[].object` is now refused at composition where it previously composed with only a warning. If you compose unparsed stacks, that is the one behavioural change to expect; a malformed `permissions` / `data` on such an input is still skipped with the existing non-array warning rather than raising. +**What that changes about which inputs `composeStacks` accepts.** `defineStack` itself is unchanged for a stack that does not pass `artifactObjects` — every single-package app validates exactly as before. `composeStacks` is not: it applies the two artifact-scoped rules to **every** input carrying objects, not only the ones that opted in. For an input that passed the strict `defineStack` parse **and did not opt in**, that is a no-op, so such an input cannot newly fail — its references were already resolved against its own objects, which are a subset of the composed set. (An input that *did* opt in also passed the strict parse, but it resolved against its own objects plus the names it listed; checking a listed name against the real artifact is what this pass is for, so it can fail here by design.) For an input that **bypassed** the strict parse the no-op argument does not apply at all: `defineStack(config, { strict: false })` returns before cross-reference validation runs, and a hand-built stack object never enters it, so these two rules have never been applied to it. Such an input carrying a dangling `permissions[].objects` key or `data[].object` is now refused at composition where it previously composed with no diagnostic at all — the existing non-array warning covers a malformed collection key, not a dangling reference. If you compose unparsed stacks, that is the one behavioural change to expect, and there is no earlier warning to have noticed it by; a malformed `permissions` / `data` on such an input is still skipped with that non-array warning rather than raising. diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts index 27f6cfd802..b1322f4641 100644 --- a/packages/spec/src/stack-artifact-crossref.test.ts +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -44,13 +44,16 @@ * The compatibility statement that holds is not "nothing can newly fail". It * is two statements: * - * - an input that passed the strict `defineStack` parse cannot newly fail at - * composition — its references already resolved against its own objects, a - * subset of the composed set; + * - an input that passed the strict `defineStack` parse AND did not opt in + * cannot newly fail at composition — its references already resolved against + * its own objects, a subset of the composed set. An opted-in input also + * passed that parse, but it resolved against its own objects plus the names + * it listed, and checking a listed name is what this pass is for — the + * `ArtifactPass` block below is that refusal; * - an input that BYPASSED the strict parse (`strict: false`, a hand-built * stack object) is checked for these two rules at composition for the FIRST * time, and a dangling reference in it is refused where it previously - * composed. + * composed with no diagnostic at all. * * The second is a narrowing, it is deliberate, and the three blocks at the * bottom of this file pin it as declared behaviour: the refusals themselves, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 690c80dd44..6a3a622ffc 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -3804,10 +3804,15 @@ function assemblePackageBody(stack: ObjectStackDefinition): AssembledPackageBody * narrower than "nothing can newly fail", and the narrower statement is the * true one: * - * - **An input that passed the strict `defineStack` parse cannot newly fail - * here.** Its references already resolved against its own objects, and its - * own objects are a subset of the composed set — so re-running the two rules - * over a superset is a no-op. + * - **An input that passed the strict `defineStack` parse and did NOT opt in + * cannot newly fail here.** Its references already resolved against its own + * objects, and its own objects are a subset of the composed set — so + * re-running the two rules over a superset is a no-op. The qualifier is not + * decoration: an input that DID opt in also passed the strict parse, but its + * references resolved against its own objects PLUS the names it listed, and + * a listed name is exactly what this pass exists to check. Redeeming that + * claim against the real artifact is the whole point, so an opted-in input + * can and does fail here — that is the `ArtifactPass` fixture, not a gap. * - **An input that BYPASSED the strict parse is checked for these two rules * here for the first time.** `defineStack(config, { strict: false })` returns * before {@link validateCrossReferences} runs at all, and a hand-built stack