Repository navigation
Commit 615c696
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
- .changeset
- content/docs
- automation
- deployment
- getting-started
- kernel/runtime-services
- references/automation
- ui
- examples
- app-crm/src/flows
- app-showcase
- src/automation/flows
- test
- app-todo
- src/flows
- test
- packages
- lint/src
- metadata-protocol/src
- plugins/plugin-approvals/src
- qa/dogfood/test
- fixtures
- services/service-automation
- src
- builtin
- spec
- api-surface
- export-origins
- src
- automation
- data
- migrations/entries/semantic
- triggers/trigger-record-change/src
- verify/src
- skills/objectstack-automation/references
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| 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 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
0 commit comments