Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,7 +84,45 @@ export default defineStack({
// resolver. Do not silence it: the only ways to are installing the
// enterprise package (not wanted in this repo) or deleting this entry,
// which puts the hard error back.
requires: ['automation', 'hierarchy-security'],
//
// `triggers` is what makes an autolaunched flow actually FIRE. `automation`
// ships the flow ENGINE and the `FlowTrigger` wiring; it registers no
// concrete trigger, so without this token every flow in the app is inert.
//
// ONE token mounts all four kinds — there is no second declaration to make.
// `triggers` is the only trigger entry in the platform vocabulary
// (`PLATFORM_CAPABILITY_TOKENS`), and 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`). So `record_change` (the #33 fan-out) and
// `time_relative` (the three #70 sweeps) come from this single entry.
// The sweeps also need the job service; `job` is in
// `PLATFORM_ALWAYS_ON_CAPABILITIES` and mounts whether or not it is named.
//
// ⛔ Omitting it fails SILENT — that is the whole reason this comment is
// long. Unlike `hierarchy-security` above, whose absence
// `validateHierarchyScopeCapability` turns into an author-time HARD ERROR,
// an undeclared trigger capability is caught by nothing at author time.
// Measured on @objectstack/cli 17.2.0 with this entry absent: `validate`,
// `typecheck`, `test` and `build` all exit 0, and `validate` prints
// `Logic: 4 Flows` and says nothing further. The ONLY channel that tells
// you is the CLI startup banner:
//
// 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']
//
// Four gates green, `defineStack` happy, and the assignment fan-out dark
// from the day it merged. With this entry the same boot reads
// `4 flow(s) 4 bound to triggers` and prints no unbound warning.
//
// `test/trigger-capability.test.ts` goes red if this token is ever dropped
// while `dulyFlows` is non-empty — the four gates will not catch it again.
// The author-time asymmetry itself is filed upstream as
// objectstack-ai/objectstack#14153.
requires: ['automation', 'triggers', 'hierarchy-security'],

plugins: [
new ConnectorRestPlugin(),
Expand Down
229 changes: 229 additions & 0 deletions test/trigger-capability.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';

import { FlowSchema } from '@objectstack/spec/automation';
import {
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
} from '@objectstack/spec';

import stackConfig from '../objectstack.config.js';
import { dulyFlows } from '../src/flows/index.js';

/**
* ⚠️ STOPGAP — delete this file when objectstack-ai/objectstack#14153 lands.
*
* Same convention as `test/flow-predicates.test.ts` and
* `test/metadata-bindings.test.ts`: this is not a house rule that wants
* maintaining forever, it is a repo-local guard over a platform author-time
* gap. When `defineStack` refuses an undeclared trigger capability the way it
* already refuses an undeclared hierarchy scope, the right move is to REMOVE
* this file, not to keep two guards in step.
*
* ── What it guards, and what it cost to find (issue #68) ──────────────────
* `requires: ['automation', 'hierarchy-security']` gave this app a flow
* ENGINE and no TRIGGER. Every flow in the app was inert for five rounds:
* #33's assignment fan-out never fanned out and #70's three reminder sweeps
* never swept. Measured on `@objectstack/cli` 17.2.0, `PORT=3117 pnpm start`:
*
* BEFORE (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']
* …the same warning for all three reminder sweeps ('time_relative')
*
* AFTER (this file's invariant holding)
* Plugins: 39 loaded … RecordChangeTriggerPlugin, ScheduleTriggerPlugin,
* TimeRelativeTriggerPlugin, ApiTriggerPlugin
* Flows: 4 flow(s) 4 bound to triggers
* (record_change, schedule, time_relative, api)
*
* `validate`, `typecheck`, `test` and `build` all exited **0** on the BEFORE
* state, and `validate` printed `Logic: 4 Flows` and said nothing further.
* Four gates, five rounds, zero signal. That is the whole reason this file
* exists: the defect has to stop being invisible to `pnpm test`.
*
* ── Why this is a STATIC check and not the boot audit #68 suggested ───────
* #68 proposed asserting `getTriggerBindingAudit()` — the engine probe the
* startup banner reads. Measured, that is not reachable from vitest: 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 so —
*
* INFO Info: Optional service not present: automation
*
* — so `kernel.getService('automation')` yields nothing and there is no audit
* to read. `requires` is resolved by the CLI's `serve` command, which is the
* 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 — precisely the
* test-side-bind false green that `test/dispatch-wiring.test.ts` was written
* to avoid. So the assertion is made where the fact actually lives: the
* declaration that the host reads.
*
* ── Why one token is the whole answer ────────────────────────────────────
* `triggers` is the ONLY trigger entry in the platform vocabulary, and the
* CLI's capability map keys it to `@objectstack/trigger-record-change` plus
* three `extras` — `ScheduleTriggerPlugin` and `TimeRelativeTriggerPlugin`
* (from `@objectstack/trigger-schedule`) and `ApiTriggerPlugin` (from
* `@objectstack/trigger-api`). `record_change`, `schedule`, `time_relative`
* and `api` therefore all arrive from this single entry; there is no second
* declaration to make. The last describe block below re-derives that from the
* platform's own tables every run, so if the vocabulary is ever split the
* suite says so instead of this comment quietly going stale.
*/

/** The one platform token that mounts every concrete trigger. */
const TRIGGERS_TOKEN = 'triggers';

/** The engine the triggers hand a fired flow to. Inert without it too. */
const AUTOMATION_TOKEN = 'automation';

/**
* Flow `type` members that are launched by something other than a trigger.
*
* Stated as the COMPLEMENT deliberately: every other member of the enum —
* including one added after this file was written — counts as trigger-launched
* and demands the capability. An unknown flow type failing loudly is the safe
* direction; being waved through is the #68 failure all over again.
*/
const NON_TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['screen', 'autolaunched']);

/** Flow `type` members that are trigger-launched, as of protocol 17. */
const TRIGGER_FLOW_TYPES: ReadonlySet<string> = new Set(['record_change', 'schedule', 'api']);

/**
* Start-node config keys that declare a trigger binding on their own. Mirrors
* the engine's routing chain (and `@objectstack/lint`'s own `isAutoTriggered`
* predicate): a flow reaches a trigger via its `type` OR via these keys.
*/
const TRIGGER_START_CONFIG_KEYS = ['triggerType', 'timeRelative', 'schedule'] as const;

interface AuthoredFlow {
readonly name?: unknown;
readonly type?: unknown;
readonly nodes?: unknown;
}

const nameOf = (flow: AuthoredFlow): string =>
typeof flow.name === 'string' ? flow.name : '(unnamed flow)';

function startConfigOf(flow: AuthoredFlow): Record<string, unknown> {
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
const start = nodes.find(
(n): n is { config?: unknown } =>
!!n && typeof n === 'object' && (n as { type?: unknown }).type === 'start',
);
const config = start?.config;
return config && typeof config === 'object' ? (config as Record<string, unknown>) : {};
}

/** True when this flow will never run unless a trigger is registered for it. */
function declaresTrigger(flow: AuthoredFlow): boolean {
const type = typeof flow.type === 'string' ? flow.type : undefined;
if (type !== undefined && !NON_TRIGGER_FLOW_TYPES.has(type)) return true;
const config = startConfigOf(flow);
return TRIGGER_START_CONFIG_KEYS.some((key) => config[key] != null);
}

const requires: readonly string[] = Array.isArray(
(stackConfig as { requires?: unknown }).requires,
)
? ((stackConfig as { requires: readonly string[] }).requires)
: [];

const flows = dulyFlows as readonly AuthoredFlow[];
const triggerLaunched = flows.filter(declaresTrigger);

describe('#68 — a flow that declares a trigger has the capability that fires it', () => {
it('is not passing vacuously: flows exist and at least one declares a trigger', () => {
// A guard that can pass because it found nothing to check is the same
// class of silence #68 was. Pin both halves.
expect(flows.length, 'dulyFlows is empty — this guard would pass vacuously').toBeGreaterThan(0);
expect(
triggerLaunched.map(nameOf),
'no flow was detected as trigger-launched — either every flow really is ' +
'screen/autolaunched, or `declaresTrigger` has stopped matching how ' +
'flows are authored here. Check the second before trusting this suite.',
).not.toHaveLength(0);
});

it(`declares '${TRIGGERS_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger — ` +
`${triggerLaunched.map(nameOf).join(', ')} — but objectstack.config.ts ` +
`does not declare '${TRIGGERS_TOKEN}' in \`requires\`. ` +
'The flow engine loads, every gate stays green, and NOT ONE OF THOSE ' +
'FLOWS EVER FIRES. The only channel that says so is the CLI startup ' +
"banner: `Flows: N flow(s) 0 bound to triggers`. Add " +
`'${TRIGGERS_TOKEN}' back to \`requires\` — see issue #68.`,
).toContain(TRIGGERS_TOKEN);
});

it(`declares '${AUTOMATION_TOKEN}' in requires`, () => {
expect(
requires,
`${triggerLaunched.length} flow(s) declare a trigger, but ` +
`'${AUTOMATION_TOKEN}' is not in \`requires\`. Triggers fire INTO the ` +
'automation engine; without it the boot summary reports the flows as ' +
'declared and disabled, and nothing runs. Same silence as #68, one ' +
'layer down.',
).toContain(AUTOMATION_TOKEN);
});
});

describe('#68 — the platform facts this guard rests on', () => {
it(`'${TRIGGERS_TOKEN}' is the platform's own spelling, not ours`, () => {
expect(
isKnownPlatformCapability(TRIGGERS_TOKEN),
`'${TRIGGERS_TOKEN}' is no longer a platform capability token. The ` +
'vocabulary moved; re-derive what mounts the triggers before editing ' +
'this file. (`defineStack` rejects an unknown token outright, so a ' +
'stale spelling here would take the whole app down at load.)',
).toBe(true);
expect(PLATFORM_CAPABILITY_TOKENS).toContain(TRIGGERS_TOKEN);
});

it('one token still covers every trigger kind — no second declaration to make', () => {
const triggerTokens = PLATFORM_CAPABILITY_TOKENS.filter((token) => /trigger/i.test(token));
expect(
triggerTokens,
'the platform capability vocabulary now carries more than one ' +
`trigger token (${triggerTokens.join(', ')}). #68 established that ` +
"record_change / schedule / time_relative / api ALL arrive from the " +
"single 'triggers' entry. If that has been split, this app's " +
'`requires` needs the new token(s) too — and the flows that depend on ' +
'them are inert until it gets them.',
).toEqual([TRIGGERS_TOKEN]);

const provider = PLATFORM_CAPABILITY_PROVIDERS[TRIGGERS_TOKEN];
expect(provider?.package, 'no provider package for the triggers token').toBeTruthy();
expect(
provider?.edition,
`the triggers capability is now a '${provider?.edition}' capability. ` +
'It was `open` (provided by @objectstack/trigger-record-change, pulled ' +
'in transitively), which is why declaring it needs no install here. A ' +
'non-open edition means this app must add the package explicitly or ' +
'its flows go dark again.',
).toBe('open');
});

it('the flow `type` vocabulary has not grown a member this file cannot classify', () => {
const options = (FlowSchema as unknown as { shape: { type: { options: readonly string[] } } })
.shape.type.options;
const unclassified = options.filter(
(option) => !NON_TRIGGER_FLOW_TYPES.has(option) && !TRIGGER_FLOW_TYPES.has(option),
);
expect(
unclassified,
`the flow \`type\` enum has gained ${unclassified.join(', ')}. Classify ` +
'each new member in this file: trigger-launched (needs the `triggers` ' +
'capability) or not. Until then `declaresTrigger` treats it as ' +
'trigger-launched, which is the safe direction but not an answer.',
).toEqual([]);
});
});
Loading