Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25514-artifact-flow-dataset-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat: a package's action can launch a flow, and its dashboard widget can bind a dataset, that another package of the same release artifact ships

Clause-②: yes (widening)
Metadata-protocol: added

One release artifact carries several packages (ADR-0130 D4). Two per-package checks judged a reference against one package and refused a target a sibling package provides. The artifact carries the target, and the runtime serves it.

- **`@objectstack/spec`: `defineStack(config, { artifactFlows })`.** This new `DefineStackOptions` key lists flow names that the other packages of the artifact provide. A `type: 'flow'` action's `target` resolves against the stack's own `flows` plus these names. That covers a top-level action, an action authored on an object, and an inline page action. It works the way `artifactObjects` already does for permission grants and seed rows.
- The option widens a check that already runs. A stack that declares no flows of its own still leaves its flow targets unresolved (a plugin may serve them).
- `defineStack` records every target that resolved only through the list, as a non-enumerable mark on the stack it returns. `composeStacks` refuses the composition when no input defines that flow. The refusal uses the same `STACK_CROSS_REFERENCE_INVALID` envelope and the same per-finding message as the per-stack pass. An input that did not opt in cannot newly fail at composition.
- A flow that no package of the artifact defines is refused, as before.
- **`@objectstack/lint`: `widget-dataset-unknown`.** The rule resolves a widget's `dataset` against the datasets that every `packages[]` body declares, as well as the stack's own. This is the resolution context that `os build`, `os validate` and `os lint` already pass to each package's run. Dataset-level checks still judge only the package's own datasets. A dataset that no package declares is refused, as before. The runtime publish gate passes no `packages[]`, so its behaviour is unchanged.

Fix for a module split such as HotCRM's: in the app package, write `defineStack(appConfig, { artifactFlows: revenue.flows?.map((f) => f.name) })` and compose both packages with `composeStacks([...], { manifest: 'preserve' })`. A cross-package dataset binding needs no change.
9 changes: 9 additions & 0 deletions content/docs/getting-started/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,15 @@ The rules worth knowing before you split a product this way:
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.
- **Actions may launch a sibling package's flow; dashboards may bind its dataset.**
A `type: 'flow'` action resolves its `target` against the artifact once you
hand `defineStack` the sibling flow names:
`defineStack(appConfig, { artifactFlows: ordersStack.flows?.map((f) => f.name) })`.
The claim is checked the same way: in a composition of two or more packages,
`composeStacks` refuses a claimed flow that no package defines. A dashboard
widget's `dataset` needs no option. `os build`, `os validate` and `os lint`
resolve it against every package of the artifact, and still refuse a dataset
that no package declares.
- **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
106 changes: 106 additions & 0 deletions packages/lint/src/validate-widget-bindings.artifact-datasets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25514 — `widget-dataset-unknown` resolves a widget's `dataset` against the
* ARTIFACT's `packages[]`, not the one package body it judges.
*
* The CLI's per-package pass (`runPerPackageAuthoringRules`) hands each rule
* one package body re-read as a stack, with the artifact's `packages[]` as
* resolution context (`packageBodyAsStack`). An app-package dashboard binding a
* dataset a module package owns was refused there — measured on HotCRM's
* module split as `dataset "product_metrics" does not resolve to a declared
* dataset.` while the composed artifact carried the dataset.
*
* Both directions are pinned: a sibling-provided dataset is accepted (and its
* dimensions / measures are then judged like a local one), and a name no
* package provides is refused exactly as before.
*/
import { describe, it, expect } from 'vitest';
import { runAuthoringRules } from './authoring-rules.js';
import { validateWidgetBindings, WIDGET_DATASET_UNKNOWN, WIDGET_MEASURE_UNKNOWN } from './validate-widget-bindings.js';

const revenueBody = {
id: 'app.objectstack.hotcrm.revenue',
name: 'revenue',
objects: [{ name: 'crm_product', fields: { name: { type: 'text' }, price: { type: 'currency' } } }],
datasets: [{
name: 'product_metrics',
label: 'Product metrics',
object: 'crm_product',
measures: [{ name: 'product_count', label: 'Products', aggregate: 'count' }],
dimensions: [{ name: 'name', field: 'name' }],
}],
};

const appBody = (dataset: string, values: string[] = ['product_count']) => ({
id: 'app.objectstack.hotcrm',
name: 'hotcrm',
dashboards: [{
name: 'crm_overview_dashboard',
label: 'CRM Overview',
widgets: [{
id: 'top_products',
type: 'table',
dataset,
dimensions: ['name'],
values,
layout: { x: 0, y: 0, w: 6, h: 4 },
}],
}],
});

/** `packageBodyAsStack`'s shape: the body, its own manifest, the artifact's entries. */
const asPackageStack = (body: Record<string, unknown>, siblings: Record<string, unknown>[]) => ({
...body,
manifest: body,
packages: [body, ...siblings].map((manifest) => ({ manifest })),
});

const datasetUnknown = (stack: Record<string, unknown>) =>
validateWidgetBindings(stack).filter((f) => f.rule === WIDGET_DATASET_UNKNOWN);

describe('widget-dataset-unknown — the artifact resolves a sibling package’s dataset', () => {
it('ACCEPTS an app widget bound to a dataset the module package owns', () => {
expect(datasetUnknown(asPackageStack(appBody('product_metrics'), [revenueBody]))).toEqual([]);
});

it('still REFUSES a dataset NO package of the artifact declares', () => {
const findings = datasetUnknown(asPackageStack(appBody('product_nowhere'), [revenueBody]));
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'error',
where: 'dashboard "crm_overview_dashboard" › widget "top_products"',
});
});

it('REFUSES the sibling dataset when judged WITHOUT the artifact — the unchanged single-package answer', () => {
expect(datasetUnknown(appBody('product_metrics'))).toHaveLength(1);
});

it('judges the resolved sibling dataset’s measures like a local one', () => {
const findings = validateWidgetBindings(asPackageStack(appBody('product_metrics', ['revenue_sum']), [revenueBody]));
expect(findings.map((f) => f.rule)).toContain(WIDGET_MEASURE_UNKNOWN);
});

it('does not judge a sibling’s dataset-level conditions on this package’s pass', () => {
const incoherent = {
...revenueBody,
datasets: [{
...revenueBody.datasets[0],
measures: [{ name: 'price_total', label: 'Total', aggregate: 'sum', field: 'price' }],
}],
};
const findings = validateWidgetBindings(asPackageStack(appBody('product_metrics', ['price_total']), [incoherent]));
expect(findings.filter((f) => f.path.startsWith('datasets['))).toEqual([]);
});

it('reaches the rule table the per-package pass runs', () => {
const stack = asPackageStack(appBody('product_metrics'), [revenueBody]);
const findings = runAuthoringRules('validate', { normalized: stack, parsed: stack });
expect(findings.filter((f) => f.rule === WIDGET_DATASET_UNKNOWN)).toEqual([]);
const dangling = asPackageStack(appBody('product_nowhere'), [revenueBody]);
expect(
runAuthoringRules('validate', { normalized: dangling, parsed: dangling }).filter((f) => f.rule === WIDGET_DATASET_UNKNOWN),
).toHaveLength(1);
});
});
18 changes: 18 additions & 0 deletions packages/lint/src/validate-widget-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
indexObjectGraph,
isUnjudgeable,
joinablePrefixes,
packagesOf,
recordsOf,
resolveFieldPath,
suggestName,
Expand Down Expand Up @@ -691,6 +692,23 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
for (const ds of recordsOf(stack.datasets)) {
if (typeof ds.name === 'string') datasets.set(ds.name, ds);
}
// [#25514] A widget's `dataset` resolves against the ARTIFACT: the datasets
// every `packages[]` body declares join the map as RESOLUTION CONTEXT, the way
// `validate-object-references.ts` widens rung ① with the artifact's objects.
// A cross-domain dashboard in the app package reads a module's dataset (the
// ADR-0130 plan rule "multi-module dashboards move to the app package"), and
// the runtime serves every package's datasets from one analytics service.
// ⛔ It changes what a widget can RESOLVE, never what is JUDGED: the
// dataset-level checks below still walk `stack.datasets` alone, and a name no
// package declares still resolves nowhere. The stack's own declaration wins a
// name it shares with a body — on the per-package pass that body IS this one.
for (const entry of packagesOf(stack)) {
const body = entry.manifest;
if (!body || typeof body !== 'object' || Array.isArray(body)) continue;
for (const ds of recordsOf((body as AnyRec).datasets)) {
if (typeof ds.name === 'string' && !datasets.has(ds.name)) datasets.set(ds.name, ds);
}
}

// ── (0) dataset measures aggregate their field coherently ──
// A measure that SUMs a percentage/rate field produces a meaningless total
Expand Down
184 changes: 184 additions & 0 deletions packages/spec/src/stack-artifact-flow-crossref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #25514 — a `type: 'flow'` action's target resolves against the ARTIFACT once
* the stack declares its siblings' flows (`DefineStackOptions.artifactFlows`),
* and a flow NO package of the artifact defines stays refused.
*
* ## The defect
*
* `validateCrossReferences` built its flow scope from the stack's own `flows`
* alone, so a sales action on an app-owned object launching a flow that a
* module package of the SAME artifact ships was refused at `defineStack` —
* measured downstream on HotCRM's module split as three "which is not defined
* in flows." refusals, while the composed artifact carried all three flows.
*
* ## The two directions this file pins, per position
*
* - a sibling-provided flow is ACCEPTED at `defineStack` and at `composeStacks`;
* - a flow no package provides is REFUSED — at `defineStack` when it is not
* claimed, and at `composeStacks` (the artifact pass) when it is claimed but
* no input defines it, with the per-stack pass's own message.
*
* Plus the fences: the option never switches the check on for a stack with no
* flows of its own, and an input that did not opt in cannot newly fail at
* composition even when it bypassed the strict parse.
*/
import { describe, it, expect } from 'vitest';
import { composeStacks, defineStack } from './stack.zod';

const appManifest = {
id: 'app.objectstack.hotcrm',
name: 'HotCRM',
version: '3.1.0',
type: 'app' as const,
namespace: 'crm',
};

const moduleManifest = {
id: 'app.objectstack.hotcrm.revenue',
name: 'HotCRM Revenue',
version: '3.1.0',
type: 'module' as const,
namespace: 'crm',
dependencies: { 'app.objectstack.hotcrm': '^3.1.0' },
};

const flow = (name: string) => ({ name, label: name, type: 'autolaunched' as const, nodes: [], edges: [] });

/** Owned by the module package — the flow every cross-package action launches. */
const SIBLING = 'quote_generation';
/** The flow NO package in the artifact defines. */
const NOWHERE = 'quote_nowhere';

const account = { name: 'crm_account', label: 'Account', fields: { title: { type: 'text' as const } } };
const quote = { name: 'crm_quote', label: 'Quote', fields: { title: { type: 'text' as const } } };

type Envelope = Error & { code?: string; status?: number; issues?: readonly string[] };

function refusalOf(run: () => unknown): Envelope | null {
try {
run();
return null;
} catch (e) {
return e as Envelope;
}
}

const anyStack = (config: Record<string, unknown>) => config as unknown as Parameters<typeof defineStack>[0];

/** The module package: owns `crm_quote` and the flow. */
const revenueStack = () =>
defineStack(anyStack({ manifest: moduleManifest, objects: [quote], flows: [flow(SIBLING)] }));

/**
* The app package: owns `crm_account` and a flow of its own (so the per-stack
* flow check runs at all), and launches `target` from all three positions.
*/
const appConfig = (target: string, ownFlows = [flow('crm_local_flow')]) => anyStack({
manifest: appManifest,
objects: [
{
...account,
actions: [{ name: 'quote_from_account', label: 'Quote', type: 'flow', target }],
},
],
flows: ownFlows,
actions: [{ name: 'generate_quote', label: 'Generate Quote', type: 'flow', target }],
pages: [
{
name: 'crm_home',
label: 'Home',
type: 'home',
regions: [
{
name: 'main',
components: [
{
type: 'element:button',
properties: { label: 'Go', action: { name: 'inline_quote', type: 'flow', target } },
},
],
},
],
},
],
});

const messages = (target: string) => [
`Action 'generate_quote' references flow '${target}' which is not defined in flows.`,
`Action 'quote_from_account' on object 'crm_account' references flow '${target}' which is not defined in flows.`,
`Inline action 'inline_quote' on page 'crm_home' (regions.0.components.0) references flow '${target}' which is not defined in flows.`,
];

describe('the per-stack pass, without the opt-in — unchanged', () => {
it('REFUSES the sibling-provided flow from all three positions — the reported defect', () => {
const refused = refusalOf(() => defineStack(appConfig(SIBLING)));
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
expect(refused?.status).toBe(422);
expect(refused?.issues).toEqual(messages(SIBLING));
});
});

describe('`artifactFlows` resolves a sibling package’s flow', () => {
it('ACCEPTS all three positions at `defineStack`', () => {
const revenue = revenueStack();
expect(
refusalOf(() => defineStack(appConfig(SIBLING), { artifactFlows: (revenue.flows ?? []).map((f) => f.name) })),
).toBeNull();
});

it('ACCEPTS the composed artifact — the claim is redeemed by the sibling', () => {
const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] });
expect(refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' }))).toBeNull();
});

it('keeps refusing a name it does NOT list, beside one it does', () => {
const refused = refusalOf(() => defineStack(appConfig(NOWHERE), { artifactFlows: [SIBLING] }));
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
expect(refused?.issues).toEqual(messages(NOWHERE));
});
});

describe('the ARTIFACT pass: a claimed flow NO package defines is refused', () => {
const composeClaiming = (name: string) => {
const app = defineStack(appConfig(name), { artifactFlows: [name] });
return refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' }));
};

it('REFUSES every position with the per-stack pass’s own message and envelope', () => {
const refused = composeClaiming(NOWHERE);
expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID');
expect(refused?.status).toBe(422);
expect(refused?.issues).toEqual(messages(NOWHERE));
expect(refused?.message).toContain('composeStacks artifact cross-reference validation failed');
});

it('resolves against a nested multi-package input, whose flows live only in its package bodies', () => {
const inner = composeStacks(
[revenueStack(), defineStack(anyStack({ manifest: { ...moduleManifest, id: 'x.other' }, objects: [] }))],
{ manifest: 'preserve' },
);
const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] });
expect(refusalOf(() => composeStacks([inner, app], { manifest: 'preserve' }))).toBeNull();
});
});

describe('fences', () => {
it('a stack with NO flows of its own keeps the plugin leniency — the option switches nothing on', () => {
const app = defineStack(appConfig(NOWHERE, []), { artifactFlows: [SIBLING] });
// No claim was relied on, so the composition has nothing to redeem.
expect(refusalOf(() => composeStacks([revenueStack(), app], { manifest: 'preserve' }))).toBeNull();
});

it('an input that did not opt in cannot newly fail at composition, even past `strict: false`', () => {
const loose = defineStack(appConfig(NOWHERE), { strict: false });
expect(refusalOf(() => composeStacks([revenueStack(), loose], { manifest: 'preserve' }))).toBeNull();
});

it('the claim record is not data — the built stack carries no new enumerable key', () => {
const app = defineStack(appConfig(SIBLING), { artifactFlows: [SIBLING] });
expect(Object.keys(app)).toEqual(Object.keys(defineStack(appConfig('crm_local_flow'))));
expect(JSON.stringify(app)).not.toContain('artifact-flow-claims');
});
});
Loading
Loading