Repository navigation
fix(spec): a value-slot remedy reads a CEL-claimed head through vars (list.0 → vars["list"][0]) - #22524
Conversation
A `{list.0}` path whose head variable is named like a CEL type printed the
remedy `list[0]`, which CEL reads as its own `list` type. `celPath` now reads
every head the CEL engine claims (cel-js 8.0.0: type identifiers, namespace
constants, reserved words, keywords) through `vars`, the route a `$`-named
head already took, and indexes a later keyword segment by name. `has()`
guards are printed only where `has()` accepts the argument.
The #19939 changeset row's example now uses a head that is not a type name.
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <[email protected]>
…tor's value through CEL Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <[email protected]>
…s printed guard Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <[email protected]>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a5a78639b09e402adf5a079472d1c42a359544ea && git checkout a5a78639b09e402adf5a079472d1c42a359544ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee8751d41e61a18f7819e4d3ad2c340f51ab2418 b828131c612bc3713834ca7544c048a04a5a4faf && git checkout -B drift-repro ee8751d41e61a18f7819e4d3ad2c340f51ab2418 && git merge --no-ff b828131c612bc3713834ca7544c048a04a5a4faf
node scripts/docs-audit/affected-docs.mjs --json ee8751d41e61a18f7819e4d3ad2c340f51ab2418 |
Seat confirmation: the edit to
|
…ype-name head The flows guide's template table and the protocol-18 semantic migration prose taught list[0] for a variable named list, which CEL reads as its own type. Same swap as the #19939 changeset row. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <[email protected]>
…ade guide Generated by check:generated --fix from the semantic entry's items[0] swap. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude <[email protected]>
Contract reviewServed-tier: Rendered 2026-10-09T19:45Z on PR #22524 (card #22290), from the card's body and its six comments, the PR's body, file list and net diff against ① Derived judgments
Every printed remedy is valid CEL for the scope the flow engine builds — RIGHT. The two in-place additions are bounded and right.
Accept-set: unchanged — RIGHT. The diff to Public surface: unchanged — RIGHT. The example swaps ( Pins. The spec pin restates the 36-name set and fails when the constant drifts; the grammar pin evaluates every printed spelling (source and guard, parsed off the message) through Check-runs on the head (42): 36 success, 4 skipped, 2 failure, 0 pending. The seven required contexts are green: ② Semver level
The edit to
③ Boundary flags
Implemented-by: VERDICT: PASS |
…e merged tree (main f782f17) The os-regen text merge kept this branch's stale copy of main's flow-value-slot-template-dialect-refused replacement text (list[0]); main's source entry reads items[0] since 40a6ee5 (#22524). Generators only: pnpm --filter @objectstack/spec gen:spec-changes and gen:upgrade-guide. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <[email protected]>
Fixes #22290
Clause-②: no (the remedy a refusal prints, and anything else that shares
celPath, now evaluates; nothing is newly accepted or refused)What was wrong
A value-slot
{…}refusal prints the CEL spelling of a path token throughcelPath. That function passed every head through bare except a$-named one. When the head variable is named like an identifier CEL binds for itself, the bare name reads CEL's binding and not the variable.Measured at
4e9fe9ff6through the built@objectstack/specand@objectstack/formula, with a flow variablelist = ['first', 'second']in the flow CEL scope:objectstack validateon that remedy{list.0}list[0]expression-invalid:invalid CEL value: Cannot index type 'type' with type 'int'Cannot index type 'type' with type 'int'{list}listlist(a cel-jsTypeobject), not the variable. Nothing reports it.{list.tags}list.tagsThe card's open question. The
objectstack validatereading is from the built CLI, on a scratch stack with three flows: the old remedy as an envelope, the new remedy as an envelope, and the{list.0}template.validaterefusessource: 'list[0]'before run time, because the envelope's own CEL check catches it. So the door that refuses{list.0}was telling the author to write a spelling the same door then refused. The bare{list}→listremedy is worse: it passesvalidateand writes the wrong value. After the fix, the same run printsWrite {list.0} as { dialect: 'cel', source: 'vars["list"][0]' }, and the flow carryingvars["list"][0]draws no issue.The set, read off the engine this repo builds
@objectstack/formularesolves@marcbachmann/cel-js8.0.0. One set,CEL_CLAIMED_IDENTIFIERS, now sits besidecelPathinflow-template-token.ts, in four groups:bool,bytes,double,int,list,map,null_type,string,type,uint. These arelib/registry.jsTYPES, bound as constants when an environment is built (for (const n in TYPES) this.registerConstant(n, 'type', TYPES[n])).google(lib/functions.js,registerConstant('google', …)),cel(lib/macros.js) andoptional(lib/optional.js, bound because the engine setsenableOptionalTypes: true).lib/globals.jsRESERVED, refused by the parser asReserved identifier: …. They areas,break,const,continue,else,for,function,if,import,let,loop,namespace,package,return,var,void,while,__proto__andprototype.true,false,null(literals) andin(operator), as the lexer reads them.The first two groups equal
getDefinitions().variablesof an environment built with the engine's options plus the exportedregisterStdLib(13 names, measured).timestamp,durationanddynare not in the set. The card expectedtimestampanddurationto collide, but here they are functions, not bindings, and a variable of either name already reads correctly (control rows). The card's list also missedcel,googleandoptional.The fix
celPathreads a claimed head throughvars, the route it already took for a$-named head:{list.0}→vars["list"][0],{list}→vars["list"].{record.in}printedrecord.in, which CEL refuses (Expected IDENTIFIER, got IN). It now printsrecord["in"].guardOfprints thehas()guard only wherehas()accepts it. CEL refuseshas()over an index when it runs (has() invalid argument, measured). Before,{items.1.key}printedhas(items[1].key) ? …, whichevaluateValueEnvelope's author-time check refused on the stale-dist run of the new pins. A path with an index anywhere, a$head or a keyword segment now gets no guard. A claimed head is guarded ashas(vars.list.tags) ? vars.list.tags : null.list[0]→items[0], the claim amended in6087190357):content/docs/automation/flows.mdx, the template table row;18.flow-value-slot-template-dialect-refused.ts;registry.ts,spec-changes.json,docs/protocol-upgrade-guide.md), regenerated throughcheck:generated --fix.Callers of
celPath(git grep): onlyflow-value-slot-template.ts, inguardOf, inremedyFor's whole-path branch and in its text-with-holes branch.flow-text-slot-template.tsimportscelExpressionandtemplateTokensOf, notcelPath. No ADR-0087 conversion or migration entry calls it, soClause-②: nostands. The semantic entry18.flow-value-slot-template-dialect-refusedcarrieslist[0]as prose (see Acceptance notes), not ascelPathoutput.Pins
packages/spec/src/automation/flow-value-slot-template.test.ts: the set equals the enumeration; one row per claimed name (36 rows), coveringcelPathfor a bare head, an indexed head and an index-then-key head, plus the printed remedy for{NAME.0}; guard rows; keyword segments; an index in the middle; text with holes; controls. The controls are the ordinary headsitems,record,timestamp,duration,dyn,lists,map_ofandvars, and the$errorhead.packages/services/service-automation/src/builtin/value-slot-template-grammar.test.ts(declared on [PM seat] domain:services — 🟢 zhuangjianguo · session_013j5gkUCpqQiti4GgPqqmnt #6021, test only). The spec cannot import the engine, so this file holds the evaluation pins. It already pins the judge's reading against the interpolator's. The new describe adds a third reading: every CEL spelling the refusal prints (the envelope source, and the guard when one is printed) goes throughAutomationEngine.evaluateValueEnvelope, meaning the author-time envelope check and then the built formula engine over the real flow CEL scope. It must give the valueinterpolateStringread from the template. The rows:{NAME},{NAME.0},{NAME.1.key}and{NAME.tags}.__proto__is claimed but left out: the flow CEL scope is a plain object, where__proto__names the prototype rather than a key, so no CEL spelling reads a variable of that name.items,timestamp,duration,dyn).$-head control.Ablation. Run at
347d12026(fix committed) throughscripts/ablation-replace.mjs: thecelPathhead branch was reverted to the$-only test. Anchor ×1 → ×0, marker ×0 → ×1, blob9b18b790d96f→26df4bf13885. The spec was rebuilt, andablation-dist-preflightfound the marker in 20 built files. Spec pin file: 37 failed | 46 passed (83). Grammar pin file: 35 failed | 26 passed (61), for examplelist: expected {} to deeply equal [ 'first', { key: 'second' } ]. Restore: blob after restore9b18b790d96fequals HEAD,git diff HEADis empty and the whole-tree porcelain is clean. The spec was rebuilt, and--absentfound the marker in none of 232 built files. Spec 83 passed, grammar 61 passed. After the ablation,07a6a4dc0added one line to each name row ({NAME.tags}) and the guard-read row. Those were not ablated.Changesets
.changeset/22290-value-slot-remedy-cel-claimed-head.md:@objectstack/specpatch..changeset/19939-flow-value-slot-template-dialect-refused.md: one row corrected,'{list.0}'/list[0]→'{items.0}'/items[0]. Nothing else in the file changed. This edits another card's pending release note on purpose: the DELIBERATE CORRECTION class.Check Changeset's stepReject an empty-frontmatter changeset added by this PR(scripts/check-empty-changeset.mjs, the foreign-changeset rule) is red by design on this edit, and stays red. Locally,node scripts/check-empty-changeset.mjs --base origin/mainexits 1 and names.changeset/19939-flow-value-slot-template-dialect-refused.md.pr-automation.ymlruns onpull_requestonly, notmerge_group, andCheck Changesetis not one of the seven required contexts. The correction needs confirming on this PR. Confirmed as a DELIBERATE CORRECTION in6087200290.Local verification
At head
b828131c6. This is the patch round on07a6a4dc0, with no merge ofmain; the PR stays mergeable.localtier,vitest run --project local, under the shared lock: Test Files 630 passed (630); Tests 18840 passed, 1 todo. Lock verdictcommand-exit 0.repotier,vitest run --project repo: 54 files / 915 tests.src/api/error-catalog-docs.test.tsfirst failed to load on a transient file that a concurrentcheck:skill-examplesrun writes and deletes. Re-run alone under the lock, it passed 5/5.check:generated(15 up to date),check:migration-registry,check:spec-changesandcheck:upgrade-guideall exit 0.07a6a4dc0:value-slot-template-grammar.test.ts62 passed;flow-value-slot-template.test.tsandflow-text-slot-template.test.ts97 passed. The ablation turned the spec pin file red (37 failed) and the grammar pin file red (35 failed), and the restore was blob == HEAD.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatb828131c6derived 115 commands: round 1's 87 plus the docs and migration-registry families.node scripts/check-empty-changeset.mjs --base origin/main, the confirmed deliberate correction.--ran:115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.check:api-surface:public API surface + factory signatures unchanged.b828131c6: all seven required contexts are green.Check Changesetis red by design (confirmed in6087200290). There is no other red.Acceptance notes
list[0](the docs table row, the semantic migration prose and its generated copies) are corrected in this PR, in commits650e87277andb828131c6.celPath.celExpressionrewrites divisors only.{int * 2}printsint * 2, refused (no such overload), and{items.0 * 2}printsitems.0 * 2, refused (Expected IDENTIFIER); both measured throughevaluateValueEnvelope. Fixing them means tokenising the expression, which is not a mechanical change. They are reported to the seat.varsis shadowed by the scope's ownvarsnamespace (vars[0]→No such key: 0;vars["vars"][0]reads it). That binding belongs to the flow runtime, not CEL, so it is not in this set.source: 'list'still reads the CEL type. That is CEL's meaning of the identifier, not a remedy this module prints.Generated by Claude Code