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
22 changes: 22 additions & 0 deletions .changeset/31224-flow-start-config-refusals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/spec': minor
---

fix(spec)!: the flow parse refuses a start node's `config.filter` beside `config.timeRelative`, and a `config.schedule` the schedule readers cannot use

Clause-②: yes (narrowing) — `FlowSchema` refuses two start-node `config` shapes it accepted before, at every door that parses a flow; `@objectstack/spec/automation` also exports `flowStartConfigRefusals`, `flowStartScheduleReadsNothing` and the `FlowStartConfigRefusal` type.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` and `content/docs/**` teach `timeRelative.filter` inside the descriptor and typed `{ type: 'cron', expression }` schedules, which this accepts. The generated `skills/objectstack-automation/references/_index.md` gains the source files the flow closure now reaches (`system/job.zod.ts` and its imports).

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: FlowNodeSchema.config stays an open record, and the parse now refuses two shapes on it. The filter arm refuses an undeclared key on that open-record config: the published time-relative descriptor declares filter inside timeRelative and nowhere else, so filter beside timeRelative was never a declared shape and no protocol step migrates from it (the #4277 class). The schedule arm refuses values the published ScheduleSchema refuses, or that the schedule reader reads nothing from. Each refusal message names the cure at the refused key. -->

**BREAKING** accept-set narrowing of `FlowSchema` (every door that parses a flow: `defineStack`, `os validate` / `os build`, the metadata save door including drafts, publish, and the automation engine's registration).

- **`config.filter` beside `config.timeRelative`.** Before, it parsed and nothing read it: the time-relative sweep reads its row filter from `timeRelative.filter`, so the flow launched for every record in the date window. Now it is refused at `nodes[i].config.filter`, when `config.timeRelative` is an object (the start node the engine routes as a sweep); a `timeRelative: null` start node is not judged.
- **A `config.schedule` the readers cannot use.** Both readers (the schedule trigger, and the time-relative sweep's cadence) pass it through `normalizeSchedule`. When that reads nothing, a schedule flow stays unbound (one warn in the server log) and a sweep falls back to daily 08:00 UTC. Now, at `nodes[i].config.schedule`:
- an object carrying `type` is judged against the published `ScheduleSchema` (the discriminant, a cron `timezone` that is an IANA zone, a positive integer `intervalMs`, an ISO `at`), and then for being readable at all (a cron `expression` written as an envelope is not). The schema does not declare the aliases `cron` and `every` on a typed object, so the two typed aliases the reader happens to bind today, `{ type: 'cron', cron }` and `{ type: 'interval', every }`, are refused too, and the refusal names the declared spelling (`expression`, `intervalMs`). Neither occurs in objectstack or hotcrm;
- any other value is refused only when the reader reads nothing from it (an empty string, `{}`, `{ cadence: 'daily' }`, a number, an array, a non-positive interval);
- accepted as before: a bare cron string, and the untyped aliases the reader takes (`{ cron }`, `{ expression }`, `{ every }`, `{ intervalMs }`, `{ at }`).
- **One reading, pinned.** `flowStartScheduleReadsNothing` mirrors `normalizeSchedule` branch for branch, and `@objectstack/trigger-schedule` pins the two equal over every form either side distinguishes.

**What an author does.** Nothing for a sweep whose filter sits inside `timeRelative`, or a schedule that binds today in a form the published schema declares. For a refused shape, the parse refusal names where the key belongs, or the schedule spellings the readers take.
3 changes: 3 additions & 0 deletions packages/spec/api-surface/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
"FlowSchema (const)",
"FlowSlotRefusalCode (type)",
"FlowSlotRefusalParams (interface)",
"FlowStartConfigRefusal (interface)",
"FlowTextSlot (interface)",
"FlowTriggerKind (type)",
"FlowValueSlot (type)",
Expand Down Expand Up @@ -346,6 +347,8 @@
"flowNodeTextSlotSources (function)",
"flowNodeValueTemplateRefusals (function)",
"flowScreenFieldOptionKey (function)",
"flowStartConfigRefusals (function)",
"flowStartScheduleReadsNothing (function)",
"flowTextSlotSources (function)",
"getApprovalNodeConfigJsonSchema (function)",
"getBuiltinNodeConfigContracts (function)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)",
"FlowSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalCode (type)",
"FlowSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalParams (interface)",
"FlowStartConfigRefusal": "src/automation/flow-start-config-refusals.ts#FlowStartConfigRefusal (interface)",
"FlowTextSlot": "src/automation/flow-text-slot-template.ts#FlowTextSlot (interface)",
"FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)",
"FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)",
Expand Down Expand Up @@ -339,6 +340,8 @@
"flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)",
"flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)",
"flowScreenFieldOptionKey": "src/automation/flow-screen-option-key.ts#flowScreenFieldOptionKey (function)",
"flowStartConfigRefusals": "src/automation/flow-start-config-refusals.ts#flowStartConfigRefusals (function)",
"flowStartScheduleReadsNothing": "src/automation/flow-start-config-refusals.ts#flowStartScheduleReadsNothing (function)",
"flowTextSlotSources": "src/automation/flow-text-slot-template.ts#flowTextSlotSources (function)",
"getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)",
"getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)",
Expand Down
147 changes: 147 additions & 0 deletions packages/spec/src/automation/flow-start-config-refusals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import { FlowSchema } from './flow.zod';
import { flowStartConfigRefusals, flowStartScheduleReadsNothing } from './flow-start-config-refusals';

const flowWith = (config: Record<string, unknown>) => ({
name: 'start_config_probe',
label: 'Start Config Probe',
type: 'schedule',
runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

/** The parse's issues anchored on the start node's config. */
const startIssues = (config: Record<string, unknown>) => {
const parsed = FlowSchema.safeParse(flowWith(config));
if (parsed.success) return [];
return parsed.error.issues
.filter((i) => i.path[0] === 'nodes' && i.path[1] === 0 && i.path[2] === 'config')
.map((i) => ({ path: i.path.join('.'), message: i.message }));
};

const TIME_RELATIVE = {
object: 'contract',
dateField: 'end_date',
offsetDays: -30,
};

describe('flow start config — `filter` beside `timeRelative` (#31224 B1)', () => {
it('refuses it at the parse, naming where the filter belongs', () => {
const issues = startIssues({ triggerType: 'time_relative', timeRelative: TIME_RELATIVE, filter: { status: 'active' } });
expect(issues).toHaveLength(1);
expect(issues[0]!.path).toBe('nodes.0.config.filter');
expect(issues[0]!.message).toMatch(/nothing reads it/);
expect(issues[0]!.message).toMatch(/launches for every record in the date window/);
expect(issues[0]!.message).toMatch(/Move it into `config\.timeRelative\.filter`/);
});

it('accepts the filter inside `timeRelative`, and a `filter` on a start node with no `timeRelative`', () => {
expect(flowStartConfigRefusals(flowWith({ timeRelative: { ...TIME_RELATIVE, filter: { status: 'active' } } }))).toEqual([]);
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: '0 8 * * *', filter: { a: 1 } }))).toEqual([]);
});

it('judges no `filter` beside `timeRelative: null`: the trigger-kind resolver does not route that start node as a sweep', () => {
expect(flowStartConfigRefusals(flowWith({ timeRelative: null, filter: { status: 'active' } }))).toEqual([]);
expect(startIssues({ timeRelative: null, filter: { status: 'active' } })).toEqual([]);
});
});

describe('flow start config — `schedule` (#31224 B1, ruling option (a))', () => {
it('accepts the bare cron string, the three typed forms, and the untyped aliases the reader takes', () => {
for (const schedule of [
'0 8 * * *',
{ type: 'cron', expression: '0 8 * * *' },
{ type: 'cron', expression: '0 8 * * *', timezone: 'America/New_York' },
{ type: 'interval', intervalMs: 60000 },
{ type: 'once', at: '2027-01-01T08:00:00Z' },
{ cron: '0 8 * * *' },
{ expression: '0 8 * * *' },
{ every: 60000 },
{ intervalMs: 60000 },
{ at: '2027-01-01T08:00:00Z' },
]) {
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })), JSON.stringify(schedule)).toEqual([]);
}
});

it('refuses a typed cron whose timezone is not an IANA zone, at the parse', () => {
const issues = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'UTC+8' } });
expect(issues).toHaveLength(1);
expect(issues[0]!.path).toBe('nodes.0.config.schedule');
expect(issues[0]!.message).toMatch(/is not a schedule at `schedule\.timezone`/);
expect(issues[0]!.message).toMatch(/a schedule flow stays unbound/);
});

it('refuses a typed form the published ScheduleSchema refuses (unknown type, non-positive interval, no expression)', () => {
for (const schedule of [
{ type: 'hourly' },
{ type: 'interval', intervalMs: 0 },
{ type: 'cron' },
{ type: 'once', at: 'tomorrow' },
]) {
const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule }));
expect(found.map((f) => f.path.join('.')), JSON.stringify(schedule)).toEqual(['nodes.0.config.schedule']);
}
});

it('refuses an untyped value the reader reads nothing from, and an empty string', () => {
for (const schedule of [{ cadence: 'daily' }, {}, [], 42, true, ' ', { every: -5 }]) {
const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule }));
expect(found, JSON.stringify(schedule)).toHaveLength(1);
expect(found[0]!.message).toMatch(/schedule flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC/);
}
});

it('refuses the typed aliases the published ScheduleSchema does not declare, naming the declared spelling', () => {
const cron = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', cron: '0 8 * * *' } });
expect(cron).toHaveLength(1);
expect(cron[0]!.path).toBe('nodes.0.config.schedule');
expect(cron[0]!.message).toMatch(/is not a schedule at `schedule\.expression`/);
expect(cron[0]!.message).toMatch(/names its cron in `expression`, not `cron`/);

const every = startIssues({ triggerType: 'schedule', schedule: { type: 'interval', every: 60000 } });
expect(every).toHaveLength(1);
expect(every[0]!.path).toBe('nodes.0.config.schedule');
expect(every[0]!.message).toMatch(/is not a schedule at `schedule\.intervalMs`/);
expect(every[0]!.message).toMatch(/names its period in `intervalMs`, not `every`/);

// Without the alias key, no alias hint.
const bare = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: { type: 'cron' } }));
expect(bare[0]!.message).not.toMatch(/not `cron`/);
});

it('refuses a typed cron whose expression is an envelope: it passes the schema, and the reader reads nothing', () => {
const found = flowStartConfigRefusals(flowWith({
triggerType: 'schedule',
schedule: { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } },
}));
expect(found).toHaveLength(1);
expect(found[0]!.message).toMatch(/names no schedule the readers can use/);
});

it('judges no absent or null schedule', () => {
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: null }))).toEqual([]);
expect(flowStartConfigRefusals(flowWith({ triggerType: 'manual' }))).toEqual([]);
});

it('reads only top-level start nodes', () => {
const flow = flowWith({ triggerType: 'manual' }) as any;
flow.nodes.splice(1, 0, { id: 'mid', type: 'assignment', label: 'Mid', config: { schedule: {} } });
expect(flowStartConfigRefusals(flow)).toEqual([]);
});
});

describe('flowStartScheduleReadsNothing — the reader\'s precedence', () => {
it('the first candidate present decides, as `normalizeSchedule` does', () => {
// `intervalMs` is present and unusable, so the reader never reaches `every`.
expect(flowStartScheduleReadsNothing({ intervalMs: -1, every: 60000 })).toBe(true);
expect(flowStartScheduleReadsNothing({ expression: '', cron: '0 8 * * *' })).toBe(false);
expect(flowStartScheduleReadsNothing(undefined)).toBe(false);
});
});
Loading
Loading