From 425803088a36399de2529a94f732428c45f31d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:29:41 +0000 Subject: [PATCH 1/4] fix(lint): type a time-relative flow's conditions against its swept object when the start names no objectName Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D --- .../lint/src/validate-expressions.test.ts | 92 +++++++++++++++++-- packages/lint/src/validate-expressions.ts | 57 ++++++++++-- 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index c471e4892e0..7bba384afe2 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -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' }] } }, @@ -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], @@ -1149,6 +1153,73 @@ 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) => + 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([]); + } + }); + + // [#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([]); + }); + // [#29959] `seedRunVariables` seeds the flow's declared variables before it // flattens the record, and the flattening skips a name already bound: a // bare `title` here reads the NUMBER variable and `title > 5` runs. Only @@ -3975,7 +4046,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'], diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ae84329646e..0854ab590e7 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2198,6 +2198,10 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { */ export function runStackExpressionPasses(stack: AnyRec, options: StackExpressionOptions): ExprIssue[] { const issues: ExprIssue[] = []; + // [#30026] How many `-run-may-fail` warnings {@link check} has served: a + // flow whose own count moved is one an author is told why of, where the + // reason is the flow's own (the time-relative note in the flow walk). + let runMayFailServed = 0; // [#22019] See {@link StackExpressionOptions.runtimeWriteType}: on an object // write only the passes over the object's own body judge: the field-formula // pass and (#22032) the validation-rule, field-rule-slot, per-option @@ -2527,7 +2531,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression if (fieldRuleVerdictIssued && isBareReferenceToAny(e.message, FIELD_RULE_NOWHERE_BOUND_ROOTS)) continue; issues.push({ where, message: e.message, source: e.source, severity: 'error' }); } - for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); + for (const w of res.warnings) { + if (w.code.endsWith('-run-may-fail')) runMayFailServed++; + issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); + } if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule'); if (objectName && traversalHydration) refuseUndeclaredHopField(where, raw, objectName, 'rule'); // [#8116] Provenance rides every object-bound predicate this helper @@ -2710,10 +2717,22 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // contractually typed `(stack) => Issue[]` — the crash `lintFlowPatterns` // was filed for, reached through this rule instead, on the same document. const edges = recordsOf(flow.edges); - // The record-change target object — `record.*` refs resolve against it. + // The object the flow's conditions are typed against — `record.*` refs and + // bare names resolve against its fields: the start node's `objectName` + // (a record-change run's trigger object), else, on a time-relative flow, + // the object it sweeps. `TimeRelativeTriggerSchema` requires + // `timeRelative.object`, and `time-relative-trigger.ts` launches each run + // with a row of that object as `record`, whatever the start node names, so + // a sweep whose start names no `objectName` is typed against the swept + // object: its record IS that row. const startNode = nodes.find(n => n.type === 'start'); const startCfg = (startNode?.config ?? {}) as AnyRec; - const objectName = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined; + const startObject = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined; + const triggerKind = resolveFlowTriggerKind(flow); + const sweep = startCfg.timeRelative && typeof startCfg.timeRelative === 'object' ? startCfg.timeRelative as AnyRec : {}; + const sweepObject = triggerKind === 'time_relative' && typeof sweep.object === 'string' ? sweep.object : undefined; + const objectName = startObject ?? sweepObject; + const runMayFailBefore = runMayFailServed; // #4347 — every graph in the flow, not just `flow.nodes`/`flow.edges`. An // ADR-0031 container keeps a whole sub-graph in its `config`, so the @@ -2819,18 +2838,20 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression * - Outcome `run-fails`: the condition throws, the run fails, and the * trigger isolates the write that fired it. */ - const triggerKind = resolveFlowTriggerKind(flow); const nullGuardObject = triggerKind === 'record_change' ? objectName : undefined; // [#29926] The ledger's flow rows: `run-fails` on a record-triggered flow, // for both verdicts, and on a time-relative sweep of the typed object // (#29959; the null-guard twin stays on record-triggered flows, the one // entrance that binds `previous` too); nothing declared on any other flow. - // The sweep's object is `timeRelative.object`, else the start node's. - const sweep = startCfg.timeRelative && typeof startCfg.timeRelative === 'object' ? startCfg.timeRelative as AnyRec : {}; + // The sweep's object is `timeRelative.object` and nothing else: the schema + // requires the key, and a descriptor without it never binds + // (`flow-time-relative-descriptor-invalid`), so no run of such a flow + // binds a row of the typed object and none is declared. The typed object + // is the swept one wherever the start names no `objectName` (above). const flowFaultOutcome = faultOutcomeOf({ kind: 'flow condition', triggerKind, - sweepsTypedObject: typeof sweep.object !== 'string' || sweep.object === objectName, + sweepsTypedObject: sweepObject !== undefined && sweepObject === objectName, }); // [#29959] The names the run binds over the flattening — its declared // variables and node ids, and the engine's own — which a bare read holds @@ -3248,6 +3269,28 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression } } } + + // [#30026] Why a sweep of another object keeps the run-may-fail warning: + // the conditions are typed against the start node's `objectName`, while + // each run's `record` is a row of the swept object. Said once per flow, at + // the key that makes it so, and only where a warning it explains was served. + if (startObject !== undefined && sweepObject !== undefined && startObject !== sweepObject + && runMayFailServed > runMayFailBefore) { + const startIndex = flowIndex >= 0 && startNode && Array.isArray(flow.nodes) ? flow.nodes.indexOf(startNode) : -1; + issues.push({ + where: `flow '${flowName}' · node '${String(startNode?.id)}' (start) config.objectName`, + message: + `this time-relative flow sweeps '${sweepObject}' (config.timeRelative.object), so each run's record is a ` + + `row of '${sweepObject}', while its conditions are checked against the fields of '${startObject}' (the ` + + `start node's config.objectName). That is why a type mismatch in its conditions is a warning here and ` + + `not a refusal: the names hold a '${sweepObject}' row, not the '${startObject}' fields they were checked ` + + `against. Set config.objectName to '${sweepObject}', or remove it: the conditions are then checked ` + + `against the swept object, and a type mismatch in them is refused, as on a record-triggered flow.`, + source: startObject, + severity: 'warning', + ...(startIndex >= 0 ? { path: `flows[${flowIndex}].nodes[${startIndex}].config.objectName` } : {}), + }); + } } // ── Object validation-rule + formula predicates ──────────────────── From 3f315cd39f177b3fbaed23a8c3cf43e872c37f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:31:39 +0000 Subject: [PATCH 2/4] feat(mcp): validate_expression takes a time-relative flow's swept object Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D --- .../30026-time-relative-sweep-typing.md | 22 ++++++++ packages/mcp/src/mcp-http-tools.ts | 54 ++++++++++++++----- .../mcp/src/mcp-validate-expression.test.ts | 39 +++++++++----- packages/mcp/src/skill-md.ts | 6 ++- 4 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 .changeset/30026-time-relative-sweep-typing.md diff --git a/.changeset/30026-time-relative-sweep-typing.md b/.changeset/30026-time-relative-sweep-typing.md new file mode 100644 index 00000000000..39183970d86 --- /dev/null +++ b/.changeset/30026-time-relative-sweep-typing.md @@ -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 there is 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 a `type-mismatch` in the start, edge and `decision` conditions of a time-relative flow whose start node names no `objectName`, which it passed with no type 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. + + + +**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 a text-vs-number comparison in its start, edge or `decision` conditions is refused as `type-mismatch-run-fails`, the verdict a sweep of the start node's object already gets. Field reads, the shadowing warning 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`: `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. A flow whose start names one object and sweeps another: set the start node's `objectName` to the swept object, or remove it. diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index a06be3b9361..d6592578d35 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -644,9 +644,15 @@ const VALIDATE_SITE_MAP: Record< * [#29904, #29926, #29959] What the gate's runtime does on a fault at this * site, from the caller's optional `severity` / `triggerKind` — the two * facts `@objectstack/lint`'s `faultOutcomeOf` ledger reads off the rule - * and the flow. Absent: the site declares none. + * and the flow — and (#30026) `sweepsTypedObject`, the ledger's third: a + * time-relative sweep's object is `objectName` (no `sweepObject`, or the + * same one). Absent: the site declares none. */ - faultOutcome?: (inputs: { severity: ValidationSeverity; triggerKind: FlowTriggerOrNone }) => ExprFaultOutcome | undefined; + faultOutcome?: (inputs: { + severity: ValidationSeverity; + triggerKind: FlowTriggerOrNone; + sweepsTypedObject: boolean; + }) => ExprFaultOutcome | undefined; } > = { formula: { role: 'value', scope: 'record', slot: EvaluatedExpressionInputSchema, optionLabels: true }, @@ -663,14 +669,15 @@ const VALIDATE_SITE_MAP: Record< // branch condition (the default `triggerKind`): the trigger record is // flattened into the run's variables, a fault fails the run while the write // that fired it lands, and the gate grades a text-vs-number comparison an - // error worded so. A time-relative sweep launches each run with the swept - // row of `objectName` as its record, so it is graded the same. Any other - // trigger kind declares no outcome: the gate's `type-mismatch-run-may-fail` - // warning. + // error worded so. A time-relative sweep launches each run with a row of + // the swept object as its record, so a sweep of `objectName` is graded the + // same; a sweep of another object (`sweepObject`, #30026) binds a row of + // THAT object, and like any other trigger kind declares no outcome: the + // gate's `type-mismatch-run-may-fail` warning. flow_condition: { role: 'predicate', scope: 'flattened', slot: EvaluatedExpressionInputSchema, - faultOutcome: ({ triggerKind }) => - (triggerKind === 'record_change' || triggerKind === 'time_relative' ? 'run-fails' : undefined), + faultOutcome: ({ triggerKind, sweepsTypedObject }) => + (triggerKind === 'record_change' || (triggerKind === 'time_relative' && sweepsTypedObject) ? 'run-fails' : undefined), }, template: { role: 'template', scope: 'record', slot: TemplateExpressionInputSchema }, }; @@ -1092,11 +1099,12 @@ export function registerObjectTools( 'build-time errors (bare field refs, unknown fields, unknown functions) and advisory warnings ' + '(text/boolean fields misused in arithmetic, an error at a `validation` site, worded for the rule\'s ' + '`severity`, and at a `flow_condition` of a record-triggered or time-relative flow, where it fails the run; ' + - 'a warning on any other flow; date-equality pitfalls), plus the fields and stdlib ' + + 'a warning on any other flow, a time-relative sweep of another object included; date-equality pitfalls), plus the fields and stdlib ' + 'functions in scope so you can self-correct. `site` says where the expression will live: a `formula` ' + 'field, a `validation`/predicate, or a `flow_condition` (fields are bound bare in flow conditions). ' + - 'Pass the rule\'s `severity` (validation) or the flow\'s `triggerKind` (flow_condition) so the verdict ' + - 'is the one the publish gate gives that rule or flow.', + 'Pass the rule\'s `severity` (validation) or the flow\'s `triggerKind` (flow_condition), and a time-relative ' + + 'flow\'s `sweepObject` when it sweeps another object than objectName, so the verdict is the one the publish ' + + 'gate gives that rule or flow.', inputSchema: strictToolInput( { surface: 'this validate_expression call', @@ -1133,11 +1141,20 @@ export function registerObjectTools( 'is an error on a record_change or time_relative flow (its record is a row of objectName) and a warning ' + 'on any other. Default: record_change.', ), + sweepObject: z + .string() + .optional() + .describe( + 'site flow_condition with triggerKind time_relative only: the flow\'s `timeRelative.object`, whose rows ' + + 'the sweep hands each run as its record. objectName is the object the conditions are typed against: the ' + + 'start node\'s objectName, else the swept object itself. When sweepObject is another object, a type ' + + 'mismatch is a warning, not an error: the run\'s record is a row of sweepObject. Default: objectName.', + ), }, ), annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, }, - async ({ objectName, expression, site, severity, triggerKind }) => { + async ({ objectName, expression, site, severity, triggerKind, sweepObject }) => { const bad = guard(objectName); if (bad) return errorResult(bad); // [#29959] Each input describes one site; on another it would be @@ -1148,6 +1165,13 @@ export function registerObjectTools( if (triggerKind !== undefined && site !== 'flow_condition') { return errorResult(`\`triggerKind\` describes a flow; it applies only with site "flow_condition", not "${site ?? 'formula'}".`); } + // [#30026] The swept object describes a time-relative flow alone. + if (sweepObject !== undefined && (site !== 'flow_condition' || triggerKind !== 'time_relative')) { + return errorResult( + `\`sweepObject\` describes a time-relative sweep; it applies only with site "flow_condition" and triggerKind ` + + `"time_relative", not site "${site ?? 'formula'}" with triggerKind "${triggerKind ?? 'record_change'}".`, + ); + } try { const def = (await bridge.describeObject(objectName)) as { fields?: unknown } | null; if (!def) return errorResult(`Object "${objectName}" not found`); @@ -1160,7 +1184,11 @@ export function registerObjectTools( if (typeof f?.type === 'string') fieldTypes[f.name] = f.type; } const { role, scope, slot, optionLabels, faultOutcome: outcomeOf } = VALIDATE_SITE_MAP[site ?? 'formula']; - const faultOutcome = outcomeOf?.({ severity: severity ?? 'error', triggerKind: triggerKind ?? 'record_change' }); + const faultOutcome = outcomeOf?.({ + severity: severity ?? 'error', + triggerKind: triggerKind ?? 'record_change', + sweepsTypedObject: sweepObject === undefined || sweepObject === objectName, + }); // [#28715] The object's option-bearing fields, where the gate reads // them: `label(record.)` on a declared field outside this list // is refused as `label-field-no-options` by the one validator. diff --git a/packages/mcp/src/mcp-validate-expression.test.ts b/packages/mcp/src/mcp-validate-expression.test.ts index 587f1921724..d3ece5775d6 100644 --- a/packages/mcp/src/mcp-validate-expression.test.ts +++ b/packages/mcp/src/mcp-validate-expression.test.ts @@ -163,24 +163,31 @@ describe('validate_expression MCP tool (#1928)', () => { } // The severity column is the ledger's own answer (`faultOutcomeOf`): a - // time-relative sweep binds a row of `objectName` as its record (#29959). - for (const [triggerKind, startConfig, flowType, severity] of [ - [undefined, { triggerType: 'record-after-update' }, 'autolaunched', 'error'], - ['record_change', { triggerType: 'record-after-create' }, 'autolaunched', 'error'], - ['time_relative', { timeRelative: { object: 'crm_opportunity', field: 'close_date', offsetDays: 1 }, schedule: '0 * * * *' }, 'autolaunched', 'error'], - ['schedule', { schedule: '0 * * * *' }, 'autolaunched', 'warning'], - ['api', {}, 'api', 'warning'], - ['none', {}, 'autolaunched', 'warning'], + // time-relative sweep binds a row of the swept object as its record + // (#29959) — `objectName`'s, unless `sweepObject` names another (#30026). + // `startObject: null` is a start node naming no objectName: the gate then + // types the conditions against the swept object, the tool's `objectName`. + const sweep = (object: string) => ({ timeRelative: { object, field: 'close_date', offsetDays: 1 }, schedule: '0 * * * *' }); + for (const [label, triggerKind, sweepObject, startObject, startConfig, flowType, severity] of [ + ['(omitted)', undefined, undefined, 'crm_opportunity', { triggerType: 'record-after-update' }, 'autolaunched', 'error'], + ['record_change', 'record_change', undefined, 'crm_opportunity', { triggerType: 'record-after-create' }, 'autolaunched', 'error'], + ['time_relative', 'time_relative', undefined, 'crm_opportunity', sweep('crm_opportunity'), 'autolaunched', 'error'], + ['time_relative, sweepObject objectName', 'time_relative', 'crm_opportunity', 'crm_opportunity', sweep('crm_opportunity'), 'autolaunched', 'error'], + ['time_relative, start names no objectName', 'time_relative', undefined, null, sweep('crm_opportunity'), 'autolaunched', 'error'], + ['time_relative, sweepObject another object', 'time_relative', 'crm_task', 'crm_opportunity', sweep('crm_task'), 'autolaunched', 'warning'], + ['schedule', 'schedule', undefined, 'crm_opportunity', { schedule: '0 * * * *' }, 'autolaunched', 'warning'], + ['api', 'api', undefined, 'crm_opportunity', {}, 'api', 'warning'], + ['none', 'none', undefined, 'crm_opportunity', {}, 'autolaunched', 'warning'], ] as const) { - it(`site flow_condition, triggerKind ${triggerKind ?? '(omitted)'}`, async () => { + it(`site flow_condition, triggerKind ${label}`, async () => { const expression = 'title * 2 > 10'; const gate = typeVerdicts(validateStackExpressions({ - objects: [{ name: 'crm_opportunity', fields }], + objects: [{ name: 'crm_opportunity', fields }, { name: 'crm_task', fields: { close_date: { type: 'date' } } }], flows: [{ name: 'f', type: flowType, nodes: [ - { id: 'start', type: 'start', config: { objectName: 'crm_opportunity', condition: expression, ...startConfig } }, + { id: 'start', type: 'start', config: { ...(startObject ? { objectName: startObject } : {}), condition: expression, ...startConfig } }, { id: 'end', type: 'end' }, ], edges: [{ id: 'e1', source: 'start', target: 'end' }], @@ -188,7 +195,10 @@ describe('validate_expression MCP tool (#1928)', () => { })); expect(gate).toHaveLength(1); expect(gate[0]![0]).toBe(severity); - const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression, site: 'flow_condition', ...(triggerKind ? { triggerKind } : {}) }); + const r = await validate(runtime, bridge, { + objectName: 'crm_opportunity', expression, site: 'flow_condition', + ...(triggerKind ? { triggerKind } : {}), ...(sweepObject ? { sweepObject } : {}), + }); expect(toolVerdicts(r)).toEqual(gate); }); } @@ -200,6 +210,11 @@ describe('validate_expression MCP tool (#1928)', () => { { severity: 'info' }, { site: 'validation', triggerKind: 'api' }, { triggerKind: 'record_change' }, + // [#30026] The swept object describes a time-relative flow alone. + { site: 'flow_condition', sweepObject: 'crm_task' }, + { site: 'flow_condition', triggerKind: 'record_change', sweepObject: 'crm_task' }, + { site: 'validation', sweepObject: 'crm_task' }, + { sweepObject: 'crm_task' }, ]) { const { json } = await call(runtime, toolsCall(1, 'validate_expression', { objectName: 'crm_opportunity', expression: 'record.title > 5', ...args }), bridge); const { result } = json as { result: { isError?: boolean; content: { text: string }[] } }; diff --git a/packages/mcp/src/skill-md.ts b/packages/mcp/src/skill-md.ts index 590c08b3247..5b1d1afc524 100644 --- a/packages/mcp/src/skill-md.ts +++ b/packages/mcp/src/skill-md.ts @@ -264,7 +264,7 @@ create/update payload. - **list_objects()** — list available objects (system \`sys_*\` objects are hidden). - **describe_object({ objectName })** — an object's fields and features. -- **validate_expression({ objectName, expression, site?, severity?, triggerKind? })** — check a CEL +- **validate_expression({ objectName, expression, site?, severity?, triggerKind?, sweepObject? })** — check a CEL expression against an object's schema *before* you author it into a formula, validation, or flow condition. Returns build-time errors (bare field refs, unknown fields/functions) and advisory warnings (text/boolean fields misused @@ -274,7 +274,9 @@ create/update payload. stdlib functions in scope. \`site\` is \`formula\` | \`validation\` | \`flow_condition\` | \`template\` (default \`formula\`). Pass the rule's \`severity\` or the flow's \`triggerKind\` (\`record_change\` by default) to - get the verdict the publish gate gives that rule or flow. + get the verdict the publish gate gives that rule or flow; for a + \`time_relative\` flow that sweeps another object than \`objectName\`, also + pass its \`timeRelative.object\` as \`sweepObject\` (then a mismatch warns). - **query_records({ objectName, where?, fields?, limit?, offset?, orderBy? })** — read records. \`where\` is a field→value match, e.g. \`{ "status": "open" }\`. Results are page-capped; use \`limit\`/\`offset\` to page. From 52faf73cc8fdc7dbdd5f93e108a1070fde49cc2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:54:04 +0000 Subject: [PATCH 3/4] fix(lint): name every new refusal of a sweep-only flow, and count only the warning the time-relative note explains Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D --- .../30026-time-relative-sweep-typing.md | 10 +++--- .../lint/src/validate-expressions.test.ts | 31 +++++++++++++++++++ packages/lint/src/validate-expressions.ts | 11 ++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/.changeset/30026-time-relative-sweep-typing.md b/.changeset/30026-time-relative-sweep-typing.md index 39183970d86..2f13a7cbbe5 100644 --- a/.changeset/30026-time-relative-sweep-typing.md +++ b/.changeset/30026-time-relative-sweep-typing.md @@ -3,20 +3,20 @@ '@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 there is 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) +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 a `type-mismatch` in the start, edge and `decision` conditions of a time-relative flow whose start node names no `objectName`, which it passed with no type verdict at all before. Added beside it (the widening half): the optional `sweepObject` input of the `validate_expression` MCP tool. +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. - + **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 a text-vs-number comparison in its start, edge or `decision` conditions is refused as `type-mismatch-run-fails`, the verdict a sweep of the start node's object already gets. Field reads, the shadowing warning and the root check are judged against the same object. +- **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`: `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. A flow whose start names one object and sweeps another: set the start node's `objectName` to the swept object, or remove it. +**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. diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index de3deb136cc..c49a33b1b9c 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1191,6 +1191,37 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { ] 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'); }); // [#30026] Typed against the swept object, a well-typed sweep-only flow diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 649eda06554..908b810cef3 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2201,9 +2201,12 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { */ export function runStackExpressionPasses(stack: AnyRec, options: StackExpressionOptions): ExprIssue[] { const issues: ExprIssue[] = []; - // [#30026] How many `-run-may-fail` warnings {@link check} has served: a - // flow whose own count moved is one an author is told why of, where the - // reason is the flow's own (the time-relative note in the flow walk). + // [#30026] How many `type-mismatch-run-may-fail` warnings {@link check} has + // served: a flow whose own count moved is one an author is told why of, + // where the reason is the flow's own (the time-relative note in the flow + // walk). Only that code: the date verdicts' `-run-may-fail` arm is an error + // whatever the outcome, so a sweep of another object does not soften it and + // there is nothing to explain. let runMayFailServed = 0; // [#22019] See {@link StackExpressionOptions.runtimeWriteType}: on an object // write only the passes over the object's own body judge: the field-formula @@ -2535,7 +2538,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression issues.push({ where, message: e.message, source: e.source, severity: 'error' }); } for (const w of res.warnings) { - if (w.code.endsWith('-run-may-fail')) runMayFailServed++; + if (w.code === 'type-mismatch-run-may-fail') runMayFailServed++; issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); } if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule'); From 9b4bdc659cbd82460d1964cf163b0a2e13483028 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:58:28 +0000 Subject: [PATCH 4/4] test(lint): pin the date-text arithmetic refusal on a sweep-only flow Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CaUg3ibF939L2RFB5D --- packages/lint/src/validate-expressions.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index c49a33b1b9c..2fe9450f6fe 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1222,6 +1222,15 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { 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