From 532b289f434b6d945b8c5c09cd59d6fdfc261eeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 23:34:45 +0000 Subject: [PATCH 1/2] fix(spec)!: refuse a start-node filter beside timeRelative and a schedule the readers cannot use A start node's config is an open record, so the flow parse never judged two shapes that then do nothing: a `filter` beside `timeRelative` (the sweep reads `timeRelative.filter`, so the flow launches for every record in the window), and a `schedule` normalizeSchedule reads nothing from (a schedule flow stays unbound, a sweep falls back to daily 08:00 UTC). FlowSchema now refuses both, through flowStartConfigRefusals: a typed schedule object is judged against the published ScheduleSchema and then for being readable; any other value only when the reader reads nothing, so the bare cron string and the untyped aliases that bind today stay accepted. flowStartScheduleReadsNothing mirrors normalizeSchedule branch for branch, and trigger-schedule pins the two equal. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV --- .../31224-flow-start-config-refusals.md | 22 +++ packages/spec/api-surface/automation.json | 3 + packages/spec/export-origins/automation.json | 3 + .../flow-start-config-refusals.test.ts | 124 ++++++++++++++++ .../automation/flow-start-config-refusals.ts | 132 ++++++++++++++++++ packages/spec/src/automation/flow.zod.ts | 10 ++ packages/spec/src/automation/index.ts | 1 + .../src/schedule-reader-spec-parity.test.ts | 38 +++++ .../references/_index.md | 4 + 9 files changed, 337 insertions(+) create mode 100644 .changeset/31224-flow-start-config-refusals.md create mode 100644 packages/spec/src/automation/flow-start-config-refusals.test.ts create mode 100644 packages/spec/src/automation/flow-start-config-refusals.ts create mode 100644 packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts diff --git a/.changeset/31224-flow-start-config-refusals.md b/.changeset/31224-flow-start-config-refusals.md new file mode 100644 index 00000000000..d6c5f24a576 --- /dev/null +++ b/.changeset/31224-flow-start-config-refusals.md @@ -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). + + + +**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`. **FROM** `config: { timeRelative: { … }, filter: { status: 'active' } }` **TO** `config: { timeRelative: { …, filter: { status: 'active' } } }`. +- **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); + - 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. For a refused shape: move the filter into `timeRelative.filter`; write the schedule as a cron string or one of `{ type: 'cron', expression, timezone? }`, `{ type: 'interval', intervalMs }`, `{ type: 'once', at }`. diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index b11e147d815..7a7b47ae248 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -201,6 +201,7 @@ "FlowSchema (const)", "FlowSlotRefusalCode (type)", "FlowSlotRefusalParams (interface)", + "FlowStartConfigRefusal (interface)", "FlowTextSlot (interface)", "FlowTriggerKind (type)", "FlowValueSlot (type)", @@ -346,6 +347,8 @@ "flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey (function)", + "flowStartConfigRefusals (function)", + "flowStartScheduleReadsNothing (function)", "flowTextSlotSources (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4a6d1d8853e..740a3e6ff88 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -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)", @@ -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)", diff --git a/packages/spec/src/automation/flow-start-config-refusals.test.ts b/packages/spec/src/automation/flow-start-config-refusals.test.ts new file mode 100644 index 00000000000..4741700335e --- /dev/null +++ b/packages/spec/src/automation/flow-start-config-refusals.test.ts @@ -0,0 +1,124 @@ +// 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) => ({ + 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) => { + 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([]); + }); +}); + +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 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); + }); +}); diff --git a/packages/spec/src/automation/flow-start-config-refusals.ts b/packages/spec/src/automation/flow-start-config-refusals.ts new file mode 100644 index 00000000000..ec1a04bafe1 --- /dev/null +++ b/packages/spec/src/automation/flow-start-config-refusals.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ScheduleSchema } from '../system/job.zod'; + +/** + * Two start-node `config` shapes that parse and then do nothing (#31224 item + * B, ruling B1 option (a)). + * + * A flow's start node `config` is an open record (`FlowNodeSchema.config`), so + * the parse never judged these two keys, and the readers that do read them + * skip what they cannot use without telling the author: + * + * - **`config.filter` beside `config.timeRelative`.** A time-relative sweep + * reads its row filter from `timeRelative.filter` (`time-relative-trigger.ts` + * in `@objectstack/trigger-schedule`); nothing reads a `filter` on the start + * node's own `config`, so a sweep written that way launches the flow for + * every row in the date window. Refused with the move as the prescription. + * + * - **A `config.schedule` the schedule readers cannot use.** Both readers + * (`ScheduleTrigger` for a schedule flow, `TimeRelativeTrigger` for a sweep's + * cadence) pass it through `normalizeSchedule` (`schedule-trigger.ts`). A + * descriptor it reads nothing from leaves a schedule flow unbound (one warn + * in the server log) and a sweep on its default daily 08:00 UTC cadence, + * either way not what was written. Three readings, one per form: + * - a bare cron STRING is not judged here (the reader takes it verbatim); + * - an object that carries `type` is judged against the published + * {@link ScheduleSchema} (the discriminant, a cron `timezone` that is a + * member of `iana_time_zone`, a positive integer `intervalMs`, an ISO + * `at`), and then, as an untyped one is, for being readable at all; + * - any other value is refused only when the reader reads nothing from it + * ({@link flowStartScheduleReadsNothing}). The untyped aliases the + * reader accepts (`{ cron }`, `{ expression }`, `{ every }`, + * `{ intervalMs }`, `{ at }`) run today and stay accepted. + * + * Top-level `start` nodes only: a start node is never a region member. + */ + +export interface FlowStartConfigRefusal { + readonly path: ReadonlyArray; + readonly message: string; +} + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * `true` when the flow schedule readers read NOTHING from `raw`: the exact + * complement of what `normalizeSchedule` (`@objectstack/trigger-schedule`) + * turns into a job schedule, which pins the two together over a table of + * shapes. `null` / `undefined` read as "not declared" and answer `false`: an + * absent schedule is not a written one. + */ +export function flowStartScheduleReadsNothing(raw: unknown): boolean { + if (raw == null) return false; + if (typeof raw === 'string') return raw.trim().length === 0; + if (typeof raw !== 'object') return true; + const s = raw as Record; + const type = typeof s.type === 'string' ? s.type : undefined; + // Each branch is `normalizeSchedule`'s, step for step (truthiness included): + // the first candidate present decides, and a decided value that is not + // usable reads nothing even when a later alias would have been. + if (type === 'cron' || (!type && (typeof s.cron === 'string' || typeof s.expression === 'string'))) { + const expression = + (typeof s.expression === 'string' && s.expression) || (typeof s.cron === 'string' && s.cron) || undefined; + return !expression; + } + if (type === 'interval' || (!type && (typeof s.intervalMs === 'number' || typeof s.every === 'number'))) { + const intervalMs = + (typeof s.intervalMs === 'number' && s.intervalMs) || (typeof s.every === 'number' && s.every) || undefined; + return !intervalMs || intervalMs <= 0; + } + if (type === 'once' || (!type && typeof s.at === 'string')) { + return !(typeof s.at === 'string' && s.at); + } + return true; +} + +function scheduleRefusal(raw: unknown): string | undefined { + if (raw == null || typeof raw === 'string') { + return typeof raw === 'string' && flowStartScheduleReadsNothing(raw) + ? 'The start node\'s `config.schedule` is an empty string, so no schedule is read from it: a schedule ' + + 'flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC. Write a cron expression ' + + "(e.g. '0 8 * * *') or remove the key." + : undefined; + } + if (isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, 'type')) { + const parsed = ScheduleSchema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const at = issue && issue.path.length > 0 ? ` at \`schedule.${issue.path.join('.')}\`` : ''; + return ( + `The start node's \`config.schedule\` is not a schedule${at}: ${issue?.message ?? 'refused'}. The schedule ` + + 'readers cannot use it, so a schedule flow stays unbound and a time-relative sweep falls back to daily ' + + "08:00 UTC. Write `{ type: 'cron', expression: '0 8 * * *', timezone: 'UTC' }`, " + + "`{ type: 'interval', intervalMs: 60000 }` or `{ type: 'once', at: '' }`, or a bare cron string." + ); + } + } + if (!flowStartScheduleReadsNothing(raw)) return undefined; + return ( + 'The start node\'s `config.schedule` names no schedule the readers can use (a cron `expression` as a ' + + 'string, a positive `intervalMs`, or an `at` datetime), so a schedule flow stays unbound and a ' + + 'time-relative sweep falls back to daily 08:00 UTC. Write a cron string such as \'0 8 * * *\', or ' + + "`{ type: 'cron', expression: '0 8 * * *' }`, `{ type: 'interval', intervalMs: 60000 }` or " + + "`{ type: 'once', at: '' }`." + ); +} + +/** The refusals of every top-level start node's `config` in `flow`. */ +export function flowStartConfigRefusals(flow: unknown): FlowStartConfigRefusal[] { + if (!isRecord(flow) || !Array.isArray(flow.nodes)) return []; + const out: FlowStartConfigRefusal[] = []; + flow.nodes.forEach((node, index) => { + if (!isRecord(node) || node.type !== 'start' || !isRecord(node.config)) return; + const config = node.config; + if (config.timeRelative !== undefined && Object.prototype.hasOwnProperty.call(config, 'filter')) { + out.push({ + path: ['nodes', index, 'config', 'filter'], + message: + 'The start node declares `config.filter` beside `config.timeRelative`, and nothing reads it: a ' + + 'time-relative sweep takes its row filter from `timeRelative.filter`, so as written the flow ' + + 'launches for every record in the date window. Move it into `config.timeRelative.filter`.', + }); + } + if (Object.prototype.hasOwnProperty.call(config, 'schedule')) { + const message = scheduleRefusal(config.schedule); + if (message) out.push({ path: ['nodes', index, 'config', 'schedule'], message }); + } + }); + return out; +} diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 3219078af4c..dbce9c700ee 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -28,6 +28,7 @@ import { predicateSlotRefusal, resolveFlowNodeExpressions, type FlowNodeSiblingB import { flowNodeConfigRefusals } from './flow-node-config-refusals'; import { apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; import { apiTriggerSignatureRefusals } from './api-trigger-signature.zod'; +import { flowStartConfigRefusals } from './flow-start-config-refusals'; import { saveNoticePlacementRefusals } from './save-notice-placement'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one // table, shared with the two D2 lifts that still run at rehydration. @@ -1704,6 +1705,15 @@ export const FlowSchema = lazySchema(() => strictObject( ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); } + // [#31224 B1] Two start-node `config` shapes that parse and then do nothing: + // a `filter` beside `timeRelative` (the sweep reads `timeRelative.filter`), + // and a `schedule` the schedule readers cannot use. Why each, and why the + // untyped aliases the reader accepts stay accepted, is + // `flow-start-config-refusals.ts`'s header. + for (const refusal of flowStartConfigRefusals(flow)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + // [#28251] A `save_notice` node answers the write that fired the run, so it // stands only in a flow a create or update record trigger starts — anywhere // else no write waits on the run. Refused at each such node, at any region diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index a64470e56d0..d4033e6e477 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -48,6 +48,7 @@ export * from './time-relative-trigger.zod'; export * from './flow-trigger-kind'; // An `api` flow's hook secret named by reference — the deployment fills it. export * from './api-trigger-secret-ref.zod'; +export * from './flow-start-config-refusals'; // A third-party sender's signature scheme on an `api` hook, keyed by `secretRef`. export * from './api-trigger-signature.zod'; // The acting-organization declaration a time-triggered flow carries, and the diff --git a/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts b/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts new file mode 100644 index 00000000000..6fcda19808c --- /dev/null +++ b/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { flowStartScheduleReadsNothing } from '@objectstack/spec/automation'; +import { normalizeSchedule } from './schedule-trigger.js'; + +/** + * [#31224 B1] The flow parse refuses a start-node `config.schedule` this + * reader reads nothing from (`flowStartConfigRefusals`, `@objectstack/spec`), + * through a predicate that mirrors {@link normalizeSchedule} branch for + * branch. If the two drift, the parse either refuses a schedule that runs or + * accepts one that never binds, so they are pinned together here, over every + * form either side distinguishes. + */ +const SHAPES: unknown[] = [ + '0 8 * * *', '', ' ', + { type: 'cron', expression: '0 8 * * *' }, { type: 'cron', expression: '' }, { type: 'cron' }, + { type: 'cron', cron: '0 8 * * *' }, { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } }, + { cron: '0 8 * * *' }, { expression: '0 8 * * *' }, { expression: '', cron: '0 8 * * *' }, { cron: '' }, + { type: 'interval', intervalMs: 60000 }, { type: 'interval', intervalMs: 0 }, { type: 'interval', intervalMs: -1 }, + { type: 'interval', every: 60000 }, { type: 'interval' }, { intervalMs: 60000 }, { every: 60000 }, + { intervalMs: -1, every: 60000 }, { intervalMs: 0, every: 60000 }, { every: Number.NaN }, + { type: 'once', at: '2027-01-01T08:00:00Z' }, { type: 'once', at: '' }, { type: 'once' }, { at: '2027-01-01T08:00:00Z' }, + { type: 'hourly' }, { type: 7, cron: '0 8 * * *' }, { cadence: 'daily' }, {}, [], 42, true, +]; + +describe('flowStartScheduleReadsNothing ⇔ normalizeSchedule(x) === null', () => { + for (const shape of SHAPES) { + it(JSON.stringify(shape) ?? String(shape), () => { + expect(flowStartScheduleReadsNothing(shape)).toBe(normalizeSchedule(shape) === null); + }); + } + + it('an absent schedule is not a written one: neither side binds it, and the parse does not refuse it', () => { + expect(normalizeSchedule(undefined)).toBeNull(); + expect(flowStartScheduleReadsNothing(undefined)).toBe(false); + }); +}); diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index c4775792008..d273230fa9c 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -24,9 +24,11 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference. - `node_modules/@objectstack/spec/src/automation/api-trigger-signature.zod.ts` — An inbound `api` hook's verifier for a THIRD-PARTY sender, declared. - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** +- `node_modules/@objectstack/spec/src/automation/schedule-organization.zod.ts` — The ACTING ORGANIZATION of a time-triggered flow — the one start-node key - `node_modules/@objectstack/spec/src/automation/schemaless-node-config.zod.ts` — Config contracts for the **descriptor-schemaless** builtins whose designer - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the - `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes +- `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol @@ -34,6 +36,8 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/retry-policy.zod.ts` — The **single declaration** of the exponential-backoff retry policy. - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities +- `node_modules/@objectstack/spec/src/shared/value-domain.zod.ts` — Standard value domains: one closed vocabulary and one membership predicate for settings and fields. +- `node_modules/@objectstack/spec/src/system/job.zod.ts` — Exports: CronScheduleSchema, IntervalScheduleSchema, OnceScheduleSchema, ScheduleSchema, JobSchema ## How to read these From bdc95ace88d92c914d25a04dba2cd2725ac1baab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 05:23:40 +0000 Subject: [PATCH 2/2] fix(spec): narrow the start-node sweep guard, name typed schedule aliases, restate the ADR-0087 reason The filter refusal now fires only when `config.timeRelative` is a non-null object, the test the trigger-kind resolver routes a sweep by. A typed `{ type: 'cron', cron }` or `{ type: 'interval', every }` schedule is refused with the declared spelling named (`expression`, `intervalMs`). The changeset's ADR-0087 reason says what is true of each arm, and the release note no longer carries a FROM/TO rewrite: the parse refusal names where the key belongs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV --- .../31224-flow-start-config-refusals.md | 8 ++--- .../flow-start-config-refusals.test.ts | 23 +++++++++++++ .../automation/flow-start-config-refusals.ts | 34 +++++++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.changeset/31224-flow-start-config-refusals.md b/.changeset/31224-flow-start-config-refusals.md index d6c5f24a576..0e57b0d64af 100644 --- a/.changeset/31224-flow-start-config-refusals.md +++ b/.changeset/31224-flow-start-config-refusals.md @@ -8,15 +8,15 @@ Clause-②: yes (narrowing) — `FlowSchema` refuses two start-node `config` sha 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). - + **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`. **FROM** `config: { timeRelative: { … }, filter: { status: 'active' } }` **TO** `config: { timeRelative: { …, filter: { status: 'active' } } }`. +- **`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); + - 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. For a refused shape: move the filter into `timeRelative.filter`; write the schedule as a cron string or one of `{ type: 'cron', expression, timezone? }`, `{ type: 'interval', intervalMs }`, `{ type: 'once', at }`. +**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. diff --git a/packages/spec/src/automation/flow-start-config-refusals.test.ts b/packages/spec/src/automation/flow-start-config-refusals.test.ts index 4741700335e..d6501fc7636 100644 --- a/packages/spec/src/automation/flow-start-config-refusals.test.ts +++ b/packages/spec/src/automation/flow-start-config-refusals.test.ts @@ -45,6 +45,11 @@ describe('flow start config — `filter` beside `timeRelative` (#31224 B1)', () 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))', () => { @@ -93,6 +98,24 @@ describe('flow start config — `schedule` (#31224 B1, ruling option (a))', () = } }); + 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', diff --git a/packages/spec/src/automation/flow-start-config-refusals.ts b/packages/spec/src/automation/flow-start-config-refusals.ts index ec1a04bafe1..837ceab7107 100644 --- a/packages/spec/src/automation/flow-start-config-refusals.ts +++ b/packages/spec/src/automation/flow-start-config-refusals.ts @@ -26,7 +26,11 @@ import { ScheduleSchema } from '../system/job.zod'; * - an object that carries `type` is judged against the published * {@link ScheduleSchema} (the discriminant, a cron `timezone` that is a * member of `iana_time_zone`, a positive integer `intervalMs`, an ISO - * `at`), and then, as an untyped one is, for being readable at all; + * `at`), and then, as an untyped one is, for being readable at all. + * By ruling, the typed aliases the reader happens to bind + * (`{ type: 'cron', cron }`, `{ type: 'interval', every }`) are refused + * too, because the published schema does not declare those keys; the + * refusal names the declared spelling (`expression`, `intervalMs`); * - any other value is refused only when the reader reads nothing from it * ({@link flowStartScheduleReadsNothing}). The untyped aliases the * reader accepts (`{ cron }`, `{ expression }`, `{ every }`, @@ -76,6 +80,24 @@ export function flowStartScheduleReadsNothing(raw: unknown): boolean { return true; } +/** + * The key a typed schedule spells another way. `normalizeSchedule` reads the + * untyped aliases `cron` and `every` inside its typed branches too, but the + * published {@link ScheduleSchema} declares only `expression` and + * `intervalMs` on a typed object (ruling 6717636410 §2), so the refusal names + * the declared spelling. + */ +function typedAliasHint(raw: Record): string { + const has = (k: string) => Object.prototype.hasOwnProperty.call(raw, k); + if (raw.type === 'cron' && has('cron') && !has('expression')) { + return " A typed cron schedule names its cron in `expression`, not `cron`: write `{ type: 'cron', expression: '…' }`."; + } + if (raw.type === 'interval' && has('every') && !has('intervalMs')) { + return " A typed interval schedule names its period in `intervalMs`, not `every`: write `{ type: 'interval', intervalMs: … }`."; + } + return ''; +} + function scheduleRefusal(raw: unknown): string | undefined { if (raw == null || typeof raw === 'string') { return typeof raw === 'string' && flowStartScheduleReadsNothing(raw) @@ -90,7 +112,9 @@ function scheduleRefusal(raw: unknown): string | undefined { const issue = parsed.error.issues[0]; const at = issue && issue.path.length > 0 ? ` at \`schedule.${issue.path.join('.')}\`` : ''; return ( - `The start node's \`config.schedule\` is not a schedule${at}: ${issue?.message ?? 'refused'}. The schedule ` + + `The start node's \`config.schedule\` is not a schedule${at}: ${issue?.message ?? 'refused'}.` + + typedAliasHint(raw) + + ' The schedule ' + 'readers cannot use it, so a schedule flow stays unbound and a time-relative sweep falls back to daily ' + "08:00 UTC. Write `{ type: 'cron', expression: '0 8 * * *', timezone: 'UTC' }`, " + "`{ type: 'interval', intervalMs: 60000 }` or `{ type: 'once', at: '' }`, or a bare cron string." @@ -114,7 +138,11 @@ export function flowStartConfigRefusals(flow: unknown): FlowStartConfigRefusal[] flow.nodes.forEach((node, index) => { if (!isRecord(node) || node.type !== 'start' || !isRecord(node.config)) return; const config = node.config; - if (config.timeRelative !== undefined && Object.prototype.hasOwnProperty.call(config, 'filter')) { + // A sweep is a start node whose `timeRelative` is a non-null object, the + // exact test the trigger-kind resolver (`flow-trigger-kind.ts`) routes by, + // so `timeRelative: null` is not judged as a sweep. + const isSweep = config.timeRelative != null && typeof config.timeRelative === 'object'; + if (isSweep && Object.prototype.hasOwnProperty.call(config, 'filter')) { out.push({ path: ['nodes', index, 'config', 'filter'], message: