Skip to content

Commit 615c696

Browse files
feat(spec,service-automation,lint)!: a CRUD node's filter operands are value slots, the query placeholders stay — the {…} token refused (#19939 pass 4, S6) (#23154)
Part of #19939 Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) Pass 4, stage S6 of #19939: the CRUD filter operands, under Fork R17 → **A** (stage plan `6096263424`). S7 follows, so this PR does not close the card. Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is now a value slot. That covers a field's value, an operator's comparand and each element of a list comparand, at any depth of `$and` / `$or` / `$not` and of a nested-relation condition. An operand takes one of three forms: - **A CEL value envelope.** The executor evaluates it in the run's scope before the query. - **A query placeholder** (`{current_user_id}`, `{today}`, `{30_days_ago}`, … from `data/context-tokens.zod.ts`). It is handed to the query engine untouched, as before. - **A literal.** Every other single-brace `{…}` token at an operand is refused at every door, and the refusal names the envelope to write instead. The `#3810` guard holds for an envelope that evaluates to `null` or `undefined`: the node is refused, and nothing is queried. ## FROM → TO, per position The positions are the `filter` of `get_record`, `update_record` and `delete_record`. Within each filter, the operand positions are: | operand position | FROM (the template) | TO | |:--|:--|:--| | a field's value | `{ id: '{record.id}' }` | `{ id: { dialect: 'cel', source: 'record.id' } }` | | an operator's comparand | `{ amount: { $gt: '{record.cap}' } }` | `{ amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | | a list comparand's element | `{ id: { $in: ['{a}', '{b}'] } }` | `{ id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | | a computed combinator member | `{ $and: '{record.conds}' }` | `{ $and: { dialect: 'cel', source: 'record.conds' } }` (one operand; the stored-metadata refusal still judges what it evaluates to) | | a nested-relation condition | `{ account: { owner: '{x}' } }` | `{ account: { owner: { dialect: 'cel', source: 'x' } } }` | | the run user | `{ owner: '{$User.Id}' }` | `{ owner: { dialect: 'cel', source: 'current_user.id' } }`, behind a `decision` on `current_user != null` | | a date macro | `{ due: { $lt: '{TODAY() - 3}' } }` | `{ due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | | a query placeholder | `{ owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | **unchanged** | | a variable named like a placeholder | `'{today}'` (the variable shadowed the placeholder) | `{ dialect: 'cel', source: 'today' }`; `'{today}'` is now always the placeholder | Each FROM row is refused at `objectstack validate`, at `registerFlow`, at the metadata save door and at the executor's parse, located at the operand (`config.filter.id.$in[1]`), with the envelope as the remedy. Each TO row passes all four doors. ## Census, per shape (base `6662e3f59`, a TypeScript AST walk over every tracked `.ts`/`.js` file plus a `git grep` over `.md`/`.mdx`/`.json`/`.yaml`) | shape | shipped (examples, docs, dogfood, spec text) | tests | after this PR | |:--|:--|:--|:--| | a field's value, flow token | 7 in examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2); 6 in dogfood (5 files); 5 in docs (`common-patterns` 1, `kernel/runtime-services/examples` 2, `service-automation` README 2); the flows guide's `{uid}` row; 2 spec remedy texts (`CRUD_RECORD_ID_GUIDANCE`, the run-user remedy); the `FlowSchema` example | about 87 real flow tokens in 34 files | all migrated, except 14 in `validate-flow-template-paths.test.ts` and 1 in `reference-integrity-suite.test.ts`, which S7 owns, and 3 in the deleted rule's test | | an operator's comparand | query placeholders only: 1 (`app-todo` `{3_days_ago}`) | 2 placeholders; 1 flow token (`{record.guess}` through a helper argument) | placeholders unchanged; the token migrated | | a combinator member, computed | 0 | 1 (`$and: '{record.conds}'`) | migrated to an envelope | | a list comparand's element | 0 | 0 | — | | a nested-relation condition | 0 | 0 | — | | text with holes at an operand | 0 | 0 | — | | a whole-string query placeholder | `app-todo` `{tomorrow}`, `{3_days_ago}` | `{90_days_ago}` (`flow.test.ts`), the door controls | unchanged, pinned as positive controls | A flow variable or output variable named like a placeholder: 0 in the repository. An object declaring a field named `dialect`: 0. hotcrm was not measured. ## Mechanism assumptions, measured - **A1 holds.** At the base, `interpolateFilter` had exactly one caller, `crud-nodes.ts:104` (`resolveNodeFilter`), reached by `get_record`, `update_record` and `delete_record`. No other executor read a filter through it or an equivalent. The three schemas carried `filter: z.record(z.string(), z.unknown()).optional()`. All three are now `flowFilterSlotSchema`, and `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. - **A2, one classifier.** There were two grammars: - At the base, the executor resolved a whole token against the variables first, and only an unresolved known placeholder survived. - The lint rule classified through `flow-template-grammar`'s own table. Now `classifyFlowFilterOperandString` in `@objectstack/spec/automation` draws the line, using `FILTER_TOKEN_WRAPPED_RE` and `isKnownFilterToken`. A whole wrapped known token is `placeholder`. Any other string carrying a token is `flow-token`. The rest is `literal`. The contract, `registerFlow`, `objectstack validate`, the save door and the executor's parse all read it, through the value-slot judge in its filter mode. The executor does not classify strings itself: it evaluates envelopes and hands everything else on as written, after the parse has refused the tokens. The lint rule `flow-filter-token-unknown` retires; see below. On shadowing: a flow variable named like a placeholder no longer shadows it, and the variable is read through an envelope. Zero instances were measured; the change is pinned in `crud-filter-guard.test.ts`. - **A3 holds.** An operand envelope that evaluates to `null` or `undefined` refuses the node as a guard (`errorClass: 'guard'`; a `fault` edge cannot route it), and nothing is queried: - The step error code is `NODE_FAILURE`, pinned on `update_record`, `delete_record` and `get_record`. - It is pinned per position (comparand, list element, `$or` member) and for a partial filter. - An absent variable or key fails the run before any query with `EXECUTION_ERROR`, naming it. - A literal `null` operand is an authored IS NULL condition and is handed on. - **A4.** The census above. - **A5, no fork.** An envelope is an object whose `dialect` is a string. At an operand position, an object is either an operator map (`$` keys) or a nested-relation condition, and neither names a string `dialect`. The one ambiguous spelling is a nested-relation condition on a related field named `dialect`. It is written `{ dialect: { $eq: 'x' } }`, which is unambiguous. No object in the repository declares such a field. ## The lint rule `flow-filter-token-unknown` retires (a choice settled in-stage, flagged for the seat) The rule reported a `{FUNC()}` filter token that neither dialect resolves. Every token it reported is now refused by the value-slot judge, at the same doors (save door included), at the same severity and position, and with the envelope remedy instead of the retired dialect's. Keeping the rule would report one defect twice, with two remedies. The four axes: 1. **Business need:** zero shipped uses. Its one-line `fix:` (merged in #23097) teaches a single-brace spelling that is now refused. 2. **Long-term soundness:** one judge per position, with no second grammar (order A2). 3. **Preventing AI mistakes:** the refusal stays as loud, stays at the same doors and names the right remedy. 4. **Startup focus:** retired at once, with no dual window. The rule, its `os explain` entry and its three exports are removed. The `os validate` and `os build` transcripts now count 49 author-time rules. ## ADR-0087 The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended: - **surface:** each CRUD filter operand. - **replacement:** the envelope, the placeholders, null, and the `dialect` escape. - **reason:** shadowing; an erased condition against a run fault. - **acceptance:** re-run the queries and compare the rows matched. There is no new entry, no D2 conversion (no lossless spelling: a whole token that resolved to nothing refused the node, where CEL faults on an absent root) and no tombstone. ## Verification record (head `3d5476360`) All on `3d5476360`, the final head (origin/main `1ca7a805c` merged in through `scripts/pm/os-regen-merge.sh`, the shared refinement ledger resolved as both sides' entries), unless a line names another commit. **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 127 families. - 126 ran, each exit captured before any pipe, and every one is `exit 0`. - `pnpm check:type-check-debt` is NOT MEASURED, because the dispatch rules it out. - `--ran` reconciliation verdict: `127 derived famil(ies) accounted for — 126 run, 1 NOT-MEASURED (0 DERIVED from a recorded exit 3, 1 claimed)`. - `check:generated`: `All 14 generated artifacts are up to date`. - The regenerations (`gen:skill-refs`, api-surface, export-origins, the reference page) are committed in `cc5febeef`. - On both merges the regen-merge step 2 kept the branch's bytes of the four generated files only this branch moved; the rebuild regenerated nothing. - The first battery, at `e44491bf3`, failed seven gates: - Five spec gates that read the built dist exited 3 (`PREREQUISITE NOT MET`: the dist predated a test edit), and `check:generated` exited 1 against the same stale dist. All six went green after `pnpm --filter @objectstack/spec build`. - `check:docs-transcript-drift` exited 1, and that one was real: four CLI transcripts printed `author-time rules (50)`, but the registry derives 49 once the rule retires. Fixed in `524fd95ce`. **Typecheck.** `turbo run typecheck --force` (0 cached) covers spec, service-automation, lint, metadata-protocol, plugin-approvals, trigger-record-change, verify, dogfood, and example-crm, -showcase and -todo. Result: 11/11 tasks. The spec, service-automation and lint scripts include `check:test-typecheck`. **Tests on the final head.** - spec `--project local`, `src/automation` plus `context-tokens`: 37/37 files. - The four spec repo-project tree walks: 4/4 (see the notes). - service-automation: 189/189 files. - lint: 137/137 files. - metadata-protocol: 231 passed, 3 skipped. - `@objectstack/cli` `--project unit`: 285/285 files (4283 tests). The integration tier is declared to CI. - On `e938866f6` (the first merge, origin/main `bcebd8409`). These were not re-run after the second merge, which reaches them only through main's own spec, lint, metadata-protocol, objectql, runtime and plugin-security changes; CI runs them on the PR: - turbo group 04 (eleven `service-*` packages): 11/11; - example-crm, -showcase and -todo: 3/3; - `view-container-cross-package-default.dogfood.test.ts`: 1/1. **The affected closure, before the merge.** - `turbo ls --affected` names 76 packages. Its blind spot: the set comes from the package graph, so it cannot see a test that reads another package's source from disk, and the root-level suites are not on that graph. - Runs: - spec local: 657/657 files (`524fd95ce`). - spec repo: 57 files, of which the four tree walks went red only because of the gitignored upgrade guide. With it set aside they are green. - service-automation: 189 files. lint: 136. metadata-protocol: 229 passed, 3 skipped. - Turbo groups of the remaining packages: - 00: 11/11. - 01: 8/10. Two example pins depended on the migrated flows: an envelope `id` names one row, and the body-less control now fails on the operand's unbound variable. Both were re-judged in `34e9f8e5a` and are green at `7edc95fa5`. - 02: 12/12. 03: 11/11. 05: 10/10. - dogfood: 257 files passed, 1 skipped, 1 failed. The failure was pre-existing at the merge base (see the notes). **eslint, a measured narrowing.** - Command: `eslint --no-inline-config --format json` over the 77 changed files present at `3d5476360`. - Population, read off eslint's own JSON output: 63 files linted. The 14 left out are `.md`, `.mdx` and `.json` files the config does not cover. - Result: 0 errors, 0 warnings. - Why the narrowing excludes nothing: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules; its own header, around line 326). So this diff cannot move a verdict on a file it does not touch. **Pins, per door.** | door | what it answers | pinned | |:--|:--|:--| | node contract (executor parse) | `custom` issue at the operand, with the envelope; a malformed envelope, led by the slot-neutral sentence | `builtin-node-config.test.ts`: 5 positions × 3 schemas; envelope, literal and placeholder controls; the published JSON marker | | `registerFlow` | a plain `Error` (this door carries no `code`/`status`), located: `get_record filter operand at config.filter.owner: …` | `crud-filter-guard.test.ts` × 3 node types | | executor run | `success: false`, `errorClass: 'guard'`, nothing queried; a null operand gives step code `NODE_FAILURE` (update, delete, get); an absent key gives `EXECUTION_ERROR` | `crud-filter-guard.test.ts` (28 tests), `guard-refusal-inventory`, `fault-edge-guard-containment` | | `objectstack validate` | severity `error`, `where` at the operand, the refusal sentence and envelope; an operand envelope judged as CEL | `validate-expressions.test.ts`: 4 positions × 3 types, plus controls | | metadata save door | `code: 'INVALID_METADATA'`, `status: 422`, issue rule `expression-invalid` at the operand | `runtime-authoring-gate.flow-filter-operand.test.ts` (9 tests) | **Ablations.** Each mutation went through `scripts/ablation-replace.mjs` (anchor hit, blob changed, restore proven by blob equal to `HEAD` and an empty `git diff HEAD`), with a `trap` restore. Red, then green: | line ablated | suite | red | |:--|:--|:--| | contract: the token issue (`flowFilterSlotSchema`) | spec | 15 | | contract: the envelope issue | spec | 3 | | judge: the placeholder exemption | spec | 5 | | judge: the filter-operand call in `flowNodeValueTemplateRefusals` | spec | 5 | | judge: the filter remedy | spec | 18 | | ledger: the operand walk | spec | 3 | | executor: the null-operand guard | service-automation | 17 | | **dist**, contract token issue plus filter-operand judge | service-automation / lint / metadata-protocol | 6 / 13 / 6 | - The dist row: `@objectstack/spec` was rebuilt, and `ablation-dist-preflight` found the marker in 20 and 2 built files. After the restore, a rebuild and `--absent` on both markers, those suites are back to 28/28, 467/467 and 9/9. - One branch I first wrote, a `filterOperands` arm in `atOrInsideJudgedLedgerSlot`, stayed green under ablation. It was inert and is removed (see the notes). **Sweep.** A TypeScript AST walk over every tracked `.ts`/`.js` file at the head finds flow tokens at a CRUD filter operand only in these places: - the S7 fixtures named in the notes; - the walk and refusal cases of this PR's own pins; - a `resolveFlowNodeExpressions` control. Every shipped example, doc and dogfood site is an envelope or a query placeholder. ## Acceptance notes - **S7's single-brace readers, left as the scope fence says.** - `validate-flow-template-paths` still path-checks a single-brace token at a filter operand. Its test keeps 14 such fixtures, and `reference-integrity-suite.test.ts` keeps 1. They pass because that rule reads the single-brace body, and S7 retires that reading. Carrier: S7. - The lint's bare-`$` hint arm ("Wrap it and bind a variable: `{x}`") is now reachable only at a position no slot judges, such as a plugin node's own config. No builtin position reaches it. Carrier: S7. - **The lookup-hop remedy at this position is gone.** - The template's guard message ended "a relation field holds a scalar id — a lookup hop needs the relation in the start node's `config.expand`". - Under CEL, a hop through a scalar id (`record.account.name`) faults in `evaluateValueEnvelope`. It gets the engine's generic evaluation message, the same as every other value slot since S1, so that clause no longer prints here. A null result still refuses the node, with the guard's message. - Carrier: none. It is noted, not filed: it is wording on the family's engine-wide fault text. - **Text that still names the retired rule or the single brace (seat-carried or release-owned).** - `skills/objectstack-query/rules/filters.md:168` names `flow-filter-token-unknown`. That is skill text, which the seat carries with its #22585 pointer. - Pending changesets on this line that this PR makes false. They are not edited here (one changeset per stage); they are offered for an in-place correction like #23126: - `.changeset/19939-flow-value-slot-template-dialect-refused.md` says "A `filter` value keeps the template dialect". After this PR, a filter operand is a value slot too. - `.changeset/19939-flow-value-slot-run-user-refused.md` (the `$User.Email` row) names `filter: { id: '{uid}' }`. It should read `filter: { id: { dialect: 'cel', source: 'current_user.id' } }`, with no assignment step. This changeset already says it supersedes that line. - `.changeset/22161-lint-slice-10-one-line.md` and `.changeset/22161-lint-slice-14-one-line-fix.md` describe `flow-filter-token-unknown`'s message and one-line fix, for a rule this PR retires in the same release. - The migration registry's step-18 text-slot entry (`registry.ts.template`, `flow-text-slot-single-brace-refused`) still says "Every other flow string keeps the single-brace dialect". It has been left alone, as S1–S5 left it. Carrier: S7, which deletes the resolver. - **The inert value arm.** - The first cut added a `filterOperands` branch to `atOrInsideJudgedLedgerSlot`. Its ablation stayed green: `insideValueSlot` already leaves every issue under a ledger value slot to the envelope pass and the judge, and the new `filter` rows bring each operand under one. - The branch was removed in `7edc95fa5`. The pin "one finding, never two" stays, held by the ledger rows. - **A local-only red that is not this change.** Four spec repo-project tree walks read the gitignored `content/docs/protocol-upgrade/`, which the `@objectstack/docs` build writes through `gen:upgrade-guide`: - `export-job-family-retirement`, `compliance-families-retirement`, `connector-connection-timeout-retirement` and `currency-mode-family-closure` rule B. - With the generated tree present they are red on retired names quoted in `18.md`. With it set aside they are 4/4 green. Reported to the seat as a finding. - **A pre-existing dogfood red.** At the merge base, `view-container-cross-package-default.dogfood.test.ts` was red (DELETE `/meta/view/…` answered 400). Main fixed the test in #23105 (`2808438e9`). On the merged head the file is green (1/1). - **Not measured:** - hotcrm, which lives in another repository. - `pnpm check:type-check-debt`, which the dispatch rules out. - The objectui designer's single-brace filter pickers, carried by the seat's companion card. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31a872d commit 615c696

79 files changed

Lines changed: 1664 additions & 1040 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/service-automation': major
4+
'@objectstack/lint': major
5+
---
6+
7+
Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did.
8+
9+
Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports)
10+
11+
<!-- adr-0087: not-required (already-registered flow-value-slot-template-dialect-refused) The value-slot retirement's step-18 D3 entry, registered on this line before this change, is amended in this diff: its surface widens to each operand of a get_record, update_record and delete_record filter, and its replacement, reason and acceptance criteria say what an operand evaluates to, that the query placeholders stay as written, and that an operand evaluating to null refuses the node. No new D3 entry and no D2 conversion: a whole token that resolved to nothing erased its condition and refused the node under the template, where under CEL an absent variable or key fails the run before the query; and a flow variable named like a query placeholder shadowed it under the template, where the placeholder is now always the query engine's. -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).
14+
15+
**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes.
16+
17+
**One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge.
18+
19+
**What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand.
20+
21+
**What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one.
22+
23+
**A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder.
24+
25+
**A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field.
26+
27+
**The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy.
28+
29+
## FROM → TO
30+
31+
| you wrote | write instead | what changes |
32+
|:--|:--|:--|
33+
| `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node |
34+
| `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before |
35+
| `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — |
36+
| `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` |
37+
| `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days |
38+
| `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's |
39+
| `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable |
40+
41+
**The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.**
42+
43+
**Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. The value-slot retirement's run-user changeset on this line names `filter: { id: '{uid}' }` for a `$User.Email` read; this one supersedes that line: read the user record with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }`. hotcrm was not measured.
44+
45+
**Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load.
46+
47+
### The kit
48+
49+
- **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value.
50+
- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`.
51+
- **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted.
52+
- **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires.
53+
- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion.

0 commit comments

Comments
 (0)