Skip to content

fix(lint): resolve flow-variable template roots, and gate the record trigger on the record root alone - #18583

Merged
os-try-charles merged 6 commits into
mainfrom
claude/issue-17305-flow-template-variable-roots
Sep 17, 2026
Merged

os-try-charles merged 6 commits into
mainfrom
claude/issue-17305-flow-template-variable-roots

Conversation

@os-try-charles

Copy link
Copy Markdown
Collaborator

Fixes #17305

Clause-②: no

The diff adds no export, removes none, and widens no accepted shape — it makes an
existing build-time guardrail resolve two more kinds of template root, so strictly
more input is refused. git diff origin/main...HEAD -- packages/lint/src | grep -E '^\+export'
is empty, and so is the ^-export half.

What changed

packages/lint/src/validate-flow-template-paths.ts could resolve exactly one template
root — record — and it skipped any flow that was not record-triggered. Both limits hid
the failure the rule exists to catch, and neither is unresolvable:

  1. Variable roots. A get_record node declares objectName and outputVariable
    in one config, so the name it binds holds a record of a known object. A loop declares
    collection and iteratorVariable, so when the collection names one of those
    multi-record outputs, each element is a record of that same object. Both are static
    bindings read off authored metadata — the same kind of resolution boundObjectOf()
    already did for record, with a different root.
  2. The trigger gate now applies to the record root alone. It is right there (no
    record trigger, no triggering record) and meaningless for a name a node inside the flow
    binds: a schedule flow's get_record output is as statically typed as a
    record-change flow's. {record.…} on a non-record-triggered flow stays unjudged,
    exactly as before.

Resolved roots are judged by the two rules that already owned this class —
flow-template-unknown-field and flow-template-lookup-traversal — at the same
position-based severity
: error inside a filter-guarded CRUD node's filter (the node
refuses to run at execution time, framework#3810), warning everywhere else. The rule was
widened, not rewritten: the trigger-root messages and hints are unchanged byte-for-byte,
and every one of the 42 pre-existing pins passes untouched.

limit > 1 switches get_record to a multi-record read, so that name is tracked as a
list, never as a record root — {staleCases.some_field} stays silent.

The conservatism, which is the old one

A variable root resolves only when nothing else in the flow can bind that name.
seedRunVariables keeps one flat map per run, so an assignment target, another node's
outputVariable, an indexVariable / errorVariable, a node id (the engine writes each
node's outputs under a nodeId.key variable and evaluateCondition expands that dotted
key into an object at the node id), or a trigger field flattened to top level all make the
name ambiguous — and ambiguous stays silent.

A flow.variables declaration is deliberately not a second binder. It declares the
slot the node then fills (seedDeclaredVariables runs first; the node's write replaces
what it seeded), and it is the shape the platform's own canon ships — examples/app-todo
declares tasksToRemind / overdueTasks beside the get_record that fills them. Reading
the declaration as a collision would have made this whole resolution inert on exactly the
flows it was written for. First draft did read it that way; the corpus measurement below
is what caught it.

Also deliberately unresolved, each silent rather than guessed: an outputVariable on any
node type other than get_record; a loop whose collection is not a bare variable name
holding a multi-record get_record output; and the flow-template-field-unprovisioned
rule, which stays a trigger-root question (see Acceptance notes).

Evidence — both directions, because a green suite proves nothing here

The three token-root shapes #17305 measured are transplanted as fixtures.
⛔ The flows that report measured lives in a different repository and is not readable from
this container, so nothing here claims anything about that site count — what is pinned
is the SHAPES.

fixture root kind before after
{caseRecord.owner_id.manager} get_record output, schedule flow silent reported
{currentCase.owner_id.manager} loop iterator, schedule flow silent reported
{record.owner_id.manager} trigger record, record_change flow reported reported, same severity

Before — the pre-#17305 rule restored into the tree (git restore --source=497655fe5,
mutation proved on disk by hash and marker count: resolveVariableRoots 3 hits to 0,
blob 1c9e1c2e to 9540d763), then the new suite run against it:

Tests  6 failed | 54 passed (60)
× flags a lookup hop off a get_record output, on a schedule flow
× flags a typo off a get_record output
× gates a variable-root hop in a filter-guarded position (#3810 severity split)
× resolves a get_record output on a RECORD-TRIGGERED flow too (the root dimension alone)
× flags a lookup hop off a loop iterator bound to a multi-record read
× accepts the bare-name collection spelling loop-node.ts falls back to
AssertionError: expected [] to have a length of 1 but got +0     (x6)

All six failures are expected [] to have a length of 1 — the old rule returned an EMPTY
array on every one of the three shapes. That is the positive control for the new pins: they
are not vacuous. The twelve negative-control pins (is silent when …) passed in that same
run, before and after. Restore verified byte-identical: git diff HEAD empty,
git hash-object back to the HEAD blob.

After — pnpm --filter @objectstack/lint test:

Test Files  103 passed (103)
     Tests  3864 passed (3864)

pnpm --filter @objectstack/lint typecheck — exit 0 (tsc --noEmit + check:test-typecheck,
2 files / 6 errors / 2 pinned signatures held in the shrink-only ledger, unchanged).

Does the platform's own metadata go red? Measured: no.

The rule was run over this repo's own example apps' objects + flows, with the pre-fix rule
and with this one, same script, same tree:

app before after
examples/app-crm (6 objects / 1 flow) 0 0
examples/app-multi-package (2 / 0) 0 0
examples/app-showcase (22 / 30) 0 0
examples/app-todo (1 / 4) 0 1 warning

The one new finding is a true positive, at warning (advisory — it does not move an
exit code, and no CI job runs objectstack validate over examples/):

[warning] flow-template-unknown-field @ flows[1].nodes[4] · flow "overdue_escalation" node "notify"
  template references '{currentTask.days_overdue}', but 'days_overdue' is not a field on
  object 'todo_task' — it resolves to an empty string at runtime (silently).

examples/app-todo/src/flows/task.flow.ts:136 renders
'Due {currentTask.due_date}, {currentTask.days_overdue} day(s) overdue.', and
todo_task declares no days_overdue (18 declared fields, none of them that). Every
overdue escalation this app sends reads "…, day(s) overdue.". Not fixed here — the
remedy is a product decision (a formula field, or a different message), not a mechanical
one, so it is reported for filing rather than ridden in on this PR. Nothing goes red, so
this PR carries no red required context.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 60 commands from this diff; all 60
were run and reconciled with --ran. 55 exit 0. The rest:

  • pnpm check:doc-authoring — was red on my diff, now green. My two new hint strings
    carried a tracker id ((#3475)) into runtime prose. Stripped from the strings; the
    adjacent source comment keeps the anchor. Re-run: exit 0, "sibling-package prose ids hold
    the baseline — 821 pinned sites … no growth".
  • pnpm check:cross-package-test-inputs — red, and pre-existing. It reports
    packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/spec/dist/.
    Control run with my three files restored to origin/main content in the same tree: exit 1,
    same finding, same rooted test. Not mine.
  • check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — exit 3,
    PREREQUISITE NOT MET (each reads built output of ~75 packages this worktree has not
    built). Their own text: "This is NOT MEASURED. It is neither a pass nor a failure." CI
    builds the closure and measures them.

Changeset

patch on @objectstack/lint, and the criterion was measured rather than assumed: that
package's files is ["dist","README.md","CHANGELOG.md"], and after pnpm --filter @objectstack/lint build the new hint text START node's opt-in is present in
dist/index.js, dist/index.cjs, dist/runtime.js and dist/runtime.cjs — alongside the
positive control config.expand (#3475), an existing string, in the same four files.
Something published moved, so skip-changeset would have been wrong.

Acceptance notes

  • flow-template-field-unprovisioned (lint: view-filter / page-binding field checks resolve against the blanket SYSTEM_FIELDS union, so the #8116 unprovisioned-anchor warning cannot reach filter surfaces #8340) stays a trigger-root question. A
    get_record on an ADR-0015 external object binds a variable whose registry-injected
    anchors are just as unprovisioned, so the same silent-empty failure is reachable through a
    variable root. Applying it there was outside this card's dispatch (which named the other
    two rules), so the boundary is documented in the module header and pinned by a test
    (leaves the unprovisioned-anchor rule a TRIGGER-root question) rather than left to be
    rediscovered. Reported to the dispatching seat for filing.
  • The nodeId.outputKey spelling is not a root. {fetch.records} / {fetch.record}
    are real variable keys the engine writes, and resolving them would need two-segment roots.
    Left unresolved (silent) on purpose; noted, not filed — no PR or person is heading for
    this file with that question, so it has no receiver today.
  • map node iterators are poisoned, not resolved. map carries collection +
    iteratorVariable like loop does; the dispatch named loop. A map iterator therefore
    makes its name ambiguous and stays silent — the conservative direction. Noted, not filed;
    receiver: whoever widens this rule next, from the module header.

Generated by Claude Code

… trigger on the record root alone

`validate-flow-template-paths` could resolve exactly one template root,
`record`, and skipped any flow that was not record-triggered. Both limits
hid the same failure it exists to catch: a `get_record` node declares
`objectName` and `outputVariable` in one config, and a `loop` declares
`collection` and `iteratorVariable`, so the names they bind hold records of
a known object — statically, from the authored metadata alone.

- Resolve those variable roots and judge their `.<field>` paths with the
  existing `flow-template-unknown-field` and `flow-template-lookup-traversal`
  rules, at the same position-based severity (ERROR inside a filter-guarded
  CRUD node's `filter`, WARNING elsewhere).
- Apply the record-trigger gate to the `record` root alone. A `schedule`
  flow's `get_record` output is as statically typed as a record-change
  flow's; `{record.…}` on such a flow stays unjudged as before.
- A variable root resolves only when nothing else in the flow can bind the
  name (declared variables, assignment targets, other `outputVariable`s,
  `indexVariable`/`errorVariable`, node ids, flattened trigger fields).
  Ambiguous means silent — the conservatism the rule already had.

The trigger-root messages and hints are unchanged byte-for-byte.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
Poisoning a root because `flow.variables` declares it would make the new
resolution inert on the canonical sweep shape the platform's own examples
ship (app-todo declares `tasksToRemind` beside the `get_record` that fills
it). `seedDeclaredVariables` runs first and the node's write replaces what
it seeded, so the declaration is one slot, not two writers.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…te root resolution

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…-authoring)

A runtime string reaches authors and generated surfaces, none of whom can
resolve a tracker id; the adjacent source comment keeps the anchor.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 14 documentable anchor(s).

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

  • content/docs/ai/actions-as-tools.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/ai/agents.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/ai/connect-mcp.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/ai/index.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/ai/natural-language-queries.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/ai/tools.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/api/index.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/automation/flows.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/automation/jobs.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/getting-started/build-with-claude-code.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/kernel/runtime-services/examples.mdx (via get_record (literal, a string literal in resolveVariableRoots))

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

  • content/docs/releases/v17/17-0.mdx (via get_record (literal, a string literal in resolveVariableRoots))
  • content/docs/releases/v17/17-3.mdx (via fieldTypes (symbol, a field of interface TemplateRoot))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 35 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 4 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 5ed7ad9df84a319a9842ffceb97c030406a508a3 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5ed7ad9df84a319a9842ffceb97c030406a508a3

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

复核:ACCEPT —— 硬停条件被正面撞上了一次,而它按规矩停在了正确的一侧

domain:devx 执行席(座位贴 #6023,session session_017ef78bLdybu3AffehKkhfk,round 16)。判据取自 GitHub 与 diff 本身,读数时刻 2026-09-17T04:08Z。

⭐ 那条硬停:加宽之后本仓自己的流会不会变红

派发令写死:⛔ 不许为了让 CI 绿而把规则缩回去;⛔ 也不许让这张 PR 带着红的必需上下文落地;两者都做不到就停下回报。

它把四个示例 app 各跑了两遍(修前的规则 restore 进树,再跑 HEAD):

            修前   修后
app-crm       0  →  0
app-multi-pkg 0  →  0
app-showcase  0  →  0
app-todo      0  →  1      ← 唯一新增
TOTAL         0  →  1

那一条是 advisory warning、真阳性:examples/app-todo/src/flows/task.flow.ts:136 渲染 Due {currentTask.due_date}, {currentTask.days_overdue} day(s) overdue.,而 todo_task 声明的 18 个字段里没有 days_overdue ⇒ 每一封逾期升级通知都渲染成「Due …, day(s) overdue.」,该有数字的地方是空的。

⇒ 没有变红(warning 不动退出码,且没有 CI 作业对 examples/ 跑 objectstack validate)⇒ 硬停未触发,规则没有被缩回去,PR 不带红的必需上下文。⭐ 而且它没有顺手改掉那条警告 —— 理由正确:补法是产品判断(加一个公式字段 vs 改写文案),⛔ 不是"就地修"豁免所要求的机械形状。

⇒ 这条新发现由本席立卡,⛔ 不在本 PR 里修。

条款②:本席按实际 diff 重判,⛔ 不按卡片语义

packages/lint/src 下  新增的 ^export 行 = 0
                      删除的 ^export 行 = 0
CONTROL 该路径下 +/- 行总数 = 787        ← grep 在读

⇒ 零导出变动,且本改动是更多拒绝 = 收窄接受集 ⇒ Clause-②: no 由 diff 成立,不只是被申报。(本班已有一张 PR 因为多了一行 export type 而触条款②,那次是本席批的扩面造成的 —— 所以这一条每次都要重判。)

两个方向都测了,而且消融腿就是阳性对照

把修前的规则 restore 回树(on-disk 变更以 blob 哈希 1c9e1c2e → 9540d763 与标记计数 resolveVariableRoots 3 → 0 双证;本席读到这两组哈希的时刻 2026-09-17T04:08Z),同一套用例:

修前:Tests 6 failed | 54 passed —— 六条失败全部读作
      `AssertionError: expected [] to have a length of 1 but got +0`
      ⇒ 旧规则在这三种形状上产出的是**空数组**
      而同一轮里 12 条静默 pin 全绿 ⇒ 它们不是在复述实现
修后:Test Files 103 passed / Tests 3864 passed
      触发记录那一种仍被报出,filter 内 error、filter 外 warning —— 严重度不变

⭐ 消融腿同时充当了两件事:证明新 pin 不空转(修前必红,且红的签名逐字可辨),与证明负控不空转(两棵树上都绿)。

验收口径的那一处改写,它照做且照实写明了边界

那 6 个下游站点在 objectstack-ai/hotcrm,本容器无检出 ⇒ 它移植了三种形状做夹具,并在报告里写明「NOT CLAIMED: only the SHAPES are measured here」。⇒ ⛔ 没有声称"下游 6 个已验证"。这正是本席改写口径时要的东西。

其余放行判据

⚠️ CI 仍在收敛(16 条在跑)。绿了本席走三步,并按本班新写死的一条:转 ready 后的重读必须看到 total_count 真的动过。⛔ 承接者不要自己转。


Generated by Claude Code

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

Projects

None yet

2 participants