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
2 changes: 1 addition & 1 deletion .changeset/22565-flow-cel-unbound-root-refused.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': major
'@objectstack/spec': major
Expand Down Expand Up @@ -31,7 +31,7 @@
- a `type: 'flow'` action launching the flow on an undeclared object;
- a `map` node feeding the flow whose `itemObject` is undeclared or absent;
- a `subflow` or `map` parent that is itself such a flow.
- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
- **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.

A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's.

Expand Down
13 changes: 13 additions & 0 deletions .changeset/22636-flow-cel-record-trigger-no-object-opens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/lint': minor
---

A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused.

Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there)

**What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before.

**The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot.

**Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after.
2 changes: 1 addition & 1 deletion .changeset/22677-flow-cel-record-entrance.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/lint': major
---
Expand All @@ -17,7 +17,7 @@

A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below.

**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record` until that snapshot carries them.
**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`).

## FROM → TO

Expand Down
28 changes: 23 additions & 5 deletions packages/lint/src/flow-cel-root-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@
*
* - **a record trigger** — the start node's `config.objectName` (the trigger
* object a record-change run's row belongs to) names an undeclared object;
* - **a record trigger that names no object** — the trigger kind is
* `record_change` and the start node names no `config.objectName`: the
* record-change trigger then registers its hook with no object filter
* (`record-change-trigger.ts`), so it fires on a write to ANY object, one the
* stack does not declare included, and hands that row;
* - **a time-relative sweep** — the start node's `config.timeRelative.object`
* (`TimeRelativeTriggerSchema`; its own key, independent of `objectName`, and
* the object whose rows the sweep hands each run) is undeclared;
Expand Down Expand Up @@ -130,7 +135,7 @@
* its `context.record`. A parent's own `record` VARIABLE is not handed on —
* the child gets the parent's context, not its variables. Read to a fixpoint.
*
* ## The runtime publish gate stands down (#22636 is the carrier)
* ## The runtime publish gate stands down, by design
*
* The same pass runs at the runtime publish gate on a flow write, and there the
* per-write snapshot carries the written flow alone beside `objects` /
Expand All @@ -139,9 +144,16 @@
* `map` node could hand this flow any record. A false 422 on the only door a
* Studio tenant has is worse than the gap, so this one judgment stands down there
* ({@link perWriteSnapshotEntrance}); every other expression verdict at that door
* is unchanged, and `objectstack validate` judges under the rule above. #22636
* widens that snapshot to carry flows and actions; when it lands, this
* stand-down goes.
* is unchanged, and `objectstack validate` judges under the rule above.
*
* ⛔ Not a hold-out awaiting a wider snapshot. Who launches a flow and what
* record it is handed are read from OTHER items of the stack, so this is a
* whole-stack judgment, and it belongs to `objectstack validate` (ADR-0109
* Decision §3's boundary); the runtime publish gate judges the written item
* against the four collections it carries. The snapshot stays as it is: ruling
* `5791822697` on #19527 (item 3: `RuntimeStackContext` / `CONTEXT_STACK_KEYS`
* are unchanged, because every gated write pays for what they carry), held for
* this judgment by ruling `6104584601` on #22636 (letter B).
*/

import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula';
Expand Down Expand Up @@ -380,6 +392,10 @@ export function flowCelEntrances(
if (triggerObject && undeclared(triggerObject)) {
open(name, `its trigger object '${triggerObject}' (start config.objectName) is not declared in this stack, so the record's keys are not in hand`);
}
if (kind === 'record_change' && !triggerObject) {
// No object filter on the hook: any object's write fires it and hands its row.
open(name, `its record trigger '${String(config.triggerType)}' names no object (start config.objectName), so it fires on a write to any object and the record's keys are not in hand`);
}
const sweep = config.timeRelative && typeof config.timeRelative === 'object' ? (config.timeRelative as AnyRec) : undefined;
const sweepObject = sweep ? nameOf(sweep.object) : undefined;
if (sweepObject && undeclared(sweepObject)) {
Expand Down Expand Up @@ -466,7 +482,9 @@ export function flowCelEntrances(
/**
* The runtime publish gate's entrance: none visible. Its per-write snapshot
* carries the written flow alone (module note), so this one judgment stands down
* there until #22636 widens the snapshot.
* there, by design: it is a whole-stack judgment, `objectstack validate`'s, and
* the snapshot is not widened for it (rulings `5791822697` item 3 and
* `6104584601`, module note).
*/
export function perWriteSnapshotEntrance(): FlowCelEntrance {
return {
Expand Down
47 changes: 44 additions & 3 deletions packages/lint/src/validate-expressions.flow-cel-root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,22 @@ describe('the entrances the stack declares (A′)', () => {
expect(userRefusals(flowStack({ edgeCondition: USER, objectName: 'acct' }))).toHaveLength(1);
});

it('a record trigger that names no start config.objectName stands down; the same trigger on a declared object is judged', () => {
// With no `objectName` the record-change trigger registers its hook with no
// object filter (`record-change-trigger.ts`), so it fires on a write to ANY
// object — one this stack does not declare included — and hands that row.
expect(userRefusals(flowStack({ edgeCondition: USER, triggerType: 'record-after-update' }))).toEqual([]);
expect(userRefusals(flowStack({ edgeCondition: USER, triggerType: 'record-after-update', objectName: 'acct' }))).toHaveLength(1);
// And with no `record-*` trigger there is no such entrance: the flow is judged.
expect(userRefusals(flowStack({ edgeCondition: USER }))).toHaveLength(1);
const entrance = flowCelEntrances(
{ flows: [{ name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start', config: { triggerType: 'record-after-create' } }] }] },
new Map([['acct', ['status']]]),
).get('f');
expect(entrance?.openedBy).toContain("'record-after-create'");
expect(entrance?.bound).toEqual(['record']);
});

it('a time-relative sweep: an undeclared config.timeRelative.object stands down; a declared one is judged', () => {
const sweep = (object: string): AnyRec => {
const stack = flowStack({ edgeCondition: USER });
Expand Down Expand Up @@ -361,6 +377,25 @@ describe('the entrances the stack declares (A′)', () => {
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), judgedParent))).toHaveLength(1);
});

it('a parent that is itself open: its `map` child stands down, to a fixpoint; a judged parent leaves the child judged', () => {
// `map-node.ts` sets the child's `record` only for an id-bearing item; any
// other item leaves the child holding the parent's record. The declared
// `itemObject` keeps the node from opening `f` itself, so only the parent's
// openness can.
const each = (child: string): AnyRec => ({ id: 'each', type: 'map', config: { collection: '{rows}', flowName: child, itemObject: 'acct' } });
const openParent = parentFlow({ type: 'api', node: each('f') });
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), openParent))).toEqual([]);
expect(flowCelEntrances(
{ flows: [{ name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start' }] }, openParent] },
new Map([['acct', ['status']]]),
).get('f')?.openedBy).toMatch(/^map node 'each' in flow 'g' .*'g' is itself open$/);
// Two levels: g (the inbound hook) → map → h → map → f.
const middle = { ...parentFlow({ node: each('f') }), name: 'h', label: 'H' };
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), parentFlow({ type: 'api', node: each('h') }), middle))).toEqual([]);
// RED CONTROL — the same `map` node in a parent no entrance opens leaves the child judged.
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), parentFlow({ node: each('f') })))).toHaveLength(1);
});

it('the reader names the entrance', () => {
const entrances = flowCelEntrances(
{
Expand All @@ -377,9 +412,14 @@ describe('the entrances the stack declares (A′)', () => {
* S (triage `6094850599`): at the runtime publish gate the per-write snapshot
* holds the written flow alone, so no entrance is visible and this one judgment
* stands down there. Every other expression verdict at that door is unchanged.
* #22636 widens the snapshot; when it lands, this stand-down goes.
* By design, not a hold-out: who launches a flow and what record it is handed
* are read from other items of the stack, a whole-stack judgment that belongs to
* `objectstack validate` (ADR-0109 Decision §3's boundary), and the snapshot
* stays as it is (ruling `5791822697` on #19527, item 3; ruling `6104584601` on
* #22636, letter B). These pins hold the asymmetry: the gate stands down, the
* build door refuses.
*/
describe('the runtime publish gate (S)', () => {
describe('the runtime publish gate stands down by design (S)', () => {
const written = {
name: 'f',
label: 'F',
Expand Down Expand Up @@ -415,7 +455,8 @@ describe('the runtime publish gate (S)', () => {
* binds `context.record` and `celScope` binds no `record` of its own. So the
* build door binds it only where an entrance the stack declares hands the flow a
* record, or the flow binds a variable named `record`; otherwise `record.X` is
* refused. The runtime publish gate's stand-down (S) covers it until #22636.
* refused. The runtime publish gate's stand-down (S) covers it, by design (the S
* block above).
*/
describe('`record` — bound only where an entrance hands the flow a record (#22677)', () => {
const READ = 'record.status == "open"';
Expand Down
6 changes: 4 additions & 2 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2169,8 +2169,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// another flow's `subflow` / `map` node). At the runtime publish gate
// (`runtimeWriteType` set) the per-write snapshot holds the written flow alone,
// so no entrance is visible there and the judgment stands down
// (`perWriteSnapshotEntrance`) until #22636 widens that snapshot; every other
// verdict this pass gives at that door is unchanged.
// (`perWriteSnapshotEntrance`), by design: a judgment that reads other items of
// the stack is `objectstack validate`'s, and that snapshot is not widened for
// it (rulings `5791822697` item 3 and `6104584601`; `flow-cel-root-scope.ts`'s
// module note). Every other verdict this pass gives at that door is unchanged.
const runtimeGateWrite = options.runtimeWriteType !== undefined;
const flowEntrances: ReadonlyMap<string, FlowCelEntrance> =
objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex);
Expand Down
Loading