Skip to content

Commit 83c8cae

Browse files
os-devclaude
andcommitted
fix(lint): report a config.timeRelative descriptor the sweep will refuse (#5496)
A flow start node declaring `config.timeRelative` got zero authoring-time diagnostics when the descriptor could not parse. The two rules that look at the slot each looked at something else: `lint-flow-patterns` decides "time-relative flow" from `timeRelative != null` alone, never the shape, and `validate-flow-trigger-readiness`'s existing check reads only `timeRelative.object`, to compare it against the stack's objects. `TimeRelativeTriggerSchema` does reject a bad descriptor, but the only place it ran was BIND time, inside `TimeRelativeTriggerPlugin.start()`, which warns and returns: the sweep is never installed, the flow reports itself armed, and the author's sole feedback is one line in a server log — outside an AI author's feedback loop entirely. New rule `flow-time-relative-descriptor-invalid` (warning) runs that same schema at authoring time and forwards its issue list verbatim, rendered exactly as the bind-time warning renders it. No shape knowledge is re-implemented and no consumer-side tolerance is added: the verdict and its wording stay the schema's, so the rule tracks the descriptor's contract instead of drifting from a second copy of it. The new rule and the existing object-name check decide different facts and cannot report the same one twice — only the stack knows whether an object name exists, only the schema knows the shape. Its guard mirrors the engine's routing predicate character for character, so the rule speaks for exactly the flows the engine hands to the time-relative trigger. Verified: every time-relative descriptor shipped in the repo parses, and `os validate` output on all three example apps is identical before and after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
1 parent e6b1bb0 commit 83c8cae

4 files changed

Lines changed: 345 additions & 6 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): report a `config.timeRelative` descriptor the sweep will refuse, at authoring time (#5496)
6+
7+
A flow start node declaring `config.timeRelative` got **zero** authoring-time
8+
diagnostics when its descriptor could not parse. The two rules that look at the
9+
slot each looked at something else: `lint-flow-patterns` decides "this is a
10+
time-relative flow" from `timeRelative != null` alone (never the shape), and
11+
`validate-flow-trigger-readiness`'s existing check reads only
12+
`timeRelative.object`, to compare it against the stack's objects. So
13+
14+
```ts
15+
config: { timeRelative: { object: 'task', field: 'due_at', offsetDays: -1 } }
16+
```
17+
18+
— three separate schema violations: `dateField` missing, `offsetDays` declared
19+
as an int **array** and written as a scalar, and `field` an unrecognized key —
20+
passed `os validate` silently. `TimeRelativeTriggerSchema` does reject it, but
21+
the only place that schema ran was **bind time**, inside
22+
`TimeRelativeTriggerPlugin.start()`, which warns and returns: the sweep is never
23+
installed, the flow reports itself armed, and the author's sole feedback is one
24+
line in a server log. For an AI author that line is outside the feedback loop
25+
entirely; `os validate` is what it reads.
26+
27+
**New rule — `flow-time-relative-descriptor-invalid` (warning).** A start node
28+
whose `config.timeRelative` is present runs that same schema at authoring time,
29+
and a failure is reported naming `config.timeRelative` with the schema's own
30+
issue list forwarded — so the diagnostic carries the missing key, the wrong type,
31+
and, for an unrecognized key, the "did you mean" the schema already computes
32+
(`field``dateField`) plus its wrong-layer guidance (a `schedule` written
33+
*inside* the descriptor is told it belongs beside it). The list is rendered
34+
exactly as the bind-time warning renders it, so the two channels tell one story.
35+
36+
Nothing is shifted except **when** the schema runs. No shape knowledge is
37+
re-implemented in the rule and no consumer-side tolerance is added: the verdict
38+
and every word of its wording remain `TimeRelativeTriggerSchema`'s, so the rule
39+
tracks the descriptor's contract as it evolves instead of drifting from a second
40+
copy of it.
41+
42+
The rule and the existing object-name check decide different facts and cannot
43+
report the same one twice — only the stack knows whether an object name exists,
44+
and only the schema knows the descriptor's shape. A descriptor wrong in both ways
45+
gets both findings, at their own paths. Canonical descriptors are unaffected:
46+
every one shipped in the repo (the showcase `Task Due Reminder`, the
47+
`content/docs` examples) parses, so this adds no diagnostic to existing apps.

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export {
5353
validateFlowTriggerReadiness,
5454
FLOW_TRIGGER_UNKNOWN_OBJECT,
5555
FLOW_DRAFT_STATUS_AMBIGUOUS,
56+
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
5657
} from './validate-flow-trigger-readiness.js';
5758
export type {
5859
FlowTriggerReadinessFinding,

packages/lint/src/validate-flow-trigger-readiness.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { TimeRelativeTriggerSchema } from '@objectstack/spec/automation';
45
import {
56
validateFlowTriggerReadiness,
67
FLOW_TRIGGER_UNKNOWN_OBJECT,
78
FLOW_DRAFT_STATUS_AMBIGUOUS,
89
FLOW_TRIGGER_UNKNOWN_EVENT,
10+
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
911
} from './validate-flow-trigger-readiness.js';
1012

1113
function recordFlow(overrides: Record<string, unknown> = {}) {
@@ -174,6 +176,207 @@ describe('validateFlowTriggerReadiness', () => {
174176
expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_OBJECT);
175177
expect(findings[0].message).toContain("'contract'");
176178
expect(findings[0].path).toBe('flows[0].nodes[0].config.timeRelative.object');
179+
// The SHAPE is canonical, so the descriptor rule stays out of it — the two
180+
// halves of 1b decide different facts and must not both fire on one.
181+
expect(TimeRelativeTriggerSchema.safeParse({
182+
object: 'contract', dateField: 'end_date', withinDays: 60,
183+
}).success).toBe(true);
184+
expect(findings.some((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID)).toBe(false);
185+
});
186+
187+
// ── #5496 — the descriptor's SHAPE ────────────────────────────────────────
188+
//
189+
// `TimeRelativeTriggerSchema` is the only thing that can judge a
190+
// `config.timeRelative` descriptor (the node `config` slot is open by design,
191+
// ADR-0018, so no outer flow gate sees inside it), and until this rule the only
192+
// place it ran was BIND time — one warn in a server log, nothing in
193+
// `os validate`. These tests pin the forwarding, not a second copy of the
194+
// shape: where a message is asserted it is asserted against what the schema
195+
// itself produces, so the rule cannot drift from the contract it speaks for.
196+
describe('config.timeRelative descriptor shape (#5496)', () => {
197+
/** The stack from the issue: `task` EXISTS, flow is active and runs as system. */
198+
function timeRelativeStack(timeRelative: unknown, objectName = 'task') {
199+
return {
200+
objects: [{ name: objectName, label: 'Task', fields: {} }],
201+
flows: [
202+
{
203+
name: 'task_due_reminder',
204+
type: 'schedule',
205+
status: 'active',
206+
runAs: 'system',
207+
nodes: [
208+
{ id: 'start', type: 'start', config: { timeRelative } },
209+
{ id: 'end', type: 'end' },
210+
],
211+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
212+
},
213+
],
214+
};
215+
}
216+
217+
/** The exact descriptor #5496 was filed for — three separate zod issues. */
218+
const badDescriptor = { object: 'task', field: 'due_at', offsetDays: -1 };
219+
220+
it('flags the descriptor from #5496 and names every key zod named', () => {
221+
const findings = validateFlowTriggerReadiness(timeRelativeStack(badDescriptor));
222+
expect(findings).toHaveLength(1);
223+
const [f] = findings;
224+
expect(f.rule).toBe(FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID);
225+
expect(f.severity).toBe('warning');
226+
// Criterion 1: the finding NAMES config.timeRelative, in both channels the
227+
// CLI prints (`• where: message` then `at path`).
228+
expect(f.path).toBe('flows[0].nodes[0].config.timeRelative');
229+
expect(f.message).toContain('config.timeRelative');
230+
expect(f.where).toBe('flow "task_due_reminder" › start node');
231+
// …and carries zod's own key names: the missing `dateField`, the scalar
232+
// `offsetDays`, and the unrecognized `field` with the schema's suggestion.
233+
expect(f.message).toContain('dateField: Invalid input: expected string, received undefined');
234+
expect(f.message).toContain('offsetDays: Invalid input: expected array, received number');
235+
expect(f.message).toContain('Unrecognized key(s)');
236+
expect(f.message).toContain('`field`');
237+
expect(f.message).toContain('Did you mean `field` → `dateField`?');
238+
// The consequence, which is the whole reason this is not just a log line.
239+
expect(f.message).toMatch(/never runs/);
240+
expect(f.hint).toContain('TimeRelativeTriggerSchema');
241+
});
242+
243+
it('forwards the schema verbatim rather than restating it (anti-drift pin)', () => {
244+
// Every problem segment is `TimeRelativeTriggerSchema`'s own text, rendered
245+
// the way `TimeRelativeTrigger.start()` renders the identical issue list at
246+
// bind time. Derived from the schema HERE too, so this assertion tracks the
247+
// contract instead of freezing today's wording: if the schema's message for
248+
// a rejected descriptor changes, the rule's output changes with it and this
249+
// test keeps passing — but a hand-written copy in the rule would not.
250+
const parsed = TimeRelativeTriggerSchema.safeParse(badDescriptor);
251+
expect(parsed.success).toBe(false);
252+
const expected = parsed.error!.issues
253+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message.replace(/\s+/g, ' ').trim()}`)
254+
.join('; ');
255+
const [f] = validateFlowTriggerReadiness(timeRelativeStack(badDescriptor));
256+
expect(f.message).toContain(expected);
257+
// Single-line, so the CLI's bulleted list stays aligned (the schema's
258+
// guidance bullets arrive with newlines in them).
259+
expect(f.message).not.toContain('\n');
260+
expect(f.hint).not.toContain('\n');
261+
});
262+
263+
it('stays silent on the canonical descriptors — including the ones shipped in the repo', () => {
264+
// Criterion 2. Each is pinned against the schema as well as against the
265+
// rule, so a fixture cannot rot into an unbindable descriptor and keep this
266+
// test green for the wrong reason (#4966's lesson, one layer down).
267+
const canonical: Array<[string, Record<string, unknown>]> = [
268+
['#5496 acceptance shape', { object: 'task', dateField: 'due_at', offsetDays: [-1] }],
269+
// examples/app-showcase `Task Due Reminder` (#1874) — the showcase flow
270+
// criterion 2 names by hand.
271+
['showcase Task Due Reminder', {
272+
object: 'task',
273+
dateField: 'due_date',
274+
offsetDays: [3, 1],
275+
filter: { status: { $ne: 'done' } },
276+
}],
277+
// content/docs/references/automation/time-relative-trigger.mdx, all three
278+
// examples, and content/docs/automation/flows.mdx's `renewalReminder`.
279+
['docs T-minus example', {
280+
object: 'task', dateField: 'end_date', offsetDays: [60, 30, 7], filter: { status: 'active' },
281+
}],
282+
['docs expiring-soon example', { object: 'task', dateField: 'expires_on', withinDays: 30 }],
283+
['docs overdue example', {
284+
object: 'task', dateField: 'due_date', withinDays: -14, filter: { status: 'open' },
285+
}],
286+
['with maxRecords', { object: 'task', dateField: 'due_at', withinDays: 7, maxRecords: 50 }],
287+
];
288+
for (const [label, descriptor] of canonical) {
289+
expect(TimeRelativeTriggerSchema.safeParse(descriptor).success, `${label} must be spec-valid`).toBe(true);
290+
expect(validateFlowTriggerReadiness(timeRelativeStack(descriptor)), label).toEqual([]);
291+
}
292+
});
293+
294+
it('reports a wrong object name and a wrong shape as two facts, not one twice', () => {
295+
// Criterion 3. `contract` is not in the stack AND the descriptor does not
296+
// parse. The two findings are distinguishable by rule id and by path, and
297+
// neither restates the other's fact: the unknown-object warning says nothing
298+
// about the shape, and the schema — which has no stack knowledge — cannot
299+
// say anything about the name.
300+
const findings = validateFlowTriggerReadiness(
301+
timeRelativeStack({ object: 'contract', field: 'end_date', withinDays: 60 }),
302+
);
303+
expect(findings).toHaveLength(2);
304+
expect(findings.map((f) => f.rule)).toEqual([
305+
FLOW_TRIGGER_UNKNOWN_OBJECT,
306+
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
307+
]);
308+
expect(findings.map((f) => f.path)).toEqual([
309+
'flows[0].nodes[0].config.timeRelative.object',
310+
'flows[0].nodes[0].config.timeRelative',
311+
]);
312+
// The name warning talks only about the name…
313+
expect(findings[0].message).toContain("'contract'");
314+
expect(findings[0].message).not.toContain('dateField');
315+
// …and the shape warning only about the shape (it never echoes the name).
316+
expect(findings[1].message).toContain('dateField');
317+
expect(findings[1].message).not.toContain("'contract'");
318+
});
319+
320+
it('forwards the exactly-one-window rule (both modes, and neither)', () => {
321+
for (const descriptor of [
322+
{ object: 'task', dateField: 'due_at', withinDays: 3, offsetDays: [1] },
323+
{ object: 'task', dateField: 'due_at' },
324+
]) {
325+
const findings = validateFlowTriggerReadiness(timeRelativeStack(descriptor));
326+
expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]);
327+
expect(findings[0].message).toContain('exactly one of `withinDays`');
328+
}
329+
});
330+
331+
it("forwards the schema's wrong-layer guidance for a `schedule` written INSIDE the descriptor", () => {
332+
// The cadence knob is a SIBLING of `timeRelative` on the same config. The
333+
// schema carries that prescription; the value of forwarding is that the
334+
// author reads it from `os validate` instead of from a server log.
335+
const findings = validateFlowTriggerReadiness(
336+
timeRelativeStack({
337+
object: 'task',
338+
dateField: 'due_at',
339+
withinDays: 3,
340+
schedule: { type: 'cron', expression: '0 8 * * *' },
341+
}),
342+
);
343+
expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]);
344+
expect(findings[0].message).toContain('`schedule` is a sibling of `timeRelative`');
345+
});
346+
347+
it('flags an array descriptor — the engine routes it, so the trigger refuses it', () => {
348+
const findings = validateFlowTriggerReadiness(timeRelativeStack([{ object: 'task' }]));
349+
expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]);
350+
expect(findings[0].message).toContain('expected object, received array');
351+
});
352+
353+
it('says nothing about a non-object timeRelative — the engine does not route it here', () => {
354+
// `AutomationEngine`'s trigger resolution requires `typeof … === 'object'`,
355+
// so `timeRelative: 'daily'` never reaches the time-relative trigger and no
356+
// descriptor verdict applies to it. Whatever that flow's defect is, it is
357+
// not this rule's, and guessing here would make the rule speak for flows
358+
// the engine hands somewhere else.
359+
const findings = validateFlowTriggerReadiness(timeRelativeStack('daily'));
360+
expect(findings.some((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID)).toBe(false);
361+
});
362+
363+
it('is inert on flows that declare no timeRelative at all', () => {
364+
const findings = validateFlowTriggerReadiness({
365+
objects: [candidateObject],
366+
flows: [recordFlow({ status: 'active' })],
367+
});
368+
expect(findings).toEqual([]);
369+
});
370+
371+
it('still flags the draft-status ambiguity alongside a bad descriptor', () => {
372+
const stack = timeRelativeStack(badDescriptor);
373+
delete (stack.flows[0] as Record<string, unknown>).status;
374+
const findings = validateFlowTriggerReadiness(stack);
375+
expect(findings.map((f) => f.rule)).toEqual([
376+
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
377+
FLOW_DRAFT_STATUS_AMBIGUOUS,
378+
]);
379+
});
177380
});
178381

179382
it('passes the record-after-write (create-OR-update) token (#3427)', () => {

0 commit comments

Comments
 (0)