Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/30283-time-relative-object-mismatch.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: the flow schema still admits objectName beside a timeRelative descriptor, and what narrows is the publish door's accept set for one combination of two existing keys. No rewrite keeps what the author meant: the start names two objects, and whether the author meant to sweep the swept object (drop objectName) or the named one (change timeRelative.object) is a judgement no ledger entry can derive. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers this combination (flow-record-read-and-decision-type-mismatch-refused prescribes a rewrite of a condition, not of the start node) and this diff adds none (not registered, not already-registered); no published export is removed or retyped (not runtime-export-retired, runtime-interface-only or type-surface-only). -->

**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.
5 changes: 4 additions & 1 deletion packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -5684,6 +5716,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = 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,
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
6 changes: 5 additions & 1 deletion packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down
132 changes: 132 additions & 0 deletions packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, unknown>, 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 = {
Expand Down
Loading
Loading