Skip to content

Declare the triggers capability so the app's flows actually fire - #72

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-68-declare-triggers
Sep 1, 2026
Merged

os-warren merged 1 commit into
mainfrom
claude/issue-68-declare-triggers

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #68

objectstack.config.ts declared requires: ['automation', 'hierarchy-security']. automation gives the app a flow engine; it registers no trigger. Every flow in the app was inert — #33's assignment fan-out never fanned out, #70's three reminder sweeps never swept — while all four gates stayed green.

Verified on a real boot

PORT=3117 pnpm start, @objectstack/cli 17.2.0, both runs on this branch's tree.

Before (at e3d6c7f, requires without triggers):

  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 — …

  ⚠ Boot diagnostics — 9 warnings logged during startup:

After (at 52afd5c, this branch's head):

  Plugins: 39 loaded
           … AutomationServicePlugin, RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
             TimeRelativeTriggerPlugin, ApiTriggerPlugin, …
  Flows:   4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api)

  ⚠ Boot diagnostics — 5 warnings logged during startup:

Zero of four → four of four. No NOT bound warning survives; the four [Automation] boot diagnostics are gone (9 → 5), and the four trigger plugins now appear in the loaded list.

One token covers all four kinds — no second declaration

The warning names @objectstack/trigger-* plural, so this was worth establishing rather than assuming. It is a single declaration:

  • triggers is the only trigger entry in PLATFORM_CAPABILITY_TOKENS (28 tokens; nothing else matches /trigger/i).
  • The CLI's capability map keys it to @objectstack/trigger-record-change plus three extras — ScheduleTriggerPlugin and TimeRelativeTriggerPlugin (both from @objectstack/trigger-schedule) and ApiTriggerPlugin (from @objectstack/trigger-api).
  • PLATFORM_CAPABILITY_PROVIDERS.triggers.edition is open and the packages are already on disk transitively, so nothing needs installing.
  • The sweeps also need the job service; job is in PLATFORM_ALWAYS_ON_CAPABILITIES and mounts whether or not it is named.

The boot line confirms it empirically: one token, (record_change, schedule, time_relative, api).

The regression guard

test/trigger-capability.test.ts (6 tests) goes red if triggers is ever dropped while any flow declares a trigger. It does not restate platform facts — it re-derives them each run from the platform's own tables, so it reports a change instead of going stale:

assertion breaks when
requires contains triggers the token is dropped — the #68 defect
requires contains automation the engine is dropped — same silence, one layer down
non-vacuity: flows exist and one is detected as trigger-launched the detector stops matching how flows are authored, so the guard would pass by finding nothing
isKnownPlatformCapability('triggers') the vocabulary renames the token
exactly one /trigger/i token, edition: 'open' the vocabulary is split — the app would then need the new token(s) too
every flow type enum member is classified the enum grows a member nobody has judged trigger-launched or not

Trigger detection ORs the two routes the engine and @objectstack/lint both use — the flow's type, or a start node carrying triggerType / timeRelative / schedule. The type set is stated as its complement (screen, autolaunched are the non-trigger ones), so a future enum member demands the capability rather than being waved through.

Ablation — the guard was proven to fail

Committed first, then mutated and measured in one shell invocation under a restoring trap; the mutation was confirmed on disk by grep counts on both the injected and the removed text (1/0 → flipped) plus a non-empty git diff --stat, not by the editor's exit code.

Dropping 'triggers' from requires:

test/trigger-capability.test.ts   1 failed | 5 passed (6)
whole suite                       1 failed | 18 passed (19)     ← 511 tests, only the new one catches it
pnpm validate                     EXIT=0    "✓ Validation passed"  ·  "Logic: 4 Flows"
AssertionError: 4 flow(s) declare a trigger — duly_assignment_fanout,
duly_task_lead_time_reminder, duly_task_due_soon_reminder,
duly_task_overdue_owner_escalation — but objectstack.config.ts does not declare
'triggers' in `requires`. … expected [ 'automation', 'hierarchy-security' ] to
include 'triggers'

Exactly one of the six failed, and the other 18 test files stayed green — which is the measured restatement of #68's complaint: the existing suite is blind to this, and validate still exits 0 while every flow is dark. No rebuild step is involved: vitest transforms the config and src/ TypeScript directly, so there is no dist/ to go stale between the mutation and the measurement.

Why nothing caught this — filed upstream as objectstack-ai/objectstack#14153

The card asked whether the asymmetry is real. It is, and it is one predicate wide:

  • defineStack runs exactly two validators that read requires: validateKnownCapabilities (typo guard) and validateHierarchyScopeCapability, which throws on a hierarchy scope declared without its capability. There is no third.
  • @objectstack/lint's validate-flow-trigger-readiness.ts already computes isAutoTriggered — and uses it for one finding only (flow-draft-status-ambiguous). The stack it receives carries requires right there. The two are never joined.
  • The runtime already knows: the boot banner resolves the flow's intended trigger type and prints the exact remedy. The diagnostic text exists; it just fires after deploy.

And the unguarded case is the worse one: a hierarchy scope without its capability fails closed (visibility narrows — wrong but conservative, and someone notices missing rows). An unbound flow fails silent — the automation simply does not happen and nothing anywhere says so.

test/trigger-capability.test.ts is therefore labelled a stopgap in the house convention (test/flow-predicates.test.ts, test/metadata-bindings.test.ts): it points at #14153 and is written to be deleted when that lands, not maintained.

Why the guard is static rather than the getTriggerBindingAudit() boot check #68 suggested

Measured, not assumed: a kernel built the way test/dispatch-wiring.test.ts builds one (createStandaloneStack + AppPlugin + bootstrap()) mounts no capability plugins at all. Its own boot log says INFO Info: Optional service not present: automation, so getService('automation') yields nothing and there is no audit to read — requires is resolved by the CLI serve host, not by the kernel. Mounting the trigger plugins by hand inside the test would assert the test's wiring rather than the config's, which is the test-side-bind false green test/dispatch-wiring.test.ts was written to avoid. So the assertion is made where the fact lives — the declaration the host reads — and the real-boot half is evidenced above.

Gates

All four green on 52afd5c (the head of this branch), tree clean, run after the final commit:

pnpm validate    EXIT=0   ✓ Validation passed (289ms)
pnpm typecheck   EXIT=0   tsc --noEmit
pnpm test        EXIT=0   Test Files 19 passed (19) · Tests 511 passed (511)
pnpm build       EXIT=0   ✓ Build complete (524ms)

validate still prints the one expected hierarchy-security capability-provider warning — AGENTS.md rule 7 says that warning is this repo's expected state. No new warning appeared for triggers: its provider packages resolve.

Files changed

  • objectstack.config.ts — triggers added to requires, with a comment recording what it turns on, that one token covers all four kinds, and that omitting it is caught by nothing at author time.
  • test/trigger-capability.test.ts — new.

Out of scope, filed unassigned: #71 — two catalog action handlers (duly_catalog_apply, duly_catalog_sync) are registered without a defineAction declaration, so ADR-0110 D3 refuses both at dispatch on every boot. Visible in the boot diagnostics above; not touched here.

src/data/, src/flows/, src/objects/ and AGENTS.md untouched.


Generated by Claude Code

`objectstack.config.ts` declared `requires: ['automation',
'hierarchy-security']`. `automation` gives the app a flow ENGINE; it
registers no TRIGGER. Every flow in the app was therefore inert: the
assignment fan-out (#33) never fanned out and the three reminder sweeps
(#70) never swept.

Measured on @objectstack/cli 17.2.0, `PORT=3117 pnpm start`:

  before:  Plugins: 35 loaded
           Flows:   4 flow(s) 0 bound to triggers
           + one "declares a '<type>' trigger but is NOT bound" warning
             per flow

  after:   Plugins: 39 loaded (RecordChangeTriggerPlugin,
             ScheduleTriggerPlugin, TimeRelativeTriggerPlugin,
             ApiTriggerPlugin)
           Flows:   4 flow(s) 4 bound to triggers
             (record_change, schedule, time_relative, api)
           no unbound warnings; boot diagnostics 9 -> 5

One token covers all four kinds: `triggers` is the only trigger entry in
`PLATFORM_CAPABILITY_TOKENS`, and the CLI keys it to
@objectstack/trigger-record-change plus extras for the schedule,
time-relative and api plugins. No second declaration is needed.

`validate`, `typecheck`, `test` and `build` all exited 0 with every flow
unbound, so `test/trigger-capability.test.ts` pins the invariant: it goes
red if `triggers` is dropped while any flow declares a trigger, and it
re-derives its assumptions (token spelling, one-token coverage, the flow
`type` vocabulary) from the platform's own tables rather than restating
them. It is a labelled stopgap over an author-time platform gap, filed as
objectstack-ai/objectstack#14153, and is meant to be deleted when that
lands.

Part of #68
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 08:49
@os-warren
os-warren merged commit 45ab6a0 into main Sep 1, 2026
1 check passed
os-warren added a commit that referenced this pull request Sep 1, 2026
Keeps every non-designated row inside the fortnight while putting real
values in the 7-to-14-day band, so the dashboard's nested >7d / >14d / >30d
tiles read 6 / 3 / 2 rather than 3 / 3 / 2.

Also corrects the fan-out comment after #72: the reason a seeded assignment
does not fan out is the loader's own skipTriggers, not an unbound trigger.

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
os-warren added a commit that referenced this pull request Sep 1, 2026
* Seed the demo: the product working on first boot

`pnpm dev` on an empty database now opens on a running system rather than
five empty grids — a three-level business-unit tree, thirteen people, a
twenty-item role catalog across three position codes, thirty-one duties and
six months of dispatched history.

History is produced by the dispatcher's own planner (`planDispatch`) rather
than by a second period walk, so every period key is the engine's spelling by
construction and "standing duties hold zero tasks" is structurally impossible
to violate rather than merely absent from the fixture.

`last_update_at` is written by a second `mode: 'update'` seed pass, per #32 /
PR #64 — an insert can never carry it, and without that pass the "Not moving"
view is empty while the seed reports success.

Fixes #7

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Spread in-flight touch ages by how long a task has been open

Keeps every non-designated row inside the fortnight while putting real
values in the 7-to-14-day band, so the dashboard's nested >7d / >14d / >30d
tiles read 6 / 3 / 2 rather than 3 / 3 / 2.

Also corrects the fan-out comment after #72: the reason a seeded assignment
does not fan out is the loader's own skipTriggers, not an unbound trigger.

Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No flow in this app auto-launches — requires: ['triggers'] is undeclared, and the boot summary says so on every start

2 participants