Skip to content

Commit 6981246

Browse files
feat(lint): a record trigger that names no object opens its flow to the CEL root judge; the runtime gate stand-down is by design (#22762)
Fixes #22636 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) Executes ruling `6104584601` (letter B) on #22636: ruling `5791822697` item 3 stands, so the runtime publish gate keeps its per-write snapshot and its stand-down on the flow CEL unbound-root judgment is by design. W (the snapshot carrying flows and actions) is not built. This PR carries the three items the ruling names, all in `packages/lint`, plus (patch round 1, seat order `6105130863`) the correction of two pending release notes that still called the stand-down temporary. ## What changes 1. **S is reworded from a hold-out to the ruled boundary.** The stand-down itself (`perWriteSnapshotEntrance`) is unchanged in code. Its wording changes from "until #22636 widens the snapshot" to "by design": 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 belongs to `objectstack validate` (ADR-0109 Decision §3's boundary). The text cites ruling `5791822697` (item 3) and ruling `6104584601`. It changes in: - `flow-cel-root-scope.ts`: the module header section (now "The runtime publish gate stands down, by design") and `perWriteSnapshotEntrance`'s docblock; - `validate-expressions.ts`: the comment above `runtimeGateWrite` (comment only); - `validate-expressions.flow-cel-root.test.ts`: the S block's docblock and describe title (now `the runtime publish gate stands down by design (S)`), and the `record` block's docblock. The S pins themselves are unchanged: the gate stands down, and the build door refuses the same root on the same flow. 2. **A `record-*` trigger with no start `config.objectName` opens the flow.** `flowCelEntrances` gains one open reason. It applies when the trigger kind is `record_change` (`resolveFlowTriggerKind`: start `config.triggerType` is a `record-*` string) and the start node names no `config.objectName`. Measured on the runtime: `deriveTriggerBinding` (`engine.ts`) binds `object: undefined`, and `record-change-trigger.ts` registers the hook with `object: binding.object`, so the hook has no object filter. It fires on a write to any object, including one the stack does not declare, and hands that row (`binding.object ?? ctx.object`). The build door cannot know the record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an undeclared object. ⛔ The trigger shape is not refused (no new gate, no narrowing). The module header's entrance list gains the bullet. 3. **The owed `map`-child pin** (carry note `6096005997`, from contract review `6095756455` ③). A `map` child of an open parent is opened by the fixpoint. The pin has three parts: one level, two levels (g → map → h → map → f), and a red control (the same `map` node in a parent no entrance opens leaves the child judged). Each `map` node's `itemObject` names a declared object, so the node cannot open the child by itself; only the parent's openness can. No behaviour change: the pin was green before the fix commit (below), and `map-node.ts` spreads the parent context and overrides `record` only for an id-bearing item. 4. **Two pending release notes say "by design" (patch round 1, `2c06d027e8`).** Under ruling B, two `@objectstack/lint` changesets that have not been released yet were stating something false. In each, only the "yet" / "until …" clause changes; the rest of the sentence, the front matter, the levels and the `Clause-②` lines are byte-identical. - `.changeset/22565-flow-cel-unbound-root-refused.md:34`: "a flow write there is not judged for unbound roots ~~yet~~, by design (rulings `5791822697` item 3 and `6104584601`)." - `.changeset/22677-flow-cel-record-entrance.md:20`: "…and a flow write there is not refused for `record` ~~until that snapshot carries them~~, by design (rulings `5791822697` item 3 and `6104584601`)." `.changeset/22636-flow-cel-record-trigger-no-object-opens.md`: `@objectstack/lint` `minor`, carrying the `Clause-②` line above. `check-changeset-no-major`'s level axis requires at least `minor` for a `yes (widening)` declaration. ## Measured before writing - **Today's behaviour for the objectName-less record trigger: closed and judged.** `flowCelEntrances` hands the flow `record` and opens nothing. Its root is judged against every declared object's fields, so `user.id` is refused there. The new pin was committed first (`49c47a4e51`) and run on the unfixed source: `Tests 1 failed | 78 passed (79)`, the failure being `a record trigger that names no start config.objectName stands down…` with `AssertionError: expected [ { …(4) } ] to deeply equal []`. The `map`-child pin was green in the same run. - **The corpus has no such trigger.** Loaded configs (`tsx` import of each `objectstack.config.ts`), at `e7e6544ab9`: `app-crm` 1 flow and 0 record triggers; `app-multi-package` 0 flows; `app-showcase` 30 flows, 19 record triggers, 19 naming `objectName`; `app-todo` 4 flows, 1 record trigger, 1 naming it. - `RULE_EXPLANATIONS` (`rule-explanations.ts`) carries no S wording: no "until", "snapshot" or stand-down sentence for the flow CEL root judgment. Not touched. ## Tests (at `bd22088e0b`; the later commits `337de825af` and `2c06d027e8` edit only changeset text) - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` (the whole package): `Test Files 136 passed (136)`, `Tests 6353 passed (6353)`. - `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit` plus `check:test-typecheck`, whose `tsconfig.test.json` includes `src/**/*`, so it compiles the test file): exit 0, `check:test-typecheck: OK … 2 file(s) / 6 error(s) / 2 pinned signature(s) held` (pre-existing, unchanged; the edited test file is not in the ledger, so it compiles with zero errors). - `pnpm --filter @objectstack/lint build`: exit 0, `check-dts-emitted: @objectstack/lint - 6/6 declared declaration file(s) present`. **Ablation of the new open reason.** `node scripts/ablation-replace.mjs --file packages/lint/src/flow-cel-root-scope.ts --anchor "if (kind === 'record_change' && !triggerObject) {" --replacement "if (false && kind === 'record_change' && !triggerObject) {" -- pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-expressions.flow-cel-root.test.ts` - The mutation landed on disk: `anchor x1 -> x0`, `replace x0 -> x1`, blob `274a60f90d10 -> 392c0b3b88f6`. - Run with the mutation in place: `Tests 1 failed | 78 passed (79)`, the one red being the new record-trigger pin. - Restored and proven: `blob == HEAD (274a60f) and git diff HEAD is empty`, and `git status --porcelain` was empty afterwards. - The test imports the module by relative path (`./flow-cel-root-scope.js`), so the run reads `src/`, not `dist/`. No rebuild leg was needed. ## `objectstack validate` on the four example apps, before and after `node packages/cli/bin/run.js validate --json` in each example directory. Before: lint `dist/index.js` built at `49c47a4e51`, the unfixed source, sha256 `5a2bfdb33967b164…`. After: rebuilt at `bd22088e0b`, sha256 `3a0412ddb92568a7…`. Every app exits 0 with `valid: true` both times, and the error and warning lists are byte-identical before and after (`diff` of the sorted finding arrays: 0 lines). | app | errors | warnings by id (before = after) | |---|---|---| | `app-crm` | 0 | `field-no-consumers` 9 | | `app-multi-package` | 0 | `field-no-consumers` 3 | | `app-showcase` | 0 | `approval-approvers-may-resolve-empty` 11, `field-no-consumers` 51, `flow-draft-status-ambiguous` 1, `flow-loop-body-uncontained` 2, `liveness-planned-property` 2, `sdui/jsx-parse-level-only` 1, `security-private-no-readscope` 2, `title-unresolvable` 1 | | `app-todo` | 0 | `field-no-consumers` 5, `flow-draft-status-ambiguous` 2 | The widening removes nothing here because no shipped record trigger lacks `objectName` (above). It adds nothing anywhere: the change only sets an `openedBy`, and an open flow is judged for no root. ## No text still promises the snapshot will widen ```text $ git grep -n "22636" 337de82 -- packages content/docs skills packages/lint/src/flow-cel-root-scope.ts:156: * this judgment by ruling `6104584601` on #22636 (letter B). packages/lint/src/validate-expressions.flow-cel-root.test.ts:419: * #22636, letter B). These pins hold the asymmetry: the gate stands down, the packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts:20:// Probe E-A (#5493 comment 5226364929) is where that costs something real: a packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts:23:// issue comment 5226364929): `checkEdit` abstains — and `canEdit` therefore (exit 0) $ git grep -n -i -E "widens? (that |the )?snapshot|until #22636|#22636 (is the carrier|widens)" 337de82 -- packages content/docs skills (exit 1) $ git grep -n -i -E "widens? (that |the )?snapshot|until #22636|#22636 (is the carrier|widens)" e7e6544 -- packages content/docs skills # control: the base packages/lint/src/flow-cel-root-scope.ts:133: * ## The runtime publish gate stands down (#22636 is the carrier) packages/lint/src/flow-cel-root-scope.ts:143: * widens that snapshot to carry flows and actions; when it lands, this packages/lint/src/flow-cel-root-scope.ts:469: * there until #22636 widens the snapshot. packages/lint/src/validate-expressions.flow-cel-root.test.ts:380: * #22636 widens the snapshot; when it lands, this stand-down goes. packages/lint/src/validate-expressions.flow-cel-root.test.ts:418: * refused. The runtime publish gate's stand-down (S) covers it until #22636. packages/lint/src/validate-expressions.ts:2172: // (`perWriteSnapshotEntrance`) until #22636 widens that snapshot; every other (exit 0) $ git grep -n -i -E "snapshot carries them|unbound roots yet" HEAD -- .changeset # HEAD = 2c06d02 (patch round 1) (exit 1) $ git grep -n -i -E "snapshot carries them|unbound roots yet" 337de82 -- .changeset # control: the previous head .changeset/22565-flow-cel-unbound-root-refused.md:34:- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. … .changeset/22677-flow-cel-record-entrance.md:20:**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publi… (exit 0) ``` The two remaining `#22636` hits in `packages/lint` cite this card's ruling and promise nothing. The other two hits are a comment id that contains the digits. A broader `until … widen` grep over the same roots finds only unrelated sentences (sharing grants, driver-memory, the spec changelog). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the final head `337de825af`: 62 commands (30 pnpm, 32 direct node), the same list as at `bd22088e0b`. Each was run with its exit code captured before any pipe. The diff-reading ones (the changeset, citation, docs-audit, doc-authoring and nul-byte gates) were re-run at `337de825af` after the last commit. Reconciled with `--ran`: `✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED (0 DERIVED from a recorded exit 3, 1 claimed)`. - **61 run, all exit 0.** Among them: - `check-adr-0087-registration`: `this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)`; - `check-changeset-no-major`: `This diff introduces no major bump`; its level axis, driven offline with `--event` over this body, reads `LEVEL AXIS: this PR declares clause-② yes (widening), and no package whose packages/**/src/** it moves is graded patch` and exits 0; - `check-empty-changeset`: `No changeset from the merge base modified or deleted by this diff`; - `check-issue-citations`: `every citation this change adds resolves`; - `check:nul-bytes`: `scanned 10782 text file(s) … no raw ASCII control bytes`; - `check:dts-closure`: `161/161 declared declaration file(s) present across 62 package(s)`; - `check:test-source-alias` and `check:cross-package-test-inputs`: OK. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: six unrelated packages had no `dist/`). Those six were built under the verify lock, and it then exited 0. - **NOT MEASURED: `pnpm check:type-check-debt`.** The script is `check-type-check-coverage.mjs --re-measure`, which the dispatch forbids running locally; CI runs it. `pnpm check:type-check-coverage` (no re-measure) ran: exit 0. - Also run, outside this derivation: the three roster gates that keep their roster under `packages/` (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), all exit 0. - **NOT MEASURED, CI's own shell** (no local invocation): the five path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and the workspace type-check lanes. The lint package's own `typecheck` ran (above). **Patch round 1, at `2c06d027e8`.** `dispatch-gates --commands --repo objectstack-ai/objectstack` on the two changeset paths derived 20 commands. 19 exited 0, among them: - `check-adr-0087-registration --base origin/main`: `this PR adds no declared-breaking changeset (3 non-breaking changeset(s) seen)`; - `check-changeset-no-major --base origin/main`: `This diff introduces no major bump`; - `check:nul-bytes`, `check:changeset-gate-self-tests`, `check:published-files` and `check:pm-changeset-deadline-census`. **One is red by design:** `check-empty-changeset --base origin/main` exits 1 on the foreign-changeset rule, naming both files. This is its DELIBERATE CORRECTION class, so the confirmation request is below. `Check Changeset` (not a required context) is `success` at `337de825af` and `failure` at `2c06d027e8`. Its two failure annotations are those two files. ## Acceptance notes - **Confirmation requested — DELIBERATE CORRECTION of two pending release notes (`check-empty-changeset`'s foreign-changeset rule).** This PR changes `.changeset/22565-flow-cel-unbound-root-refused.md` (from PR #22609) and `.changeset/22677-flow-cel-record-entrance.md` (from PR #22730). Both are notes this PR did not add. What changed under them: ruling `6104584601` made the runtime gate's stand-down permanent, so "not judged … yet" and "not refused … until that snapshot carries them" became false. Each now says "by design", and nothing else in either file moved. Neither note has been consumed into `packages/lint/CHANGELOG.md`. Ordered by the `domain:spec` seat in `6105130863`; ⛔ do not restore either file from the base, which would republish the false sentence. Per the workflow's route for this class, `skip-changeset` is not applied and the red is left standing for the approver to merge over. - `RULE_EXPLANATIONS` carries no S wording, so nothing is routed behind the `domain:spec` seat's #22161 slice 8. - The ruling names one contract-review-tier review for this PR (its `Clause-②` is `yes (widening)`). - The advisory Docs Drift Check listed 13 pages through the `record_change` string literal. None of them describes the build door's unbound-root judgment: `git grep -n -i -E "unbound root|Unknown variable: (user|record)|is not bound in|flowCelEntrances|objectName.{0,40}(omit|without|no )"` over the 13 exits 1. - `runtime-gate.ts` and `authoring-rules.ts` cite ruling `5791822697` and contain no "until #22636". They are untouched. - A `triggerType` array (`['record-after-create', …]`) resolves to no kind in `resolveFlowTriggerKind`, so it does not reach the new open reason. The engine routes that shape only so it can refuse it at bind, and lint already reports it as `flow-trigger-unknown-event`. Unchanged, by that resolver's documented decision. ## Not in this PR W as filed: no change to `RuntimeStackContext`, `CONTEXT_STACK_KEYS`, `protocol.ts`, `packages/spec` or the `RuntimeAuthoringIssueSchema.path` contract. --- _Generated by [Claude Code](https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95a8435 commit 6981246

6 files changed

Lines changed: 86 additions & 12 deletions

‎.changeset/22565-flow-cel-unbound-root-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The user spellings are told to write `current_user`, with the guard for a flow t
3131
- a `type: 'flow'` action launching the flow on an undeclared object;
3232
- a `map` node feeding the flow whose `itemObject` is undeclared or absent;
3333
- a `subflow` or `map` parent that is itself such a flow.
34-
- **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.
34+
- **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.
3535

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

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 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.
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.

‎.changeset/22677-flow-cel-record-entrance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Clause-②: no (narrowing)
1717

1818
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.
1919

20-
**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.
20+
**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`).
2121

2222
## FROM → TO
2323

‎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.flow-cel-root.test.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,22 @@ describe('the entrances the stack declares (A′)', () => {
294294
expect(userRefusals(flowStack({ edgeCondition: USER, objectName: 'acct' }))).toHaveLength(1);
295295
});
296296

297+
it('a record trigger that names no start config.objectName stands down; the same trigger on a declared object is judged', () => {
298+
// With no `objectName` the record-change trigger registers its hook with no
299+
// object filter (`record-change-trigger.ts`), so it fires on a write to ANY
300+
// object — one this stack does not declare included — and hands that row.
301+
expect(userRefusals(flowStack({ edgeCondition: USER, triggerType: 'record-after-update' }))).toEqual([]);
302+
expect(userRefusals(flowStack({ edgeCondition: USER, triggerType: 'record-after-update', objectName: 'acct' }))).toHaveLength(1);
303+
// And with no `record-*` trigger there is no such entrance: the flow is judged.
304+
expect(userRefusals(flowStack({ edgeCondition: USER }))).toHaveLength(1);
305+
const entrance = flowCelEntrances(
306+
{ flows: [{ name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start', config: { triggerType: 'record-after-create' } }] }] },
307+
new Map([['acct', ['status']]]),
308+
).get('f');
309+
expect(entrance?.openedBy).toContain("'record-after-create'");
310+
expect(entrance?.bound).toEqual(['record']);
311+
});
312+
297313
it('a time-relative sweep: an undeclared config.timeRelative.object stands down; a declared one is judged', () => {
298314
const sweep = (object: string): AnyRec => {
299315
const stack = flowStack({ edgeCondition: USER });
@@ -361,6 +377,25 @@ describe('the entrances the stack declares (A′)', () => {
361377
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), judgedParent))).toHaveLength(1);
362378
});
363379

380+
it('a parent that is itself open: its `map` child stands down, to a fixpoint; a judged parent leaves the child judged', () => {
381+
// `map-node.ts` sets the child's `record` only for an id-bearing item; any
382+
// other item leaves the child holding the parent's record. The declared
383+
// `itemObject` keeps the node from opening `f` itself, so only the parent's
384+
// openness can.
385+
const each = (child: string): AnyRec => ({ id: 'each', type: 'map', config: { collection: '{rows}', flowName: child, itemObject: 'acct' } });
386+
const openParent = parentFlow({ type: 'api', node: each('f') });
387+
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), openParent))).toEqual([]);
388+
expect(flowCelEntrances(
389+
{ flows: [{ name: 'f', type: 'autolaunched', nodes: [{ id: 'start', type: 'start' }] }, openParent] },
390+
new Map([['acct', ['status']]]),
391+
).get('f')?.openedBy).toMatch(/^map node 'each' in flow 'g' .*'g' is itself open$/);
392+
// Two levels: g (the inbound hook) → map → h → map → f.
393+
const middle = { ...parentFlow({ node: each('f') }), name: 'h', label: 'H' };
394+
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), parentFlow({ type: 'api', node: each('h') }), middle))).toEqual([]);
395+
// RED CONTROL — the same `map` node in a parent no entrance opens leaves the child judged.
396+
expect(userRefusals(withFlows(flowStack({ edgeCondition: USER }), parentFlow({ node: each('f') })))).toHaveLength(1);
397+
});
398+
364399
it('the reader names the entrance', () => {
365400
const entrances = flowCelEntrances(
366401
{
@@ -377,9 +412,14 @@ describe('the entrances the stack declares (A′)', () => {
377412
* S (triage `6094850599`): at the runtime publish gate the per-write snapshot
378413
* holds the written flow alone, so no entrance is visible and this one judgment
379414
* stands down there. Every other expression verdict at that door is unchanged.
380-
* #22636 widens the snapshot; when it lands, this stand-down goes.
415+
* By design, not a hold-out: who launches a flow and what record it is handed
416+
* are read from other items of the stack, a whole-stack judgment that belongs to
417+
* `objectstack validate` (ADR-0109 Decision §3's boundary), and the snapshot
418+
* stays as it is (ruling `5791822697` on #19527, item 3; ruling `6104584601` on
419+
* #22636, letter B). These pins hold the asymmetry: the gate stands down, the
420+
* build door refuses.
381421
*/
382-
describe('the runtime publish gate (S)', () => {
422+
describe('the runtime publish gate stands down by design (S)', () => {
383423
const written = {
384424
name: 'f',
385425
label: 'F',
@@ -415,7 +455,8 @@ describe('the runtime publish gate (S)', () => {
415455
* binds `context.record` and `celScope` binds no `record` of its own. So the
416456
* build door binds it only where an entrance the stack declares hands the flow a
417457
* record, or the flow binds a variable named `record`; otherwise `record.X` is
418-
* refused. The runtime publish gate's stand-down (S) covers it until #22636.
458+
* refused. The runtime publish gate's stand-down (S) covers it, by design (the S
459+
* block above).
419460
*/
420461
describe('`record` — bound only where an entrance hands the flow a record (#22677)', () => {
421462
const READ = 'record.status == "open"';

‎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)