From 49c47a4e51222cc9e97b5682330c4aa8ae8645ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:43:30 +0000 Subject: [PATCH 1/4] test(lint): pin the objectName-less record trigger entrance and the map child of an open parent; reword S as by design The record-trigger pin is red until flowCelEntrances gains the open reason in the next commit; the map-child pin holds behaviour already on main. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- ...validate-expressions.flow-cel-root.test.ts | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-expressions.flow-cel-root.test.ts b/packages/lint/src/validate-expressions.flow-cel-root.test.ts index 794173fb048..850ef8f5349 100644 --- a/packages/lint/src/validate-expressions.flow-cel-root.test.ts +++ b/packages/lint/src/validate-expressions.flow-cel-root.test.ts @@ -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 }); @@ -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( { @@ -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', @@ -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"'; From bd22088e0b1b1baebf07a7104ed41360f4bfdb85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:04:05 +0000 Subject: [PATCH 2/4] feat(lint): a record trigger that names no object opens its flow to the CEL root judge; the runtime gate's stand-down is by design flowCelEntrances gains one open reason: a flow whose trigger kind is record_change and whose start node names no config.objectName. The record-change trigger then registers its hook with no object filter, fires on a write to any object and hands that row, so the build door cannot know the record's keys. The trigger shape is not refused. The runtime publish gate's stand-down of this judgment (S) is reworded from a hold-out awaiting a wider per-write snapshot to the ruled boundary: a judgment that reads other items of the stack is objectstack validate's, and the snapshot is not widened for it. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- ...flow-cel-record-trigger-no-object-opens.md | 13 +++++++++ packages/lint/src/flow-cel-root-scope.ts | 28 +++++++++++++++---- packages/lint/src/validate-expressions.ts | 6 ++-- 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 .changeset/22636-flow-cel-record-trigger-no-object-opens.md diff --git a/.changeset/22636-flow-cel-record-trigger-no-object-opens.md b/.changeset/22636-flow-cel-record-trigger-no-object-opens.md new file mode 100644 index 00000000000..e9c5f538af5 --- /dev/null +++ b/.changeset/22636-flow-cel-record-trigger-no-object-opens.md @@ -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 still does not judge unbound flow CEL roots on a flow write, and it will not: 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. Read the runtime publish gate notes of the two earlier entries for this rule (the unbound-root refusal and the `record` entrance) this way: that stand-down 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. diff --git a/packages/lint/src/flow-cel-root-scope.ts b/packages/lint/src/flow-cel-root-scope.ts index 1f19fd39e07..274a60f90d1 100644 --- a/packages/lint/src/flow-cel-root-scope.ts +++ b/packages/lint/src/flow-cel-root-scope.ts @@ -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; @@ -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` / @@ -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'; @@ -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)) { @@ -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 { diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 62ad8d3a9fc..57f0808aa58 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -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 = objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex); From 337de825afaf2dfa19a2af0cd67e0c5ea35b205c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:12:54 +0000 Subject: [PATCH 3/4] docs(changeset): state the runtime gate's stand-down as the intended boundary, without pointing at other entries Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .changeset/22636-flow-cel-record-trigger-no-object-opens.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22636-flow-cel-record-trigger-no-object-opens.md b/.changeset/22636-flow-cel-record-trigger-no-object-opens.md index e9c5f538af5..74f10ee5cf7 100644 --- a/.changeset/22636-flow-cel-record-trigger-no-object-opens.md +++ b/.changeset/22636-flow-cel-record-trigger-no-object-opens.md @@ -8,6 +8,6 @@ Clause-②: yes (widening: a flow whose `record-*` trigger names no start `confi **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 still does not judge unbound flow CEL roots on a flow write, and it will not: 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. Read the runtime publish gate notes of the two earlier entries for this rule (the unbound-root refusal and the `record` entrance) this way: that stand-down is the intended boundary, not a gap waiting on a wider snapshot. +**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. From 2c06d027e8eb501d1f6c053f45d1f4f1a15a0b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:45:40 +0000 Subject: [PATCH 4/4] docs(changeset): the two pending flow CEL root notes call the runtime gate's stand-down by design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each note's "yet" / "until that snapshot carries them" clause becomes "by design", citing rulings 5791822697 item 3 and 6104584601; the rest of each sentence, the front matter, the levels and the Clause-② lines are unchanged. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .changeset/22565-flow-cel-unbound-root-refused.md | 2 +- .changeset/22677-flow-cel-record-entrance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22565-flow-cel-unbound-root-refused.md b/.changeset/22565-flow-cel-unbound-root-refused.md index cbddb31a642..b4f04117ca0 100644 --- a/.changeset/22565-flow-cel-unbound-root-refused.md +++ b/.changeset/22565-flow-cel-unbound-root-refused.md @@ -31,7 +31,7 @@ The user spellings are told to write `current_user`, with the guard for a flow t - 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. diff --git a/.changeset/22677-flow-cel-record-entrance.md b/.changeset/22677-flow-cel-record-entrance.md index 4d8e6b70785..cc0df6456a6 100644 --- a/.changeset/22677-flow-cel-record-entrance.md +++ b/.changeset/22677-flow-cel-record-entrance.md @@ -17,7 +17,7 @@ Clause-②: no (narrowing) 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