Skip to content
22 changes: 22 additions & 0 deletions .changeset/30026-time-relative-sweep-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/lint': minor
'@objectstack/mcp': minor
---

fix(lint)!: a time-relative flow whose start node names no `objectName` has its conditions typed against the object it sweeps (`timeRelative.object`), so a text-vs-number comparison, a read of a field the swept object does not declare and date arithmetic there are refused like on a sweep of the start node's object; a sweep of another object is told why it only warns; and `validate_expression` takes the swept object (#30026)

Clause-②: yes (narrowing) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) refuses, in the start, edge and `decision` conditions of a time-relative flow whose start node names no `objectName`, a `type-mismatch` (`type-mismatch-run-fails`), a `record.FIELD` read of a field the swept object does not declare (`unknown field`), and date arithmetic (`date-arithmetic-run-fails` / `date-text-arithmetic-run-fails`), which it passed with no verdict at all before. Added beside it (the widening half): the optional `sweepObject` input of the `validate_expression` MCP tool.
Metadata-protocol: narrowed
Skills-docs: updated here — the MCP skill text in `packages/mcp/src/skill-md.ts` (the new `sweepObject` input); `skills/**` teaches no time-relative flow condition with a text-vs-number comparison, so no skill or eval moves.

<!-- adr-0087: not-required (already-registered flow-record-read-and-decision-type-mismatch-refused) the entry already prescribes this exact rewrite for a time-relative flow's conditions: the same refusal code, the same replacement and the same reason (each run's record is a row of the swept object); this change extends it to a sweep whose start names no objectName; the unknown-field and date-arithmetic arms are the verdicts every flow with a typed object already gets, served here for the same reason and with the rewrite in their own message, and the entry's surface text is widened to this shape in the spec lane -->

**BREAKING** accept-set narrowing at the author-time expression gate, served by `os build`, `os validate` and `POST /api/v1/packages/:id/publish-drafts`.

- **Before.** A time-relative flow was typed only against its start node's `objectName`. A start that named none, with the swept object in `timeRelative.object` (the key the descriptor requires), got no type verdict on its conditions at all: `title > 5` on a text field published clean, and every run the sweep launched failed at that condition.
- **Now.** The conditions of such a flow are typed against `timeRelative.object`: each run's record is a row of that object, so its start, edge and `decision` conditions get every verdict a sweep of the start node's object already gets. Three of them are new refusals there: a text-vs-number comparison (`type-mismatch-run-fails`), a `record.FIELD` read of a field the swept object does not declare (`unknown field FIELD on OBJECT`; the run fails on the missing key), and arithmetic on a date field (`date-arithmetic-run-fails`, or `date-text-arithmetic-run-fails` against text). The shadowing and near-miss warnings and the root check are judged against the same object.
- **A sweep of another object** (the start node's `objectName` is not `timeRelative.object`) keeps the `type-mismatch-run-may-fail` warning, and the flow now gets one more warning, at its start node's `config.objectName`, saying why: the conditions are checked against that object's fields while each run's record is a row of the swept one. It is served only on a flow that got a warning it explains.
- **`validate_expression` (MCP).** New optional input `sweepObject` (site `flow_condition` with `triggerKind: 'time_relative'` only; a tool error anywhere else): the flow's `timeRelative.object`. When it is another object than `objectName`, a type mismatch is the gate's warning instead of the run-fails error. A call that does not pass it answers as before.
- **Correction to the 18.x changeset of the flow-condition type check.** It described a time-relative flow whose `timeRelative.object` "is omitted" as sweeping the start node's object. That shape never binds: `TimeRelativeTriggerSchema` requires `object`, and the gate refuses its absence (`flow-time-relative-descriptor-invalid`). Such a flow now gets the warning, not the run-fails verdict, on its conditions, since no run of it binds a row.

**FROM → TO.** In a time-relative flow whose start names no `objectName`, sweeping `crm_opportunity` with a text field `title` and a date field `close_date`: `title > 5` (or `record.title > 5`, or a decision branch `title * 2 > 10`) → `size(title) > 5` for its length, a number field for a value, or `double(title) > 5` only when every value is numeric text. `record.nope > 1`, where `crm_opportunity` declares no `nope` → the field's declared name, or the object that declares it as `timeRelative.object`. `close_date + 1 > …` → `addDays(close_date, 1) > …` to shift a date (`addMonths(d, n)` and `daysFromNow(n)` likewise), or `daysBetween(a, b)` for a span in whole days. A flow whose start names one object and sweeps another: set the start node's `objectName` to the swept object, or remove it.
132 changes: 122 additions & 10 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,15 +1107,19 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
// record-change trigger's — so a sweep of the object the conditions are
// typed against gets the run-fails verdict, bare name included. A sweep of
// another object binds a row of THAT object: the warning stays.
it('refuses a type mismatch on a time-relative sweep of the flow object, and warns on a sweep of another (#29959)', () => {
const flowOf = (name: string, sweepObject: string | undefined) => ({
// [#30026] A start naming no `objectName` is typed against the swept
// object (`timeRelative.object`, which the schema requires): its record IS
// that row, so `tr_sweep_only` is refused like `tr_same`. Before, it got no
// type verdict at all.
it('refuses a type mismatch on a time-relative sweep of the typed object, and warns on a sweep of another (#29959, #30026)', () => {
const flowOf = (name: string, startObject: string | undefined, sweepObject: string) => ({
name,
nodes: [
{
id: 'start', type: 'start',
config: {
objectName: 'crm_opportunity', condition: 'title > 5', schedule: '0 * * * *',
timeRelative: { ...(sweepObject ? { object: sweepObject } : {}), dateField: 'close_date', offsetDays: 3 },
...(startObject ? { objectName: startObject } : {}), condition: 'title > 5', schedule: '0 * * * *',
timeRelative: { object: sweepObject, dateField: 'close_date', offsetDays: 3 },
},
},
{ id: 'route', type: 'decision', config: { conditions: [{ label: 'Long', expression: 'record.title > 5' }] } },
Expand All @@ -1130,12 +1134,12 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } },
{ name: 'crm_task', fields: { close_date: { type: 'date' } } },
];
for (const [name, sweepObject, severity] of [
['tr_default', undefined, 'error'],
['tr_same', 'crm_opportunity', 'error'],
['tr_other', 'crm_task', 'warning'],
for (const [name, startObject, sweepObject, severity] of [
['tr_sweep_only', undefined, 'crm_opportunity', 'error'],
['tr_same', 'crm_opportunity', 'crm_opportunity', 'error'],
['tr_other', 'crm_opportunity', 'crm_task', 'warning'],
] as const) {
const found = typeMismatches(validateStackExpressions({ objects, flows: [flowOf(name, sweepObject)] }));
const found = typeMismatches(validateStackExpressions({ objects, flows: [flowOf(name, startObject, sweepObject)] }));
expect(found.map(i => [i.where, i.severity]), name).toEqual([
[`flow '${name}' · node 'start' (start) condition`, severity],
[`flow '${name}' · node 'route' (decision) decision branch expression at config.conditions[0].expression`, severity],
Expand All @@ -1149,6 +1153,113 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
}
});

// [#30026] Where the sweep is of another object, the author is told why the
// verdict is only a warning: once, at the start node's `objectName`, and
// only on a flow that was served a warning it explains.
it('says why a time-relative sweep of another object only warns, once, where a warning was served (#30026)', () => {
const objects = [
{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } },
{ name: 'crm_task', fields: { close_date: { type: 'date' } } },
];
const flowOf = (name: string, startObject: string | undefined, sweepObject: string, condition: string) => ({
name,
nodes: [
{
id: 'start', type: 'start',
config: {
...(startObject ? { objectName: startObject } : {}), condition, schedule: '0 * * * *',
timeRelative: { object: sweepObject, dateField: 'close_date', offsetDays: 3 },
},
},
{ id: 'end', type: 'end' },
],
edges: [{ id: 'e1', source: 'start', target: 'end', condition }],
});
const notes = (issues: ReturnType<typeof validateStackExpressions>) =>
issues.filter(i => i.where.endsWith('(start) config.objectName'));
const other = validateStackExpressions({ objects, flows: [flowOf('tr_other', 'crm_opportunity', 'crm_task', 'title > 5')] });
expect(notes(other).map(i => [i.where, i.severity, i.path, i.source])).toEqual([
["flow 'tr_other' · node 'start' (start) config.objectName", 'warning', 'flows[0].nodes[0].config.objectName', 'crm_opportunity'],
]);
expect(notes(other)[0]!.message).toContain("each run's record is a row of 'crm_task'");
expect(notes(other)[0]!.message).toContain("Set config.objectName to 'crm_task', or remove it");
// No warning to explain, or no other object: no note.
for (const [name, startObject, sweepObject, condition] of [
['tr_other_clean', 'crm_opportunity', 'crm_task', 'size(title) > 5'],
['tr_same', 'crm_opportunity', 'crm_opportunity', 'title > 5'],
['tr_sweep_only', undefined, 'crm_opportunity', 'title > 5'],
] as const) {
expect(notes(validateStackExpressions({ objects, flows: [flowOf(name, startObject, sweepObject, condition)] })), name).toEqual([]);
}
// A date verdict is an error whatever the outcome: the sweep softens
// nothing there, so there is nothing to explain.
const dateOnly = validateStackExpressions({ objects, flows: [flowOf('tr_other_date', 'crm_opportunity', 'crm_task', 'close_date + 1 > 3')] });
expect(dateOnly.filter(i => /^date arithmetic/.test(i.message)).map(i => i.severity)).toEqual(['error', 'error']);
expect(notes(dateOnly)).toEqual([]);
});

// [#30026] Typed against the swept object, a sweep-only flow gets every
// verdict a sweep of the start object gets, not only the type check: a
// `record.FIELD` read of a field the swept object does not declare, and
// date arithmetic. Before, it got none of them.
it('refuses an unknown record.FIELD read and date arithmetic on a time-relative flow typed only by its swept object (#30026)', () => {
const objects = [{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } }];
const sweepOnly = (condition: string) => ({
name: 'tr_sweep_only',
nodes: [
{
id: 'start', type: 'start',
config: { condition, schedule: '0 * * * *', timeRelative: { object: 'crm_opportunity', dateField: 'close_date', offsetDays: 3 } },
},
{ id: 'end', type: 'end' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
const unknown = validateStackExpressions({ objects, flows: [sweepOnly('record.nope > 1')] });
expect(unknown.map(i => i.severity)).toEqual(['error']);
expect(unknown[0]!.message).toMatch(/^unknown field `nope` on `crm_opportunity`/);
const date = validateStackExpressions({ objects, flows: [sweepOnly('close_date + 1 > 3')] });
expect(date.map(i => i.severity)).toEqual(['error']);
expect(date[0]!.message).toMatch(/^date arithmetic/);
expect(date[0]!.message).toContain(': this faults at runtime, and a flow condition');
// Two date fields, which the runtime holds as their text: the
// `date-text-arithmetic` verdict, run-fails worded, refused too.
const dateText = validateStackExpressions({
objects: [{ name: 'crm_opportunity', fields: { close_date: { type: 'date' }, open_date: { type: 'date' } } }],
flows: [sweepOnly('close_date - open_date > duration("1h")')],
});
expect(dateText.map(i => i.severity)).toEqual(['error']);
expect(dateText[0]!.message).toMatch(/^date arithmetic `DateText - DateText`/);
expect(dateText[0]!.message).toContain(': this faults at runtime, and a flow condition');
});

// [#30026] Typed against the swept object, a well-typed sweep-only flow
// stays clean: its fields resolve, so nothing is refused that runs.
it('leaves a well-typed time-relative flow typed only by its swept object clean (#30026)', () => {
const issues = validateStackExpressions({
objects: [{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, amount: { type: 'number', required: true }, close_date: { type: 'date' } } }],
flows: [{
name: 'tr_sweep_only',
nodes: [
{
id: 'start', type: 'start',
config: {
condition: 'size(title) > 5 && record.amount > 10', schedule: '0 * * * *',
timeRelative: { object: 'crm_opportunity', dateField: 'close_date', offsetDays: 3 },
},
},
{ id: 'route', type: 'decision', config: { conditions: [{ label: 'Big', expression: 'amount > 10' }] } },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'route', condition: "record.title != 'x'" },
{ id: 'e2', source: 'route', target: 'end', label: 'Big' },
],
}],
});
expect(issues).toEqual([]);
});

// [#30016] The date verdicts were left saying a flow condition "silently
// evaluates to null"; `evaluateCondition` throws on a fault on every flow.
// Where the ledger proves the run holds the field (`run-fails`) the fault
Expand Down Expand Up @@ -4034,7 +4145,8 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
keys: () => shapeKeysOf(ObjectStackSchema.shape.flows),
},
{ receiver: 'node', expected: ['config', 'id', 'type'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
{ receiver: 'startNode', expected: ['config'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
// [#30026] `id`: the time-relative note names the start node it sits on.
{ receiver: 'startNode', expected: ['config', 'id'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
{
receiver: 'edge',
expected: ['condition', 'id', 'source', 'target'],
Expand Down
Loading
Loading