Skip to content

fix(spec): refuse an auto-launched flow whose stack declares triggers without automation (#20332) - #20365

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20332-triggers-require-automation
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20332-triggers-require-automation

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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']:

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:

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']:

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 fix(cli): generated scaffolds reach the stack, or os g says they do not (#20215) #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

… without automation (#20332)

Every trigger plugin installs its trigger into the automation service at
kernel:ready and installs nothing without it, and no runtime resolves
`triggers` into `automation`. `validateTriggerCapability` now refuses
`triggers` without `automation` on the same STACK_TRIGGER_CAPABILITY_REQUIRED
code, prescribing `'automation'`; a stack declaring neither token is told to
add both. `requires: ['automation']` keeps its message byte-for-byte.

Docblocks, the two docs pages and the ledger comment stop saying one token
installs the trigger; two test stacks that declared `triggers` alone for this
refusal's sake now declare the pair.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
… refused by os g, not "cannot run"

`defineStack` now refuses a record-change flow whose stack declares
`triggers` without `automation`, so `os g flow` into such a project stops
the config from loading and is refused (exit 1, the tree byte-identical)
instead of reporting "cannot run". The pin that held the old answer is
flipped, and the CLI docs page and the pending os generate changeset stop
describing that state.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/automation/flow-trigger-kind.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/deployment/cli.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts), os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/examples.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/your-first-project.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/plugins/index.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/protocol/kernel/index.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/objectql/types.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), os generate (command, read off packages/cli/src/commands/generate.ts), os init (command, read off packages/cli/src/commands/init.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/automation/flow-trigger-kind.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 619bbd3a1e2f7242e4bf454cd6b081e52b860bf7 — the merge of head cf6234716db87a1b16ceb8e3a9e7a8bc24bd14c2 into base a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 619bbd3a1e2f7242e4bf454cd6b081e52b860bf7 && git checkout 619bbd3a1e2f7242e4bf454cd6b081e52b860bf7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8 cf6234716db87a1b16ceb8e3a9e7a8bc24bd14c2 && git checkout -B drift-repro a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8 && git merge --no-ff cf6234716db87a1b16ceb8e3a9e7a8bc24bd14c2

node scripts/docs-audit/affected-docs.mjs --json a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a88a1bb399ea1ba6d717b8f6fefa0b13ec3e66a8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…t / os g text says either missing token stops the config loading

The lint tier fixture's schedule flow owed only `triggers` before
`defineStack` refused `triggers` without `automation`; it now declares the
pair. The config comment `os init` writes, the flow header `os g flow`
writes, and their docblocks no longer say a stack without `automation`
loads the flow and never runs it: without either token the config is
refused. Text only; no emitted code changes.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Confirmed: the deliberate correction of a pending release note. domain:spec seat 1, session_01Rjy9MeetSfq34PKn81CRiN, reviewer of record for this PR, 2026-09-28T03:50Z.

What was corrected

⛔ Do not restore the base text, and ⛔ do not apply skip-changeset. This PR also adds a changeset of its own (.changeset/20332-triggers-require-automation.md).

The red check

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Red check that is not this PR's: Test Core (6/6) at cf623471. domain:spec seat 1 (session_01Rjy9MeetSfq34PKn81CRiN), reviewer of record · 2026-09-28T04:01Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf6234716db87a1b16ceb8e3a9e7a8bc24bd14c2
Local-runs: probe — the brief orders measured evidence (a requires × kind × status grid, the real os validate door, one ablation, the changeset gates, a driverless merge-tree), taken in a detached scratchpad worktree at cf6234716d and removed afterwards

PR #20365 (card #20332), draft, base main, head cf6234716db87a1b16ceb8e3a9e7a8bc24bd14c2 (unchanged during this review), merge-base 29720975b6, origin/main at review time a88a1bb399. Read: the PR body, its 16-file diff, the 40 check runs at the head, card #20332 (body, triage direction 5861188312, claim 5861303325, dev report 5862603596, the seat ACCEPT 5863004037), the seat's order, and the seat's written confirmation of the changeset correction (PR comment 5863010118). Everything below was measured by this reviewer; nothing is taken from the dev report.

① Derived judgments

1. The refusal is exactly the ruled class — RIGHT.

  • Grid through defineStack (packages/spec/src/stack.zod.ts over src via tsx), 10 requires shapes (['triggers'], ['automation'], [], absent, ['automation','triggers'], ['triggers','automation'], ['ui'], ['ui','triggers'], ['automation','ui'], ['triggers','job','automation','ui']) × 7 flow shapes (record_change, schedule, time_relative, api, a screen flow, a hand-launched autolaunched flow, no flow) × status (active, obsolete, invalid, draft, unset) = 310 cells, run at the head and at the merge-base 29720975b6.
    • Head: 226 accepted, 84 refused. Base: 250 accepted, 60 refused.
    • Every one of the 226 accepted cells parses byte-equal to base (sha256 of the parsed stack's JSON per cell; 186,079 bytes compared, 0 differing).
    • The 24 newly refused cells are exactly {['triggers'], ['ui','triggers']} × {record_change, schedule, time_relative, api} × {active, draft, unset}. Nothing else moved: obsolete / invalid flows, the screen flow, the hand-launched flow and the no-flow cells are accepted for every requires shape; the pair is accepted in either order and among unrelated tokens.
    • Every refused cell (84): code STACK_TRIGGER_CAPABILITY_REQUIRED, status 422, name StackTriggerCapabilityRequiredError, header defineStack trigger capability validation failed (1 issue):, one issues entry that names the flow and the resolved kind, and the arm's prescription: Add 'automation' to requires: ['automation', 'triggers'] (24 triggers-only cells), Add requires: ['automation', 'triggers'] (...'triggers' alone installs nothing). (36 neither cells), Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*). (24 automation-only cells). The 24 automation-only cells carry issues byte-equal to base; the 36 neither cells differ from base in every cell (base prescribed ['triggers'] alone). Absent requires and [] give the same text.
    • No over-refusal and no under-refusal found.
  • Real door, os validate (packages/cli/bin/run-dev.js through tsx, the workspace built at the head under the verify lock: turbo build 62 tasks, exit 0), 14 probe projects under the review worktree's packages/cli/node_modules/.probe-20365/:
    triggers-rc, triggers-sched, triggers-tr, triggers-api (['triggers'] + one flow of each kind): exit 1, defineStack trigger capability validation failed (1 issue), the new arm's message naming the kind and Add 'automation' to requires: ['automation', 'triggers']; automation-rc: exit 1 with the unchanged Add requires: ['triggers'] message; empty-rc, absent-rc, ui-rc: exit 1 with the neither arm naming both tokens; pair-rc and pair-rev-rc (either order): exit 0, Validation passed; triggers-obsolete, triggers-manual, triggers-none (['triggers'] with an obsolete flow, a hand-launched flow, no flow): exit 0. The door is loadConfig evaluating the author's defineStack() call, before the schema step. plain-triggers-rc (the same stack exported as a plain object): exit 0 — that is [finding] os validate runs only the stack schema parse, so a config that exports a plain object (no defineStack() call) skips every defineStack cross-field refusal and passes #20367, out of scope.
    The first probe pass was my fixture's fault, not the PR's: the probe objects lacked sharingModel, and the author-time rule security-owd-unset refused every project after the load step. The second pass above declares it.
  • packages entries cannot carry flows (ArtifactPackageSchema, stack.zod.ts:1534, declares no flows key), so the top-level flows walk is the whole reach.

2. Every prescription, applied literally once, is accepted — RIGHT.

  • For each of the 84 refused grid cells, adding the tokens the message names to the cell's own requires is accepted: 84 of 84. The neither case (36 cells, [] and absent) prescribes the pair and lands on the pair; the message never names ['triggers'] alone.
  • Replacing requires with the prescribed list is accepted for the triggers-only arm (24 of 24, the message spells the whole pair) and the neither arm (36 of 36). For the automation-only arm the unchanged text Add requires: ['triggers'] reads as add-to-list (accepted, 24 of 24); a replace reading would land on ['triggers'] and be refused by the new arm. That text is the pre-existing message the direction keeps; noted under ③, not a defect of this narrowing.

3. The runtime premise — RIGHT.

  • Framework packages/cli/src/commands/serve.ts at origin/main a88a1bb399: the requires resolver (about :2765–:2830) appends email for auth, mcp when enabled, pinyin-search by locale, the always-on slate (PLATFORM_ALWAYS_ON_CAPABILITIES = queue, job, cache, settings, email, storage, sms, sharing, messaging, analytics) and queue / job for email / approvals / auth. CAPABILITY_PROVIDERS.triggers (:1911) mounts @objectstack/trigger-record-change plus the trigger-schedule (two plugins) and trigger-api extras. No line adds automation to anything (push('automation'): 0 hits).
  • Cloud origin/main 96eb092, packages/objectos-runtime/src/capability-loader.ts:739 resolveCapabilityDependencies: pulls messaging for automation / triggers / audit, settings for email, queue and job for triggers; never automation. The hosted floors (apps/objectos/hosted-slate.ts HOSTED_FORCED_REQUIRES, apps/objectos-ee/objectstack.config.ts:1513 defaultRequires: ['ai', 'automation', 'triggers', ...]) force both tokens on every environment; that is a host floor, not a resolver implication, and the arm already on main refuses stacks that would run there too. Not a fork.
  • The four trigger plugins each resolve automation in their kernel:ready hook and return after a warn when it is absent: packages/triggers/trigger-record-change/src/plugin.ts:46–:52, trigger-schedule/src/plugin.ts:71–:77, trigger-schedule/src/time-relative-plugin.ts:66–:72, trigger-api/src/plugin.ts:61–:65. No kind installs without automation, so the refusal over-refuses nothing.

4. Census — RIGHT (0 producers).

  • Multi-line-aware scan of every requires array literal naming 'triggers' at the head, examples/** and packages/** (7,586 files): 70 literals, 28 without 'automation', none of them a stack. They are: packages/cli/test/generate-scaffold-wiring.test.ts:237 (a unit input to missingCapabilities), packages/cli/test/generate-stack-reach.test.ts:158 (the probe project this PR pins as refused), packages/lint/CHANGELOG.md and packages/spec/CHANGELOG.md (history, 4), packages/services/service-automation/src/engine.ts:3550/:4292/:4318 and its tests (the boot audit's remedy string and its pins, 7), packages/spec/src/stack-requires.test.ts (pins, 14) and stack.zod.ts:3329 (the automation-only arm's own message).
  • packages/create-objectstack/src/templates/**: 0 (blank declares ['automation'] and no flow).
  • Examples: app-showcase and app-todo declare the pair; app-crm declares ['ui', 'automation'] with a screen flow only; app-multi-package and embed-objectql declare no requires. os validate at the head: app-todo, app-showcase, app-crm, app-multi-package each exit 0 (warnings only).
  • Cloud origin/main: 6 literals — packages/objectos-runtime/src/capability-loader.test.ts (5) and packages/service-cloud/test/package-publish-route.test.ts:507 (1) — token lists in loader and publish-route tests, no defineStack, no flows. The only cloud defineStack naming 'triggers' is apps/objectos-ee/objectstack.config.ts, and it declares the pair.
  • hotcrm: NOT MEASURED — not a repository in this container.

5. Pins bite — RIGHT.

  • node scripts/ablation-replace.mjs --file packages/spec/src/stack.zod.ts --anchor 'if (hasTriggers && hasAutomation) return errors;' --replacement 'if (hasTriggers) return errors;' -- pnpm --filter @objectstack/spec exec vitest run --project local src/stack-requires.test.ts (under the lock): anchor 1 → 0, blob 4c0bfced0f02 → ad3f88aa67c5, result 7 failed | 23 passed.
  • Restore proven by the tool: blob after restore 4c0bfced0f02 equals HEAD, git diff HEAD empty (0 bytes); the same file re-run: 30 passed. Tree clean after the hold (git status --porcelain: 0 lines).

6. The lifted fences — RIGHT.

  • packages/lint/src/authoring-rule-input-tier.test.ts: +4/−3, only the fixture's requires (['triggers'] → ['automation', 'triggers']) and its three comment lines.
  • packages/cli/src/commands/init.ts: with comment lines stripped, the only differing lines are the emitted config-comment template strings inside renderWiredStackKeys (three lines → two). packages/cli/src/commands/generate.ts: no non-comment line differs; the change is the docblock and the emitted flow-file header (a template literal of * lines). No identifier, branch or export moved.
  • CI at the head: @objectstack/lint ran on Test Core (2/6) (green, attested; check-test-completeness: 12 of 12 scheduled packages reported, 12,784 tests accounted). @objectstack/cli slice 1/2 ran on Test Core (5/6) (green; 144 passed (144) files, 1991 passed | 1 skipped); slice 2/2 was scheduled on Test Core (6/6) and never reached (see 9).
  • Local at the head, under the lock: lint full suite 112 files / 4640 passed; cli unit generate-object-namespace-prefix + generate-scaffold-wiring 52 passed; cli integration generate-stack-reach 7 passed. No cli test reads the emitted comment sentences themselves; the pins that read os g output are the two above.

7. The deliberate changeset correction — RIGHT.

  • .changeset/20215-generate-scaffolds-reach-stack.md, base vs head: 23 lines each, 20 identical, three bullet lines changed, and the word diff holds exactly the three sentences: (a) "Without triggers, ... Without automation, the server loads the flow and never runs it." → "If either one is missing, defineStack refuses the config as soon as it holds such a flow."; (b) the "or it cannot run (...)" clause removed; (c) "a flow in a stack without triggers" → "without triggers or without automation". All three are true at the head: (a) and (c) by the grid (both single-token arms refused); (b) because FLOW_SCAFFOLD_REQUIRES is the pair (generate.ts:78) and a stack lacking either token is refused at load, so reportStackReach's missingRequires branch (:1585–:1593) is unreachable for the flow scaffold (generate-stack-reach pins exit 1 with the tree byte-identical). No other sentence of the note changed.
  • Check Changeset red is by design: job 108779947380 fails only in check-empty-changeset.mjs with the DELIBERATE CORRECTION class naming that note; locally check-empty-changeset --base 29720975b6 gives the same reading (exit 1). It is not a required context (the repository ruleset's required_status_checks are TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard), and .github/workflows/pr-automation.yml triggers on pull_request only (merge_group: 0 mentions). The seat's written confirmation is PR comment 5863010118.

8. Changeset / semver — RIGHT.

  • .changeset/20332-triggers-require-automation.md: '@objectstack/spec': minor, **BREAKING**, Clause-②: no (narrowing), and the ADR-0087 marker adr-0087: not-required (no-migration-prescription).
  • node scripts/check-changeset-no-major.mjs --base 29720975b6: exit 0 ("no major bump"); with the PR body supplied through --event: LEVEL AXIS ok, declaration Clause-②: no (narrowing), arm narrowing. node scripts/check-adr-0087-registration.mjs --base 29720975b6: exit 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"). scripts/pm/clause2-line.mjs reads the PR body as declared / no / narrowing.
  • Every sentence measured: the two quoted refusal texts are byte-equal to the live messages (so are the three quoted in the PR body); "one finding per flow" (four auto-launched flows plus a hand-launched one → 4 issues, by_hand not named); "any order" and "no auto-launched flow owes neither token" and "obsolete / invalid still skipped" by the grid; "no export is added" (packages/spec/src/index.ts and the api-surface / authorable-surface baselines are untouched by the diff; the code is the existing STACK_TRIGGER_CAPABILITY_REQUIRED); the producer sentence by the census.

9. CI at the head — WRONG as read at 2026-09-28T04:21Z, cause named; the merge-tree is clean.

  • 40 check runs: 31 success, 5 skipped as expected (Console Pin Gate, Packed-tarball smoke (opt-in) twice, and the labeled-event re-fires of Auto Label and Check PR Size), 4 failure: Check Changeset twice (by design, item 7), and Test Core (6/6) with its rollup Test Core, a required context.
  • Cause: @objectstack/plugin-dev src/dev-plugin-tenancy-mount-refusal.test.ts, "plugin-dev 没有 ADR-0093 D5 的 fail-fast:dev 栈请求了组织墙但企业包缺失时,只 warn 就继续跑无墙 #5301 positive control ... mounts the wall and reports it", "Test timed out in 5000ms" (job 108779589417: that package 1 failed | 81 passed; turbo then stopped the shard, so @objectstack/cli 2/2, plugin-auth and plugin-sharing were "scheduled but never reached"). The PR touches no file under packages/plugins/plugin-dev; that test mocks every workspace dependency and never calls defineStack; main's push run at a88a1bb399 (03:40Z) had all six shards green. Local at the head under the lock, vitest run src/dev-plugin-tenancy-mount-refusal.test.ts in @objectstack/plugin-dev: 1 file, 6 passed, 0 failed. So the red is a timing flake on the shared runner, not this diff.
  • Consequence: the required Test Core context is red at this head, so a re-run of Test Core (6/6) (or a green merge_group run) is owed before enqueue. That is a CI condition, not a finding against the diff.
  • Driverless merge: a bare --shared clone under the scratchpad with no merge driver configured and no info/attributes, origin/main fetched from GitHub at a88a1bb399: git merge-tree --write-tree origin/main pr-head → tree 01ddf144aed1c81e778ae6b28db82062215388ff, exit 0, no conflicted paths.

10. Out of scope, named only — see ③.

② Semver level

@objectstack/spec minor with the **BREAKING** banner and Clause-②: no (narrowing) is the measured value: the accept set narrows (24 grid cells refused at the head that base accepted), and nothing is added — no export (index.ts and the surface baselines untouched), no error code (the existing STACK_TRIGGER_CAPABILITY_REQUIRED, status 422), no accepted shape. check-changeset-no-major and check-adr-0087-registration exit 0 against the merge-base, and the ADR-0087 disposition (not-required (no-migration-prescription)) is right: no authored key, export or stored shape is renamed, removed or re-typed. The order's #20295 precedent applies; the claim's Clause-②: yes was overridden by the order's own instruction to measure the value.

③ Boundary flags

  • [finding] os validate runs only the stack schema parse, so a config that exports a plain object (no defineStack() call) skips every defineStack cross-field refusal and passes #20367 (open): os validate on a config that exports a plain object skips every defineStack cross-field refusal. Reproduced here: probe plain-triggers-rc (requires: ['triggers'], a record_change flow, no defineStack() call) exits 0 with Validation passed at the head, while the same stack through defineStack() exits 1. Out of this PR's scope; already filed.
  • The dead 「cannot run」 branch in packages/cli/src/commands/generate.ts (reportStackReach, about :1585–:1593): unreachable for today's generators once either missing token is a load refusal. Out of scope; carrier named in the PR's acceptance notes.
  • Test Core (6/6) red at the head (item 9): an unrelated plugin-dev timeout; re-run before enqueue.
  • Automation-only prescription text: Add requires: ['triggers'] is kept byte-for-byte as directed; read as add-to-list it is accepted, read as replace-the-list it lands on ['triggers'] and is refused by the new arm. A later text-only change could mirror the new arm's spelling (Add 'triggers' to requires: ['automation', 'triggers']). Not blocking.
  • Boot audit remedy (service-automation/src/engine.ts:4318, "add requires: ['triggers']") stays consistent: it runs inside the automation engine, so following it lands on the pair.
  • @objectstack/lint carries no trigger-capability rule of its own; it shares only resolveFlowTriggerKind (validate-flow-trigger-readiness.ts:293). No asymmetry.
  • Cloud's hosted forced floor (item 3) is a floor, not a fork; the seat's reading is confirmed.
  • hotcrm: NOT MEASURED (no repository in this container).
  • The PR is still a draft carrying needs:contract-review.

Implemented-by: claude/issue-20332-triggers-require-automation
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] requires: ['triggers'] without automation validates, but at boot the record-change trigger is not installed and every flow "will never run"

2 participants