Repository navigation
Commit 0f77ff5
Fixes #22677
Clause-②: no (narrowing)
## What
`record` leaves the build door's always-bound roots
(`ENGINE_BOUND_ROOTS` in `packages/lint/src/flow-cel-root-scope.ts`).
Since PR #22674 (#22642) the run binds `record` only to the record its
entrance handed it, or to a flow's own `record` variable, so
`objectstack validate` passed a `record.X` that faults at run time with
`Unknown variable: record`. Now:
- `flowCelEntrances` reads a second set beside "opened": the flows an
entrance hands ANY record. `record` goes into those flows' bound set.
- A flow that declares a `record` variable (or assigns one) binds it
through `collectFlowVariableNames`, as any other name.
- Everywhere else `record.X` is refused as `expression-invalid`,
`error`, with a `record`-specific message that names the remedy: read
the variable by its name or through `vars` (`record.status` becomes
`status` / `vars.status`), or give the flow the entrance that hands it
its record.
- `previous`, `vars` and `current_user` stay engine roots. The generic
refusal's closing clause now says so, and adds that `record` is bound
when an entrance hands the run one.
- The runtime publish gate's stand-down (`perWriteSnapshotEntrance`) is
unchanged and covers `record` too, until #22636.
`validate-expressions.ts` is unchanged.
## The two sets, measured on the engine at `f66fdc7973`
| entrance | hands a record (`record` bound) | opens the flow (stands
down) |
|:--|:--|:--|
| record trigger: kind `record_change` (start `config.triggerType` is a
`record-*` string) | yes, the row | when start `config.objectName` is
undeclared |
| time-relative sweep: kind `time_relative` | yes, each swept row | when
`config.timeRelative.object` is undeclared |
| inbound hook: kind `api` | yes, the request body | always |
| `type: 'flow'` action targeting the flow | yes, on any object or none
(an object-less action hands an empty record, still a record) | when the
action's object is undeclared |
| `map` node naming the flow | yes, when `config.itemObject` is set |
when `itemObject` is undeclared or absent |
| `subflow` / `map` parent | yes, when the parent is handed one
(fixpoint) | when the parent is open (fixpoint) |
| start `config.objectName` alone, a REST trigger route, a declared
endpoint, a cron schedule | no | (unchanged) |
Sources:
- `seedRunVariables` binds `context.record` as `record` and nothing else
does.
- `deriveTriggerBinding` routes `record_change` only for a `record-*`
`triggerType`.
- `subflow-node.ts` / `map-node.ts` spread the parent's context.
- `dispatchFlowAction` always passes a record.
A parent's own `record` VARIABLE is not handed on, because a child gets
its parent's context, not its variables. That case is pinned.
`previous` has no such problem on main: `seedRunVariables` sets it on
every run (`context?.previous ?? null`), so it is never unbound. It
stays in `ENGINE_BOUND_ROOTS`.
## Corrected pending release notes: the DELIBERATE CORRECTION class
`check-empty-changeset` is red **by design**, naming exactly two
pending, unreleased notes that this PR changes the behaviour of:
- `.changeset/22565-flow-cel-unbound-root-refused.md` (#22565);
- `.changeset/22642-flow-cel-record-binding.md` (#22642).
Each is corrected in place, and neither may be restored from the base.
### `.changeset/22565-flow-cel-unbound-root-refused.md`: three `record`
sentences, verbatim
1. **Why** paragraph
- FROM: "the trigger record's fields flattened beside them, and
`record`, `previous`, `vars` and `current_user` bound by the engine."
- TO: "the trigger record's fields flattened beside them, `record` bound
to the record the run was handed (where an entrance hands it one), and
`previous`, `vars` and `current_user` bound by the engine."
2. **What is refused**, first bullet
- FROM: "- the engine's roots: `record`, `previous`, `vars`,
`current_user`;"
- TO: "- the engine's roots: `previous`, `vars`, `current_user`;"
- followed by a new bullet: "- `record`, where an entrance the stack
declares hands the flow a record — a record trigger (a `record-*`
`triggerType`), a time-relative sweep, the inbound hook, a `type:
'flow'` action, a `map` node with an `itemObject`, or a `subflow` /
`map` parent handed one — or where the flow binds a variable named
`record`;"
3. **Who is affected**
- FROM: "every root those flows read is an engine root, a binding of the
flow's own or a field of its trigger object."
- TO: "every root those flows read is an engine root, `record` on a flow
an entrance hands a record, a binding of the flow's own or a field of
its trigger object."
### `.changeset/22642-flow-cel-record-binding.md`: one sentence,
verbatim, nothing else in the file (seat order `6102049574`)
- FROM: "`objectstack validate` does not refuse such a `record` read
yet; the run fails it, naming the root and the source."
- TO: "`objectstack validate` refuses such a `record` read too, naming
the root (see `@objectstack/lint`'s note)."
### The D3 entry
The landed D3 entry `flow-cel-unbound-root-refused` gets a text-only
change to `reason`, and nothing else in the entry changes:
- FROM: "the trigger record's fields flattened beside them, and record,
previous, vars and current_user bound by the engine."
- TO: "the trigger record's fields flattened beside them, record bound
to the record the run was handed (where an entrance the stack declares
hands the flow one, or the flow binds a variable named record), and
previous, vars and current_user bound by the engine."
`registry.ts` is still tracked, and it was regenerated with
`gen:migration-registry` (a 4-line diff in the same region).
## New changeset and ADR-0087 disposition
`.changeset/22677-flow-cel-record-entrance.md`:
- `@objectstack/lint: major`. That is the pre-mode level #22565's note
took for the same rule's narrowing; `check-changeset-no-major` exempts
it in pre mode.
- `Clause-②: no (narrowing)`.
- Marker `not-required (already-registered
flow-cel-unbound-root-refused,
flow-cel-record-variables-alias-retired)`. No new ledger entry is owed:
- a `record` read on a flow no entrance hands a record is a root the
flow does not bind, which is exactly the surface of the unreleased D3
entry `flow-cel-unbound-root-refused`, and its corrected `reason` now
says so;
- the runtime half is #22642's own D3 entry;
- both exist at the merge base, and neither has shipped.
The first version of this note ended with a line saying the #22642
note's sentence no longer held. That line was dropped at `0e51b65d09`,
once the #22642 sentence itself was corrected.
## Fixture census, taken before any fixture was edited
These are the fixtures whose flow reads `record.X` with no record
entrance. Each is a start node with `config.objectName` and no
`record-*` trigger, except the `lint-flow-patterns` one, which has
neither.
Lint suite with only the judge change: 34 failed in 3 files.
- `packages/lint/src/validate-expressions.test.ts`: 29 tests, 9
fixtures. The start node at each line below got `triggerType:
'record-after-update'` (text only):
- `:47` passes a clean stack;
- `:81` unknown record field;
- `:478` dotted spelling;
- `:2760` the `flowWith` helper for the region tests;
- `:3125` null-guard flow conditions. Its #4811 comment "the engine
binds both roots unconditionally" is corrected to "a record-triggered
run binds both roots";
- `:4031` `withCondition`, 15 tests of #5378;
- `:4456` `flowWith` of #15662, 4 tests;
- `:4623` `flowWith` of #17495, 2 tests;
- `:4754` `stackWith` of #15742, 2 tests.
- `packages/lint/src/lint-flow-patterns.test.ts:2787`: `valueFlow`, 2
tests. It prescribes `record.title` on an autolaunched flow with no
entrance. It became a `record_change` flow on a declared `task` object,
so the judgment stays live rather than standing down.
- `packages/lint/src/validate-expressions.flow-cel-root.test.ts`: 3 of
#22565's own pins:
- the "engine roots" control dropped `record` (a new control reads it on
a record-triggered flow);
- the CEL-type-name control reads `type(vars)`;
- the generic message pin follows the corrected closing clause.
- `@objectstack/metadata-protocol` (226 files, built closure, lint
`dist` carrying the new judge): 0. Its flows go through the runtime
gate, which stands down.
- CLI files that run flows through `validate`: 0. That is 5 `unit` files
and 3 `integration` files that spawn the source CLI.
## Reach with the real judge
`validateStackExpressions` and `flowCelEntrances` from this worktree.
Positive control: a no-entrance flow that reads `record.status` is
counted, with 1 refusal.
| scope | flows | read `record` | newly refused |
|:--|:--|:--|:--|
| `examples/**` (`app-crm`, `app-todo`, `app-multi-package`,
`app-showcase`) | 35 | 14 (12 `record_change`, 1 `time_relative`, 1
`api`) | 0 |
| `packages/platform-objects` (source scan) | 0 | 0 | 0 |
| `objectstack-ai/hotcrm` at `1d7148b` (read-only sparse clone, deleted
after) | 32 | 14 (all `record_change`) | 0 |
No shipped example refuses, and no example was changed.
## Pins and ablations
The new block in `validate-expressions.flow-cel-root.test.ts` pins:
- `validate` refuses `record.X` with no entrance (`where`, the message
head, and the `vars` remedy);
- a start `objectName` alone is refused;
- control: a record-triggered flow passes;
- control: a `subflow` child of a record-handing parent passes,
including two levels;
- red control: a `subflow` child of a non-handing parent is refused;
- a parent's `record` variable is not handed on;
- controls for a time-relative sweep, an action on a declared object, an
object-less action, a `map` with `itemObject`, and a declared `record`
variable;
- the reader's `bound` per entrance;
- the runtime publish gate does not refuse `record.X` on a flow write
while `validate` refuses it on the same flow.
Ablations ran through `scripts/ablation-replace.mjs` at `641f940df7`,
with the fix committed. Each one restored blob-equal to HEAD
(`1f19fd39e079`), with `git diff HEAD` empty.
- `'record'` put back in `ENGINE_BOUND_ROOTS`: 5 failed / 72 passed. All
5 are the refusal-side pins (the refusal, `objectName` alone, the red
subflow control, the parent's variable, the runtime gate pin).
- The record-bound read dropped (`names` without `handed`): 8 failed /
69 passed. These are the record-trigger and `subflow` controls, plus the
time-relative, both action and `map` controls, the reader pin, and the
record-trigger row of the binding-kind controls. The declared-variable
control stays green, as it should.
## Verification
Run at `8a65474075` (the merge of `origin/main` `762db996ad` through
`os-regen-merge.sh`) unless stated.
- `@objectstack/lint` suite: 135 files, 6318 passed; `typecheck` exit 0
(`tsc --noEmit` and `check:test-typecheck` OK). Lint and its typecheck
were also green at `641f940df7`.
- `@objectstack/spec`:
- `check:migration-registry`: registry current;
- `check:generated`: all 15 artifacts current, after a `spec` build. The
first run, against a stale `dist`, said PREREQUISITE NOT MET, so it does
not count.
- `@objectstack/metadata-protocol` suite, with its closure built: 223
files passed and 3 skipped, 28101 tests passed. CLI flow-validate files:
`--project unit`, 5 files, 78 passed; `--project integration` (they
spawn the source CLI), 3 files, 15 passed. Both were measured on the
pre-merge tree. The judge source is byte-identical at `8a65474075`, and
the merge touched neither package's flow path.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 91 commands at `8a65474075`, and 89
ran:
- 88 exit 0;
- `check-empty-changeset` exit 1 — the DELIBERATE CORRECTION above, red
by design.
- `--ran` reconcile: 91 derived, 89 run, 2 unrun. Those 2 are NOT
MEASURED by order:
- `check:dual-build-cjs-loads` needs a whole-workspace build;
- `check:type-check-debt` is `--re-measure`.
- Artifact rosters: 46 ran without PR context:
- 45 exit 0;
- `check:published-readme-exports` exit 3 PREREQUISITE NOT MET: 7
unbuilt packages, none touched here. NOT MEASURED.
- The 3 PR-context guards were run against this PR at `8a65474075`:
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths` each exit 0.
Patch round at `0e51b65d09`, a text-only change to the two `.changeset/`
files named by `git diff --name-only 8a65474 0e51b65`. No code
changed, so no suite was re-run:
- `node scripts/check-empty-changeset.mjs --base origin/main`: exit 1,
naming exactly `.changeset/22565-flow-cel-unbound-root-refused.md` and
`.changeset/22642-flow-cel-record-binding.md`. This is the DELIBERATE
CORRECTION class, red by design;
- `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0
(the `major` is exempt in pre mode);
- `node scripts/check-changeset-fixed.mjs`: exit 0;
- `pnpm check:nul-bytes`: exit 0.
## Acceptance notes
1. **The `opened`-set gap.** A `record-*` trigger with no start
`config.objectName` makes the record-change trigger register its hook
with no object, so the hook fires on any object's write. Such a run can
be handed a record whose keys are not in hand, yet `flowCelEntrances`
opens a flow only when `objectName` is named and undeclared. This is a
read-only inference with no public-door reproduction. Carrier: #22636,
the same judge's next card.
2. **A declared `record` variable** binds `record` at the build door, as
every declared variable does (`collectFlowVariableNames`; #22565's
judge-wide design). At run time a declared variable is bound only with a
supplied input or a `defaultValue` (`seedDeclaredVariables`).
3. **The clause "none of these roots ever evaluated in a flow"** in the
#22565 note, the D3 `reason` and the registry's step text is left as it
is (seat order `6102049574`, question 2 answered A). #22642's own D3
entry, `flow-cel-record-variables-alias-retired`, carries `record`'s
behaviour change with its FROM → TO.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a360cee commit 0f77ff5
9 files changed
Lines changed: 268 additions & 48 deletions
File tree
- .changeset
- packages
- lint/src
- spec/src/migrations
- entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
| 14 | + | |
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
| 18 | + | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
45 | 46 | | |
46 | 47 | | |
47 | 48 | | |
48 | | - | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
0 commit comments