Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/artifact-scoped-cross-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@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.

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 **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.
12 changes: 12 additions & 0 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,18 @@ 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 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) })`.
**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.
Expand Down
Loading
Loading