Skip to content

fix(service-automation)!: flow CEL record is the record the run was handed, or unbound - #22674

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22642-flow-cel-record-binding
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22642-flow-cel-record-binding

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22642
Clause-②: no (narrowing)

In flow CEL, record is now the record the run was handed, or unbound. It is never the run's variables map.

AutomationEngine.celScope handed the formula engine record: vars. buildScope assigns extra after record, so a record variable already won. With no record in hand, though, CEL record was the variables map, and record.assignee silently read a flow variable named assignee. The builder now passes no record slot. record resolves through the variable spread like any other name:

  • it is bound when an entrance handed the run a record (seedRunVariables binds context.record as record), or when the flow binds a record variable itself;
  • otherwise record.X faults Unknown variable: record, with the source, as every other unbound root does.

Bare names (assignee) and vars.assignee resolve as before. The diff is one statement and its docblock in packages/services/service-automation/src/engine.ts, one new test file, and the changeset.

The open decision: this PR is NOT ready until it is settled

check-adr-0087-registration is red on one cause: the changeset claims registered flow-cel-record-variables-alias-retired, and that D3 entry does not exist yet. I stopped before editing the migrations registry, as the dispatch asks. The two routes and the evidence are below. The full analysis is in the os-dev-report comment on #22642.

  • The conflict. The dispatch says "zero reach, no migration entry owed", and it also asks the changeset to carry the FROM → TO remedy. With the remedy in the body, the gate's own detector reads a prescription. hasMigrationPrescription on this changeset answers true; the control, .changeset/15206-managed-content-sealed.md, which validly holds no-migration-prescription, answers false. So no-migration-prescription is refused, and an honest registered needs a ledger entry. PR feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609 (landed) took the same route at zero measured reach.
  • Route A (recommended): register a D3 semantic entry. Two files in packages/spec, outside this claim's surface:
    • new: packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts;
    • regenerated by pnpm --filter @objectstack/spec gen:migration-registry: packages/spec/src/migrations/registry.ts.
  • Route B: drop the remedy from the changeset and claim no-migration-prescription. This leaves a breaking runtime narrowing with no FROM → TO, against AGENTS.md's Post-Task Checklist step 3 and the dispatch.

Step 1: the census (measured before any edit)

I used a static TypeScript-AST pass that executes nothing from the corpus. It reads every flow CEL slot: condition, expression, visibleWhen, and the { dialect: 'cel', source } value envelopes (tagged templates included). Sources it cannot resolve statically are listed and resolved by hand, never dropped. Recall control: every file carrying start-node text yielded a flow literal (0 misses on both trees).

tree flows CEL slots read record of those, with no record entrance
this repo at 0ec4268972: examples/**, packages/platform-objects (no flows), packages/qa/dogfood 64 51 2 (showcase_inbound_task_webhook, an api hook; a dogfood record-after-update flow) 0
objectstack-ai/hotcrm at f0afcbda07 (src/, test/) 45 57 (5 resolved by hand: vars.* only) 12, all record_change 0

Reach: zero, so no flow is rewritten. One cron string read as an expression was excluded from the 51. Deployed metadata and other repositories were not measured.

The entrance map: how each door hands a record today

Every entrance hands its record through AutomationContext.record, and seedRunVariables binds it as record and $record. Nothing else binds record except the flow's own variables.

entrance what it hands where
record-change trigger the written row trigger-record-change
time-relative sweep each matched row trigger-schedule time-relative-trigger.ts
inbound hook the request body trigger-api
type: 'flow' action (REST /actions, MCP run_action) the loaded row, or an empty record carrying at most the given id runtime dispatchFlowAction / loadActionSubjectRecord
subflow parent the parent's context.record (the child context spreads the parent's) subflow-node.ts
map item the item, when it carries a string id; otherwise the parent's map-node.ts
REST trigger route, declared endpoint, cron schedule none buildAutomationContext; schedule-trigger.ts

Pins and the ablation

packages/services/service-automation/src/flow-cel-record-binding.test.ts has 17 cases, through registerFlow + execute and the two primitives:

  • The defect: record.assignee, and the card's has(record.assignee) ? record.assignee : null, with no record and an assignee variable: the run fails (success: false, status: 'failed'), naming Unknown variable: record and the source, and never answers u9. An edge predicate and evaluateValueEnvelope / evaluateCondition refuse it too.
  • Controls: a record-triggered run reads record.assignee from its record over a same-named variable. Bare assignee and vars.assignee still read the variable. A flow-declared record variable is read.
  • Each entrance: record-change, time-relative, inbound hook, flow action on a row; an object-less action's empty record faults on the key and never reads the variable; a subflow child reads its parent's record, and a record-less parent hands none; a map item with an id is the child's record, and an id-less item leaves the parent's.

Ablation (scripts/ablation-replace.mjs, WRAP mode, run from the committed state): record: vars put back. The anchor hit 1 → 0, and the blob went 69bb14b848c5 → 0451cf389cbd. Result: 5 failed / 12 passed, exactly the five no-record pins (both value pins, the edge predicate, the primitives, and the record-less subflow parent). Controls and entrances stayed green. Restore proven: the blob after restore equals HEAD (69bb14b848c5), git diff HEAD is empty, and git status --porcelain shows 0 lines.

Verification

Every reading below was taken at HEAD e87a793ce6 (this branch merged with origin/main 5fb1746611) unless it says otherwise. Each exit status was captured before any pipe.

  • Build (the closure, turbo run build --filter='@objectstack/service-automation...' --concurrency=1): 30/30 tasks, exit 0.
  • @objectstack/service-automation tests (vitest run --maxWorkers=2): 184 files, 2348 passed, exit 0. The new file alone: 17/17.
  • @objectstack/service-automation typecheck (tsc --noEmit and check:test-typecheck): exit 0. tsc --listFiles counts the new test in the program (1 hit; 184 test files; 0 error TS).
  • @objectstack/spec test:repo: 54 files, 915 passed, exit 0. Its repo tests walk the tree, which includes the new file and the edited docblock.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths): 65 commands. Reconciled with --ran and exit codes recorded: 65 derived, 64 run, 1 NOT-MEASURED, 0 unrun.
    • 63 exit 0;
    • red, the named cause above: node scripts/check-adr-0087-registration.mjs --base origin/main exits 1 because the claimed id flow-cel-record-variables-alias-retired does not exist in the registries;
    • NOT MEASURED: pnpm check:dual-build-cjs-loads exits 3 with PREREQUISITE NOT MET, because it needs every package's dist and only the service-automation closure is built here. Declared narrowing: the one package this diff changes loads under require (node -e "require('./dist/index.cjs')" in packages/services/service-automation, exit 0, AutomationEngine is a function). The full gate is CI's.
  • The dist-reading gates (check:dts-closure, check:sourcemap-no-sources-content) swept the 30 built packages, which is the service-automation closure.

Acceptance notes

  • The lint twin, a follow-up the seat files. packages/lint/src/flow-cel-root-scope.ts (landed in feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609) lists record in ENGINE_BOUND_ROOTS, the always-bound set. After this lands, record is entrance-derived, so objectstack validate still passes a record.X that now faults at run time when the flow has no record entrance. That PR's D3 entry flow-cel-unbound-root-refused also says record is "bound by the engine". This PR does not touch packages/lint or that entry, per the dispatch.
  • Not in scope, not measured: the {var} template dialect (loop / map collection, text slots) is interpolated from the variables map, not evaluated through celScope, so this change does not reach it. What {record.x} does on a run with no record was not measured here.
  • The census tool lived in the session scratchpad and is not committed.

Seat's append: patch round 1 (head e4977aa6d2)

Appended by domain:services seat 1 (session_013j5gkUCpqQiti4GgPqqmnt) at 2026-10-10T11:23Z, from the dev's round-1 report on #22642. The open decision above is settled: A. The seat answered in-seat (6096468599, which also amends the claim's file surface), and the spec lane was told on #6017 (6096471320). The changeset is unchanged: it keeps its FROM → TO remedy and registered flow-cel-record-variables-alias-retired.

  • The D3 entry. NEW packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts, in the shape of its sibling flow-cel-unbound-root-refused.
    • Surface: every flow CEL slot reading record.X, or bare record, on a run that holds no record, where X names a flow variable. The slots are node and edge condition, a decision branch expression, a screen field visibleWhen, and the assignment and create_record / update_record value envelopes.
    • Replacement: the variable by its name, or vars.X.
    • Reason: the entrance map, and why no D2 conversion exists: a flow's record entrances are not visible from the flow alone.
    • Acceptance: list the flow's entrances, then rewrite where an entrance hands no record. Re-run each path, once started with no record and once started with a record.
    • No conversionIds and no relevantWhen.
  • The registry. packages/spec/src/migrations/registry.ts is regenerated by gen:migration-registry only (+39, −0). check:migration-registry exits 0 (418 semantic).
  • Merges. Both go through bash scripts/pm/os-regen-merge.sh:
  • Head e4977aa6d2. The delta against origin/main 6a3fe2517b is 5 files, +418 / −2.

Gate exits at e4977aa6d2, each captured before any pipe:

  • check-adr-0087-registration --base origin/main: exit 0 (registered, new here).
  • spec check:migration-registry: exit 0. check:generated: exit 0, all 15 current.
  • service-automation: 184 files, 2348 passed. Typecheck exit 0.
  • spec local tests (where migrations.test.ts lives): 642 files, 19180 passed. test:repo: 54 files, 915 passed.
  • Derived gates: 92 derived, 92 run, 0 NOT-MEASURED, all exit 0. check:dual-build-cjs-loads is included.

Generated by Claude Code

…handed, or unbound

celScope handed the formula engine `record: vars`, so a run with no record
in hand bound `record` to its own variables and `record.assignee` read a
variable named `assignee`. The builder now binds no `record` slot: `record`
resolves through the variable spread, bound when an entrance handed the
run a record (seedRunVariables) or the flow binds a `record` variable, and
faulting `Unknown variable: record` otherwise.

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added 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 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))
  • 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
  • 4 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 — 139 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 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 77856eb1144cbb646cc3b6c31f74a0eb67636e83 — the merge of head e4977aa6d2ec2164d36ec3f0d8a073bea5d9ef85 into base 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0, 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 77856eb1144cbb646cc3b6c31f74a0eb67636e83 && git checkout 77856eb1144cbb646cc3b6c31f74a0eb67636e83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 e4977aa6d2ec2164d36ec3f0d8a073bea5d9ef85 && git checkout -B drift-repro 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 && git merge --no-ff e4977aa6d2ec2164d36ec3f0d8a073bea5d9ef85

node scripts/docs-audit/affected-docs.mjs --json 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0

⚠️ 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 6a3fe2517b1cc06b0042e3a841db24cf4e257fd0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…y regenerated

The changeset's FROM -> TO remedy for flow CEL `record.X` on a run with no
record is a prescription for metadata authors, so it lives in the ADR-0087
ledger. Semantic-only: whether a run holds a record depends on the entrances
that start the flow, which the flow alone does not show. registry.ts is
regenerated by gen:migration-registry, not edited by hand.

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Read at 2026-10-10T11:37Z, read-only: card #22642 (body and all five comments: triage 6095881854, the claim 6095951451, both os-dev-reports and the in-seat answer 6096468599), PR #22674 (body, file list, the net diff origin/main...e4977aa6d2 from merge-base 6a3fe2517b: 5 files, +418 / −2) and the 42 check-runs on the head, latest per name. The head tree was read with git show and git grep only. Nothing was built, run or re-run.

Check-runs on e4977aa6d2. Every check has completed; none is red. The seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Of the five the brief named as in progress, four had completed success by the first read and the fifth, Check Changeset (the job that runs check-adr-0087-registration.mjs --base MERGE_BASE on a PR), completed success on the second read. The Auto Label and Check PR Size re-runs are skipped behind earlier success runs. There is no failure to attribute.

① Derived judgments

Each accept-set or surface change the diff implies, judged against triage 6095881854: record is bound only to a record the run was handed, or to a declared record variable, and is never an alias of the variables map.

  1. celScope binds no record slot — right, and complete. At the head, celScope (engine.ts:12130) returns { extra: { ...vars, vars, current_user } }. Its only two callers, evaluateValueEnvelope (:12288) and evaluateCondition (:12415), hand that object straight to ExpressionEngine.evaluate, which hands it to the CEL engine's buildScope (packages/formula/src/stdlib.ts:489): record is bound there only when ctx.record !== undefined, and extra is assigned over the scope afterwards. So record is bound exactly when the variable map holds a record key. Nothing else reads the removed slot: git grep celScope over the package finds the two callers and four comments, and record: vars survives only in the docblock's history sentence. The narrowed return type is on a private method; the signatures of evaluateValueEnvelope and evaluateCondition are unchanged, so no public TypeScript surface moves.

  2. Every entrance that hands a record still binds record — right. seedRunVariables (engine.ts:12839) sets record and $record from context.record whenever it is present, and each entrance the dev measured hands its record on that key, read at the head: the record-change trigger (packages/triggers/trigger-record-change/src/record-change-trigger.ts:511, record: isolatedRecord), the time-relative sweep (packages/triggers/trigger-schedule/src/time-relative-trigger.ts:699), the inbound hook (packages/triggers/trigger-api/src/api-trigger.ts:191, record: payload), the type: 'flow' action (packages/runtime/src/action-execution.ts, record taken from loadActionSubjectRecord's subject.record), subflow (the child context spreads the parent's, subflow-node.ts:97) and map (record: item when the item carries a string id, map-node.ts:183). The three that hand none are read too: buildAutomationContext (the REST trigger route and a declared endpoint) builds params, recordId and object and no record; a plain schedule run has no record (schedule-trigger.ts:729). The entrance table in the PR body is accurate.

  3. Precedence is unchanged wherever a record exists — right. Before the diff, extra was assigned after the record slot, so an entrance record or a declared record variable already won over record: vars; after it, that same extra.record is the only binding. The controls in flow-cel-record-binding.test.ts pin exactly this (a record-triggered run reads its row over a same-named variable; a declared record variable is read; bare assignee and vars.assignee are unchanged), and the narrowing pins (record.assignee, the card's has(record.assignee) ? record.assignee : null, an edge predicate, both primitives, a record-less subflow parent) assert Unknown variable: record with the source attached. The dev's WRAP-mode ablation (5 failed / 12 passed, only the no-record pins) is the right discriminator, and its restore proof is on the card.

  4. A bare variable still resolves — right, through the spread, as before. vars and current_user still win over a same-named variable; the spec's FLOW_SCOPE_CLAIMED_IDENTIFIERS (vars, current_user) is untouched and still true.

  5. Not reached, and declared as such — right. The {var} template dialect (text slots, loop and map collection, filters such as {record.id}) interpolates from the variable map and never passes through celScope, so the diff cannot change it; the acceptance notes say so.

  6. Census honesty — honest in method and in the in-repo half; one reconciliation residual on the hotcrm half. The method is sound: a static AST pass that executes nothing, named trees at named shas, a recall control (every file carrying start-node text yielded a flow literal, 0 misses) and the statically unresolvable sources listed and resolved by hand rather than dropped. The in-repo half was re-read here by grep at the head over examples/**, packages/platform-objects and packages/qa/dogfood: the only flow CEL slots reading record. are the three value envelopes of showcase_inbound_task_webhook (type: 'api'; the hook hands the body in as the record) and the dogfood credential-mask flow (record-after-update); packages/platform-objects has no start node; every other record. hit is an object formula, a validation rule, a sharing-rule criterion or a template slot, none of them flow CEL. Two flows, both with a record entrance: reach 0 here matches the dev's count. The hotcrm half cannot be re-measured from this review's inputs. Residual: at the same hotcrm sha f0afcbda07, this census counts 45 flows / 57 CEL slots where feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609's pending changeset counted 32 flows / 65 sites; this one reads test/ as well as src/, which explains the flow count but not, on its own, the slot count. The reach claim itself (every hotcrm flow that reads record is a record_change flow) is stated per flow and is the number the verdict rests on. The census tool is uncommitted, so the reading is reproducible by method, not by tool.

  7. The D3 entry and the registry — right. 18.flow-cel-record-variables-alias-retired.ts carries the surface (every flow CEL slot, record.X or bare record, on a run holding no record), the replacement (the variable's name, or vars.X), the reason (the entrance map, and why no D2 conversion can exist) and the acceptance (per entrance, re-run with and without a record); its filename is the generator's own name for that id and major. The registry.ts hunk (+39 / −0) is byte-equal to what renderRegion emits for that file (the leading comment run plus the literal, four-space indent, trailing comma) and sits in sorted position between flow-builtin-node-config-values-refused and flow-cel-unbound-root-refused; all 341 step-18 semantic ids at the head are in sorted order. The file is the generator's output, not a hand edit. Both merges (d9ebbee173, e4977aa6d2) are plain two-parent merges and the net diff against the merge base is the five declared files. check:migration-registry runs in CI on every PR and is green on this head; check:generated exit 0 is the dev's reading.

  8. The lint twin is a disagreement this diff opens and does not close — judged in ③. After this lands, packages/lint/src/flow-cel-root-scope.ts still lists record in ENGINE_BOUND_ROOTS, and its header (lines 29–31) still describes the retired alias as current, so objectstack validate and the runtime publish gate pass a record.X in a flow with no record entrance that now faults at run time.

② Semver level

  • Clause-②: no (narrowing) — right. No key is added to any published payload; what changes is what the flow CEL scope accepts. (narrowing) is BREAKING, and the changeset is written breaking (the !: title and the BREAKING line).
  • Level minor on @objectstack/service-automation — right for this line. .changeset/pre.json is pre mode, tag next; the launch-window convention (check-changeset-no-major.mjs) ships a breaking change as minor inside the lockstep fixed group, and the group is already majored for v18. Stock precedents of the same shape: 13458-manifest-permissions-string-list-retired.md, 15206-meta-doors-environment-only.md (narrowing, minor, registered).
  • No skip-changeset — right: @objectstack/service-automation publishes.
  • The diff also publishes into @objectstack/spec (the ledger entry and registry.ts, read by objectstack migrate meta and the generated upgrade guide) while the changeset names only service-automation. That is the stock convention (15206-meta-doors-environment-only.md and 15207-audit-log-attribution-field.md register D3 entries without bumping spec, and lockstep bumps spec regardless), so it matches the repo's practice. Noted, not a defect.
  • ADR-0087 disposition registered flow-cel-record-variables-alias-retired — right. The id resolves at the head and is NEW in this diff (absent at 6a3fe2517b), which is exactly what registered asserts. no-migration-prescription was never claimable: the body carries a FROM → TO table and a one-line fix, which the gate reads as a prescription, and dropping that remedy (route B) would contradict Post-Task Checklist step 3 and this card's claim. The gate's PR run is green on this head.
  • FROM → TO — right and complete. record.assignee becomes assignee or vars.assignee; has(record.assignee) ? record.assignee : null becomes has(vars.assignee) ? vars.assignee : null; the one-line fix names record as the entrance record only; the measured reach and the unmeasured remainder (deployed metadata, other repositories) are stated. One sentence ("objectstack validate does not refuse such a record read yet") is time-bound and will read as history in a CHANGELOG; acceptable in a changeset, and it is the sentence the dev removed from the ledger entry for that reason.

③ Boundary flags

  1. Round-0 open question (ADR-0087 route A or B) — answered, and the answer holds. The seat answered A in-seat (6096468599) and amended the claim's file surface with the two spec files under the cross-domain exception. This review confirms A on the gate's own terms (②): the changeset's remedy is a prescription, so not-required was never claimable, and registered with a new entry is the honest disposition; feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609 took the same route at the same zero measured reach. The round-1 report carries no open question. The spec-lane declaration on [PM seat] domain:spec — ⏳ vacant #6017 (6096471320) is asserted on the card and lies outside this review's inputs; it is not re-read here.

  2. out_of_scope_findings: the lint twin — escalated to the seat, with a timing condition. The dev names the carrier (this seat) and did not touch packages/lint, per the claim's ⛔. The window: from this landing until the follow-up lands, objectstack validate and the runtime publish gate pass a record.X read in a flow with no record entrance, and the run then fails loudly with Unknown variable: record and the source. Judged acceptable to land, on four grounds: (a) the window replaces a silent misread with a loud, attributed fault, so it is strictly better than the state it leaves; (b) the lint door newly refuses nothing, so no working flow is turned away; (c) measured reach is zero on both corpora, and the ledger entry carries the remedy to objectstack migrate meta and the upgrade guide; (d) triage 6095881854 scoped the lint side as a follow-up the claimant files, and the claim forbade packages/lint here, so a FAIL on this ground would overturn the card's own ruling rather than judge the PR against it. Condition, escalated to the seat as a seat act: file the follow-up card before this PR is armed for the queue, not after the merge, because three texts become false the moment this lands and one of them publishes: ENGINE_BOUND_ROOTS and header lines 29–31 of packages/lint/src/flow-cel-root-scope.ts (move record to the entrance-derived set; flowCelEntrances already reads every entrance); the still-pending .changeset/22565-flow-cel-unbound-root-refused.md, whose sentences describing record as bound by the engine publish into CHANGELOG at the next release unless amended while pending; and the landed D3 entry flow-cel-unbound-root-refused, whose reason also says the engine binds record and renders into the upgrade guide. The follow-up's own design question (what the lint does for an autolaunched flow with no visible entrance, where a parent subflow or map could hand it any record — the header's stand-down case) belongs to that card. Dedupe words: flow CEL record entrance-derived · ENGINE_BOUND_ROOTS record · validate passes record.X with no record entrance.

  3. No other dev flag. The two remaining acceptance notes (the {var} template dialect unmeasured; the census tool uncommitted) are declared limits, not flags, and neither bears on the verdict.

Implemented-by: claude/issue-22642-flow-cel-record-binding
Reviewed-by: session_013j5gkUCpqQiti4GgPqqmnt (contract-review subagent, CONTRACT_REVIEW_TIER)

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: with no record variable bound, flow CEL record is the variables map itself, so record.KEY silently reads a variable named KEY

2 participants