defineStack refuses to load a stack that grants an ADR-0057 hierarchy scope without requires: ['hierarchy-security']. It accepts, without a word, a stack full of flows that declare triggers while requires omits triggers. The second failure mode is the worse of the two, and it is the one that is unguarded.
The asymmetry, measured
@objectstack/spec 17.2.0 — defineStack runs exactly two validators that read requires:
| validator | what it does |
|---|
validateKnownCapabilities | rejects an unknown token (typo guard) |
validateHierarchyScopeCapability | throws when a permission-set grant uses readScope/writeScope of unit / unit_and_below / own_and_reports and requires omits hierarchy-security |
There is no third. Grepping dist/index.js for function validate* bodies that mention requires returns those two and nothing else. A flow that declares a record_change, schedule, time_relative or api trigger while requires omits triggers loads clean, validates clean, builds clean, and never fires.
What it cost downstream
objectstack-ai/duly shipped requires: ['automation', 'hierarchy-security']. automation gives a flow engine; it registers no trigger. Measured on @objectstack/cli 17.2.0, PORT=3117 pnpm start:
Plugins: 35 loaded
Flows: 4 flow(s) 0 bound to triggers
⚠ flow 'duly_assignment_fanout' declares a 'record_change' trigger but is NOT bound —
no 'record_change' trigger is registered — add requires: ['triggers']
(record_change/schedule/time_relative/api ship in @objectstack/trigger-*)
⚠ flow 'duly_task_lead_time_reminder' declares a 'time_relative' trigger but is NOT bound — …
⚠ flow 'duly_task_due_soon_reminder' declares a 'time_relative' trigger but is NOT bound — …
⚠ flow 'duly_task_overdue_owner_escalation' declares a 'time_relative' trigger but is NOT bound — …
Zero of four. On that exact tree pnpm validate, pnpm typecheck, pnpm test and pnpm buildall exit 0, and validate prints Logic: 4 Flows and says nothing further. The app's entire automation layer was dark across five merged rounds — a record-change fan-out and three reminder sweeps, every one of them authored correctly — and no gate in the project could see it. Adding the one token flips the same boot to 4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api).
The runtime already knows, at boot
The banner names the flow, resolves and names the intended trigger type, and prints the exact remedy including the token to add. Everything needed for an author-time diagnostic already exists as a string; it is simply emitted after deploy instead of at authoring. service-automation's own note explains why even that channel is fragile: "the boot-quiet stdout window swallows plain warn/info logs, so the summary is the reliable channel in os dev / os start" — a branch that never boots the app sees nothing at all.
The predicate is already computed at author time — it is just never joined to requires
@objectstack/lint's validate-flow-trigger-readiness.ts already derives exactly the needed fact:
constisAutoTriggered=isRecordTriggered2||triggerType==="api"||config.schedule!=null||isTimeRelative||flow.type==="schedule"||flow.type==="api";
It uses it for one finding only — flow-draft-status-ambiguous. The stack object handed to the rule carries requires right there. The two are never joined.
That file already reasons about trigger availability — FLOW_TRIGGER_UNROUTABLE's message argues that "a plugin can supply the record-change trigger itself, and it still would not help, because the flow never reaches the point of asking for one." It considers whether a trigger could be supplied, and never asks whether one is declared.
Why the unguarded case is the worse one
A hierarchy scope without its capability fails closed: visibility narrows to owner-only. Wrong, but conservative, and a user notices missing rows. An autolaunched flow without its capability fails silent: the automation simply does not happen. Nothing narrows, nothing errors, nobody notices — the tasks that should have been created just do not exist. The platform guards the fail-closed case at authoring time and leaves the fail-silent one to a boot line.
Proposed
A sibling to validateHierarchyScopeCapability in defineStack:
defineStack trigger capability validation failed (1 issue):
✗ flow 'duly_assignment_fanout' declares a 'record_change' trigger but `requires` does not
include 'triggers'. No trigger would be registered, so the flow would never auto-launch.
Add requires: ['triggers'] (record_change/schedule/time_relative/api ship in
@objectstack/trigger-*).
The wording already exists — it is what the boot banner prints today.
Two notes on scope:
triggers is a single token covering all four kinds (PLATFORM_CAPABILITY_PROVIDERS.triggers → @objectstack/trigger-record-change plus extras for ScheduleTriggerPlugin, TimeRelativeTriggerPlugin and ApiTriggerPlugin), so the check is one membership test, not a per-kind table.- If a hard error is judged too strong — an app could legitimately ship a
type: 'record_change' flow it only ever launches by hand — the smaller version is an error-severity lint rule in validate-flow-trigger-readiness.ts, where the predicate already lives and where pnpm validate would surface it. Either lands the diagnostic before deploy; today neither does.
Repro
git clone https://github.com/objectstack-ai/duly &&cd duly && pnpm install
# edit objectstack.config.ts: requires: ['automation', 'hierarchy-security']
pnpm validate && pnpm typecheck && pnpm test&& pnpm build # all exit 0
PORT=3117 pnpm start # "4 flow(s) 0 bound to triggers"
Found while implementing objectstack-ai/duly#68, which adds the missing token plus a repo-local test/trigger-capability.test.ts stopgap. That stopgap is written to be deleted when this lands.
Generated by Claude Code
defineStackrefuses to load a stack that grants an ADR-0057 hierarchy scope withoutrequires: ['hierarchy-security']. It accepts, without a word, a stack full of flows that declare triggers whilerequiresomitstriggers. The second failure mode is the worse of the two, and it is the one that is unguarded.The asymmetry, measured
@objectstack/spec17.2.0 —defineStackruns exactly two validators that readrequires:validateKnownCapabilitiesvalidateHierarchyScopeCapabilityreadScope/writeScopeofunit/unit_and_below/own_and_reportsandrequiresomitshierarchy-securityThere is no third. Grepping
dist/index.jsforfunction validate*bodies that mentionrequiresreturns those two and nothing else. A flow that declares arecord_change,schedule,time_relativeorapitrigger whilerequiresomitstriggersloads clean, validates clean, builds clean, and never fires.What it cost downstream
objectstack-ai/dulyshippedrequires: ['automation', 'hierarchy-security'].automationgives a flow engine; it registers no trigger. Measured on@objectstack/cli17.2.0,PORT=3117 pnpm start:Zero of four. On that exact tree
pnpm validate,pnpm typecheck,pnpm testandpnpm buildall exit 0, andvalidateprintsLogic: 4 Flowsand says nothing further. The app's entire automation layer was dark across five merged rounds — a record-change fan-out and three reminder sweeps, every one of them authored correctly — and no gate in the project could see it. Adding the one token flips the same boot to4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api).The runtime already knows, at boot
The banner names the flow, resolves and names the intended trigger type, and prints the exact remedy including the token to add. Everything needed for an author-time diagnostic already exists as a string; it is simply emitted after deploy instead of at authoring.
service-automation's own note explains why even that channel is fragile: "the boot-quiet stdout window swallows plain warn/info logs, so the summary is the reliable channel inos dev/os start" — a branch that never boots the app sees nothing at all.The predicate is already computed at author time — it is just never joined to
requires@objectstack/lint'svalidate-flow-trigger-readiness.tsalready derives exactly the needed fact:It uses it for one finding only —
flow-draft-status-ambiguous. Thestackobject handed to the rule carriesrequiresright there. The two are never joined.That file already reasons about trigger availability —
FLOW_TRIGGER_UNROUTABLE's message argues that "a plugin can supply the record-change trigger itself, and it still would not help, because the flow never reaches the point of asking for one." It considers whether a trigger could be supplied, and never asks whether one is declared.Why the unguarded case is the worse one
A hierarchy scope without its capability fails closed: visibility narrows to owner-only. Wrong, but conservative, and a user notices missing rows. An autolaunched flow without its capability fails silent: the automation simply does not happen. Nothing narrows, nothing errors, nobody notices — the tasks that should have been created just do not exist. The platform guards the fail-closed case at authoring time and leaves the fail-silent one to a boot line.
Proposed
A sibling to
validateHierarchyScopeCapabilityindefineStack:The wording already exists — it is what the boot banner prints today.
Two notes on scope:
triggersis a single token covering all four kinds (PLATFORM_CAPABILITY_PROVIDERS.triggers→@objectstack/trigger-record-changeplusextrasforScheduleTriggerPlugin,TimeRelativeTriggerPluginandApiTriggerPlugin), so the check is one membership test, not a per-kind table.type: 'record_change'flow it only ever launches by hand — the smaller version is an error-severity lint rule invalidate-flow-trigger-readiness.ts, where the predicate already lives and wherepnpm validatewould surface it. Either lands the diagnostic before deploy; today neither does.Repro
Found while implementing objectstack-ai/duly#68, which adds the missing token plus a repo-local
test/trigger-capability.test.tsstopgap. That stopgap is written to be deleted when this lands.Generated by Claude Code