Skip to content

Commit cfb602c

Browse files
fix(lint)!: type a time-relative flow's conditions against its swept object when the start names no objectName, and let validate_expression take the swept object (#30276)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg1bQRcYtBcNuyy3fjtaaNaz)_ domain:server#2 · os-bill · session_01Utp6CaUg3ibF939L2RFB5D Fixes #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. ## 维护者速读 - 改了什么:时间相对(定时扫描)流程的开始节点不写 `objectName`、只在 `timeRelative.object` 写了被扫描的对象时,条件现在按被扫描对象的字段做类型检查,文本与数字比较会被发布拦下(以前完全不检查);开始节点写的对象与扫描对象不一致时,仍只给警告,但会多一条说明告诉作者为什么、怎么改;MCP 的 `validate_expression` 新增可选输入 `sweepObject`,能给出与发布门一致的判决。 - 为什么改:这类流程每次运行拿到的 `record` 就是被扫描对象的一行,条件里文本与数字比较每次都会让运行失败,但发布时一声不吭;另外 #30015 的注释和测试描述了一个根本绑不上的形状(不写 `timeRelative.object`)。 - 风险与回滚:只收窄一种形状的发布(BREAKING 已在 changeset 写明 FROM → TO),已登记的 ADR-0087 语义条目覆盖同一修法;其余流程判决不变;回滚即撤 PR。 - 先看:`packages/lint/src/validate-expressions.ts` 流程遍历开头的 `objectName = startObject ?? sweepObject` 与 `sweepsTypedObject`,同文件流程末尾的说明警告;`packages/mcp/src/mcp-http-tools.ts` 的 `sweepObject` 输入与 `flow_condition` 判决。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-expressions.test.ts -t "time-relative"`,预期 3 个用例通过,其中 `tr_sweep_only` 三处条件均为 error。 - 需要你做的:无,席位落地(收窄面由席位安排 CONTRACT_REVIEW_TIER 复审)。 ## What changes - **Lint — the typed object of a flow** (`validateStackExpressions`, the flow walk). The object a flow's conditions are typed against is the start node's `objectName`, else, on a time-relative flow, `timeRelative.object`. `TimeRelativeTriggerSchema` requires that key, and `time-relative-trigger.ts` launches every run with a row of that object as `record` (`engine.find(desc.object, …)`, then `{ record: row }`), whatever the start node names. So a sweep whose start names no `objectName` is now typed against the swept object at every flow site that reads the typed object: start, edge and `decision` conditions (field existence, the type check with the `run-fails` outcome, `record.FIELD` reads), the shadowed-variable warning, and the root check's did-you-mean order. The null-guard twin is unchanged (record-triggered flows only). - **Lint — `sweepsTypedObject`** is now `timeRelative.object === typed object` and nothing else. The old "else the start node's" arm graded a descriptor with no `object` as a sweep of the start object; that descriptor never binds (`flow-time-relative-descriptor-invalid`, error), so no run of it binds a row and it now gets no declared outcome (the warning). The ledger comment says so. - **Lint — why a sweep of another object only warns.** When the start node's `objectName` is not `timeRelative.object`, the `type-mismatch-run-may-fail` warning stays, and the flow gets one more warning at `flows[i].nodes[j].config.objectName` saying why: the conditions are checked against the start object's fields while each run's record is a row of the swept one; set `objectName` to the swept object or remove it. It is pushed once per flow, and only when the flow was served a `type-mismatch-run-may-fail` warning (counted in `check()` by that one `code`). The date verdicts' `-run-may-fail` arm (#30016) is not counted: it is an error whatever the outcome, so the sweep softens nothing there and there is nothing to explain; a pin says so. - **MCP — `validate_expression` takes `sweepObject`** (optional; site `flow_condition` with `triggerKind: 'time_relative'` only, a tool error anywhere else, the same refusal shape as `severity` / `triggerKind`). It is the flow's `timeRelative.object`; another object than `objectName` gives the gate's warning instead of the run-fails error. The `flow_condition` outcome reads a third input, `sweepsTypedObject`, the lint ledger's own third fact. The tool description and the MCP skill text (`skill-md.ts`) name the input. - **What else the typed object refuses.** Typed against the swept object, a sweep-only flow also gets the two other verdicts a sweep of the start object gets, both new refusals there: a `record.FIELD` read of a field the swept object does not declare (`unknown field FIELD on OBJECT`), and arithmetic on a date field (`date-arithmetic-run-fails`). Both are pinned, and both are in the changeset's Clause-② sentence and FROM → TO. - **Tests.** The `tr_default` pin (a descriptor with no `object`, a shape that cannot bind) is re-purposed to `tr_sweep_only`: a start with no `objectName` sweeping `crm_opportunity`, refused at start, decision and edge. New pins: the note on `tr_other` (once, its `where`, `path` and `source`) and its absence on a clean `tr_other`, on `tr_same` and on `tr_sweep_only`; a well-typed `tr_sweep_only` flow is clean. The MCP per-trigger-kind table gains three rows (`sweepObject` = `objectName`, start with no `objectName`, `sweepObject` another object), each asserted equal to the gate's own verdict on the same flow, and the wrong-site refusal list gains four `sweepObject` calls. The key-read meta-test now lists `id` on `startNode` (the note names the start node). - **Changeset** `.changeset/30026-time-relative-sweep-typing.md`, `minor` on `@objectstack/lint` and `@objectstack/mcp`, BREAKING with FROM → TO, ADR-0087 `already-registered flow-record-read-and-decision-type-mismatch-refused`, and a correction note for the released #30015 changeset text ("or is omitted"), which is left as is. ## Acceptance notes - **ADR-0087 disposition.** `already-registered flow-record-read-and-decision-type-mismatch-refused`: the entry prescribes this exact rewrite for a time-relative flow's conditions (same code, same replacement, same reason). Its `surface` text says "sweeping the start node's objectName", which this change makes narrower than the gate; editing it is `packages/spec/**`, outside this card's surface. The seat files it for `domain:spec`. - **A sweep of another object is still typed against the start object.** Field reads are judged against the start object too, so a `record.FIELD` read of a field only the swept object declares is still refused as unknown there, while the run would hold it. That is pre-existing and not made worse here; the new note tells the author to align the two objects, which removes it. - **The engine's binding fallback.** `deriveTriggerBinding` (`plugin-extension-api.ts`) still names `config.objectName` as the binding's `object` when `timeRelative.object` is absent. The sweep itself reads only the parsed descriptor, so no run depends on it; noted, not touched. ## Verification All readings on head `52faf73c` (merged with `main` at `959c26be`; the merge resolves the `validate-expressions.test.ts` conflict with #30016 by keeping both sides' tests) unless named. <details><summary>Build, typecheck, tests</summary> - Build: `pnpm --filter '@objectstack/lint^...' --filter '@objectstack/mcp^...' build`, VERDICT command-exit 0; full `turbo run build --filter='!@objectstack/docs'`, 119/119 tasks, VERDICT command-exit 0. - `@objectstack/lint`: `pnpm test` 8006/8006 (193 files); `pnpm typecheck` 0 (with `check:test-typecheck`). - `@objectstack/mcp`: `pnpm test` 678/678 (56 files); `pnpm typecheck` 0 (with `check:test-typecheck`). - `eslint --no-inline-config` over the five changed source and test files: 0. The repo-wide `pnpm lint` is CI's. </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs; restored blob == HEAD, `git diff HEAD` empty)</summary> - A1, lint source (the lint suite imports `src`): `objectName = startObject ?? sweepObject` → `startObject`. 2 red on `52faf73c`: `tr_sweep_only: expected [] to deeply equal [ …3 ]`, the pre-fix answer (no verdict at all), and the unknown-field / date-arithmetic pin. (On `bc376239`, before that pin existed: 1 red.) - A2 (on `bc376239`; the MCP sources are unchanged since), lint `dist/` (the MCP suite reads `@objectstack/lint` through its exports): the same mutation with a string marker, lint rebuilt, `ablation-dist-preflight` present in 4 built files; 1 red: `time_relative, start names no objectName: expected [] to have a length of 1`. Restore leg rebuilt, preflight `--absent` over all 20 built files, suite 38/38. - A3 (on `bc376239`), MCP source: `sweepsTypedObject` forced `true`. 1 red: `sweepObject another object: expected [ [ 'error', … ] ] to deeply equal [ [ 'warning', … ] ]`. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `52faf73c`: 59 families; all 59 run, each exit 0 (exit code captured per line before any pipe). - `--ran` reconciliation: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED". </details> <details><summary>Dogfood (the narrowing arm's battery, on the final head)</summary> - On `52faf73c`, after a full `turbo run build --filter='!@objectstack/docs'` (119/119): `pnpm --filter @objectstack/dogfood test` under the lock, no shard, no path: Test Files 418/422 passed (3 skipped, 1 failed), Tests 3491 passed, 36 skipped, 1 failed; VERDICT command-exit 1. - The one red is `permission-set-clone-door.dogfood.test.ts` › "a platform set … copies into its shipping package's pair": cloning `member_default` answers 422, `objects.sys_comment` / `objects.sys_activity` not defined in the target package. It is not this change: with `validate-expressions.ts`, `mcp-http-tools.ts` and `skill-md.ts` set back to `origin/main` and lint and mcp rebuilt, the same file is red the same way (1 failed / 10 passed; control taken on `bc376239`). The seat tracks it as #30255, with its revert in the merge queue. The grants arrived on `main` with #30222 (`490d136f`, "every member reads and posts comments, and reads the activity history"), inside this branch's base. </details> CI on `52faf73c` at the time of writing: 63 check-runs, 54 success, 9 skipped, 0 failed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D)_ <!-- signed: domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bfca0ef commit cfb602c

6 files changed

Lines changed: 269 additions & 44 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/mcp': minor
4+
---
5+
6+
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)
7+
8+
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.
9+
Metadata-protocol: narrowed
10+
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.
11+
12+
<!-- 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 -->
13+
14+
**BREAKING** accept-set narrowing at the author-time expression gate, served by `os build`, `os validate` and `POST /api/v1/packages/:id/publish-drafts`.
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **`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.
20+
- **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.
21+
22+
**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.

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,15 +1107,19 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
11071107
// record-change trigger's — so a sweep of the object the conditions are
11081108
// typed against gets the run-fails verdict, bare name included. A sweep of
11091109
// another object binds a row of THAT object: the warning stays.
1110-
it('refuses a type mismatch on a time-relative sweep of the flow object, and warns on a sweep of another (#29959)', () => {
1111-
const flowOf = (name: string, sweepObject: string | undefined) => ({
1110+
// [#30026] A start naming no `objectName` is typed against the swept
1111+
// object (`timeRelative.object`, which the schema requires): its record IS
1112+
// that row, so `tr_sweep_only` is refused like `tr_same`. Before, it got no
1113+
// type verdict at all.
1114+
it('refuses a type mismatch on a time-relative sweep of the typed object, and warns on a sweep of another (#29959, #30026)', () => {
1115+
const flowOf = (name: string, startObject: string | undefined, sweepObject: string) => ({
11121116
name,
11131117
nodes: [
11141118
{
11151119
id: 'start', type: 'start',
11161120
config: {
1117-
objectName: 'crm_opportunity', condition: 'title > 5', schedule: '0 * * * *',
1118-
timeRelative: { ...(sweepObject ? { object: sweepObject } : {}), dateField: 'close_date', offsetDays: 3 },
1121+
...(startObject ? { objectName: startObject } : {}), condition: 'title > 5', schedule: '0 * * * *',
1122+
timeRelative: { object: sweepObject, dateField: 'close_date', offsetDays: 3 },
11191123
},
11201124
},
11211125
{ id: 'route', type: 'decision', config: { conditions: [{ label: 'Long', expression: 'record.title > 5' }] } },
@@ -1130,12 +1134,12 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
11301134
{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } },
11311135
{ name: 'crm_task', fields: { close_date: { type: 'date' } } },
11321136
];
1133-
for (const [name, sweepObject, severity] of [
1134-
['tr_default', undefined, 'error'],
1135-
['tr_same', 'crm_opportunity', 'error'],
1136-
['tr_other', 'crm_task', 'warning'],
1137+
for (const [name, startObject, sweepObject, severity] of [
1138+
['tr_sweep_only', undefined, 'crm_opportunity', 'error'],
1139+
['tr_same', 'crm_opportunity', 'crm_opportunity', 'error'],
1140+
['tr_other', 'crm_opportunity', 'crm_task', 'warning'],
11371141
] as const) {
1138-
const found = typeMismatches(validateStackExpressions({ objects, flows: [flowOf(name, sweepObject)] }));
1142+
const found = typeMismatches(validateStackExpressions({ objects, flows: [flowOf(name, startObject, sweepObject)] }));
11391143
expect(found.map(i => [i.where, i.severity]), name).toEqual([
11401144
[`flow '${name}' · node 'start' (start) condition`, severity],
11411145
[`flow '${name}' · node 'route' (decision) decision branch expression at config.conditions[0].expression`, severity],
@@ -1149,6 +1153,113 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
11491153
}
11501154
});
11511155

1156+
// [#30026] Where the sweep is of another object, the author is told why the
1157+
// verdict is only a warning: once, at the start node's `objectName`, and
1158+
// only on a flow that was served a warning it explains.
1159+
it('says why a time-relative sweep of another object only warns, once, where a warning was served (#30026)', () => {
1160+
const objects = [
1161+
{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } },
1162+
{ name: 'crm_task', fields: { close_date: { type: 'date' } } },
1163+
];
1164+
const flowOf = (name: string, startObject: string | undefined, sweepObject: string, condition: string) => ({
1165+
name,
1166+
nodes: [
1167+
{
1168+
id: 'start', type: 'start',
1169+
config: {
1170+
...(startObject ? { objectName: startObject } : {}), condition, schedule: '0 * * * *',
1171+
timeRelative: { object: sweepObject, dateField: 'close_date', offsetDays: 3 },
1172+
},
1173+
},
1174+
{ id: 'end', type: 'end' },
1175+
],
1176+
edges: [{ id: 'e1', source: 'start', target: 'end', condition }],
1177+
});
1178+
const notes = (issues: ReturnType<typeof validateStackExpressions>) =>
1179+
issues.filter(i => i.where.endsWith('(start) config.objectName'));
1180+
const other = validateStackExpressions({ objects, flows: [flowOf('tr_other', 'crm_opportunity', 'crm_task', 'title > 5')] });
1181+
expect(notes(other).map(i => [i.where, i.severity, i.path, i.source])).toEqual([
1182+
["flow 'tr_other' · node 'start' (start) config.objectName", 'warning', 'flows[0].nodes[0].config.objectName', 'crm_opportunity'],
1183+
]);
1184+
expect(notes(other)[0]!.message).toContain("each run's record is a row of 'crm_task'");
1185+
expect(notes(other)[0]!.message).toContain("Set config.objectName to 'crm_task', or remove it");
1186+
// No warning to explain, or no other object: no note.
1187+
for (const [name, startObject, sweepObject, condition] of [
1188+
['tr_other_clean', 'crm_opportunity', 'crm_task', 'size(title) > 5'],
1189+
['tr_same', 'crm_opportunity', 'crm_opportunity', 'title > 5'],
1190+
['tr_sweep_only', undefined, 'crm_opportunity', 'title > 5'],
1191+
] as const) {
1192+
expect(notes(validateStackExpressions({ objects, flows: [flowOf(name, startObject, sweepObject, condition)] })), name).toEqual([]);
1193+
}
1194+
// A date verdict is an error whatever the outcome: the sweep softens
1195+
// nothing there, so there is nothing to explain.
1196+
const dateOnly = validateStackExpressions({ objects, flows: [flowOf('tr_other_date', 'crm_opportunity', 'crm_task', 'close_date + 1 > 3')] });
1197+
expect(dateOnly.filter(i => /^date arithmetic/.test(i.message)).map(i => i.severity)).toEqual(['error', 'error']);
1198+
expect(notes(dateOnly)).toEqual([]);
1199+
});
1200+
1201+
// [#30026] Typed against the swept object, a sweep-only flow gets every
1202+
// verdict a sweep of the start object gets, not only the type check: a
1203+
// `record.FIELD` read of a field the swept object does not declare, and
1204+
// date arithmetic. Before, it got none of them.
1205+
it('refuses an unknown record.FIELD read and date arithmetic on a time-relative flow typed only by its swept object (#30026)', () => {
1206+
const objects = [{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, close_date: { type: 'date' } } }];
1207+
const sweepOnly = (condition: string) => ({
1208+
name: 'tr_sweep_only',
1209+
nodes: [
1210+
{
1211+
id: 'start', type: 'start',
1212+
config: { condition, schedule: '0 * * * *', timeRelative: { object: 'crm_opportunity', dateField: 'close_date', offsetDays: 3 } },
1213+
},
1214+
{ id: 'end', type: 'end' },
1215+
],
1216+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
1217+
});
1218+
const unknown = validateStackExpressions({ objects, flows: [sweepOnly('record.nope > 1')] });
1219+
expect(unknown.map(i => i.severity)).toEqual(['error']);
1220+
expect(unknown[0]!.message).toMatch(/^unknown field `nope` on `crm_opportunity`/);
1221+
const date = validateStackExpressions({ objects, flows: [sweepOnly('close_date + 1 > 3')] });
1222+
expect(date.map(i => i.severity)).toEqual(['error']);
1223+
expect(date[0]!.message).toMatch(/^date arithmetic/);
1224+
expect(date[0]!.message).toContain(': this faults at runtime, and a flow condition');
1225+
// Two date fields, which the runtime holds as their text: the
1226+
// `date-text-arithmetic` verdict, run-fails worded, refused too.
1227+
const dateText = validateStackExpressions({
1228+
objects: [{ name: 'crm_opportunity', fields: { close_date: { type: 'date' }, open_date: { type: 'date' } } }],
1229+
flows: [sweepOnly('close_date - open_date > duration("1h")')],
1230+
});
1231+
expect(dateText.map(i => i.severity)).toEqual(['error']);
1232+
expect(dateText[0]!.message).toMatch(/^date arithmetic `DateText - DateText`/);
1233+
expect(dateText[0]!.message).toContain(': this faults at runtime, and a flow condition');
1234+
});
1235+
1236+
// [#30026] Typed against the swept object, a well-typed sweep-only flow
1237+
// stays clean: its fields resolve, so nothing is refused that runs.
1238+
it('leaves a well-typed time-relative flow typed only by its swept object clean (#30026)', () => {
1239+
const issues = validateStackExpressions({
1240+
objects: [{ name: 'crm_opportunity', fields: { title: { type: 'text', required: true }, amount: { type: 'number', required: true }, close_date: { type: 'date' } } }],
1241+
flows: [{
1242+
name: 'tr_sweep_only',
1243+
nodes: [
1244+
{
1245+
id: 'start', type: 'start',
1246+
config: {
1247+
condition: 'size(title) > 5 && record.amount > 10', schedule: '0 * * * *',
1248+
timeRelative: { object: 'crm_opportunity', dateField: 'close_date', offsetDays: 3 },
1249+
},
1250+
},
1251+
{ id: 'route', type: 'decision', config: { conditions: [{ label: 'Big', expression: 'amount > 10' }] } },
1252+
{ id: 'end', type: 'end' },
1253+
],
1254+
edges: [
1255+
{ id: 'e1', source: 'start', target: 'route', condition: "record.title != 'x'" },
1256+
{ id: 'e2', source: 'route', target: 'end', label: 'Big' },
1257+
],
1258+
}],
1259+
});
1260+
expect(issues).toEqual([]);
1261+
});
1262+
11521263
// [#30016] The date verdicts were left saying a flow condition "silently
11531264
// evaluates to null"; `evaluateCondition` throws on a fault on every flow.
11541265
// Where the ledger proves the run holds the field (`run-fails`) the fault
@@ -4034,7 +4145,8 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
40344145
keys: () => shapeKeysOf(ObjectStackSchema.shape.flows),
40354146
},
40364147
{ receiver: 'node', expected: ['config', 'id', 'type'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
4037-
{ receiver: 'startNode', expected: ['config'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
4148+
// [#30026] `id`: the time-relative note names the start node it sits on.
4149+
{ receiver: 'startNode', expected: ['config', 'id'], declaredBy: 'flows[].nodes[]', keys: () => shapeKeysOf(flowShape().nodes) },
40384150
{
40394151
receiver: 'edge',
40404152
expected: ['condition', 'id', 'source', 'target'],

0 commit comments

Comments
 (0)