Skip to content

Commit 2d91c9a

Browse files
fix(spec): refuse an auto-launched flow whose stack declares triggers without automation (#20332) (#20365)
Fixes #20332 Clause-②: no (narrowing) **BREAKING** (`@objectstack/spec` `minor`): a published accept set narrows. No export, no error code and no accepted shape is added. The refusal reuses `STACK_TRIGGER_CAPABILITY_REQUIRED` (`status: 422`). ## What changes `validateTriggerCapability` (`packages/spec/src/stack.zod.ts`) refused an auto-launched flow only when `requires` lacked `'triggers'`. Its docblock said the trigger "is installed by ONE token, `requires: ['triggers']`". That is false. Every trigger plugin installs its trigger into the automation service at `kernel:ready`, and without that service it warns and installs nothing. No runtime's resolver turns `triggers` into `automation`. So a stack with `requires: ['triggers']` and a `record_change` flow passed `os validate`, booted, and never fired the flow. Triage direction `5861188312`, verbatim: 「**The contract choice, decided here: refuse, do not imply.**」 The refusal now has three arms, one line per offending flow, on the same code, header and `issues` shape. Each prescription is the whole fix for the `requires` it was given: | `requires` (auto-launched flow present) | Before | After | |:---|:---|:---| | `['automation', 'triggers']` | accepted | accepted (any order) | | `['automation']` | refused: add `'triggers'` | **unchanged, byte for byte** | | `['triggers']` | **accepted** (the defect) | refused: add `'automation'` | | `[]` or absent | refused: add `['triggers']` | refused: add `['automation', 'triggers']` | | any, with no auto-launched flow, or only `obsolete` / `invalid` flows | accepted | accepted | ### The refusal text, quoted (for review) New arm, `requires: ['triggers']`: ```text flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' — 'triggers' installs the 'record_change' trigger into the automation service, and without it no 'record_change' trigger would be registered, so the flow would never auto-launch. Add 'automation' to requires: ['automation', 'triggers'] (@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it). ``` Changed arm, neither token: ```text flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing). ``` Unchanged arm, `requires: ['automation']`: ```text flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*). ``` ### A choice made here: the neither-token message names both tokens The dispatch left this open and suggested keeping today's message. Analysed on the four axes: - **Real business need (measured).** Every real producer that declares an auto-launched flow declares both tokens: `examples/app-showcase`, `examples/app-todo`, the `os init` template, and `os g flow`'s own output. The CLI boot banner already prescribes `requires: ['automation', 'triggers']`. No producer uses `['triggers']` alone. - **Long-term soundness.** One prescription that satisfies the contract. The message states the real install fact, the pair. - **Keeping AI authors from writing it wrong.** Keeping the old message would make an author who follows it literally land on `['triggers']`, and the new arm would refuse them a second time. A prescription that leads to another refusal is a trap. The pin `each prescription, applied literally once, is ACCEPTED` holds the property. - **No scope growth.** No new code path, code or export. Only the text of one arm changes. No pin anywhere parsed the old text for this case. ## Measurements (dispatch Zone 2) **§1 Reproduce on both runtimes.** - **Framework CLI**, at this branch with the pre-fix early exit (`if (hasTriggers) return errors;`) built into `packages/spec/dist`. The mutation was held by `scripts/ablation-replace.mjs` and confirmed in `dist/` by `scripts/ablation-dist-preflight.mjs`. On a probe project with `requires: ['triggers']` and one `record_change` flow: - `os validate` exited **0**. - `os serve --dev` reached `Server is ready` and printed `⚠ Flows: 1 flow(s) declared but the automation engine is not enabled — they will never run. Add requires: ['automation', 'triggers'] to objectstack.config.ts`. - It also printed four warns: `RecordChangeTriggerPlugin` / `ScheduleTriggerPlugin` / `TimeRelativeTriggerPlugin` / `ApiTriggerPlugin: automation service not available — … trigger NOT installed`. - After the restore leg (source equal to HEAD, spec rebuilt, marker absent from all 222 dist files), `os validate` on the same probe exits **1** with the new message. - **cloud** `origin/main` `96eb092`, read only: - `packages/objectos-runtime/src/capability-loader.ts` `resolveCapabilityDependencies` pulls `queue`, `job` and `messaging` for `triggers`, never `automation`. So the resolver does not imply it. - The hosted hosts force-mount both tokens on every tenant environment, whatever the artifact declares: `apps/objectos/hosted-slate.ts` `HOSTED_FORCED_REQUIRES`, and `apps/objectos-ee` `defaultRequires`. There a `['triggers']` stack runs, but so does a stack with no `requires` at all. The published arm has refused that second stack since it landed. The refusal judges the stack's own declaration, which is portable, and not one host's floor. - The fork condition ("`triggers` pulls `automation` in by itself") holds on neither resolver. **§2 Which kinds need `automation`.** All four. The boot above printed all four "NOT installed" warns. Each plugin's `kernel:ready` hook resolves `automation` and returns if it is missing: `packages/triggers/trigger-record-change/src/plugin.ts:47`, `trigger-schedule/src/plugin.ts:72`, `trigger-schedule/src/time-relative-plugin.ts:67`, `trigger-api/src/plugin.ts:62`. No kind stays accepted. **§3 Shape and door.** - One arm beside the existing one, same class and code. - `os validate` reaches it through `loadConfig`, which evaluates the author's `defineStack()` call (step 1), and not through its own stack parse (step 2). It is not a separate door. - Measured through the real CLI (`tsx bin/run-dev.js validate`) at this branch: `['triggers']` exits 1, `[]` exits 1, `['automation', 'triggers']` exits 0. **§4 Producer census** (expected 0 producers; 0 found; three test stacks): - `examples/**`: - `app-showcase` and `app-todo` declare both. `os validate` exits 0 on each at this branch. - `app-crm` declares `['ui', 'automation']` and has no auto-launched flow; `os validate` exits 0. - `app-multi-package` and `embed-objectql` declare no `requires`. - `packages/create-objectstack/src/templates/**`: `blank` declares `['automation']`. It is out of this arm's reach. - `os init` / `os g` on current `main` (PR #20329 landed as `c5dcb3ba07`): `init` declares both. `os g flow` into a `['triggers']` project was the "cannot run" answer and is now a refusal (see below). - `packages/**` test stacks with `triggers` alone and an auto-launched flow: - `packages/cli/test/generate-object-namespace-prefix.test.ts`: converted to the pair. - `packages/qa/dogfood/test/fixtures/override-composite-fixture.ts`: converted. Its boot mounts both explicitly; `verify`'s harness does not read `requires`. - `packages/lint/src/authoring-rule-input-tier.test.ts:93`: **not converted.** `packages/lint` is fenced read-only for this dispatch. See "Owed, and fenced". - cloud `main`: six `requires` literals with `triggers` and no `automation`, all loader and publish-route token-list tests. No `defineStack`, no flows, so none is affected. - hotcrm: NOT MEASURED. It is not a repository in this container. **§5 The one-token sentence**, restated at every site that repeated it: - the `validateTriggerCapability` and `StackTriggerCapabilityRequiredError` docblocks; - `automation/flow-trigger-kind.ts`; - the error-code ledger comment; - the two spec test comments; - `content/docs/automation/flows.mdx` and `content/docs/permissions/capabilities.mdx`. `@objectstack/lint` carries no trigger-capability rule of its own; it shares only `resolveFlowTriggerKind`. There is no asymmetry to report. ## Pins (table-driven: requires × kind × status) `packages/spec/src/stack-requires.test.ts`, new block. Each refusal is asserted as its envelope (`code`, `status: 422`, one `issues` entry per flow) plus the prescription text: - `['triggers']` refused for `record_change`, `schedule`, `time_relative` and `api`; - `['automation', 'triggers']` accepted for all four, and in any order (the control); - `[]` and absent: the pair named, and `Add requires: ['triggers']` asserted absent; - `['automation']`: today's `issues` entry asserted with `toEqual`; - no auto-launched flow (none, a `screen` flow, a hand-launched `autolaunched` flow) unaffected; - `obsolete` / `invalid` unaffected, while `draft` / `active` are refused; - four flows give four issues; - each prescription, applied once, is accepted. **Ablation**, committed first and restored by blob hash: - **Leg A:** the old early exit. `7 failed | 23 passed`. - **Leg B:** the neither case given the old message. `3 failed | 27 passed`. - **Restored:** `30 passed`, blob `4c0bfced0f02` equal to HEAD. - Direction as predicted (turns red). **`os g` pin flipped** (`packages/cli/test/generate-stack-reach.test.ts`, landed with PR #20329). `os g flow order_line` into a `['triggers']` project was pinned as "cannot run", exit 0. The written flow now stops the config from loading, so the write is refused. The pin is now: exit 1, the project tree byte-identical, the flow file absent, stdout names `does not include 'automation'` and prints `requires: ['automation', 'triggers']`, and no `Created`. Result: `7 passed`. ## Tests and gates The branch head is `94e32023d4`: a merge of `origin/main` `6704717188` made through `scripts/pm/os-regen-merge.sh`. It touched `error-code-ledger.zod.ts` on both sides, and both edits survive. Every reading below was taken at that head unless it names `45cfbaafee`, the last commit before the merge. The merge brought no change to any file those older readings depend on. - `@objectstack/spec` at `94e32023d4`: - `vitest run --project local`: **554 files, 16439 passed, 1 todo**. - `typecheck` exit 0. - `check:generated`: every artifact up to date, after a rebuild. - `@objectstack/cli` at `45cfbaafee`: - `generate-object-namespace-prefix` and `generate-scaffold-wiring` (unit): **52 passed**. - `generate-stack-reach` (integration): **7 passed**. - `@objectstack/lint`, the consumer suite, at `45cfbaafee`: **1 failed | 4303 passed**. The one failure is the fenced fixture described below. Its new message, re-read at `94e32023d4`, is the refusal it should be. This is expected, and owed. - dogfood: the fixture module loads (`requires = ["automation","triggers"]`). The boot pin itself is left to CI's Dogfood Regression Gate. - `os validate` on the examples at `45cfbaafee`: `app-todo`, `app-showcase` and `app-crm` each exit 0. - Gates: `dispatch-gates --commands` at `94e32023d4` derives 112. All 112 ran, each with its exit code recorded, and `--ran` reconciles them: **112 run, 0 NOT-MEASURED, 0 UNRUN**. - Exit 0: 111. This includes every `@objectstack/spec` `check:*` in the list (`api-surface`, `authorable-surface`, `docs`, `error-code-provenance`, `liveness`, `skill-examples`), `check:type-check-debt`, `check:dual-build-cjs-loads`, and the root `check:*` and `node scripts/*` set. - Exit 1, red by design: `check-empty-changeset`. See the next section. ## Deliberate correction of a pending release note `.changeset/20215-generate-scaffolds-reach-stack.md` (from PR #20329, unreleased) says two things this PR makes false: - "Without `automation`, the server loads the flow and never runs it." - that `os g` reports **cannot run** for a flow in a stack whose `requires` lacks `automation`. Both sentences are corrected in place. `content/docs/deployment/cli.mdx` gets the same correction. `check-empty-changeset` stays red on this, by design ("DELIBERATE CORRECTION … say so on the PR and get it confirmed"). **This needs a person's confirmation.** Restoring the file from base would put the false sentences back into the next release. ## Owed, and fenced (not in this PR) These are outside the dispatch's fence, so this PR does not touch them: 1. `packages/lint/src/authoring-rule-input-tier.test.ts:93`: `requires: ['triggers']` becomes `['automation', 'triggers']`, and its comment names the pair. Until then `@objectstack/lint`'s suite is red on that one test. 2. `packages/cli/src/commands/init.ts` (the emitted config comment, `:649–652`, and the `SCAFFOLD_WIRED_REQUIRES` docblock) and `packages/cli/src/commands/generate.ts` (the emitted flow-file header, `:366–369`, and its docblock). Each says that without `automation` "the server loads the flow and never runs it". After this change the config stops loading. ## Acceptance notes - The `cannot run` branch of `os g`'s reach report has no scaffold that reaches it now. The flow scaffold's only tokens are the pair, and a stack missing either is refused. This is dead for today's generators. It is noted and not filed. Carrier: the next PR to touch `packages/cli/src/commands/generate.ts`. - `os validate` on a config that exports a plain object instead of calling `defineStack()` skips every `defineStack` cross-field refusal, this one included. Measured: a plain-object probe with `requires: ['triggers']` and a `record_change` flow exits 0 at this head. This is the whole refusal family's door, not this arm's, so it is reported to the seat in the dev report and not filed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab94656 commit 2d91c9a

16 files changed

Lines changed: 388 additions & 84 deletions

‎.changeset/20215-generate-scaffolds-reach-stack.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ fix(cli): what `os generate` writes now reaches the stack, or the command says i
1010

1111
- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types.
1212
- An `index.ts` containing only `export {};` for each directory the template puts nothing in. An `index.ts` that already exists is kept as it is and never overwritten.
13-
- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. Without `triggers`, `defineStack` refuses the config as soon as it holds such a flow. Without `automation`, the server loads the flow and never runs it.
13+
- `requires: ['automation', 'triggers']`. A flow that starts on a record change is fired by `triggers` and run by `automation`. If either one is missing, `defineStack` refuses the config as soon as it holds such a flow.
1414

1515
**What `os generate` now does:**
1616

17-
- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add), or it **cannot run** (a flow in a stack whose `requires` lacks `automation`: the command prints the whole `requires` list to use). It never edits the config.
18-
- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load.
17+
- After writing, it loads the project's config again and reports on the new item. Either the stack carries it, or it is **not wired** (the file is written, the config is left untouched, and the command prints the import and `defineStack` key to add). It never edits the config.
18+
- It refuses a write that makes a config that loaded stop loading, for example an action or app bound to an object nobody declared, or a flow in a stack without `triggers` or without `automation`. It removes what it wrote, exits 1, and prints the stack's own reason. Generate the object first (`os g object customer`), then what binds to it. `dashboard` and `skill` now read the config too, so they can report, and they still generate when the config does not load.
1919
- A view's own `name` is now the object it binds to, prefix included (`my_app_order_line`, not `order_line`). The server registers a view under its object and refused, at boot, a scaffold whose `name` disagreed. That never showed while the views barrel was not loaded.
2020
- The barrel step asks the compiler whether the barrel already exports the name, instead of searching the file's text. `os g view order` after `os g view order_line` had found `order` inside `orderLine` and exported nothing.
2121
- The `flow` scaffold's header states the `requires` it needs.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `defineStack` refuses an auto-launched flow whose stack declares `triggers` without `automation` — the pair installs the trigger, `triggers` alone installs nothing (#20332)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner and the ADR-0087 disposition below, never by the level).
12+
13+
**What is refused now.** A stack whose `requires` includes `'triggers'` but not
14+
`'automation'`, and which declares a `record_change`, `schedule`,
15+
`time_relative` or `api` flow, used to pass `defineStack` and `os validate`,
16+
boot, and never fire the flow. Every trigger plugin installs its trigger into
17+
the automation service when the kernel is ready, and without that service it
18+
logs `automation service not available — … trigger NOT installed` and installs
19+
nothing. No runtime resolves `triggers` into `automation`. `defineStack` now
20+
refuses that stack with the same `STACK_TRIGGER_CAPABILITY_REQUIRED` code
21+
(`status: 422`), one finding per flow:
22+
23+
```text
24+
flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' — 'triggers' installs the 'record_change' trigger into the automation service, and without it no 'record_change' trigger would be registered, so the flow would never auto-launch. Add 'automation' to requires: ['automation', 'triggers'] (@objectstack/service-automation runs the flow; @objectstack/trigger-* only fires it).
25+
```
26+
27+
**The fix is the one the message names:** add `'automation'` to `requires`, so
28+
it reads `requires: ['automation', 'triggers']`. Nothing is renamed or removed.
29+
30+
**Also changed: the message for a stack that declares neither token.** An empty
31+
or absent `requires` with such a flow was told to add `requires: ['triggers']`,
32+
which would now be refused a second time. It is told to add both:
33+
34+
```text
35+
flow 'task_fanout' declares a 'record_change' trigger but `requires` does not include 'automation' or 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['automation', 'triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-* and install into @objectstack/service-automation — 'triggers' alone installs nothing).
36+
```
37+
38+
Unchanged: `requires: ['automation']` with such a flow keeps the message it has
39+
always had (add `'triggers'`), word for word. `requires: ['automation',
40+
'triggers']` is accepted, in any order. A stack with no auto-launched flow
41+
(none at all, a `screen` flow, or an `autolaunched` flow started by hand) owes
42+
neither token, and `obsolete` / `invalid` flows are still skipped. The refusal
43+
code, the message header and the `issues` shape are the same, and no export is
44+
added.
45+
46+
In-tree producers measured: `examples/app-showcase` and `examples/app-todo`
47+
already declare both tokens; `examples/app-crm` and the `create-objectstack`
48+
`blank` template declare `automation` without `triggers` and no auto-launched
49+
flow, so they are untouched.
50+
51+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored is renamed, removed or re-typed: no spec key changes spelling, no export moves and no stored shape changes, so `objectstack migrate meta` has nothing to rewrite. The refusal is a cross-field requirement between `requires` and `flows`, and its own message names the one-token fix. This follows the disposition of the refusal's first arm. The other categories are closed on facts: `@objectstack/spec` publishes (not `unpublished`); no ADR-0087 id covers a capability requirement (not `registered` / `already-registered`); and the change is authoring validation, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎content/docs/automation/flows.mdx‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,15 +1782,19 @@ export default defineStack({
17821782
```
17831783

17841784
Registration is not arming. A `record_change`, `schedule`, `time_relative` or
1785-
`api` flow fires only when its trigger is mounted, and the triggers ship
1786-
separately (`@objectstack/trigger-*`) behind **one** capability token on the
1787-
same stack: `requires: ['triggers']` (`automation` mounts the engine itself;
1788-
neither is in the always-on slate an absent `requires` falls back to).
1789-
`defineStack` refuses a stack that declares such a flow without the token,
1790-
naming the flow, the trigger kind it resolved and the fix — because the
1791-
alternative was measured: the flow registers, `os validate` and `os build`
1792-
pass, and it never runs. A `screen` flow, or an `autolaunched` one you start
1793-
by hand, owes nothing.
1785+
`api` flow fires only when its trigger is installed, and that takes **both**
1786+
capability tokens on the same stack: `requires: ['automation', 'triggers']`.
1787+
The triggers ship separately (`@objectstack/trigger-*`, mounted by
1788+
`triggers`), and each one installs itself into the automation engine
1789+
(`@objectstack/service-automation`, mounted by `automation`) — with
1790+
`triggers` alone the trigger plugins load, find no engine, and install
1791+
nothing. Neither token implies the other, and neither is in the always-on
1792+
slate an absent `requires` falls back to. `defineStack` refuses a stack that
1793+
declares such a flow while `requires` lacks either token, naming the flow, the
1794+
trigger kind it resolved and the fix for the tokens it is missing — because
1795+
the alternative was measured: the flow registers, `os validate` and
1796+
`os build` pass, and it never runs. A `screen` flow, or an `autolaunched` one
1797+
you start by hand, owes nothing.
17941798

17951799
The plugin is a **soft dependency** on `metadata` — it tolerates running
17961800
without `MetadataPlugin` and it logs (not throws) on per-flow registration

‎content/docs/deployment/cli.mdx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,15 +1431,14 @@ After writing, `os g` loads the config again and says which of these holds:
14311431
scaffolded in earlier releases do. The file is written, the config is left
14321432
as it was, and the command prints the import and the `defineStack` key that
14331433
wire the directory.
1434-
- **Cannot run**: the stack carries a flow, and its `requires` lacks
1435-
`automation`. The config loads and the server never runs the flow. The
1436-
command prints the whole `requires` list to use.
14371434
- **Refused**: the config loaded before the command wrote anything and no
14381435
longer loads with the new file in place, because the stack refuses it. Two
14391436
examples are an action or app bound to an object nobody declared, and a flow
1440-
in a stack without `triggers`. The command removes what it wrote, so the
1441-
project is as it was, and exits 1 with the stack's own reason. Generate the
1442-
object first (`os g object customer`), then what binds to it.
1437+
in a stack whose `requires` lacks `triggers` or `automation` (`defineStack`
1438+
refuses a record-change flow without both). The command removes what it
1439+
wrote, so the project is as it was, and exits 1 with the stack's own reason.
1440+
Generate the object first (`os g object customer`), then what binds to it,
1441+
and declare `requires: ['automation', 'triggers']` before `os g flow`.
14431442
14441443
`os g` never edits `objectstack.config.ts`: the config is yours, and the
14451444
command only loads it.

‎content/docs/permissions/capabilities.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Read the next section before you write either.
2626
| **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` |
2727
| **Entry shape** | `defineCapability({ name, label, description, scope })` (`CapabilityDeclarationSchema`) | A plain `string` |
2828
| **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) |
29-
| **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. ⚠️ `triggers` is a *capability*, not the whole declaration: a `schedule` / `time_relative` flow also [declares the organization it runs as](/docs/automation/flows#the-acting-organization), and one that does not is refused at bind rather than fired org-less |
29+
| **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` and `automation` (the triggers install into the automation engine) — without them the runtime fails closed (owner-only visibility) or, for flows, silently never fires. ⚠️ `triggers` is a *capability*, not the whole declaration: a `schedule` / `time_relative` flow also [declares the organization it runs as](/docs/automation/flows#the-acting-organization), and one that does not is refused at bind rather than fired org-less |
3030
| **Consumed by** | `systemPermissions` (grant) and `requiredPermissions` (requirement), by name string | The runtime capability loader, which resolves each token to a service plugin |
3131
| **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 |
3232
| **Spec** | ADR-0066 D1 | Platform service vocabulary — see the [CLI reference](/docs/deployment/cli) |

‎packages/cli/src/commands/generate.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -348,12 +348,13 @@ export default ${toCamelCase(name)}Action;
348348
*
349349
* It declares what it needs to run (#20215): {@link FLOW_SCAFFOLD_REQUIRES}.
350350
* `defineStack` refuses a record-change flow in a stack whose `requires`
351-
* lacks `triggers`, and a stack that has `triggers` but not `automation`
352-
* loads it and never runs it — measured on `os serve`: "1 flow(s) declared
353-
* but the automation engine is not enabled — they will never run", each
354-
* trigger plugin "NOT installed". So both tokens are declared here, `os
355-
* init` declares the union, `os g flow` names any the stack is missing, and
356-
* the emitted file says so in its own header.
351+
* lacks `triggers` or `automation` (#20332): the trigger installs into the
352+
* automation service, so a stack with `triggers` alone used to load the flow
353+
* and never run it — measured on `os serve`: "1 flow(s) declared but the
354+
* automation engine is not enabled — they will never run", each trigger
355+
* plugin "NOT installed" — and is now refused instead. So both tokens are
356+
* declared here, `os init` declares the union, `os g flow` names any the
357+
* stack is missing, and the emitted file says so in its own header.
357358
*/
358359
namesObject: true,
359360
itemName: (name: string) => `${toSnakeCase(name)}_flow`,
@@ -365,8 +366,8 @@ export default ${toCamelCase(name)}Action;
365366
*
366367
* Starts when a record changes, so the stack that carries it must declare
367368
* requires: [${FLOW_SCAFFOLD_REQUIRES.map((t) => `'${t}'`).join(', ')}]. The 'triggers' capability
368-
* fires the flow and 'automation' runs it: without 'triggers' the config does
369-
* not load, and without 'automation' the server loads the flow and never runs it.
369+
* fires the flow and 'automation' runs it: without either one the config does
370+
* not load.
370371
*/
371372
const ${toCamelCase(name)}Flow: Automation.Flow = {
372373
name: '${toSnakeCase(name)}_flow',

‎packages/cli/src/commands/init.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,10 @@ export const SCAFFOLD_WIRED_BARRELS: readonly { type: string; dir: string; stack
605605
/**
606606
* The union of the capability tokens the scaffolds need to run — today the
607607
* `flow` scaffold's pair. Declared by every template that wires the `flows`
608-
* barrel: without `triggers` a record-change flow makes `defineStack` refuse
609-
* the config, so the first `os g flow` would break the project, and without
610-
* `automation` the server loads the flow and never runs it.
608+
* barrel: without `triggers` or without `automation` a record-change flow
609+
* makes `defineStack` refuse the config (#20332: the trigger installs into the
610+
* automation service, so neither token alone installs it), and the first
611+
* `os g flow` would break the project.
611612
*/
612613
export const SCAFFOLD_WIRED_REQUIRES: readonly string[] = [
613614
...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires)),
@@ -647,9 +648,8 @@ function renderWiredStackKeys(): string {
647648
return [
648649
` // What the files \`objectstack generate\` writes need in order to run. A`,
649650
` // flow that starts on a record change is fired by 'triggers' and run by`,
650-
` // 'automation': without 'triggers' this config stops loading once it holds`,
651-
` // such a flow, and without 'automation' the server loads the flow and never`,
652-
` // runs it. Both can go if this project will never hold a flow.`,
651+
` // 'automation': without either one this config stops loading once it`,
652+
` // holds such a flow. Both can go if this project will never hold a flow.`,
653653
` requires: [${requires}],`,
654654
'',
655655
` // Every directory \`objectstack generate\` writes into is wired here: its`,

‎packages/cli/test/generate-object-namespace-prefix.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ async function generateAll(name: string, namespace?: string): Promise<Record<str
102102
/**
103103
* One stack holding every scaffold, under `manifest.namespace` when given.
104104
*
105-
* `requires: ['triggers']` is the HOST's declaration, not the scaffold's: the
106-
* flow scaffold is a `record_change` flow, and `defineStack` refuses such a
107-
* flow in a stack that does not require the trigger capability. That refusal
108-
* is about the host's capability list and has nothing to do with names.
105+
* `requires: ['automation', 'triggers']` is the HOST's declaration, not the
106+
* scaffold's: the flow scaffold is a `record_change` flow, and `defineStack`
107+
* refuses such a flow in a stack that does not require the pair that installs
108+
* its trigger (#20332). That refusal is about the host's capability list and
109+
* has nothing to do with names.
109110
*/
110111
function composedStack(artifacts: Record<string, Record<string, unknown>>, namespace?: string) {
111112
const stack: Record<string, unknown> = {
@@ -116,7 +117,7 @@ function composedStack(artifacts: Record<string, Record<string, unknown>>, names
116117
type: 'app',
117118
...(namespace ? { namespace } : {}),
118119
},
119-
requires: ['triggers'],
120+
requires: ['automation', 'triggers'],
120121
};
121122
for (const [type, artifact] of Object.entries(artifacts)) {
122123
stack[singularToPlural(type)] = [artifact];

0 commit comments

Comments
 (0)