Repository navigation
feat(spec,service-automation)!: refuse {$User.*} in flow value slots, and bind current_user in the flow CEL scope (#19939 pass 2) - #22563
Conversation
…{$User.*} in value slots (WIP)
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…18 entries, add the pins (WIP) Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
… they are; this change's note supersedes them Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…e-slot description Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
…ot and the CEL scope Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 145 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 d9b2831dab77cca866c301f2b47158d224af7044 && git checkout d9b2831dab77cca866c301f2b47158d224af7044
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbb065fd4b52dce103776e49fefd90f0138059a7 c5a343c32a194a3acf6db2c404095fa3189be2a2 && git checkout -B drift-repro fbb065fd4b52dce103776e49fefd90f0138059a7 && git merge --no-ff c5a343c32a194a3acf6db2c404095fa3189be2a2
node scripts/docs-audit/affected-docs.mjs --json fbb065fd4b52dce103776e49fefd90f0138059a7
|
…ue slot
The hint prescribed `{source.id}` and `{$User.Id}` everywhere, and a value
slot refuses both: the single-brace dialect since the first pass, the run user
since this one. In a value slot the hint now names the CEL value envelope the
spec's value-slot judge writes for the token the reference names, asked of the
judge rather than re-spelled: `current_user.id` with its guard for `$User.Id`,
the user-record read for any other `$User` path, `vars["$error"].message` for
a `$` root the engine binds, and the flow's own variable without the `$` for
any other. Whether a string sits in a value slot is asked of the judge too, so
the two legacy `assignment` shapes are covered. Text slots keep the `{{ }}`
hole, and every other position keeps the single-brace hint.
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…urrent_user
The published README's Expressions section taught `{$User.Id}` /
`{$User.Email}` and single-brace field values as value bindings, all refused
in a value slot. It now states the dialect as this change leaves it: a value
slot computes with the CEL value envelope and reads a plain string as its
literal text; `current_user` is the run's user, `null` with no user;
`{$User.*}` is refused with its remedy; the date macros are still read until
CEL can write them. The changeset gains the lint patch and both kit lines.
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN
Co-authored-by: Claude <noreply@anthropic.com>
…velope pins Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Rendered 2026-10-10T02:24Z by the adopting seat's session, on PR #22563 at the head above (branch Check-runs on the head: 42, all completed — 38 ① Derived judgmentsThe ruling, Q1 A — the binding. Right.
The ruling, Q1 A and Q2 A — the refusal and its remedies. Right.
The ledger. Right. The step-18 D3 entry H4 — H5 — the claimed heads. Right, and pinned. The public surface and the accept set —
The lint hint's probe (patch round 1). Right, and side-effect free. Showcase The in-repo sites. Right. Docs and descriptions. Right. The surface crossings (dev deviation 1; seat accepted in
② Semver levelThe line is in changesets pre mode
③ Boundary flagsDev deviations (
Out-of-scope findings, each disposition judged:
Nothing in the diff moves a governed surface, a release act, or a contract the ruling did not rule. The head delivers Q1 A and Q2 A as written, at every flow CEL site, with the ruling's remedies verbatim, the amended D3 entry and no D2; H4 and H5 are fixed and pinned; the public surface and the doors' accept set are measured unmoved, so the declaration holds; all required checks are green. Implemented-by: VERDICT: PASS |
Part of #19939
Clause-②: no (narrowing)
Pass 2 of #19939: the run-user token
{$User.*}leaves the flow value slots, and the flow CEL scope bindscurrent_user. This executes the maintainer's ruling6063191653(Q1 A, Q2 A; maintainer 「同意」 2026-10-08). The date macros{NOW()}/{TODAY() ± N}stay with pass 3 (#19939 remains open), so this PR usesPart of.What lands
The binding (
@objectstack/service-automation).AutomationEngine.celScopebindscurrent_userfrom the run context, throughcreateEvalUser:idfromuserId,positions,organizationIdfromtenantId, and the derivedisPlatformAdmin. A run with no user getsnull. Every CEL site of a flow now receives the run context: the start-node condition (runContext), edge conditions, a screen field'svisibleWhenon resume (run.context), and thedecision,assignment,create_recordandupdate_recordexecutors.evaluateConditionandevaluateValueEnvelopetake the context as an optional last argument, and without onecurrent_userisnull.varsandcurrent_userare bound after the variables are spread, so each wins over a flow variable of the same name.The refusal (
@objectstack/spec).valueSlotTemplateRefusalsno longer keeps theusertoken kind; only the date macros are kept.{$User.Id}: the refusal names{ dialect: 'cel', source: 'current_user.id' }and the guardcurrent_user != null ? current_user.id : null, with itsupdate_recordconsequence.{$User.PATH}: the refusal says it never resolved in any shipped run and names the read of the user record (current_user.idinto a variable, aget_recordonsys_user, thenme.email/me.name).current_user.idin the concatenation, with the hole's guard(current_user != null ? current_user.id : ''). Any other$Userpath is left out of the concatenation, as the template rendered nothing for it, followed by the Q2 sentence.assignments: { v: '{$User.Id}' }, which this change refuses.H4, the expression remedy (carry
6087210395).celExpressionnow tokenises the expression and writes every variable path bycelPath's rule, not only the divisors:{int * 2}→vars["int"] * 2,{items.0 * 2}→items[0] * 2. Calls, keywords, quoted strings and members of a call are left as written, and the divisor rule is byte-identical.H5, the
varshead (carry6088133752).FLOW_SCOPE_CLAIMED_IDENTIFIERS = { vars, current_user }, measured fromcelScope. A path whose head is either name is printed throughvars:vars["vars"][0],vars["current_user"].name, withhas(vars.vars.tags)guards.The ledger. The step-18 D3 entry
flow-value-slot-template-dialect-refusedis amended (surface, replacement, reason and acceptance criteria name{$User.*}and both remedies). The two text-slot entries,flow-text-slot-single-brace-refusedandflow-text-slot-unbound-dollar-root-refused, are amended where their replacement computed the run user through the refused spelling.registry.tswas regenerated (gen:migration-registry), and the hand-kept step-18 rationale fragment for the value-slot entry was updated. No D2 conversion and no new entry.Sites, docs, descriptions.
examples/app-todo/src/flows/task.flow.ts:owneruses the barecurrent_user.id, because a screen flow always has a user.packages/cli/src/commands/explain.ts(os explain flow): the example is anupdate_recordunderrecord-after-create, which a system write fires with no user. It now writescurrent_user.idand gates oncondition: 'current_user != null', so a user-less run leaves the stored value alone, as the template did. Its test is updated.examples/app-showcase/src/automation/flows/index.ts:1936(recipients: ['{record.assignee}', '{$User.Id}']) is classified, not migrated.recipientsis neither a text slot ([v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110'sFLOW_NODE_TEXT_SLOTS) nor a ledger value slot (thevaluerole is onlyassignment.assignments.*andcreate_record/update_recordfields.*). The judge does not refuse it, and the interpolator still resolves it.content/docs/automation/flows.mdx: the FROM → TO table gains the two run-user rows, the text-slot table gains the CEL remedy, the dialect table namescurrent_user, and a paragraph defines it (user-lessnull, no email or name, the shadowing of a variable namedcurrent_user/vars).FlowValueSlotSchema/AssignmentValueSchema.describe(): "the date macros are kept for now". The references were regenerated (gen:docs,builtin-node-config.mdx).Patch round 1 (seat answers
6092066429): two remedies this card made false.flow-bare-dollar-reference(packages/lint/src/lint-flow-patterns.ts). The hint now depends on the slot the string sits in. In a value slot it names the CEL envelope the spec's value-slot judge writes for the referenced token:current_user.idand its guard for$User.Id, thesys_userread for any other$Userpath,vars["$error"].messagefor an engine-bound root, and the variable without the$for any other root. Whether a position is a value slot is asked of the judge with a probe token at the string's path, so there is no second list. In a text slot, and at every other position, the hint is unchanged. Each prescribed envelope, put back in its slot, passes the value-slot judge,validateStackExpressions,FlowSchemaand the [P2] flow:create_recordnode value semantics ambiguous (literal vs CEL vs macro vs ref) #1315 lint (0 findings each).packages/services/service-automation/README.md"Expressions" (shipped infiles) is restated as this pass leaves the dialect. A value slot takes the CEL envelope and reads a plain string as its literal text,current_useris the run user (null with no user), and{$User.*}is refused with its remedy. The date macros stay read until pass 3, and the other value-like positions keep the single brace.'@objectstack/lint': patch.Measured premises (H1–H5, on
86bf9ed7d5)current_userwas not bound:celScopereturned{ ...vars, vars }only, and neither [v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110 nor fix(spec,service-automation)!: an undeclared config key on 10 more builtin node types is refused at the build doors; one judge per type #22319 bound it. Bound here per Q1 and pinned with a user and without one (flow-cel-current-user.test.ts).recipientssite is classified "neither".subflow.input,map.input,script.inputs,screen.defaults, a screen field'sdefaultValue,http) are value-like positions that keep the single-brace dialect by [v18] flow text slots: read ADR-0032 §3's{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's own classification. None is a ledgervalueslot, and none evaluates a CEL envelope, so a refusal there would have no remedy to name. They are not brought under the judge here (see Acceptance notes).{{ }}does not apply to value slots. A run-user token beside a date macro ('Due {TODAY()} by {$User.Id}') is still kept whole (pinned) until pass 3 retires the macros.main(4638625e07) was merged cleanly.evaluateValueEnvelope:{int * 2}evaluates to 10, the value the interpolator computed.{items.0 * 2}evaluates to 6; the interpolator computed nothing there.varsis a claimed head, with the reason and the pin.current_userjoins it for the same reason, since this change binds it after the spread.Surface crossings (the claim's file list, and why each crossing was needed)
The claim names the judge, the token module,
builtin/template.ts,engine.ts,validate-expressions.ts(where the value-slot refusal surfaces), the three sites, the value-slot D3 entry andregistry.ts,flows.mdxand one changeset, each with its tests. These files are outside that list. Each one was either needed for the change to work or would have stated something false after it:service-automation/src/builtin/crud-nodes.ts,logic-nodes.ts. One argument each: the run context passed toevaluateValueEnvelope/evaluateCondition. Without it,fields/assignmentsenvelopes anddecisionconditions would seecurrent_userasnullin every run. Their docblocks also said{$User.*}was kept.spec/src/automation/flow-text-slot-template.ts(and its test). Its run-user remedy namedassignments: { v: '{$User.Id}' }, a spelling this change refuses.spec/src/automation/builtin-node-config.zod.ts(and its test), with the regeneratedcontent/docs/references/automation/builtin-node-config.mdx. The two value-slot.describe()strings said the$Userpaths are kept.18.flow-text-slot-single-brace-refused.ts,18.flow-text-slot-unbound-dollar-root-refused.ts, and the hand-kept step-18 rationale fragment inregistry.ts. Their replacement text computed the run user through the refused spelling.lint-flow-patterns.test.tsandvalidate-expressions.text-slot.test.ts. In service-automation:text-slot-template.test.ts,logic-nodes.test.tsandcrud-fields-value-envelope.test.ts. One new test file:flow-cel-current-user.test.ts.packages/lintchangeslint-flow-patterns.tsin patch round 1 (above), the files the seat's answer6092066429added, withservice-automation/README.md. No governed surface is touched (skills/**,.claude/**,docs/adr/**,AGENTS.md).Tests and gates (head
caeaab982f)Head is
caeaab982f, a merge ofmainat4638625e07. The build is the dependency closures of service-automation, lint and cli (58 tasks), then the whole workspace minus docs (72 tasks).@objectstack/speclocal project: 635 files, 18974 passed and 1 todo. Repo project: 54 files, 915 passed. Both ran atcaeaab982f.@objectstack/service-automationfull suite: 181 files, 2284 passed atcaeaab982f. Typecheck exit 0, withcheck:test-typecheckOK.@objectstack/lintfull suite: 133 files, 6092 passed.@objectstack/cliunittest/commands.test.tsandtest/explain-rule-id.test.ts: 62 passed. Typecheck exit 0 for spec, lint and cli. All of these ran ata86164b899; the only later commit changes three service-automation test files.dispatch-gates --commandsre-derived on the actual diff gives 122 commands, a superset of the 93 at dispatch (the added families includecheck:engine-double-contract,check:type-check-coverage, the docs families andcheck:generated). All 122 ran atcaeaab982f, anddispatch-gates --ranreports 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN.check:skill-examples,check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parity. All five read built output that did not exist yet. Each exited 0 after the workspace build.check:empty-changeset: green. No pending changeset of another change is edited (see Acceptance notes).eslint . --no-inline-configovereslint.config.mjs.--format jsonover the 26 changed.tsfiles reports 26 linted, 0 ignored, 0 errors and 0 warnings.parserOptions.project, no typed rules;eslint.config.mjssays so at its type-aware note), so this diff cannot move any verdict on a file it does not touch. The fullpnpm lintis CI's.Patch round 1 at
c5a343c32a(no merge ofmain; the PR reads mergeable):@objectstack/lintfull suite 133 files, 6102 passed; lint typecheck exit 0.dispatch-gates --commandsre-derives the same 122, all exit 0 atc5a343c32a, and--ranreports 0 NOT-MEASURED and 0 UNRUN.check:published-readme-links,check:published-readme-exports,check:nul-bytesandcheck:doc-authoringexit 0. Narrowed lint over the 27 changed.tsfiles: 0 errors, 0 warnings. Spec, service-automation and cli code is untouched by the round, so theircaeaab982freadings stand.Ablations
Three ablations, one per behaviour, each through
scripts/ablation-replace.mjsin WRAP mode under the verify lock, from the committed statecaeaab982f. Each mutation is verified on disk (anchor x1 → x0, blob changed), and each restore is proven blob == HEAD with an emptygit diff HEAD.A1, the old judge branch.
KEPT_KINDSback to['date-macro', 'user'].flow-value-slot-template.test.tsgoes red: 8 failed, 93 passed. Every run-user refusal pin fails. Restored to blob8ee782870cd2.A2, the divisor-only
celExpression. The oldinner.replace(/\/\s*(\d+).../g, '/ $1.0')body put back. The same file goes red: 9 failed, 92 passed. The H4 pins{int * 2}and{items.0 * 2}fail, as do the call/keyword/quoted-string controls. Restored to blob8b5be3d9cad1.A3, the binding removed.
celScopeback to{ ...vars, vars }.flow-cel-current-user.test.tsgoes red: 13 failed, 1 passed. The one pass is the control that a barecurrent_user.idfaults without a user, which holds unbound too. Restored to blobd17b32e3e677.A4, the old lint hint (patch round 1, from committed
a7d0cb1b34): the firing site's hint put back for every position.lint-flow-patterns.test.tsgoes red, 7 failed and 191 passed, exactly the 7 value-slot pins. The text-slot pin and both controls stay green. Restored to blobee30b6ea9c22.A1 and A2 resolve the judge from
src(spec's own tests), and A3 resolvesengine.tsby a relative import, so nodist/sits on any ablation's path.Acceptance notes
Nothing here is filed. Each item is for the seat:
Pending release notes this change makes false. Three pending changesets still state what this change retires:
.changeset/19939-flow-value-slot-template-dialect-refused.mdlists{$User.PATH}as still accepted;{{ }}delimiter instead of single-brace{token}(notify title/message and the other flow string slots), converting only what renders the same #22110's.changeset/22110-flow-text-slot-double-brace.mdhas the row'By {$User.Id}'→ anassignmentwhose value slot still reads it;{{ $User.Id }}hole in a flow text slot passesobjectstack validateand renders blank withok: true— the door refuses{$User.Id}loudly but admits its{{ }}spelling silently #22477's.changeset/22477-flow-text-slot-dollar-root-refused.mdhasassignments: { by: '{$User.Id}' }.This PR's changeset states that it supersedes those lines, and it carries the corrected FROM → TO row. Correcting them in place is
check:empty-changeset's "DELIBERATE CORRECTION" path, which keeps the required gate red until a person confirms. That is a release decision, so it was not taken here.The other
current_useraliases. The ruling bindscurrent_useronly.user.id,ctx.user.idandos.user.idin a flow condition passvalidateStackExpressionswith 0 issues and fault at run time withUnknown variable: user/ctx/os(measured atcaeaab982f). ADR-0068 mounts one EvalUser under all four names elsewhere. → filed as automation: a flow CEL expression may name the run user asuser,ctx.useroros.user;objectstack validatepasses it and the run faultsUnknown variable, because flow CEL binds onlycurrent_user#22565.The six rider positions (
subflow.input,map.input,script.inputs,screen.defaults, a screen field'sdefaultValue,http) keep the single-brace dialect. None is a ledger value slot or evaluates a CEL envelope, so retiring the dialect there needs a value-slot declaration and executor evaluation per position. That is a separate decision, not this pass.The kept-spelling leak.
'Due {TODAY()} by {$User.Id}'is kept whole until pass 3 retires the date macros (pinned as kept).skills/objectstack-automation/SKILL.md(Tier H; finding(skills): objectstack-automation SKILL.md teaches{token}values in create_record / update_recordfieldsthat PR #22259 (#19939 pass 1) refuses, and its money-rounding sample is CEL integer division #22260 is closed, so the seat files a skills-lane card when this PR lands) still teaches{$User.PATH}as kept and the text-slot run-user remedy through the value slot.HotCRM's 5
{$User.Id}sites (pass 1's census) are in another repository, on the seat's handover ledger.The judge's remedy at positions where an envelope is literal data (carried to pass 3 on [v18] retire the
{var}template dialect in flow assignment slots: refuse at registration with per-spelling remedies (the C half of #11182 ruling D, on the v18 train) #19939). At an element of the legacyassignmentsarray (assignments[0].value), and at a string nested inside an object literal in a value slot, the judge refuses{$User.Id}and names the CEL envelope. The prescribed metadata then passes the build door andregisterFlow, and the run stores the envelope OBJECT as the value. The canonical map form evaluates correctly. This has held since pass 1 for every token at those positions. The lint hint inherits it by design, because it asks the judge, so one fix in the judge reaches both.flow-double-brace-interpolation's hint in a value slot saysUse {var} (e.g. {record.title}), which the value-slot judge refuses. False since pass 1; the slot-aware remedy is a design choice. Carried to pass 3 with the item above.Two [P2] flow:
create_recordnode value semantics ambiguous (literal vs CEL vs macro vs ref) #1315 false-positive guards inlint-flow-patterns.test.tsare titled as endorsing spellings the value-slot judge refuses ("correct single-brace interpolation", "a braced $User reference"). Their assertions hold; only the titles mislead.Clause-②. The claim line readsno, and the dispatch prescribesno (narrowing). This PR uses the dispatch's spelling, which is the same value with its direction arm stated.Generated by Claude Code