Skip to content

Commit bd22088

Browse files
committed
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 <noreply@anthropic.com>
1 parent 49c47a4 commit bd22088

3 files changed

Lines changed: 40 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
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.
6+
7+
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)
8+
9+
**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.
10+
11+
**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.
12+
13+
**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.

‎packages/lint/src/flow-cel-root-scope.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@
8282
*
8383
* - **a record trigger** — the start node's `config.objectName` (the trigger
8484
* object a record-change run's row belongs to) names an undeclared object;
85+
* - **a record trigger that names no object** — the trigger kind is
86+
* `record_change` and the start node names no `config.objectName`: the
87+
* record-change trigger then registers its hook with no object filter
88+
* (`record-change-trigger.ts`), so it fires on a write to ANY object, one the
89+
* stack does not declare included, and hands that row;
8590
* - **a time-relative sweep** — the start node's `config.timeRelative.object`
8691
* (`TimeRelativeTriggerSchema`; its own key, independent of `objectName`, and
8792
* the object whose rows the sweep hands each run) is undeclared;
@@ -130,7 +135,7 @@
130135
* its `context.record`. A parent's own `record` VARIABLE is not handed on —
131136
* the child gets the parent's context, not its variables. Read to a fixpoint.
132137
*
133-
* ## The runtime publish gate stands down (#22636 is the carrier)
138+
* ## The runtime publish gate stands down, by design
134139
*
135140
* The same pass runs at the runtime publish gate on a flow write, and there the
136141
* per-write snapshot carries the written flow alone beside `objects` /
@@ -139,9 +144,16 @@
139144
* `map` node could hand this flow any record. A false 422 on the only door a
140145
* Studio tenant has is worse than the gap, so this one judgment stands down there
141146
* ({@link perWriteSnapshotEntrance}); every other expression verdict at that door
142-
* is unchanged, and `objectstack validate` judges under the rule above. #22636
143-
* widens that snapshot to carry flows and actions; when it lands, this
144-
* stand-down goes.
147+
* is unchanged, and `objectstack validate` judges under the rule above.
148+
*
149+
* ⛔ Not a hold-out awaiting a wider snapshot. Who launches a flow and what
150+
* record it is handed are read from OTHER items of the stack, so this is a
151+
* whole-stack judgment, and it belongs to `objectstack validate` (ADR-0109
152+
* Decision §3's boundary); the runtime publish gate judges the written item
153+
* against the four collections it carries. The snapshot stays as it is: ruling
154+
* `5791822697` on #19527 (item 3: `RuntimeStackContext` / `CONTEXT_STACK_KEYS`
155+
* are unchanged, because every gated write pays for what they carry), held for
156+
* this judgment by ruling `6104584601` on #22636 (letter B).
145157
*/
146158

147159
import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula';
@@ -380,6 +392,10 @@ export function flowCelEntrances(
380392
if (triggerObject && undeclared(triggerObject)) {
381393
open(name, `its trigger object '${triggerObject}' (start config.objectName) is not declared in this stack, so the record's keys are not in hand`);
382394
}
395+
if (kind === 'record_change' && !triggerObject) {
396+
// No object filter on the hook: any object's write fires it and hands its row.
397+
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`);
398+
}
383399
const sweep = config.timeRelative && typeof config.timeRelative === 'object' ? (config.timeRelative as AnyRec) : undefined;
384400
const sweepObject = sweep ? nameOf(sweep.object) : undefined;
385401
if (sweepObject && undeclared(sweepObject)) {
@@ -466,7 +482,9 @@ export function flowCelEntrances(
466482
/**
467483
* The runtime publish gate's entrance: none visible. Its per-write snapshot
468484
* carries the written flow alone (module note), so this one judgment stands down
469-
* there until #22636 widens the snapshot.
485+
* there, by design: it is a whole-stack judgment, `objectstack validate`'s, and
486+
* the snapshot is not widened for it (rulings `5791822697` item 3 and
487+
* `6104584601`, module note).
470488
*/
471489
export function perWriteSnapshotEntrance(): FlowCelEntrance {
472490
return {

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,8 +2169,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
21692169
// another flow's `subflow` / `map` node). At the runtime publish gate
21702170
// (`runtimeWriteType` set) the per-write snapshot holds the written flow alone,
21712171
// so no entrance is visible there and the judgment stands down
2172-
// (`perWriteSnapshotEntrance`) until #22636 widens that snapshot; every other
2173-
// verdict this pass gives at that door is unchanged.
2172+
// (`perWriteSnapshotEntrance`), by design: a judgment that reads other items of
2173+
// the stack is `objectstack validate`'s, and that snapshot is not widened for
2174+
// it (rulings `5791822697` item 3 and `6104584601`; `flow-cel-root-scope.ts`'s
2175+
// module note). Every other verdict this pass gives at that door is unchanged.
21742176
const runtimeGateWrite = options.runtimeWriteType !== undefined;
21752177
const flowEntrances: ReadonlyMap<string, FlowCelEntrance> =
21762178
objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex);

0 commit comments

Comments
 (0)