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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
16 changes: 16 additions & 0 deletions .changeset/define-stack-trigger-capability-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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']`.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message itself names the one-line fix (declare the `triggers` token), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/capabilities.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 9 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,

Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/authoring-rule-input-tier.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
21 changes: 14 additions & 7 deletions packages/lint/src/validate-flow-trigger-readiness.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -131,6 +132,7 @@
"FlowRunSummaryParsed (type)",
"FlowRunSummarySchema (const)",
"FlowSchema (const)",
"FlowTriggerKind (type)",
"FlowVariableSchema (const)",
"FlowVersionHistory (type)",
"FlowVersionHistoryParsed (type)",
Expand DownExpand Up@@ -240,6 +242,7 @@
"normalizeFlowFunctionEntry (function)",
"parseFlowNodeRegions (function)",
"resolveFlowNodeExpressions (function)",
"resolveFlowTriggerKind (function)",
"validateControlFlow (function)"
]
}
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand DownExpand Up@@ -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)"
}
}
105 changes: 105 additions & 0 deletions packages/spec/src/automation/flow-trigger-kind.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
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);
});
});
Loading
Loading