Skip to content

feat(spec,service-automation)!: refuse {$User.*} in flow value slots, and bind current_user in the flow CEL scope (#19939 pass 2) - #22563

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-19939-pass2-user-token
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-19939-pass2-user-token

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

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 binds current_user. This executes the maintainer's ruling 6063191653 (Q1 A, Q2 A; maintainer 「同意」 2026-10-08). The date macros {NOW()} / {TODAY() ± N} stay with pass 3 (#19939 remains open), so this PR uses Part of.

Q1 — A. The flow CEL scope binds current_user to the run's EvalUser (id from userId, positions, organization from tenantId) when the run has a user, and to null when it has none, never a pseudo-user (ADR-0118 D1, D4). {$User.*} is refused in value slots at registration, the remedy naming current_user.id; for a flow that can run without a user the remedy names the guard current_user != null ? current_user.id : null and states that the guarded form writes null where the template wrote nothing, which on update_record clears a stored value the template left alone. The step-18 D3 entry flow-value-slot-template-dialect-refused is amended; no D2 conversion.

Q2 — A. current_user carries only what the run holds: id, positions, organization, platform-admin flag. {$User.Email} and every {$User.PATH} other than Id retire with a remedy that says they never resolved in any shipped run and names the read of the user record through current_user.id for email or name.

What lands

The binding (@objectstack/service-automation). AutomationEngine.celScope binds current_user from the run context, through createEvalUser: id from userId, positions, organizationId from tenantId, and the derived isPlatformAdmin. A run with no user gets null. Every CEL site of a flow now receives the run context: the start-node condition (runContext), edge conditions, a screen field's visibleWhen on resume (run.context), and the decision, assignment, create_record and update_record executors. evaluateCondition and evaluateValueEnvelope take the context as an optional last argument, and without one current_user is null. vars and current_user are bound after the variables are spread, so each wins over a flow variable of the same name.

The refusal (@objectstack/spec). valueSlotTemplateRefusals no longer keeps the user token kind; only the date macros are kept.

  • {$User.Id}: the refusal names { dialect: 'cel', source: 'current_user.id' } and the guard current_user != null ? current_user.id : null, with its update_record consequence.
  • Every other {$User.PATH}: the refusal says it never resolved in any shipped run and names the read of the user record (current_user.id into a variable, a get_record on sys_user, then me.email / me.name).
  • Text with holes: the run user's id becomes current_user.id in the concatenation, with the hole's guard (current_user != null ? current_user.id : ''). Any other $User path is left out of the concatenation, as the template rendered nothing for it, followed by the Q2 sentence.
  • The text-slot judge's run-user remedy now computes the id with the CEL envelope. It previously named assignments: { v: '{$User.Id}' }, which this change refuses.

H4, the expression remedy (carry 6087210395). celExpression now tokenises the expression and writes every variable path by celPath'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 vars head (carry 6088133752). FLOW_SCOPE_CLAIMED_IDENTIFIERS = { vars, current_user }, measured from celScope. A path whose head is either name is printed through vars: vars["vars"][0], vars["current_user"].name, with has(vars.vars.tags) guards.

The ledger. The step-18 D3 entry flow-value-slot-template-dialect-refused is amended (surface, replacement, reason and acceptance criteria name {$User.*} and both remedies). The two text-slot entries, flow-text-slot-single-brace-refused and flow-text-slot-unbound-dollar-root-refused, are amended where their replacement computed the run user through the refused spelling. registry.ts was 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: owner uses the bare current_user.id, because a screen flow always has a user.
  • packages/cli/src/commands/explain.ts (os explain flow): the example is an update_record under record-after-create, which a system write fires with no user. It now writes current_user.id and gates on condition: '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. recipients is 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's FLOW_NODE_TEXT_SLOTS) nor a ledger value slot (the value role is only assignment.assignments.* and create_record / update_record fields.*). 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 names current_user, and a paragraph defines it (user-less null, no email or name, the shadowing of a variable named current_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.id and its guard for $User.Id, the sys_user read for any other $User path, vars["$error"].message for 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, FlowSchema and the [P2] flow: create_record node value semantics ambiguous (literal vs CEL vs macro vs ref) #1315 lint (0 findings each).
  • packages/services/service-automation/README.md "Expressions" (shipped in files) 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_user is 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.
  • The changeset gains '@objectstack/lint': patch.

Measured premises (H1–H5, on 86bf9ed7d5)

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 and registry.ts, flows.mdx and 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 to evaluateValueEnvelope / evaluateCondition. Without it, fields / assignments envelopes and decision conditions would see current_user as null in every run. Their docblocks also said {$User.*} was kept.
  • spec/src/automation/flow-text-slot-template.ts (and its test). Its run-user remedy named assignments: { v: '{$User.Id}' }, a spelling this change refuses.
  • spec/src/automation/builtin-node-config.zod.ts (and its test), with the regenerated content/docs/references/automation/builtin-node-config.mdx. The two value-slot .describe() strings said the $User paths 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 in registry.ts. Their replacement text computed the run user through the refused spelling.
  • Tests that pinned the kept spelling or the old remedy. In lint: lint-flow-patterns.test.ts and validate-expressions.text-slot.test.ts. In service-automation: text-slot-template.test.ts, logic-nodes.test.ts and crud-fields-value-envelope.test.ts. One new test file: flow-cel-current-user.test.ts.

packages/lint changes lint-flow-patterns.ts in patch round 1 (above), the files the seat's answer 6092066429 added, with service-automation/README.md. No governed surface is touched (skills/**, .claude/**, docs/adr/**, AGENTS.md).

Tests and gates (head caeaab982f)

Head is caeaab982f, a merge of main at 4638625e07. The build is the dependency closures of service-automation, lint and cli (58 tasks), then the whole workspace minus docs (72 tasks).

  • @objectstack/spec local project: 635 files, 18974 passed and 1 todo. Repo project: 54 files, 915 passed. Both ran at caeaab982f.
  • @objectstack/service-automation full suite: 181 files, 2284 passed at caeaab982f. Typecheck exit 0, with check:test-typecheck OK.
  • @objectstack/lint full suite: 133 files, 6092 passed. @objectstack/cli unit test/commands.test.ts and test/explain-rule-id.test.ts: 62 passed. Typecheck exit 0 for spec, lint and cli. All of these ran at a86164b899; the only later commit changes three service-automation test files.
  • Gates: dispatch-gates --commands re-derived on the actual diff gives 122 commands, a superset of the 93 at dispatch (the added families include check:engine-double-contract, check:type-check-coverage, the docs families and check:generated). All 122 ran at caeaab982f, and dispatch-gates --ran reports 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN.
    • Five exited 3 (PREREQUISITE NOT MET) on the first pass: check:skill-examples, check:dual-build-cjs-loads, check:i18n, check:i18n-coverage and check: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).
  • Repo lint, narrowed and proven:
    • ① The population is eslint . --no-inline-config over eslint.config.mjs.
    • ② --format json over the 26 changed .ts files reports 26 linted, 0 ignored, 0 errors and 0 warnings.
    • ③ That config enables no type-aware linting (no parserOptions.project, no typed rules; eslint.config.mjs says so at its type-aware note), so this diff cannot move any verdict on a file it does not touch. The full pnpm lint is CI's.

Patch round 1 at c5a343c32a (no merge of main; the PR reads mergeable): @objectstack/lint full suite 133 files, 6102 passed; lint typecheck exit 0. dispatch-gates --commands re-derives the same 122, all exit 0 at c5a343c32a, and --ran reports 0 NOT-MEASURED and 0 UNRUN. check:published-readme-links, check:published-readme-exports, check:nul-bytes and check:doc-authoring exit 0. Narrowed lint over the 27 changed .ts files: 0 errors, 0 warnings. Spec, service-automation and cli code is untouched by the round, so their caeaab982f readings stand.

Ablations

Three ablations, one per behaviour, each through scripts/ablation-replace.mjs in WRAP mode under the verify lock, from the committed state caeaab982f. Each mutation is verified on disk (anchor x1 → x0, blob changed), and each restore is proven blob == HEAD with an empty git diff HEAD.

  • A1, the old judge branch. KEPT_KINDS back to ['date-macro', 'user']. flow-value-slot-template.test.ts goes red: 8 failed, 93 passed. Every run-user refusal pin fails. Restored to blob 8ee782870cd2.

  • A2, the divisor-only celExpression. The old inner.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 blob 8b5be3d9cad1.

  • A3, the binding removed. celScope back to { ...vars, vars }. flow-cel-current-user.test.ts goes red: 13 failed, 1 passed. The one pass is the control that a bare current_user.id faults without a user, which holds unbound too. Restored to blob d17b32e3e677.

  • 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.ts goes red, 7 failed and 191 passed, exactly the 7 value-slot pins. The text-slot pin and both controls stay green. Restored to blob ee30b6ea9c22.

A1 and A2 resolve the judge from src (spec's own tests), and A3 resolves engine.ts by a relative import, so no dist/ sits on any ablation's path.

Acceptance notes

Nothing here is filed. Each item is for the seat:


Generated by Claude Code

claude added 6 commits October 9, 2026 23:56
…{$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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 42 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowValueSlotSchema (symbol, a top-level const object), me.email (literal, a string literal in runUserPathNeverResolved))
  • content/docs/data-modeling/objects.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/deployment/troubleshooting.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/getting-started/quick-reference.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/protocol/kernel/http-protocol.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/protocol/objectql/schema.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/protocol/objectui/layout-dsl.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/ui/apps.mdx (via project_task (literal, a string literal in nodes))
  • content/docs/ui/dashboards.mdx (via project_task (literal, a string literal in nodes))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class), os explain (command, read off packages/cli/src/commands/explain.ts))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via evaluateCondition (symbol, a method of class AutomationEngine))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-automation/README.md, packages/services/service-automation/src/builtin/template.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 34 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fbb065fd4b52dce103776e49fefd90f0138059a7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d9b2831dab77cca866c301f2b47158d224af7044 — the merge of head c5a343c32a194a3acf6db2c404095fa3189be2a2 into base fbb065fd4b52dce103776e49fefd90f0138059a7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fbb065fd4b52dce103776e49fefd90f0138059a7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c5a343c32a194a3acf6db2c404095fa3189be2a2
Local-runs: none

Rendered 2026-10-10T02:24Z by the adopting seat's session, on PR #22563 at the head above (branch claude/issue-19939-pass2-user-token, merge base 4638625e07, three-dot diff 31 files, +1378 / −207, matching the PR's file list; the PR reads mergeable: true, clean). Inputs: card #19939's body and all 22 comments (ruling D on #11182 5805777944; the pass-2 premise 6061665370; the decision request 6061808462; the ruling 6063191653; the serial note 6064225872; the carry notes 6087210395 and 6088133752; the claim 6091177086; the dev reports 6092046661 and 6092505053; the seat's answers 6092066429; the pass-3 carry 6092547738); card #22565 and its triage 6092499200; the PR body as the seat edited it, its file list and its net diff against main; the 42 check-runs on the head. Nothing was built, run, re-run or ablated here; every count below is a read of the diff, the repo at the head, or GitHub.

Check-runs on the head: 42, all completed — 38 success, 4 skipped (Auto Label, Check PR Size on the edited-event run, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in progress. The seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance, Governed Surface Queue Guard) are each success. These conclusions are the gate verdicts; the PR body's local gate counts are the dev's and are not re-judged here.

① Derived judgments

The ruling, Q1 A — the binding. Right.

  • AutomationEngine.celScope(variables, context) returns extra: { ...vars, vars, current_user: runUserOf(context) }. runUserOf answers null unless context.userId is a non-empty string, else createEvalUser({ id: userId, positions: context.positions ?? [], organizationId: context.tenantId }) — id from userId, positions, organization from tenantId, isPlatformAdmin derived by the one factory (positions.includes('platform_admin')). No email, no name (Q2 A). Never a pseudo-user.
  • Every flow CEL site at the head passes the run context — measured by grep over service-automation/src excluding tests, there are exactly six evaluation call sites and two ExpressionEngine.evaluate sites, and all eight carry it: the start condition (engine.ts:6416, runContext), edge conditions (:11724, context), a screen field's visibleWhen on resume (:8400, run.context), the decision condition (logic-nodes.ts:85), the assignment envelope (:202), and the create_record / update_record field envelope (crud-nodes.ts:210). Both ExpressionEngine.evaluate calls go through celScope(variables, context). No site is left on the old arity.
  • @objectstack/formula's buildScope assigns ctx.extra last, so current_user from extra is what the predicate sees, and a flow variable named record / previous / input / os / user / ctx is not shadowed by the scope — only vars and current_user are bound after the spread (H5 below).
  • Pinned in flow-cel-current-user.test.ts with a user and without one, at the assignment envelope, the start condition, an edge, a decision and visibleWhen on resume; runAs: 'system' with a triggering user sees that user; userId: '' and {} answer null; the bare read faults naming current_user.id; the ruled guard writes null. A3 in the body (binding removed, 13 of 14 red) is the dev's ablation and is consistent with the pin set.

The ruling, Q1 A and Q2 A — the refusal and its remedies. Right.

  • KEPT_KINDS is now ['date-macro'] only; the user token kind is refused by the one judge, which FlowValueSlotSchema / AssignmentValueSchema, objectstack validate, registerFlow and the executors already call (pass 1's wiring, untouched). Registration refuses it.
  • {$User.Id} as a whole value: runUserIdRemedy names { dialect: 'cel', source: 'current_user.id' }, then the guard current_user != null ? current_user.id : null "in a flow that can run without a user (a schedule, or a record change made by a system write)", and states "the guarded form writes null where the template wrote nothing, which on update_record clears a stored value the template left alone." That is the ruling's sentence.
  • Every other {$User.PATH} (Email, Name, lowercase id, or $User.Id followed by an operator): runUserPathNeverResolved says it "never resolved in any shipped run", that current_user carries only id, positions, organizationId, isPlatformAdmin, and names the user-record read by current_user.id (an assignment of the id, a get_record on sys_user, then me.email / me.name). isRunUserIdToken mirrors resolveToken's dispatch (first segment Id, rest ignored), so the id/not-id split is the interpolator's own.
  • Text with holes: the id becomes current_user.id in the concatenation; the hole's guard (current_user != null ? current_user.id : '') is named; a non-id $User hole is left out "as the template did" with the Q2 sentence after it; a string of only such holes prints ''.
  • The text-slot judge's run-user remedy (unspellableRemedy, case 'user') now computes the id through the CEL envelope and names the guard; a non-id path gets the Q2 sentence. Its old remedy named assignments: { v: '{$User.Id}' }, which this diff refuses — a published remedy made false by this change and fixed in the same stroke, which is right.

The ledger. Right. The step-18 D3 entry flow-value-slot-template-dialect-refused is amended in surface ("the run-user paths beginning $User. included"), replacement (both remedies, the update_record consequence, the Q2 read), reason and acceptanceCriteria (find the user-less flows; guard or gate). The two text-slot entries are amended only where their replacement computed the run user through the refused spelling. registry.ts carries the same text plus the hand-kept rationale fragment. packages/spec/src/conversions/registry.ts is not in the diff: no D2, as ruled. The changeset's ADR-0087 marker is not-required (already-registered …) naming the three pre-existing ids — the honest category for amended entries this diff did not add; the gate that re-validates it is green.

H4 — celExpression tokenises. Right, and pinned. The lexeme grammar is quoted string, number, variable path (the interpolator's own VARIABLE_PATH spelling), or one character. A name in call position, a member off a ., and the CEL keywords are left as written; every other path goes through celPath, so {int * 2} is vars["int"] * 2, {items.0 * 2} is items[0] * 2, {$error.code / 2} is vars["$error"].code / 2.0. The divisor rule is byte-identical in effect: / 100 becomes / 100.0, / 100.5 and / x are left alone, and a divisor inside a quoted string is now (correctly) not rewritten. Does it change any remedy pass 1 printed beyond the measured defect? No: the only lines removed from flow-value-slot-template.test.ts are the pass-1 "kept" pins for {$User.*} and vars leaving the "ordinary heads" list; no pass-1 expression expectation (round(x * 100) / 100.0, the path and concatenation forms) moved. A remedy differs from pass 1's only where its expression held a path CEL could not read as written (a claimed head, a numeric segment, a $ head, a keyword segment) — exactly the family the carry note 6087210395 prescribed ("the path rule of celPath applied per identifier path inside the expression").

H5 — the claimed heads. Right, and pinned. FLOW_SCOPE_CLAIMED_IDENTIFIERS = { vars, current_user } is exactly the set of keys celScope binds after the spread (the object literal has no third post-spread key), and buildScope cannot add one because extra is assigned last. celHeadReadsThroughVars and guardOf route such a head through vars (vars["vars"][0], has(vars.vars.tags)). The grammar test in service-automation evaluates both spellings for a variable of each name through the real scope, so a name the scope starts binding without a line in the spec reddens there.

The public surface and the accept set — Clause-②: no (narrowing), judged hard. Right, with the measurements:

  • (a) The three-dot diff adds exactly five export lines, all in packages/spec/src/automation/flow-template-token.ts (FLOW_SCOPE_CLAIMED_IDENTIFIERS, CEL_RUN_USER_ID, CEL_RUN_USER_ID_GUARDED, isRunUserIdToken, runUserPathNeverResolved). That module is package-internal by design ("⛔ Package-internal — NOT a public export", absent from automation/index.ts; its only importers are the two judges). None of its names, including pass 1's celPath / CEL_CLAIMED_IDENTIFIERS / celExpression, appears in packages/spec/api-surface/*.json or packages/spec/export-origins/*.json, while the exported judge names (flowNodeValueTemplateRefusals, VALUE_SLOT_TEMPLATE_REFUSAL, textSlotTemplateRefusal) do. No snapshot under api-surface/, export-origins/, authorable-surface/, json-schema.manifest/ or the dual-source baseline moved in the diff, and check:api-surface (inside the green TypeScript Type Check job) agrees. lint-flow-patterns.ts adds six functions, none exported; lint's entry still exports lintFlowPatterns and the rule ids only. No package's public entry gains a name.
  • (b) evaluateCondition and evaluateValueEnvelope are methods of AutomationEngine, which service-automation's entry exports. Each gains an optional trailing context?: AutomationContext. By the repo's definition of the public surface (the entry's re-exported names plus every type reachable through their props, parameters and return types) this adds nothing: no new name, and AutomationContext was already reachable through execute(flowName, context). An optional trailing parameter is additive and breaks no caller. service-automation carries no api-surface gate, so this is a read of the diff, not a snapshot.
  • (c) The binding. Before this diff current_user.id in a flow condition or envelope was accepted by FlowSchema, by objectstack validate (validateStackExpressions, 0 issues) and by registerFlow, and faulted only at run time (the dev's P4 measurement, accepted by the seat in 6061808462). The published accept set is the set the doors accept, and for current_user it is unchanged: the build door refuses no unbound root at all (the trap automation: a flow CEL expression may name the run user as user, ctx.user or os.user; objectstack validate passes it and the run faults Unknown variable, because flow CEL binds only current_user #22565 now carries), so nothing at the doors could widen. What changes is run-time behaviour: a declared-not-enforced root (ADR-0068 D1's canonical current_user, mounted by the same formula engine under the same name wherever EvalContext.user is supplied) is now honoured under the maintainer's ruling 6063191653. Ruling D's own execution line graded the engine half of the same card family Clause-②: no (runtime behaviour; the spec half carries its own declaration), and execution-duties:104 keeps a pull-back to a declared contract outside clause ②. The P4 reading "it widens what a flow accepts at run time" is a run-time reading; the clause's criterion is the published accept set and the package surface, and both are measured unmoved. The value is therefore no; the arm (narrowing) is the {$User.*} refusal (lanes/spec.md:21–22: a narrowing does not trigger clause ② and owes this review). yes (narrowing) would also not have been an error under lanes/spec.md:22, but it is not required: nothing here is the sibling PR's case of a seat-added export reaching an entry.
  • The claim line reads Clause-②: no; the dispatch, the PR body and the changeset read no (narrowing). Same value; the arm is optional (clause2-line.mjs: an absent arm declares no direction), and the narrowing is declared where the ADR-0087 gate reads it, the changeset.

The lint hint's probe (patch round 1). Right, and side-effect free. collectTemplateStrings now carries each string's key path; valueSlotLabelAt writes {os_lint_value_slot_probe} at that path into a copy made by withValueAt (arrays copied by spread, objects by spread, along the path only — siblings are shared by reference and never written) and asks flowNodeValueTemplateRefusals(nodeType, probed) for a refusal whose source is the probe. The judge refuses a path token exactly where it judges the position, so the ledger's three value slots and both legacy assignment shapes are covered without a second list, and a cycle in a code-built flow is never walked. Where the judge refuses nothing (filter, recipients, an http payload, subflow.input, …) the single-brace dialect still reads, and the old message and hint are byte-identical there — correct. Text slots never reach this loop (withoutTextSlots) and keep their {{ }} hint. The prescribed envelopes are pinned through four judges (0 refusals each). One pre-existing imperfection, not this diff's: a bare $x.y inside a CEL envelope's source (key source is not in CEL_KEYS) still draws the single-brace hint, as it did on main; the door's own CEL error names the real fault there. Noted for the seat, not a finding against this head.

Showcase recipients. Right. #22110's text slots are notify.title / notify.message, screen.title / screen.description, end.message (FLOW_NODE_TEXT_SLOTS), and the ledger's value role is assignment.assignments.*, create_record.fields.*, update_record.fields.* (FLOW_NODE_EXPRESSION_PATHS). notify.recipients is in neither, the judge returns nothing for it, and resolveToken's $User. branch still answers it (template.ts docblock, "a filter, a notify recipients entry"). Classified, not migrated, and the README now says so. Right per the split; the position stays with the rider decision below.

The in-repo sites. Right. app-todo owner reads current_user.id bare (a screen flow always has a user). The os explain flow example, an update_record under record-after-create, writes the envelope and gates on condition: 'current_user != null' so a system write leaves assigned_to alone — the ruling's own update_record concern, answered in the catalog example that teaches it; its test pins both lines and not.toContain('{$User.').

Docs and descriptions. Right. flows.mdx gains the two run-user FROM → TO rows, the text-slot CEL row, current_user in the bindings column, and a paragraph defining it (user-less null, no email or name, the vars / current_user shadowing). The two .describe() strings and the regenerated builtin-node-config.mdx no longer say $User paths are kept (only those two lines move in the reference; the other hunks a two-dot diff shows there are main's drift, not this PR's). The changeset's prose, FROM → TO table and kit match the code.

The surface crossings (dev deviation 1; seat accepted in 6092066429). Each necessary:

  • crud-nodes.ts, logic-nodes.ts — one argument each; without them every executor would see current_user as null. Necessary.
  • flow-text-slot-template.ts and its test — its remedy named the refused spelling. Necessary.
  • builtin-node-config.zod.ts, its test, the regenerated reference — false .describe() text, and check:docs would red. Necessary.
  • The two text-slot step-18 entries and the registry's rationale fragment — false replacement text. Necessary.
  • The tests pinning the kept spelling or the old remedy (lint ×2, service-automation ×3), and the new flow-cel-current-user.test.ts — necessary.
  • Patch round 1: lint-flow-patterns.ts + test, service-automation/README.md (shipped in files), the changeset's '@objectstack/lint': patch — the seat's own file-surface amendment; necessary.
  • No governed surface is touched (file list read: no skills/**, .claude/**, docs/adr/**, AGENTS.md, CLAUDE.md). Governed Surface Queue Guard: success.

② Semver level

The line is in changesets pre mode next toward 18 (.changeset/pre.json on main), and all four packages sit in one fixed group, so every one of them lands at the same 18.0.0-next.N; the per-package grade decides only the heading each CHANGELOG section files this body under.

  • @objectstack/spec: major — right. A published authoring accept set narrows ({$User.*} refused in value slots); the body carries the BREAKING banner, the FROM → TO table, the one-line fix and the ADR-0087 marker, as the Post-Task Checklist requires.
  • @objectstack/service-automation: major — right. registerFlow and the executors refuse what they accepted, and the CEL scope's contract changes (current_user bound, null user-less).
  • @objectstack/lint: patch — right for this diff. Lint's own source change is a hint fix (flow-bare-dollar-reference); validate-expressions.ts is untouched, so the door narrows only through @objectstack/spec's judge, which the spec major declares and the shared body (BREAKING, FROM → TO) also files under lint's CHANGELOG. Pass 1 graded lint major because it changed the door's source; this pass does not.
  • @objectstack/cli: patch — right. os explain flow's catalog example and its test; no CLI contract moves.
  • Clause-②: no (narrowing) — right, on the evidence in ①: no export reaches a public entry, no snapshot moved, the optional parameter adds no name or reachable type, and the doors' accept set for current_user is unchanged; the arm names the {$User.*} narrowing. The changeset carries the line and exactly one ADR-0087 marker; Check Changeset and Lint & Repo Gates are green.

③ Boundary flags

Dev deviations (6092046661, 6092505053):

  1. Surface crossings beyond the claim's list, not stopped on — answered in ①, each necessary; the seat's acceptance stands.
  2. Clause-② claim-vs-dispatch spelling — answered: same value, optional arm; no edit required.
  3. The three pending changesets first corrected, then restored — answered below (release decision).
  4. The six rider positions classified, not brought under the judge — answered: each is a value-like position that keeps the single-brace dialect and evaluates no envelope, so a refusal there would have no evaluating remedy; naming why not is what pass 1's review asked. Escalated to the seat: the carrier "this card, a later pass or a decision" is not a line in the pass-3 carry note 6092547738; before [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 closes the seat writes the riders into a pass-3 carry line or files their card, so they do not close silently with the card.
  5. Harness attribution — answered: AGENTS.md's model-free trailer pair and session-URL footer were used; a harness-written trailer is an accepted exemption.
  6. The probe-token design, flagged for this review — answered in ①: side-effect free, correct where the judge refuses nothing.
  7. The PR body was stale after patch round 1 — answered: the seat edited it; the body at this head describes the diff at this head (lint source change, A4, the README, the lint patch grade).

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: claude/issue-19939-pass2-user-token
Reviewed-by: session_01VZqqwTj2wsihZEbfT6yyYN

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants