Skip to content

Commit ae7a35a

Browse files
fix(trigger-schedule): a scheduled run's platform seeds never answer a screen field (#19900) (#19982)
Fixes #19900 Clause-②: no ## What this changes The schedule trigger now sets `callerParamKeys: []` on every run context it builds. A tick has no caller, so none of its `params` seeds (`jobId`, `flowName`, `schedule`) is a caller's answer. The `screen` node's headless verdict reads the list when it is present (PR #19899) and no longer infers. The card's property: "A scheduled run's platform seeds never answer a screen field". A screen in a scheduled flow now pauses, whatever its fields are named. The three seeds stay in `params`, so flows that read them are unaffected. ## Premise, re-measured on `origin/main` `2c1011b01b` - `packages/triggers/trigger-schedule/src/schedule-trigger.ts:806-810` builds `params: { jobId, flowName: binding.flowName, schedule }` and sets no `callerParamKeys`. It is the only producer of an `event: 'schedule'` context under `packages/` (non-test `git grep`). - The reader treats `[]` as "the caller supplied nothing", not as absent. `callerSupplied` in `screen-input-contract.ts:357` reads `if (declared !== undefined) return Array.isArray(declared) && declared.includes(name);`. The consumer side already has a pin for this: `screen-headless-caller-signal.test.ts:160` ("an EMPTY list is an answer: it pauses a screen the inference would skip"). The reader is unchanged here. - Pre-fix red: I ran the new test file against the unfixed trigger. With a required screen field `schedule`, the run completed instead of pausing: `success: true`, `output: { schedule: { type: 'cron', expression: '0 2 * * *' } }`, and node `screen_1` had status `success`. `jobId` and `flowName` failed the same way. ## Files 1. `packages/triggers/trigger-schedule/src/schedule-trigger.ts`: adds `callerParamKeys: []` (the fix). 2. `packages/triggers/trigger-schedule/src/schedule-caller-param-keys-e2e.test.ts` (new): the real `ScheduleTrigger` is bound through the real `AutomationEngine` with builtin nodes. A `type: 'schedule'` flow runs `start`, then `screen_1`, then `end`. The job is fired by hand. The test reads the context the trigger passed to `engine.execute` and the result the engine returned to the trigger's callback. It pins: - the context carries `callerParamKeys: []`, and the seeds are still in `params`; - a required field named `schedule`, `jobId` or `flowName` pauses at `screen_1` (`it.each`), and `listSuspendedRuns()` names the run; - an all-optional screen with all three names pauses too; - control: on the same flow and engine, a context that names `schedule` as a caller key continues. The screen is not paused just because the flow is a schedule flow. 3. Four texts this change would have made false. These are outside the expected landing: - `packages/spec/src/contracts/automation-service.ts`, the `callerParamKeys` TSDoc. It now names the schedule trigger as a producer that states `[]`, and drops it from the "Absent means" list. TSDoc only; no type or key change. - `packages/spec/src/contracts/automation-context-caller-param-keys.pin.test.ts`, a comment ("an absent key is what every producer other than the two doors sends"). - `packages/services/service-automation/src/screen-input-contract.ts`, the docblock's "Absent" paragraph. Comment only; the inference is unchanged. - `content/docs/automation/flows.mdx`, the "A run started any other way" paragraph. 4. `.changeset/19900-schedule-caller-param-keys.md`: `@objectstack/trigger-schedule` patch and `@objectstack/spec` patch. The spec TSDoc ships: after the build, the new sentence is in `packages/spec/dist/contracts/index.d.ts` and `index.d.mts`, one hit each. The `service-automation` docblock does not ship: `grep -rl` over its `dist/` finds 0 files, while the positive control `judgeHeadlessScreen` is found in `index.js` and `index.cjs`. So `service-automation` has no entry. ## The pending `callerParamKeys` release note (#19846) is left as it is That note says "Record-change, schedule, time-relative and webhook triggers ... leave it absent". Once this lands, that is false for the schedule trigger. I edited it once, and `check-empty-changeset.mjs --base origin/main` refused the edit with exit 1: "Correcting a pending release note is a decision about a release". I restored it byte-identical to the merge base (blob `c5cbff2689`, both sides). This PR's own changeset now states the superseded sentence instead. If that note should be corrected as well, a person has to confirm it; the gate stays red on that edit by design. ## Time-relative trigger (dispatch item 3): measured, no defect, not changed This was a one-time probe, not committed. It fired the real `TimeRelativeTrigger` and passed its callback to the real engine. The run context had keys `record, object, event, params`, with `params === record` (the same object) and no `callerParamKeys`. A screen with required fields named after swept columns (`name`, `status`) paused at `screen_1`. An all-optional screen with the same names also paused after a `structuredClone` round trip. The inference's record leg already treats every key as a seed, because `params` is the record. So there is no defect and no change. ## Other producers (dispatch item 4): no finding The same probe ran two hand-built shapes on the real engine, each copied from its producer's code. The record-change shape (`params` is the same object as `record`, from `record-change-trigger.ts:499`) paused. The webhook shape (`record: payload, params: { ...payload }`, from `api-trigger.ts:142-143`) paused. Neither skips a screen on its own seeds, so I filed nothing. ## Tests (HEAD `eeed06a5b`) - `pnpm --filter @objectstack/trigger-schedule test`: 7 files, 150 tests passed. `typecheck` (`tsc --noEmit`) passed with exit 0, and `--listFiles` includes the new test file (7 test files in the program). - `@objectstack/service-automation`: `vitest run`, 144 files, 1725 tests passed. `typecheck` exit 0. - `@objectstack/spec`: `vitest run --project local`, 530 files, 15617 passed and 1 todo. `typecheck` exit 0 (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`). ## Ablation of the pin I committed the fix first, at `afde207`. Then `node scripts/ablation-replace.mjs --file packages/triggers/trigger-schedule/src/schedule-trigger.ts --anchor 'callerParamKeys: [],' --delete -- (the test run)`: - Mutation landed: anchor count 1 to 0; blob `5d67f8394deb` to `85d2e4c9054f`. - The test went red: 5 failed and 1 passed (the control). Failure output: `expected undefined to deeply equal []`, and four times `expected { success: true, …(4) } to match object { status: 'paused', …(1) }`. - Restored: the blob after restore is `5d67f8394deb`, the same as at HEAD, and `git diff HEAD` is empty. - The subject is imported as `./schedule-trigger.js` (source), not through a package export, so no `dist/` preflight applies. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 107 commands from the change set, with no paths passed. All 107 ran at `eeed06a5b`, each with its exit code recorded. The `--ran` reconciliation reads: 107 derived, 105 run, 2 NOT-MEASURED, 0 UNRUN. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Each exited 3 (PREREQUISITE NOT MET): both need every package built, and a turbo dry run shows 77 of 78 build tasks as cache misses. CI's lint job builds that closure before running them. - `check-plugin-teardown-shape --self-test` first exited 3, because its pinned fixture commit was missing from the shallow clone. I fetched that one commit, and the self-test passed on re-run (48 cases). - `check:skill-examples` first exited 3 because client-react was not built. I built `@objectstack/client` and `@objectstack/client-react`, and it passed on re-run (259 blocks). - ESLint, narrowed to the touched files: `eslint --no-inline-config --format json` over the 5 touched `.ts` files gave 5 files, 0 errors, 0 warnings. Population: the config's `files` globs are `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and the `packages/**` / `**/*.{ts,…}` blocks. The other two changed paths (`.md`, `.mdx`) match no `files` glob. Invariance: all 7 `parserOptions` blocks are `{ ecmaVersion, sourceType }` and none sets `project`, so there is no type-aware linting. The config reads only `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json` from disk, and this diff touches neither. So the diff cannot change the verdict on any untouched file. ## Acceptance notes - The webhook trigger passes its whole payload as both `record` and `params`. A screen in a webhook-started flow can therefore never be answered by that payload, because the inference reads every key as a record seed. That leans toward pausing (a lost skip, never a skipped screen). This is an observation, not filed. - The docblock in `packages/metadata-protocol/src/runtime-authoring-gate.ts:106-108` abbreviates the schedule context as `{ event: 'schedule', params: { jobId, flowName, schedule } }`. Its subject is `tenantId`, and it already omits the conditional `tenantId`. Left as it is. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fd3a4f commit ae7a35a

7 files changed

Lines changed: 212 additions & 9 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/trigger-schedule': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
A scheduled run no longer skips a screen whose field is named `schedule`, `jobId` or `flowName` (#19900).
7+
8+
The schedule trigger starts each run with three seeds of its own in `params` — `jobId`, `flowName` and `schedule` — and there is no caller behind the run. It stated nothing about that, so a `screen` node's headless verdict inferred who supplied each field from `params`, read those three seeds as the caller's answers, and continued past a screen whose field shared one of the names: the run completed with the trigger's value (for `schedule`, the cron descriptor) as the answer.
9+
10+
The trigger now sets `AutomationContext.callerParamKeys: []` — "the caller supplied nothing" — which the verdict reads instead of inferring. A screen in a scheduled flow pauses, whatever its fields are named. The three seeds stay in `params`; flows that read them are unaffected.
11+
12+
`@objectstack/spec`: the `callerParamKeys` TSDoc now names the schedule trigger as a producer that states the empty list, and no longer lists it among the producers that leave the key absent. No type changes.
13+
14+
This supersedes one sentence of the `callerParamKeys` entry (#19846): the schedule trigger no longer leaves the key absent. Record-change, time-relative and webhook triggers still do.

‎content/docs/automation/flows.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -957,9 +957,13 @@ a column named like one of the fields and including when a field is named like
957957
one of the seeded id keys. ⛔ `waitForInput: false` remains the wrong tool for
958958
the headless case: it skips the form for interactive users too.
959959

960-
A run started any other way — a record-change, schedule, time-relative or
961-
webhook trigger, a `subflow` or `map` child, or code calling the engine directly
962-
— carries no such list, and the screen falls back to inferring the caller's keys
960+
A scheduled run has no caller, and the schedule trigger states exactly that —
961+
an empty list — so its own `jobId`, `flowName` and `schedule` params never
962+
answer a screen field: a screen in a scheduled flow pauses.
963+
964+
A run started any other way — a record-change, time-relative or webhook
965+
trigger, a `subflow` or `map` child, or code calling the engine directly —
966+
carries no such list, and the screen falls back to inferring the caller's keys
963967
from the bag. That inference does not treat a key as the caller's when it is
964968
named `recordId` or `<object>Id`, when its value equals what the bag carries
965969
under `recordId` or `<object>Id` or the record's `id`, or when the subject

‎packages/services/service-automation/src/screen-input-contract.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,11 @@ export interface HeadlessScreenContext {
280280
*
281281
* ## Absent: the inference, kept for producers that state nothing
282282
*
283-
* A record-change, schedule, time-relative or webhook trigger, a `subflow` /
284-
* `map` child run, or code calling the engine directly carries no signal, and
285-
* for those the verdict below still infers — unchanged, and failing toward a
286-
* pause.
283+
* A record-change, time-relative or webhook trigger, a `subflow` / `map` child
284+
* run, or code calling the engine directly carries no signal, and for those the
285+
* verdict below still infers — unchanged, and failing toward a pause. (The
286+
* schedule trigger has no caller and states `[]`, #19900, so its `jobId` /
287+
* `flowName` / `schedule` seeds never reach the inference.)
287288
*
288289
* This distinction is the whole safety story of {@link judgeHeadlessScreen},
289290
* because the params bag a flow action reaches the engine with is NOT the

‎packages/spec/src/contracts/automation-context-caller-param-keys.pin.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
* not a bag of values, and not a boolean per key.
1414
* 2. **Additive.** A context literal without it still type-checks, so no
1515
* existing `IAutomationService.execute` caller moves, and an absent key is
16-
* what every producer other than the two doors sends.
16+
* what every producer other than the two doors and the schedule trigger
17+
* (which states `[]`, #19900) sends.
1718
* 3. **An empty list is a legal value** — "the caller supplied nothing" — and
1819
* it is a different value from an absent key.
1920
*

‎packages/spec/src/contracts/automation-service.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,13 @@ export interface AutomationContext {
173173
* a client that mirrors the row id into `params.recordId` is addressing the
174174
* row, not answering a question the flow asks.
175175
*
176+
* One producer with no caller at all states the empty list: the schedule
177+
* trigger (`ScheduleTrigger` in `@objectstack/trigger-schedule`, #19900).
178+
* Its `jobId`, `flowName` and `schedule` keys in {@link params} are its own
179+
* seeds, so none of them answers a question the flow asks.
180+
*
176181
* Absent means the producer does not state provenance: a record-change,
177-
* schedule, time-relative or webhook trigger, a `subflow` or `map` child run
182+
* time-relative or webhook trigger, a `subflow` or `map` child run
178183
* (those two nodes drop the parent's list from the child context, because it
179184
* describes the PARENT's bag), or code calling `execute` directly. Its
180185
* consumer, the `screen` node's headless verdict in
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19900] A scheduled run's platform seeds never answer a screen field.
5+
*
6+
* The schedule trigger starts a run with three seeds in `params` — `jobId`,
7+
* `flowName` and `schedule` — and there is no caller behind it at all. A
8+
* `screen` node on that headless run asks whether the caller already answered
9+
* each of its fields (`judgeHeadlessScreen` in `@objectstack/service-automation`).
10+
* With no statement from the producer, that answer is INFERRED from `params`,
11+
* and the inference can only rule out the row-id and record seeds the other
12+
* doors write: the schedule trigger's three names read as the caller's own
13+
* answers, so a screen with a required field named `schedule` was skipped and
14+
* the run completed with the cron descriptor as the "answer".
15+
*
16+
* The producer now states it: `callerParamKeys: []` — "the caller supplied
17+
* nothing", which the verdict reads instead of inferring (#19846).
18+
*
19+
* The rig is the one `schedule-runas-e2e.test.ts` uses: the REAL
20+
* `ScheduleTrigger` bound through the REAL `AutomationEngine`, the job fired by
21+
* hand in place of the cron tick. The result read is the one the engine
22+
* returned to the trigger's own callback — not a hand-made context.
23+
*
24+
* ⚠️ `@objectstack/service-automation` resolves to its `dist/` here (no alias:
25+
* `KNOWN_UNALIASED_TEST_IMPORTS` in `scripts/check-test-source-alias.mjs`), so
26+
* the engine half is the built one. The half this file pins — the context the
27+
* trigger builds — is `./schedule-trigger.js`, read from source.
28+
*/
29+
30+
import { describe, it, expect, vi } from 'vitest';
31+
import { AutomationEngine, installBuiltinNodes } from '@objectstack/service-automation';
32+
import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts';
33+
import { ScheduleTrigger } from './schedule-trigger.js';
34+
import { withScheduledWorkOn } from './deployment-switch.test-support.js';
35+
36+
function silentLogger(): any {
37+
const l: any = { info() {}, warn() {}, error() {}, debug() {} };
38+
l.child = () => l;
39+
return l;
40+
}
41+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
42+
43+
/** Fake IJobService slice with a deterministic `fire()` standing in for the cron tick. */
44+
function fakeJobService() {
45+
const jobs = new Map<string, { schedule: JobSchedule; handler: JobHandler }>();
46+
return {
47+
service: {
48+
async schedule(name: string, schedule: JobSchedule, handler: JobHandler) {
49+
jobs.set(name, { schedule, handler });
50+
},
51+
async cancel(name: string) {
52+
jobs.delete(name);
53+
},
54+
},
55+
has: (name: string) => jobs.has(name),
56+
fire: async (name: string, jobId = 'tick-1') => {
57+
await jobs.get(name)?.handler({ jobId } as never);
58+
},
59+
};
60+
}
61+
62+
type Field = { name: string; label: string; type: string; required?: boolean };
63+
64+
const CRON: JobSchedule = { type: 'cron', expression: '0 2 * * *' } as JobSchedule;
65+
66+
/**
67+
* `start → screen_1 → end` on a `type: 'schedule'` flow, each screen field an
68+
* `isInput` variable — so the seed of the same name IS bound into it, which is
69+
* the only way the skip could happen.
70+
*/
71+
function scheduledScreenFlow(name: string, fields: Field[]) {
72+
return {
73+
name,
74+
label: name,
75+
type: 'schedule',
76+
runAs: 'system',
77+
status: 'active',
78+
version: 1,
79+
variables: fields.map((f) => ({ name: f.name, type: 'text', isInput: true, isOutput: true })),
80+
nodes: [
81+
{ id: 'start', type: 'start', label: 'Start', config: { schedule: CRON } },
82+
{ id: 'screen_1', type: 'screen', label: 'Ask', config: { fields } },
83+
{ id: 'end', type: 'end', label: 'End' },
84+
],
85+
edges: [
86+
{ id: 'e1', source: 'start', target: 'screen_1', type: 'default' },
87+
{ id: 'e2', source: 'screen_1', target: 'end', type: 'default' },
88+
],
89+
} as never;
90+
}
91+
92+
/**
93+
* Bind the flow through the real trigger, fire one tick, and hand back what
94+
* the trigger passed to the engine and what the engine answered it with.
95+
*/
96+
async function fireOnce(flowName: string, fields: Field[]) {
97+
const engine = new AutomationEngine(silentLogger());
98+
installBuiltinNodes(engine, { logger: silentLogger(), getService: () => undefined } as never);
99+
const execute = vi.spyOn(engine, 'execute');
100+
engine.registerFlow(flowName, scheduledScreenFlow(flowName, fields));
101+
const job = fakeJobService();
102+
engine.registerTrigger(new ScheduleTrigger(() => job.service, silentLogger()));
103+
await flush(); // let ScheduleTrigger.start() schedule the job
104+
105+
const jobName = `flow-schedule:${flowName}`;
106+
expect(job.has(jobName), 'the flow was not bound to the schedule trigger').toBe(true);
107+
await job.fire(jobName, 'tick-42');
108+
109+
expect(execute).toHaveBeenCalledTimes(1);
110+
const context = execute.mock.calls[0][1] as AutomationContext;
111+
const result = await execute.mock.results[0].value;
112+
return { engine, context, result };
113+
}
114+
115+
// [#17396] The switch is OFF by default in every posture; nothing binds without it.
116+
withScheduledWorkOn('single');
117+
118+
describe('[#19900] a scheduled run\'s platform seeds never answer a screen field', () => {
119+
it('the trigger states that its run has no caller: `callerParamKeys` is an empty list', async () => {
120+
const { context } = await fireOnce('nightly_ask', [
121+
{ name: 'schedule', label: 'Schedule', type: 'text', required: true },
122+
]);
123+
// The seeds are still there — flows read them — they are just not a caller's.
124+
expect(context.params).toMatchObject({ jobId: 'tick-42', flowName: 'nightly_ask', schedule: CRON });
125+
expect(context.callerParamKeys).toEqual([]);
126+
});
127+
128+
it.each(['schedule', 'jobId', 'flowName'])(
129+
'a required screen field named `%s` pauses the run instead of taking the seed as its answer',
130+
async (seed) => {
131+
const { engine, result } = await fireOnce('nightly_ask', [
132+
{ name: seed, label: seed, type: 'text', required: true },
133+
]);
134+
// One object, so a skip prints what the run completed WITH.
135+
expect(result).toMatchObject({ status: 'paused', screen: { nodeId: 'screen_1' } });
136+
expect(engine.listSuspendedRuns()).toEqual([
137+
expect.objectContaining({ flowName: 'nightly_ask', nodeId: 'screen_1' }),
138+
]);
139+
},
140+
);
141+
142+
it('an all-optional screen named after every seed pauses too — the seeds do not "drive" it', async () => {
143+
const { result } = await fireOnce('nightly_ask', [
144+
{ name: 'schedule', label: 'Schedule', type: 'text' },
145+
{ name: 'jobId', label: 'Job', type: 'text' },
146+
{ name: 'flowName', label: 'Flow', type: 'text' },
147+
]);
148+
expect(result).toMatchObject({ status: 'paused', screen: { nodeId: 'screen_1' } });
149+
});
150+
151+
it('control: the same flow on the same engine continues when a context names `schedule` as a caller key', async () => {
152+
// The pause above is the signal's doing, not "a screen in a schedule flow
153+
// always pauses": a door that states real caller keys still answers it.
154+
const engine = new AutomationEngine(silentLogger());
155+
installBuiltinNodes(engine, { logger: silentLogger(), getService: () => undefined } as never);
156+
engine.registerFlow('nightly_ask', scheduledScreenFlow('nightly_ask', [
157+
{ name: 'schedule', label: 'Schedule', type: 'text', required: true },
158+
]));
159+
const result = await engine.execute('nightly_ask', {
160+
event: 'schedule',
161+
params: { jobId: 'tick-42', flowName: 'nightly_ask', schedule: '0 2 * * *' },
162+
callerParamKeys: ['schedule'],
163+
});
164+
expect(result.status).not.toBe('paused');
165+
expect(result.success).toBe(true);
166+
expect(result.output).toMatchObject({ schedule: '0 2 * * *' });
167+
expect(engine.listSuspendedRuns()).toEqual([]);
168+
});
169+
});

‎packages/triggers/trigger-schedule/src/schedule-trigger.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,15 @@ export class ScheduleTrigger implements FlowTrigger {
808808
flowName: binding.flowName,
809809
schedule,
810810
},
811+
// [#19900] A tick has no caller, so no key above is a
812+
// caller's answer — all three are this trigger's own seeds.
813+
// Stated rather than left to inference: a `screen` node's
814+
// headless verdict reads this list when present (#19846),
815+
// and without it inferred `jobId` / `flowName` / `schedule`
816+
// as supplied, skipping a screen whose field shared a name.
817+
// ⛔ An empty list, never an absent key — absent means
818+
// "not stated", and the inference answers that.
819+
callerParamKeys: [],
811820
};
812821
await callback(ctx);
813822
if (key) await this.settleDispatch(binding.flowName, key, 'succeeded');

0 commit comments

Comments
 (0)