diff --git a/.changeset/30283-time-relative-object-mismatch.md b/.changeset/30283-time-relative-object-mismatch.md new file mode 100644 index 00000000000..f585bbd9e77 --- /dev/null +++ b/.changeset/30283-time-relative-object-mismatch.md @@ -0,0 +1,19 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: a time-relative flow whose start node names, in `config.objectName`, another object than the one it sweeps (`config.timeRelative.object`) is refused with `flow-time-relative-object-mismatch`: each run binds a row of the swept object, while the start's `objectName` is what its conditions are checked against (#30283) + +Clause-②: yes (narrowing) — a new published readiness rule id, `flow-time-relative-object-mismatch`, graded `error`: the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) refuses a time-relative start whose `config.objectName` is set and is not `config.timeRelative.object`, which it passed with no readiness verdict before. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` and `content/docs/**` teach the time-relative sweep with `timeRelative.object` alone and no `objectName` on its start node, which this rule accepts; the MCP skill text keeps its `sweepObject` advice for `validate_expression`, whose expression verdict this change does not move. + + + +**BREAKING** accept-set narrowing at the author-time flow readiness gate, served by `os build`, `os validate`, `POST /api/v1/packages/:id/publish-drafts` and the flow publish door. + +- **Before.** A time-relative start could name `config.objectName: 'A'` and sweep `config.timeRelative.object: 'B'`. The run binds only `B`: the engine's binding resolver reads `timeRelative.object` first, and the trigger launches each run with a `B` row as `record` and `B` as the run's object. The expression checks typed the flow's conditions against `A` all the same. Measured both ways: `record.amount > 10`, where only `A` declares `amount`, passed every check and failed every run (`No such key: amount`); `record.priority > 1`, a correct read of a `B` field, was refused as `unknown field priority on A`. +- **Now.** That start is refused at `config.objectName`, naming both objects and the fix. A start whose `objectName` equals `timeRelative.object`, or that names none, is not reported; neither is a start whose `triggerType` routes the flow to the record-change trigger (a `record-` token, or an array holding one), which binds `objectName` and never sweeps. The runtime publish gate judges only the flow being written, so a stored flow that names two objects keeps being served; its own next publish is refused. +- **The `config.objectName` warning on a sweep of another object** (served where a condition's type verdict was softened to a warning) now names `flow-time-relative-object-mismatch` as the verdict that asks for the same fix. + +**What an author does.** Nothing for a time-relative start that names no `objectName`, or names the object it sweeps. A start that names two objects says which one the author meant, and nothing mechanical can: for the swept one, remove `config.objectName` (the sweep never reads it, so the run is unchanged) or set it to the value of `config.timeRelative.object`, then correct any condition the expression checks now refuse against that object; for the named one, point `config.timeRelative.object` (and its `dateField`) at it instead. The refusal names both objects. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 99debed06b4..add4247db0c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1469,7 +1469,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // (`validateFlowApiTriggerSecret`), because it reads a context input this entry // has no use for (#20611). None of those verdicts // can be changed by installing a package, so there is no reading under which - // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning` + // the flow fires. [#30283] `flow-time-relative-object-mismatch` gates on the + // same question without being a never-fire verdict: a time-relative start + // whose `objectName` is not the swept object, which the run never binds while + // the expression checks type its conditions against it. `flow-trigger-unknown-object` deliberately stayed `warning` // (the object may come from another installed package — a hedge this rule // cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire; // that one is ambiguity of intent, not a dead flow). [#24669] The hedge is diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 5126ea83562..8026e73420d 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -143,6 +143,7 @@ export { FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + FLOW_TIME_RELATIVE_OBJECT_MISMATCH, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 65fc06d36af..b0ceb0d9561 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1993,6 +1993,38 @@ const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE_EXPLANATION: RuleExplanation = { ], }; +const FLOW_TIME_RELATIVE_OBJECT_MISMATCH_EXPLANATION: RuleExplanation = { + rule: 'flow-time-relative-object-mismatch', + covers: 'why a time-relative start may not name a second object', + paragraphs: [ + 'A time-relative run binds the object its descriptor sweeps, `config.timeRelative.object`, and ' + + 'nothing else. The engine\'s binding resolver reads that key first, and the time-relative trigger ' + + 'finds the rows of that object whose date field falls in the window, then launches one run per row ' + + 'with the row as `record` and that object as the run\'s object, which is also the object a declared ' + + '`expand` is read from. No part of the run reads the start node\'s `config.objectName`.', + 'The checks do read it. When the start names an `objectName`, the flow\'s conditions are typed ' + + 'against that object\'s fields, at `os validate`, at the runtime publish gate and in the engine\'s ' + + 'registration pass, so a start naming another object than the one it sweeps is checked against ' + + 'records its runs never hold. Measured both ways: a condition reading a field only the named object ' + + 'declares passes every check and fails every run (`No such key` for `record.FIELD`, `Unknown ' + + 'variable` for a bare name), while a correct read of a field of the swept object is refused as an ' + + 'unknown field on the named object.', + 'Remove `config.objectName`, or set it to the swept object. Either way nothing the run does changes, ' + + 'and the conditions are checked against the record each run holds: a field the swept object does not ' + + 'declare is refused, and a type mismatch fails the publish, as on a record-triggered flow.', + 'Not reported: a start whose `objectName` equals `timeRelative.object` or that names none; a start ' + + 'whose `triggerType` routes the flow to the record-change trigger (a `record-` token, or an array ' + + 'holding one), because that trigger binds `objectName` and the descriptor is never swept; and a ' + + 'descriptor whose `object` is not a string, which `flow-time-relative-descriptor-invalid` refuses.', + 'The finding is an error, because the start node alone settles it and nothing installed changes which ' + + 'object the run binds: it fails `os validate` and `os build`, and the runtime publish gate refuses an ' + + 'active flow write that carries it. That gate judges only the flow being written, against the ' + + 'findings the stored stack already had, so a stored flow that names two objects keeps being served ' + + 'and never blocks another flow\'s save; what is refused is its own publish, until its start names ' + + 'one object.', + ], +}; + const FLOW_TRIGGER_UNROUTABLE_EXPLANATION: RuleExplanation = { rule: 'flow-trigger-unroutable', covers: 'why a record_change flow with no record token never fires', @@ -5684,6 +5716,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT_EXPLANATION.rule]: SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT_EXPLANATION, [FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID_EXPLANATION.rule]: FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID_EXPLANATION, [FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE_EXPLANATION.rule]: FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE_EXPLANATION, + [FLOW_TIME_RELATIVE_OBJECT_MISMATCH_EXPLANATION.rule]: FLOW_TIME_RELATIVE_OBJECT_MISMATCH_EXPLANATION, [FLOW_TRIGGER_UNROUTABLE_EXPLANATION.rule]: FLOW_TRIGGER_UNROUTABLE_EXPLANATION, [FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION, [FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION, diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 2fe9450f6fe..d80f37cfd91 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -1183,6 +1183,8 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { ]); 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"); + // [#30283] The note names the readiness rule that refuses the start itself. + expect(notes(other)[0]!.message).toContain('as flow-time-relative-object-mismatch asks of this start'); // 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'], diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 908b810cef3..3edcb93e9e6 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -3280,6 +3280,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // 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. + // [#30283] The contradiction itself is the readiness rule's verdict + // (`flow-time-relative-object-mismatch`, at the same key); this note + // explains the softened verdict only, and names that rule for the fix. if (startObject !== undefined && sweepObject !== undefined && startObject !== sweepObject && runMayFailServed > runMayFailBefore) { const startIndex = flowIndex >= 0 && startNode && Array.isArray(flow.nodes) ? flow.nodes.indexOf(startNode) : -1; @@ -3290,7 +3293,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression `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. Set config.objectName to '${sweepObject}', or remove it, as flow-time-relative-object-mismatch ` + + `asks of this start: 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', diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 80d9b140f22..7e9e78fe850 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -10,6 +10,7 @@ import { FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, + FLOW_TIME_RELATIVE_OBJECT_MISMATCH, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, @@ -1466,6 +1467,27 @@ describe('validateFlowTriggerReadiness', () => { ], }, ], + [ + FLOW_TIME_RELATIVE_OBJECT_MISMATCH, + 'error', + { + objects: [{ name: 'task', label: 'Task', fields: {} }, { name: 'project', label: 'Project', fields: {} }], + flows: [ + { + name: 'two_objects', + type: 'schedule', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + config: { objectName: 'project', timeRelative: { object: 'task', dateField: 'due_at', withinDays: 3 } }, + }, + ], + }, + ], + }, + ], [ FLOW_TRIGGER_UNKNOWN_EVENT, 'error', @@ -1785,6 +1807,116 @@ describe('validateFlowTriggerReadiness', () => { // `apiTriggerVerifierOf` (the reader the engine and the trigger share): ready on // the key `config.secretRef` names and nothing else, and never ready when the // reader answers `unusable`. +// [#30283] A time-relative start naming, in `config.objectName`, an object other +// than the one it sweeps. The run binds the swept object alone; the expression +// checks type the conditions against `objectName`. One finding, at that key. +describe('[#30283] a time-relative start whose objectName is not the swept object', () => { + const objects = [ + { name: 'crm_opportunity', label: 'Opportunity', sharingModel: 'private', fields: { amount: { type: 'number', required: true } } }, + { name: 'crm_task', label: 'Task', sharingModel: 'private', fields: { priority: { type: 'number', required: true }, close_date: { type: 'date' } } }, + ]; + /** A sweep of `crm_task`; `config` extras ride on the start node. */ + const sweepFlow = (extra: Record, timeRelative: unknown = { object: 'crm_task', dateField: 'close_date', offsetDays: [3] }) => ({ + name: 'task_due', + label: 'Task due', + type: 'schedule', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { ...extra, timeRelative, organization: FIXTURE_ORG } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + const mismatchOf = (findings: ReadonlyArray<{ rule: string }>) => + findings.filter((f) => f.rule === FLOW_TIME_RELATIVE_OBJECT_MISMATCH); + + it('refuses the start, at config.objectName, naming both objects and the fix', () => { + const findings = validateFlowTriggerReadiness({ objects, flows: [sweepFlow({ objectName: 'crm_opportunity' })] }); + expect(findings.map((f) => [f.rule, f.severity, f.where, f.path])).toEqual([ + [FLOW_TIME_RELATIVE_OBJECT_MISMATCH, 'error', 'flow "task_due" › start node', 'flows[0].nodes[0].config.objectName'], + ]); + const [f] = findings; + expect(f.message).toContain("config.objectName 'crm_opportunity'"); + expect(f.message).toContain("('crm_task')"); + expect(f.message).not.toContain('\n'); + expect(f.message.length).toBeLessThanOrEqual(200); + expect(f.hint).toMatch(/^Remove config\.objectName .*or set it to 'crm_task'/); + expect(f.hint).not.toContain('\n'); + expect(f.hint.length).toBeLessThanOrEqual(200); + }); + + it('is silent where the start names one object: the swept one, or none', () => { + for (const extra of [{ objectName: 'crm_task' }, {}]) { + expect(validateFlowTriggerReadiness({ objects, flows: [sweepFlow(extra)] }), JSON.stringify(extra)).toEqual([]); + } + }); + + it('is silent where the engine binds another trigger, which reads objectName and never sweeps', () => { + // A `record-*` token outranks the descriptor in the kind resolver, and an + // array-form record token is routed to the record-change trigger by the + // engine's pre-check. Each is paired with the shape that fires. + for (const triggerType of ['record-after-update', ['record-after-create', 'record-after-update']]) { + const findings = validateFlowTriggerReadiness({ + objects, + flows: [sweepFlow({ objectName: 'crm_opportunity', triggerType })], + }); + expect(mismatchOf(findings), JSON.stringify(triggerType)).toEqual([]); + } + expect( + mismatchOf(validateFlowTriggerReadiness({ objects, flows: [sweepFlow({ objectName: 'crm_opportunity', triggerType: 'schedule' })] })), + ).toHaveLength(1); + }); + + it('leaves a descriptor whose object is not a string to the descriptor rule, and co-fires with it otherwise', () => { + const noObject = validateFlowTriggerReadiness({ + objects, + flows: [sweepFlow({ objectName: 'crm_opportunity' }, { object: 7, dateField: 'close_date', offsetDays: [3] })], + }); + expect(noObject.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]); + // Two keys, two facts: a bad window beside a string object reports both. + const both = validateFlowTriggerReadiness({ + objects, + flows: [sweepFlow({ objectName: 'crm_opportunity' }, { object: 'crm_task', dateField: 'close_date' })], + }); + expect(both.map((f) => [f.rule, f.path]).sort()).toEqual([ + [FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, 'flows[0].nodes[0].config.timeRelative'], + [FLOW_TIME_RELATIVE_OBJECT_MISMATCH, 'flows[0].nodes[0].config.objectName'], + ]); + }); + + it('closes the measured hole: a condition only the named object can satisfy no longer passes every authoring command', () => { + // The run binds a `crm_task` row, which has no `amount`: this condition + // faults every run (`No such key: amount`). Typed against `objectName`, + // the expression checks find nothing wrong with it, so before this rule + // the stack passed every command clean. + const stack = { objects, flows: [sweepFlow({ objectName: 'crm_opportunity', condition: 'record.amount > 10' })] }; + for (const command of AUTHORING_COMMANDS) { + const errors = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter((f) => f.severity === 'error'); + expect(errors.map((f) => [f.rule, f.path]), command).toEqual([ + [FLOW_TIME_RELATIVE_OBJECT_MISMATCH, 'flows[0].nodes[0].config.objectName'], + ]); + } + }); + + it('the runtime publish gate refuses the write, judged on the flow alone', () => { + const gated = runRuntimeAuthoringRules({ type: 'flow', item: sweepFlow({ objectName: 'crm_opportunity' }) }); + expect(gated.rulesRun).toContain('validateFlowTriggerReadiness'); + expect(gated.errors.map((f) => [f.rule, f.path])).toEqual([ + [FLOW_TIME_RELATIVE_OBJECT_MISMATCH, 'flows[0].nodes[0].config.objectName'], + ]); + expect(runRuntimeAuthoringRules({ type: 'flow', item: sweepFlow({}) }).errors).toEqual([]); + }); + + it('`os explain flow-time-relative-object-mismatch` carries what the verdict does not say', () => { + expect(FLOW_TIME_RELATIVE_OBJECT_MISMATCH).toBe('flow-time-relative-object-mismatch'); + const text = explanationOf(FLOW_TIME_RELATIVE_OBJECT_MISMATCH); + for (const fact of ['No part of the run reads', 'No such key', 'refused as an unknown field', 'nothing the run does changes', 'that trigger binds `objectName`', 'what is refused is its own publish']) { + expect(text, fact).toContain(fact); + } + }); +}); + describe('[#30009] api trigger readiness reads the hook\'s one verifier (config.signature)', () => { const DOCUSIGN = { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }; const STRIPE = { diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index e3acac4216b..129bc13a1d6 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -46,6 +46,13 @@ // ADR-0018, so the scalar parses fine), not `os validate`, and not even // the one bind-time warn rule 3's case gets. // +// 4b. A time-relative start whose `config.objectName` is not the object its +// descriptor sweeps (#30283). Not a dead flow: the sweep binds and runs. +// It is a start that names two objects, and the run binds only the swept +// one, while the expression checks type the flow's conditions against +// `objectName`. So a condition the run cannot evaluate passes every check, +// and one it can is refused (1b-iii). +// // 5. A `type: 'record_change'` flow whose start-node `triggerType` the engine // routes NOWHERE — present but off-grammar (`triggerType: 'onCreate'`, // #6637's original specimen) or absent entirely (the omission shape, @@ -123,6 +130,16 @@ // reaches the point of asking that trigger. The rule speaks only for flows // whose binding the ENGINE resolves to `api` (see 1h), so every flow it // names is one the engine refuses. +// - `error` — `flow-time-relative-object-mismatch` (#30283) is not a +// never-fire verdict, and sits here on the same question all the same: is +// THIS STACK enough to know? Both names are on the one start node, and which +// one the run binds is a hardcoded read in the engine's binding resolver +// (`timeRelative.object` first), so nothing installed changes the answer. +// What it costs when left standing was measured, not assumed: a condition +// typed against `objectName` reading a field the swept object lacks passed +// every check and failed every run (`No such key`), while a correct read of +// the swept object's field was refused as unknown on `objectName`. Removing +// `objectName` changes nothing the run does, so refusing loses nothing. // ⚠️ `flow-schedule-organization-missing` was the family's one measured // exception — `error` on the criterion, held at `warning` by the shipped // corpus — and it is retired (#17396) rather than re-severitied. The @@ -246,6 +263,25 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID = 'flow-time-relative-descrip * there is no runtime channel at all for it to be moved earlier from. */ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-descriptor-unroutable'; +/** + * #30283 — a time-relative start whose `config.objectName` names an object + * other than the one its descriptor sweeps (`config.timeRelative.object`). + * + * The run binds the swept object and nothing else: the engine's binding + * resolver reads `timeRelative.object` first, and the trigger launches each run + * with a row of that object as `record` and that object as the run's object. + * `objectName` is read by no part of a time-relative run — but the expression + * checks type the flow's conditions against it whenever it is set, so the start + * names an object for its checks that its run never binds. Measured: a + * condition reading a field only the named object declares passes every check + * and faults every run; a correct read of the swept object's field is refused + * as unknown on the named one (see 1b-iii). + * + * The third id of the `flow--` family, on a different key + * from the other two (`config.objectName`, beside the descriptor), so it can + * fire together with either of them: two keys, two facts. + */ +export const FLOW_TIME_RELATIVE_OBJECT_MISMATCH = 'flow-time-relative-object-mismatch'; /** * #6637 — a `type: 'record_change'` flow whose start-node `triggerType` the * engine routes to NO trigger at all, so the flow is silently demoted to a @@ -678,6 +714,46 @@ export function validateFlowTriggerReadiness( 'and re-run until the schema accepts it: a descriptor the schema accepts binds', }); } + + // 1b-iii. #30283 — the start names one object in `config.objectName` and + // sweeps another in `timeRelative.object`. Only a flow the ENGINE binds + // to the time-relative trigger is judged: the spec's kind resolver + // says `time_relative`, and the start carries no array-form record + // token (the engine's binding pre-check routes that shape to the + // record-change trigger, which DOES bind `objectName`, ahead of the + // resolver). A `record-*` token outranks the descriptor in the + // resolver itself, so that flow is not judged either. + // + // `error`, on the family's criterion: the stack alone settles it. Both + // names sit on the one start node, and which of them the run binds is + // a hardcoded read in the engine's binding resolver, so no installed + // package changes the answer. The fix loses nothing: removing + // `objectName` changes nothing the run does. + const sweptObject = typeof tr.object === 'string' ? tr.object : undefined; + const namedObject = typeof config.objectName === 'string' ? config.objectName : undefined; + if ( + sweptObject && + namedObject !== undefined && + namedObject !== sweptObject && + resolveFlowTriggerKind(flow) === 'time_relative' && + !isArrayRecordTriggered + ) { + findings.push({ + severity: 'error', + rule: FLOW_TIME_RELATIVE_OBJECT_MISMATCH, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].config.objectName`, + // One verdict sentence; what the run binds, what the checks type + // against, and the measured outcome are + // `os explain flow-time-relative-object-mismatch`. + message: + `config.objectName '${namedObject}' is not the object this time-relative start sweeps ` + + `('${sweptObject}'): each run's record is a '${sweptObject}' row, and '${namedObject}' is never bound`, + hint: + `Remove config.objectName (the sweep never reads it), or set it to '${sweptObject}': conditions are then ` + + `checked against the record each run holds`, + }); + } } // 1c. A `record-`-prefixed triggerType the trigger cannot map to any hook —