Skip to content
6 changes: 3 additions & 3 deletions .changeset/20215-generate-scaffolds-reach-stack.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@objectstack/cli": patch
---
Expand All @@ -10,12 +10,12 @@

- `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.
- 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.
- `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.
- `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.

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

- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- The `flow` scaffold's header states the `requires` it needs.
Expand Down
51 changes: 51 additions & 0 deletions .changeset/20332-triggers-require-automation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': minor
---

fix(spec): `defineStack` refuses an auto-launched flow whose stack declares `triggers` without `automation` — the pair installs the trigger, `triggers` alone installs nothing (#20332)

Clause-②: no (narrowing)

**BREAKING** — shipped as `minor` under the launch-window convention
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
this banner and the ADR-0087 disposition below, never by the level).

**What is refused now.** A stack whose `requires` includes `'triggers'` but not
`'automation'`, and which declares a `record_change`, `schedule`,
`time_relative` or `api` flow, used to pass `defineStack` and `os validate`,
boot, and never fire the flow. Every trigger plugin installs its trigger into
the automation service when the kernel is ready, and without that service it
logs `automation service not available — … trigger NOT installed` and installs
nothing. No runtime resolves `triggers` into `automation`. `defineStack` now
refuses that stack with the same `STACK_TRIGGER_CAPABILITY_REQUIRED` code
(`status: 422`), one finding per flow:

```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).
```

**The fix is the one the message names:** add `'automation'` to `requires`, so
it reads `requires: ['automation', 'triggers']`. Nothing is renamed or removed.

**Also changed: the message for a stack that declares neither token.** An empty
or absent `requires` with such a flow was told to add `requires: ['triggers']`,
which would now be refused a second time. It is told to add both:

```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: `requires: ['automation']` with such a flow keeps the message it has
always had (add `'triggers'`), word for word. `requires: ['automation',
'triggers']` is accepted, in any order. A stack with no auto-launched flow
(none at all, a `screen` flow, or an `autolaunched` flow started by hand) owes
neither token, and `obsolete` / `invalid` flows are still skipped. The refusal
code, the message header and the `issues` shape are the same, and no export is
added.

In-tree producers measured: `examples/app-showcase` and `examples/app-todo`
already declare both tokens; `examples/app-crm` and the `create-objectstack`
`blank` template declare `automation` without `triggers` and no auto-launched
flow, so they are untouched.

<!-- 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`). -->
22 changes: 13 additions & 9 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1769,15 +1769,19 @@ export default defineStack({
```

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.
`api` flow fires only when its trigger is installed, and that takes **both**
capability tokens on the same stack: `requires: ['automation', 'triggers']`.
The triggers ship separately (`@objectstack/trigger-*`, mounted by
`triggers`), and each one installs itself into the automation engine
(`@objectstack/service-automation`, mounted by `automation`) — with
`triggers` alone the trigger plugins load, find no engine, and install
nothing. Neither token implies the other, and neither is in the always-on
slate an absent `requires` falls back to. `defineStack` refuses a stack that
declares such a flow while `requires` lacks either token, naming the flow, the
trigger kind it resolved and the fix for the tokens it is missing — 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
Expand Down
11 changes: 5 additions & 6 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1425,15 +1425,14 @@ After writing, `os g` loads the config again and says which of these holds:
`npm create objectstack` starter do. The file is written, the config is left
as it was, and the command prints the import and the `defineStack` key that
wire the directory.
- **Cannot run**: the stack carries a flow, and its `requires` lacks
`automation`. The config loads and the server never runs the flow. The
command prints the whole `requires` list to use.
- **Refused**: the config loaded before the command wrote anything and no
longer loads with the new file in place, because the stack refuses it. Two
examples are an action or app bound to an object nobody declared, and a flow
in a stack without `triggers`. The command removes what it wrote, so the
project is as it was, and exits 1 with the stack's own reason. Generate the
object first (`os g object customer`), then what binds to it.
in a stack whose `requires` lacks `triggers` or `automation` (`defineStack`
refuses a record-change flow without both). The command removes what it
wrote, so the project is as it was, and exits 1 with the stack's own reason.
Generate the object first (`os g object customer`), then what binds to it,
and declare `requires: ['automation', 'triggers']` before `os g flow`.

`os g` never edits `objectstack.config.ts`: the config is yours, and the
command only loads it.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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. ⚠️ `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 |
| **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 |
| **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
17 changes: 9 additions & 8 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,12 +348,13 @@ export default ${toCamelCase(name)}Action;
*
* It declares what it needs to run (#20215): {@link FLOW_SCAFFOLD_REQUIRES}.
* `defineStack` refuses a record-change flow in a stack whose `requires`
* lacks `triggers`, and a stack that has `triggers` but not `automation`
* loads it and never runs it — measured on `os serve`: "1 flow(s) declared
* but the automation engine is not enabled — they will never run", each
* trigger plugin "NOT installed". So both tokens are declared here, `os
* init` declares the union, `os g flow` names any the stack is missing, and
* the emitted file says so in its own header.
* lacks `triggers` or `automation` (#20332): the trigger installs into the
* automation service, so a stack with `triggers` alone used to load the flow
* and never run it — measured on `os serve`: "1 flow(s) declared but the
* automation engine is not enabled — they will never run", each trigger
* plugin "NOT installed" — and is now refused instead. So both tokens are
* declared here, `os init` declares the union, `os g flow` names any the
* stack is missing, and the emitted file says so in its own header.
*/
namesObject: true,
itemName: (name: string) => `${toSnakeCase(name)}_flow`,
Expand All @@ -365,8 +366,8 @@ export default ${toCamelCase(name)}Action;
*
* Starts when a record changes, so the stack that carries it must declare
* requires: [${FLOW_SCAFFOLD_REQUIRES.map((t) => `'${t}'`).join(', ')}]. The 'triggers' capability
* fires the flow and 'automation' runs it: without 'triggers' the config does
* not load, and without 'automation' the server loads the flow and never runs it.
* fires the flow and 'automation' runs it: without either one the config does
* not load.
*/
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
Expand Down
12 changes: 6 additions & 6 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,9 +605,10 @@ export const SCAFFOLD_WIRED_BARRELS: readonly { type: string; dir: string; stack
/**
* The union of the capability tokens the scaffolds need to run — today the
* `flow` scaffold's pair. Declared by every template that wires the `flows`
* barrel: without `triggers` a record-change flow makes `defineStack` refuse
* the config, so the first `os g flow` would break the project, and without
* `automation` the server loads the flow and never runs it.
* barrel: without `triggers` or without `automation` a record-change flow
* makes `defineStack` refuse the config (#20332: the trigger installs into the
* automation service, so neither token alone installs it), and the first
* `os g flow` would break the project.
*/
export const SCAFFOLD_WIRED_REQUIRES: readonly string[] = [
...new Set(GENERATOR_SCAFFOLD_TARGETS.flatMap((t) => t.requires)),
Expand Down Expand Up @@ -647,9 +648,8 @@ function renderWiredStackKeys(): string {
return [
` // What the files \`objectstack generate\` writes need in order to run. A`,
` // flow that starts on a record change is fired by 'triggers' and run by`,
` // 'automation': without 'triggers' this config stops loading once it holds`,
` // such a flow, and without 'automation' the server loads the flow and never`,
` // runs it. Both can go if this project will never hold a flow.`,
` // 'automation': without either one this config stops loading once it`,
` // holds such a flow. Both can go if this project will never hold a flow.`,
` requires: [${requires}],`,
'',
` // Every directory \`objectstack generate\` writes into is wired here: its`,
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/test/generate-object-namespace-prefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ async function generateAll(name: string, namespace?: string): Promise<Record<str
/**
* One stack holding every scaffold, under `manifest.namespace` when given.
*
* `requires: ['triggers']` is the HOST's declaration, not the scaffold's: the
* flow scaffold is a `record_change` flow, and `defineStack` refuses such a
* flow in a stack that does not require the trigger capability. That refusal
* is about the host's capability list and has nothing to do with names.
* `requires: ['automation', 'triggers']` is the HOST's declaration, not the
* scaffold's: the flow scaffold is a `record_change` flow, and `defineStack`
* refuses such a flow in a stack that does not require the pair that installs
* its trigger (#20332). That refusal is about the host's capability list and
* has nothing to do with names.
*/
function composedStack(artifacts: Record<string, Record<string, unknown>>, namespace?: string) {
const stack: Record<string, unknown> = {
Expand All @@ -116,7 +117,7 @@ function composedStack(artifacts: Record<string, Record<string, unknown>>, names
type: 'app',
...(namespace ? { namespace } : {}),
},
requires: ['triggers'],
requires: ['automation', 'triggers'],
};
for (const [type, artifact] of Object.entries(artifacts)) {
stack[singularToPlural(type)] = [artifact];
Expand Down
Loading
Loading