diff --git a/.changeset/define-stack-trigger-capability-refusal.md b/.changeset/define-stack-trigger-capability-refusal.md new file mode 100644 index 0000000000..8706faa2ed --- /dev/null +++ b/.changeset/define-stack-trigger-capability-refusal.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +`defineStack` now refuses a stack that declares an auto-launched flow while `requires` omits `'triggers'` (#14153) — **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + +A `record_change`, `schedule`, `time_relative` or `api` flow fires only when its trigger is mounted, and every one of those triggers ships in `@objectstack/trigger-*` behind ONE capability token, `requires: ['triggers']`. `defineStack` already hard-errors the same declared-capability class for the hierarchy scopes (`unit` / `unit_and_below` / `own_and_reports` need `'hierarchy-security'`), which fail CLOSED when the capability is missing — a user notices the missing rows. The trigger half failed SILENT: the flow registered, `validate` / `typecheck` / `test` / `build` all exited 0, and the automation simply never happened. Measured downstream: an app shipped four correctly-authored flows, zero bound, across five merged rounds, and the only diagnostic was a boot-banner line printed after deploy. + +The refusal lands in the same throw-site family as its sibling (`defineStack trigger capability validation failed (N issue(s)):` with one `✗` line per flow) and reuses the boot audit's own wording — the flow name, the resolved trigger kind, and the exact remedy (`Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`). An absent `requires` counts as omitting the token: the CLI reads it as `[]` and appends only the always-on slate, which mounts neither `automation` nor `triggers`, so a stack that declares nothing gets no trigger either. Flows whose `status` disables them (`obsolete` / `invalid`) are skipped, exactly as the engine's boot audit skips them. A stack whose flows are all `screen` or hand-launched `autolaunched` owes nothing. The fix for a refused stack is the one line the message names; a flow that was genuinely meant to be launched only by hand declares `type: 'autolaunched'` (or `'screen'`) instead of a trigger it never intended to bind. + +The kind a flow asks for is now one shared derivation, `resolveFlowTriggerKind` (`@objectstack/spec/automation`), the authoring-time mirror of the automation engine's binding chain — same start-node reads, same precedence (a `timeRelative` descriptor outranks its sibling `schedule` cadence). `@objectstack/lint`'s `validate-flow-trigger-readiness` reads it as the auto-triggered predicate behind its draft-status rule, so the two authoring surfaces cannot disagree on which flows auto-launch; its findings are unchanged. + +In-tree corpus: `examples/app-todo` declared two `schedule` flows and a `record_change` flow with no `requires` at all and now declares `requires: ['automation', 'triggers']`. + + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 2ba489d5d9..f23fae5951 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1314,11 +1314,23 @@ import { approvalFlow } from './flows/approval'; export default defineStack({ manifest: { id: 'com.example.crm', version: '1.0.0', type: 'app', name: 'CRM' }, + requires: ['automation', 'triggers'], // ← the engine, and the triggers that FIRE flows objects: [...], flows: [approvalFlow], // ← registered with the engine on boot }); ``` +Registration is not arming. A `record_change`, `schedule`, `time_relative` or +`api` flow fires only when its trigger is mounted, and the triggers ship +separately (`@objectstack/trigger-*`) behind **one** capability token on the +same stack: `requires: ['triggers']` (`automation` mounts the engine itself; +neither is in the always-on slate an absent `requires` falls back to). +`defineStack` refuses a stack that declares such a flow without the token, +naming the flow, the trigger kind it resolved and the fix — because the +alternative was measured: the flow registers, `os validate` and `os build` +pass, and it never runs. A `screen` flow, or an `autolaunched` one you start +by hand, owes nothing. + The plugin is a **soft dependency** on `metadata` — it tolerates running without `MetadataPlugin` and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup. diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index bb2c9b9b62..3612bbc92b 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -25,6 +25,7 @@ Read the next section before you write either. | **Vocabulary** | Author-chosen names, `^[a-z][a-z0-9_.]*$` — `export_data`, `billing.refund` | A **closed** vocabulary: canonical kebab-case tokens from `PLATFORM_CAPABILITY_TOKENS` — `ai`, `automation`, `hierarchy-security` | | **Entry shape** | `defineCapability({ name, label, description, scope })` (`CapabilityDeclarationSchema`) | A plain `string` | | **Unknown value** | There is no "unknown" — you are minting the name | A `defineStack` **error** at authoring time (a typo, or a token no runtime provides) | +| **Needed but undeclared** | Nothing to detect — a name is minted here, then granted | A `defineStack` **error** too: a hierarchy scope (`unit` / `unit_and_below` / `own_and_reports`) needs `hierarchy-security`, and a `record_change` / `schedule` / `time_relative` / `api` flow needs `triggers` — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires | | **Consumed by** | `systemPermissions` (grant) and `requiredPermissions` (requirement), by name string | The runtime capability loader, which resolves each token to a service plugin | | **When it bites** | Never at boot — an ungranted capability is simply held by nobody | **Fail-fast at startup**: a declared-but-missing provider aborts boot instead of degrading silently | | **Spec** | ADR-0066 D1 | Platform service vocabulary — see the [CLI reference](/docs/deployment/cli) | diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index ea55c37ffd..8774026a3c 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -48,6 +48,15 @@ export default defineStack({ engines: { protocol: '^17' }, }, + // Platform services this app needs — the closed `PLATFORM_CAPABILITY_TOKENS` + // vocabulary. `automation` mounts the flow engine; `triggers` mounts the + // record-change / schedule / time-relative / api triggers that FIRE the + // `flows` below (the task-completion flow is `record_change`, the two daily + // sweeps are `schedule`). Neither token is in the always-on slate an absent + // `requires` falls back to, so without this line the flows registered and + // never ran — `defineStack` now refuses that combination at authoring time. + requires: ['automation', 'triggers'], + // Seed Data (top-level, registered as metadata) data: TodoSeedData, diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index 73f527d1b7..da232cf21b 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -79,6 +79,10 @@ const cliTierFor = (stack: AnyRec): AnyRec => describe('the mechanism: for a defineStack config the `normalized` tier is POST-parse', () => { const flowStack = { manifest, + // A `schedule` flow auto-launches, and `defineStack` refuses one whose stack + // does not declare the trigger capability (#14153) — the flow here is only + // the vehicle for a parse-time default, so declare the token it owes. + requires: ['triggers'], flows: [ { name: 'tier_flow', diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index c7b4a49874..a741c07896 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -103,7 +103,7 @@ // flows keep being served. What IS refused is the dead flow's own publish — and, // on the CLI surface, a package build whose stack contains one. -import { TimeRelativeTriggerSchema } from '@objectstack/spec/automation'; +import { TimeRelativeTriggerSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; export type FlowTriggerReadinessSeverity = 'error' | 'warning'; @@ -273,9 +273,15 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines Array.isArray(config.triggerType) && (config.triggerType as unknown[]).some((t) => typeof t === 'string' && t.startsWith('record-')); const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === 'object'; - const isAutoTriggered = - isRecordTriggered || triggerType === 'api' || config.schedule != null || - isTimeRelative || flow.type === 'schedule' || flow.type === 'api'; + // The auto-triggered predicate is the spec's `resolveFlowTriggerKind`: the + // same start-node reads this rule makes above, in the engine's precedence, + // shared with `defineStack`'s trigger-capability refusal so the two + // authoring surfaces answer "does this flow auto-launch?" identically. + // Byte-identical to the six-term disjunction it replaces — the resolver + // answers a kind exactly when one of those terms held; the array-form + // record trigger (1d's subject) never counted here and resolves to no kind + // there either. + const isAutoTriggered = resolveFlowTriggerKind(flow) !== undefined; // 1. Record-triggered flow targeting an object this stack does not define. if (isRecordTriggered && start) { @@ -319,9 +325,10 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // the time-relative trigger, and stays silent about the ones it does not // — which is why the flows on the OTHER side of that predicate need // their own criterion, in 1e below (#5647). Widening this guard was the - // alternative and was rejected: `isTimeRelative` also feeds - // `isAutoTriggered`, so it would have moved two already-published rules' - // coverage as a side effect of adding a third. + // alternative and was rejected: the same predicate also feeds + // `isAutoTriggered` (today through the spec's `resolveFlowTriggerKind`), + // so it would have moved two already-published rules' coverage as a + // side effect of adding a third. if (isTimeRelative && start) { const tr = config.timeRelative as AnyRec; diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 4cbf96b226..fdc18197bf 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -97,6 +97,7 @@ "FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE (const)", "FLOW_STRUCTURAL_NODE_TYPES (const)", + "FLOW_TRIGGER_KINDS (const)", "Flow (type)", "FlowEdge (type)", "FlowEdgeParsed (type)", @@ -131,6 +132,7 @@ "FlowRunSummaryParsed (type)", "FlowRunSummarySchema (const)", "FlowSchema (const)", + "FlowTriggerKind (type)", "FlowVariableSchema (const)", "FlowVersionHistory (type)", "FlowVersionHistoryParsed (type)", @@ -240,6 +242,7 @@ "normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions (function)", "resolveFlowNodeExpressions (function)", + "resolveFlowTriggerKind (function)", "validateControlFlow (function)" ] } diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 33b78094f7..c188292e2d 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -97,6 +97,7 @@ "FLOW_REGION_SLOTS": "src/automation/region-slots.ts#FLOW_REGION_SLOTS (const)", "FLOW_REGION_SLOTS_BY_TYPE": "src/automation/region-slots.ts#FLOW_REGION_SLOTS_BY_TYPE (const)", "FLOW_STRUCTURAL_NODE_TYPES": "src/automation/flow.zod.ts#FLOW_STRUCTURAL_NODE_TYPES (const)", + "FLOW_TRIGGER_KINDS": "src/automation/flow-trigger-kind.ts#FLOW_TRIGGER_KINDS (const)", "Flow": "src/automation/flow.zod.ts#Flow (type)", "FlowEdge": "src/automation/flow.zod.ts#FlowEdge (type)", "FlowEdgeParsed": "src/automation/flow.zod.ts#FlowEdgeParsed (type)", @@ -131,6 +132,7 @@ "FlowRunSummaryParsed": "src/automation/execution.zod.ts#FlowRunSummaryParsed (type)", "FlowRunSummarySchema": "src/automation/execution.zod.ts#FlowRunSummarySchema (const)", "FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)", + "FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)", "FlowVariableSchema": "src/automation/flow.zod.ts#FlowVariableSchema (const)", "FlowVersionHistory": "src/automation/flow.zod.ts#FlowVersionHistory (type)", "FlowVersionHistoryParsed": "src/automation/flow.zod.ts#FlowVersionHistoryParsed (type)", @@ -240,6 +242,7 @@ "normalizeFlowFunctionEntry": "src/automation/flow-function.zod.ts#normalizeFlowFunctionEntry (function)", "parseFlowNodeRegions": "src/automation/control-flow.zod.ts#parseFlowNodeRegions (function)", "resolveFlowNodeExpressions": "src/automation/flow-node-expression-paths.ts#resolveFlowNodeExpressions (function)", + "resolveFlowTriggerKind": "src/automation/flow-trigger-kind.ts#resolveFlowTriggerKind (function)", "validateControlFlow": "src/automation/control-flow.zod.ts#validateControlFlow (function)" } } diff --git a/packages/spec/src/automation/flow-trigger-kind.test.ts b/packages/spec/src/automation/flow-trigger-kind.test.ts new file mode 100644 index 0000000000..04b4d95adb --- /dev/null +++ b/packages/spec/src/automation/flow-trigger-kind.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { FLOW_TRIGGER_KINDS, resolveFlowTriggerKind } from './flow-trigger-kind'; + +// `resolveFlowTriggerKind` is the authoring-time mirror of the automation +// engine's `resolveTriggerBinding` chain (kind only). These pins hold it to +// that chain — the reads, the precedence, and the one documented divergence — +// because two authoring surfaces (`defineStack`'s trigger-capability refusal +// and lint's `validate-flow-trigger-readiness`) answer "does this flow +// auto-launch?" through it. + +function flow(type: string, config?: Record, extra: Record = {}) { + return { + name: 'f', + label: 'F', + type, + nodes: [ + { id: 'start', type: 'start', label: 'Start', ...(config ? { config } : {}) }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }; +} + +describe('resolveFlowTriggerKind — the engine binding chain, kind only', () => { + it("record_change: a string triggerType starting with 'record-', whatever the flow's type says", () => { + expect(resolveFlowTriggerKind(flow('record_change', { objectName: 'task', triggerType: 'record-after-update' }))) + .toBe('record_change'); + expect(resolveFlowTriggerKind(flow('autolaunched', { objectName: 'task', triggerType: 'record-after-create' }))) + .toBe('record_change'); + expect(resolveFlowTriggerKind(flow('record_change', { objectName: 'task', triggerType: 'record-before-write' }))) + .toBe('record_change'); + }); + + it('time_relative: an object descriptor — and it outranks a sibling schedule cadence (the sweep interval)', () => { + const descriptor = { object: 'contract', dateField: 'end_date', offsetDays: [30, 7] }; + expect(resolveFlowTriggerKind(flow('schedule', { timeRelative: descriptor }))).toBe('time_relative'); + expect(resolveFlowTriggerKind(flow('schedule', { timeRelative: descriptor, schedule: '0 8 * * *' }))) + .toBe('time_relative'); + // `typeof … === 'object'` is the engine's routing predicate, character for + // character: an array or a Date IS routed to the sweep (and refused there + // by TimeRelativeTriggerSchema); a scalar is not routed anywhere. + expect(resolveFlowTriggerKind(flow('schedule', { timeRelative: [] }))).toBe('time_relative'); + expect(resolveFlowTriggerKind(flow('autolaunched', { timeRelative: 'daily' }))).toBeUndefined(); + }); + + it('schedule: a config.schedule cadence, or type schedule with no start config at all', () => { + expect(resolveFlowTriggerKind(flow('schedule', { schedule: '0 8 * * *' }))).toBe('schedule'); + expect(resolveFlowTriggerKind(flow('schedule', { schedule: { type: 'interval', every: '5m' } }))).toBe('schedule'); + expect(resolveFlowTriggerKind(flow('autolaunched', { schedule: '0 8 * * *' }))).toBe('schedule'); + expect(resolveFlowTriggerKind(flow('schedule'))).toBe('schedule'); + }); + + it("api: type api, or a start node whose triggerType is 'api'", () => { + expect(resolveFlowTriggerKind(flow('api'))).toBe('api'); + expect(resolveFlowTriggerKind(flow('autolaunched', { triggerType: 'api' }))).toBe('api'); + }); + + it('undefined: screen flows, autolaunched-by-hand flows, and anything that is not a flow shape', () => { + expect(resolveFlowTriggerKind(flow('screen'))).toBeUndefined(); + expect(resolveFlowTriggerKind(flow('autolaunched'))).toBeUndefined(); + expect(resolveFlowTriggerKind(flow('autolaunched', { objectName: 'task' }))).toBeUndefined(); + // A `type: 'record_change'` flow with an off-grammar token falls off the end + // of the chain exactly as it does in the engine (lint 1f names that one). + expect(resolveFlowTriggerKind(flow('record_change', { objectName: 'task', triggerType: 'onCreate' }))) + .toBeUndefined(); + expect(resolveFlowTriggerKind({ name: 'no_nodes', type: 'record_change' })).toBeUndefined(); + expect(resolveFlowTriggerKind(undefined)).toBeUndefined(); + expect(resolveFlowTriggerKind(null)).toBeUndefined(); + expect(resolveFlowTriggerKind('record_change')).toBeUndefined(); + expect(resolveFlowTriggerKind({ type: 'schedule', nodes: 'not-an-array' })).toBe('schedule'); + }); + + it('the ARRAY form of triggerType resolves to no kind — the documented divergence from the engine', () => { + // Unsupported (#3457). The engine routes it to the record-change trigger + // only so that trigger can refuse it loudly at bind time; lint reports the + // shape itself as an error. Neither authoring surface should read it as a + // flow that asks for (and could use) a trigger. + expect(resolveFlowTriggerKind(flow('record_change', { + objectName: 'task', triggerType: ['record-after-create', 'record-after-delete'], + }))).toBeUndefined(); + // …unless the same start node ALSO carries a trigger the chain does read. + expect(resolveFlowTriggerKind(flow('record_change', { + objectName: 'task', triggerType: ['record-after-create'], schedule: '0 8 * * *', + }))).toBe('schedule'); + }); + + it('reads the FIRST start node, like the engine', () => { + const f = { + type: 'autolaunched', + nodes: [ + { id: 'a', type: 'start', label: 'A', config: { schedule: '0 8 * * *' } }, + { id: 'b', type: 'start', label: 'B', config: { objectName: 'task', triggerType: 'record-after-create' } }, + ], + }; + expect(resolveFlowTriggerKind(f)).toBe('schedule'); + }); + + it('FLOW_TRIGGER_KINDS lists exactly the answers, in precedence order, and is frozen', () => { + expect([...FLOW_TRIGGER_KINDS]).toEqual(['record_change', 'time_relative', 'schedule', 'api']); + expect(Object.isFrozen(FLOW_TRIGGER_KINDS)).toBe(true); + }); +}); diff --git a/packages/spec/src/automation/flow-trigger-kind.ts b/packages/spec/src/automation/flow-trigger-kind.ts new file mode 100644 index 0000000000..2e7c813746 --- /dev/null +++ b/packages/spec/src/automation/flow-trigger-kind.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which platform trigger a flow ASKS FOR, derived from its declaration alone. + * + * This is the authoring-time mirror of the automation engine's binding + * resolution (`AutomationEngine.resolveTriggerBinding` in + * `@objectstack/service-automation`): the same start-node reads, in the same + * precedence order, answering only the KIND — which registered trigger the + * engine would hand the flow to — never the binding itself. The convention it + * reads is the one the engine reads: a flow's start node carries the trigger + * details in its `config` (`{ objectName, triggerType, condition }` for + * record-change, a `schedule` descriptor for time-based flows, a `timeRelative` + * descriptor for the declarative date sweep), and the flow's top-level `type` + * names the `schedule` / `api` flows that carry no such config. + * + * Two authoring surfaces consume it, so they cannot drift apart on the one + * question "does this flow auto-launch?": + * + * - `defineStack` refuses a stack whose flow resolves to a kind while + * `requires` omits `'triggers'` — the single token that installs every one + * of these triggers (`PLATFORM_CAPABILITY_PROVIDERS.triggers`). Without it + * the flow registers, validates, builds, and never fires. + * - `@objectstack/lint`'s `validate-flow-trigger-readiness` reads it as the + * auto-triggered predicate behind its draft-status rule. + * + * Precedence is the engine's, and it decides the NAME a diagnostic prints: a + * start node carrying BOTH a `timeRelative` descriptor and a `schedule` cadence + * is a time-relative sweep (the cadence is its sweep interval), not a plain + * schedule flow. + * + * One deliberate difference from the engine: the ARRAY form of `triggerType` + * (`['record-after-create', 'record-after-delete']`) resolves to no kind here. + * Multi-event unions are unsupported (#3457); the engine routes that shape to + * the record-change trigger ONLY so the trigger can refuse it loudly at bind + * time — a diagnostic route, not a trigger the flow could ever fire on — and + * `@objectstack/lint` already reports the shape itself as an `error` + * (`flow-trigger-unknown-event`). Reading it as "asks for a record-change + * trigger" here would have `defineStack` demand a capability for a flow that + * cannot use it, and would widen the lint rule's auto-triggered set. + */ +export type FlowTriggerKind = 'record_change' | 'time_relative' | 'schedule' | 'api'; + +/** Every kind {@link resolveFlowTriggerKind} can answer, in the engine's precedence order. */ +export const FLOW_TRIGGER_KINDS: readonly FlowTriggerKind[] = Object.freeze([ + 'record_change', + 'time_relative', + 'schedule', + 'api', +]); + +/** + * Resolve the trigger kind a flow declares, or `undefined` for a flow with no + * auto-launch trigger (a `screen` flow, or an `autolaunched` one started by + * hand or from a screen). + * + * Reads the flow structurally — `type` and the first `start` node's `config` — + * so it accepts a raw authored object, a `defineFlow` result and a parsed + * stack's flow alike; anything that is not that shape resolves to `undefined` + * rather than throwing. + */ +export function resolveFlowTriggerKind(flow: unknown): FlowTriggerKind | undefined { + if (!flow || typeof flow !== 'object') return undefined; + const f = flow as { type?: unknown; nodes?: unknown }; + const nodes = Array.isArray(f.nodes) ? (f.nodes as unknown[]) : []; + const start = nodes.find( + (n): n is { config?: unknown } => + !!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start', + ); + const config: Record = + start?.config && typeof start.config === 'object' ? (start.config as Record) : {}; + const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined; + + if (triggerType !== undefined && triggerType.startsWith('record-')) return 'record_change'; + // Before `schedule`: a time-relative sweep ALSO carries a `schedule` cadence + // (its sweep interval). Arrays and `Date` pass `typeof … === 'object'` on + // purpose — the engine routes them, and `TimeRelativeTriggerSchema` is the + // one that refuses them, which is where that verdict belongs. + if (config.timeRelative != null && typeof config.timeRelative === 'object') return 'time_relative'; + if (config.schedule != null || f.type === 'schedule') return 'schedule'; + if (f.type === 'api' || triggerType === 'api') return 'api'; + return undefined; +} diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index f675c92f51..92ebb2c1ad 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -40,6 +40,7 @@ export * from './approval.zod'; // DeclarativeConnectorEntrySchema. One capability, one contract // (Prime Directive #12); the #4480 template cluster fell the same way. export * from './time-relative-trigger.zod'; +export * from './flow-trigger-kind'; // `sync.zod.ts` (L1 "Simple Sync": DataSyncConfig, its ConflictResolution enum // and the Sync factory) was removed here (#4738, ledger #4535 C13+C15). The L1 // layer was narrative-only — zero importers across objectstack / cloud / diff --git a/packages/spec/src/stack-requires.test.ts b/packages/spec/src/stack-requires.test.ts index 033814b0f7..5c19969816 100644 --- a/packages/spec/src/stack-requires.test.ts +++ b/packages/spec/src/stack-requires.test.ts @@ -46,3 +46,157 @@ describe('defineStack requires validation (#3265/#3308)', () => { expect(stack.requires).toEqual(['aiStudio']); }); }); + +// #14153 — `defineStack` refuses an auto-launched flow (record_change / +// schedule / time_relative / api) whose stack does not declare +// `requires: ['triggers']`, the one token that installs those triggers. The +// sibling `validateHierarchyScopeCapability` already hard-errors the same +// declared-capability class for hierarchy scopes (which fail CLOSED); this one +// covers the class that fails SILENT — the flow registers, validates, builds, +// and never fires. The refusal reuses the automation engine's boot-audit +// wording (flow name, resolved trigger kind, the exact remedy). + +describe('defineStack trigger capability validation (#14153)', () => { + const node = (id: string, type: string, config?: Record) => ({ + id, + type, + label: id, + ...(config ? { config } : {}), + }); + const flow = (name: string, type: string, config?: Record, extra: Record = {}) => ({ + name, + label: name, + type, + nodes: [node('start', 'start', config), node('end', 'end')], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...extra, + }); + const task = { name: 'task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }; + const recordFlow = (name = 'task_fanout') => + flow(name, 'record_change', { objectName: 'task', triggerType: 'record-after-create' }); + const build = (stack: Record) => defineStack(stack as never); + const messageOf = (fn: () => unknown): string => { + try { + fn(); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + return ''; + }; + + it('THROWS on a record_change flow when `requires` omits triggers — naming the flow, the kind and the remedy', () => { + const msg = messageOf(() => build({ requires: ['automation'], objects: [task], flows: [recordFlow()] })); + expect(msg).toMatch(/^defineStack trigger capability validation failed \(1 issue\):/); + expect(msg).toContain("✗ flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'triggers'"); + expect(msg).toContain("no 'record_change' trigger would be registered, so the flow would never auto-launch"); + expect(msg).toContain("Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)"); + }); + + it('refuses schedule, time_relative and api flows too, each named by its RESOLVED kind', () => { + const cases: Array<[string, Record]> = [ + ['schedule', flow('daily_digest', 'schedule', { schedule: '0 8 * * *' })], + ['schedule', flow('bare_schedule', 'schedule')], + ['time_relative', flow('renewal_alert', 'schedule', { + timeRelative: { object: 'task', dateField: 'due_date', withinDays: 7 }, + })], + ['api', flow('inbound_hook', 'api')], + ['api', flow('inbound_token', 'autolaunched', { triggerType: 'api' })], + ]; + for (const [kind, f] of cases) { + const msg = messageOf(() => build({ requires: ['automation'], objects: [task], flows: [f] })); + expect(msg, `${f.name} should be refused as '${kind}'`).toContain(`flow '${f.name}' declares a '${kind}' trigger`); + } + }); + + it('a start node carrying BOTH a timeRelative descriptor and a schedule cadence is named a time_relative sweep', () => { + const f = flow('sweep', 'schedule', { + timeRelative: { object: 'task', dateField: 'due_date', withinDays: 7 }, + schedule: '0 8 * * *', + }); + const msg = messageOf(() => build({ requires: ['automation'], objects: [task], flows: [f] })); + expect(msg).toContain("flow 'sweep' declares a 'time_relative' trigger"); + expect(msg).not.toContain("declares a 'schedule' trigger"); + }); + + it('passes untouched when `requires` includes triggers', () => { + const stack = build({ requires: ['automation', 'triggers'], objects: [task], flows: [recordFlow()] }); + expect(stack.requires).toEqual(['automation', 'triggers']); + expect(stack.flows?.map((f) => f.name)).toEqual(['task_fanout']); + }); + + it('screen flows and autolaunched-by-hand flows owe no capability', () => { + const screen = flow('wizard', 'screen', undefined, { + nodes: [node('start', 'start'), node('s1', 'screen', { fields: [] }), node('end', 'end')], + edges: [ + { id: 'e1', source: 'start', target: 's1' }, + { id: 'e2', source: 's1', target: 'end' }, + ], + }); + const manual = flow('by_hand', 'autolaunched', { objectName: 'task' }); + expect(() => build({ requires: ['automation'], objects: [task], flows: [screen, manual] })).not.toThrow(); + }); + + it('a stack with no flows passes with `requires` omitted entirely', () => { + expect(() => build({ objects: [task] })).not.toThrow(); + expect(() => build({ objects: [task], flows: [] })).not.toThrow(); + }); + + it('an ABSENT `requires` counts as omitting the token — the CLI reads it as [] and nothing installs a trigger', () => { + const msg = messageOf(() => build({ objects: [task], flows: [recordFlow()] })); + expect(msg).toMatch(/^defineStack trigger capability validation failed \(1 issue\):/); + expect(msg).toContain("flow 'task_fanout' declares a 'record_change' trigger"); + }); + + it('obsolete / invalid flows are skipped, exactly as the boot audit skips them (the engine never binds them)', () => { + expect(() => build({ + requires: ['automation'], + objects: [task], + flows: [ + flow('retired', 'record_change', { objectName: 'task', triggerType: 'record-after-create' }, { status: 'obsolete' }), + flow('broken', 'schedule', { schedule: '0 8 * * *' }, { status: 'invalid' }), + ], + })).not.toThrow(); + // …while `draft` (the default) and `active` are both armed by the engine. + for (const status of ['draft', 'active']) { + const msg = messageOf(() => build({ + requires: ['automation'], + objects: [task], + flows: [flow('armed', 'record_change', { objectName: 'task', triggerType: 'record-after-create' }, { status })], + })); + expect(msg, `status '${status}' must still be refused`).toContain("flow 'armed' declares a 'record_change' trigger"); + } + }); + + it('reports every offending flow together under an (N issues) header', () => { + const msg = messageOf(() => build({ + requires: ['automation'], + objects: [task], + flows: [ + recordFlow('fanout'), + flow('digest', 'schedule', { schedule: '0 8 * * *' }), + flow('by_hand', 'autolaunched'), + flow('hook', 'api'), + ], + })); + expect(msg).toMatch(/^defineStack trigger capability validation failed \(3 issues\):/); + expect(msg).toContain("✗ flow 'fanout' declares a 'record_change' trigger"); + expect(msg).toContain("✗ flow 'digest' declares a 'schedule' trigger"); + expect(msg).toContain("✗ flow 'hook' declares a 'api' trigger"); + expect(msg).not.toContain("'by_hand'"); + }); + + it('the hierarchy-scope sibling still reports first — one throw-site family, checked in order', () => { + const msg = messageOf(() => build({ + requires: ['automation'], + objects: [task], + flows: [recordFlow()], + permissions: [{ name: 'managers', objects: { task: { allowRead: true, readScope: 'unit_and_below' } } }], + })); + expect(msg).toMatch(/^defineStack hierarchy-scope capability validation failed/); + }); + + it('non-strict mode skips validation by contract (the flow passes through unrefused)', () => { + const stack = defineStack({ objects: [task], flows: [recordFlow()] } as never, { strict: false }); + expect(stack.flows?.length).toBe(1); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index b540201d7c..d8411f4574 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -32,6 +32,7 @@ import { ActionSchema, InlineActionSchema } from './ui/action.zod'; // Automation Protocol import { FlowSchema } from './automation/flow.zod'; +import { resolveFlowTriggerKind } from './automation/flow-trigger-kind'; import { FlowFunctionEntrySchema, FlowFunctionEffectSchema } from './automation/flow-function.zod'; import { JobSchema } from './system/job.zod'; @@ -1656,6 +1657,54 @@ function validateHierarchyScopeCapability(data: unknown): string[] { return errors; } +/** + * Auto-launched flows are an ENFORCED capability class, exactly like the + * hierarchy scopes above: the trigger that fires a `record_change` / + * `schedule` / `time_relative` / `api` flow ships in `@objectstack/trigger-*` + * and is installed by ONE token, `requires: ['triggers']` + * (`PLATFORM_CAPABILITY_PROVIDERS.triggers`). A stack that declares such a + * flow while `requires` omits the token registers the flow, validates, builds + * — and never fires it. The automation engine's boot audit names it after + * deploy (`declares a '…' trigger but is NOT bound`), and nothing before that. + * That is the fail-SILENT half of the pair: a hierarchy scope without its + * capability fails closed (a user notices the missing rows); an autolaunched + * flow without its trigger fails silent (the automation simply does not + * happen). Refuse it here, at authoring, in the boot audit's own words — one + * vocabulary, moved from post-deploy to author time. + * + * An ABSENT `requires` counts as omitting the token: the CLI reads it as `[]` + * and appends only the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`), + * which carries neither `automation` nor `triggers`, so a stack that declares + * nothing gets no trigger either (measured on `serve`'s capability resolver). + * + * Flows whose `status` disables them (`obsolete` / `invalid`) are skipped — + * the engine never binds those, its boot audit skips them for the same + * reason, and a stack that deliberately retired a triggered flow owes no + * capability for it. The kind is `resolveFlowTriggerKind`, shared with + * `@objectstack/lint`, so the two authoring surfaces cannot disagree on which + * flows auto-launch. + */ +function validateTriggerCapability(data: unknown): string[] { + const errors: string[] = []; + const d = data as { requires?: unknown; flows?: unknown }; + const requires = Array.isArray(d?.requires) ? (d.requires as string[]) : []; + if (requires.includes('triggers')) return errors; + const flows = Array.isArray(d?.flows) ? (d.flows as unknown[]) : []; + for (const flow of flows) { + const f = flow as { name?: unknown; status?: unknown } | null; + if (f?.status === 'obsolete' || f?.status === 'invalid') continue; + const kind = resolveFlowTriggerKind(flow); + if (!kind) continue; + const name = typeof f?.name === 'string' ? f.name : '?'; + errors.push( + `flow '${name}' declares a '${kind}' trigger but \`requires\` does not include 'triggers' — ` + + `no '${kind}' trigger would be registered, so the flow would never auto-launch. ` + + `Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*).`, + ); + } + return errors; +} + /** * Reject `requires` tokens that are not part of the platform capability * vocabulary (framework#3265). An unknown token is a genuine typo or a stale @@ -1816,6 +1865,13 @@ export function defineStack( throw new Error(`${header}\n\n${lines.join('\n')}`); } + const triggerErrors = validateTriggerCapability(data); + if (triggerErrors.length > 0) { + const header = `defineStack trigger capability validation failed (${triggerErrors.length} issue${triggerErrors.length === 1 ? '' : 's'}):`; + const lines = triggerErrors.map((e) => ` ✗ ${e}`); + throw new Error(`${header}\n\n${lines.join('\n')}`); + } + return mergeActionsIntoObjects(data); }