Skip to content

lint: flow-template-unknown-field / flow-template-lookup-traversal never reach a {record.FIELD} template outside a node filter — the declared warning half is unreachable #16111

Description

@os-steve

Found while surveying which platform rules fire on objectstack-ai/hotcrm (its #1621, step 3 of epic #1579). Read-only survey, report-first, unassigned.

Measured against the pinned @objectstack/lint@17.3.0, hotcrm at 1670557. Every injection proven on disk before a verdict was read; every one restored by blob hash verified by observation.

What the shipped rule declares

Both ids carry a per-position severity, keyed off leaf.inFilter:

severity: inFilter ? "error" : "warning",
rule: FLOW_TEMPLATE_UNKNOWN_FIELD,

and the message body branches the same way — nodeType filter references '{record.…}' versus template references '{record.…}', the latter explaining that "the token resolves to an empty string on every run (silently)". So the non-filter position is deliberately in scope, at warning.

Measurement — one file, four injections, same run design

src/flows/billing-handoff.flow.ts carries both position kinds against the same triggering record: a get_record node with filter: { id: '{record.crm_account}' }, and a payload block that interpolates {record.id}, {record.name}, {record.amount}, {record.close_date}, … outside any filter.

injection position result exit
filter: { id: '{record.crm_account_nope}' } in filter flow-template-unknown-field at error, x2 1
filter: { id: '{record.crm_account.owner_id}' } in filter flow-template-lookup-traversal at error, x2 1
amount: '{record.amount_nope}' payload, not a filter passed: true, no finding 0
amount: '{record.crm_account.name}' payload, not a filter passed: true, no finding 0

The first two rows are the working control: the file is walked, the node is walked, recordRefsIn resolves {record.…} shapes, and the object's field map is loaded. Only the non-filter leaves are absent from leaves.

⚠️ Declared: my first attempt at the non-filter half used {billingAccount.phone_nope} — a variable ref, not a record ref — and so was out of the rule's class, not evidence of anything. It is reported here as a no-op rather than silently replaced; the table above is the redone measurement with {record.FIELD} in both positions.

Consequence

The half of these two rules that fires is the half whose consequence is loud — a filter token that resolves to nothing drops the condition and the node refuses at execution time. The half that is silent at authoring time is the half whose runtime consequence is also silent: per the rule's own message, the token renders as an empty string on every run. A hand-off payload that interpolates a renamed field ships an empty column to the billing system, and nothing anywhere says so.

Related, not duplicating

Dedupe

Targeted search returned 4 on-topic results (the three above plus #15793) — non-zero and on-topic, which is the control that the search was not silently answering empty.

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / bug / tooling / priority:p2 / pm:queue

    分诊席位。⛔ 不认领、不派发、不写代码、不合并。origin/main @ 932acc3d,2026-09-06T03:26Z。

    声明处复现,且是两个站点

    packages/lint/src/validate-flow-template-paths.ts:380    (inFilter ? `${nodeType} filter references ` : 'template references ') +
    packages/lint/src/validate-flow-template-paths.ts:396    severity: inFilter ? 'error' : 'warning',
    packages/lint/src/validate-flow-template-paths.ts:424    severity: inFilter ? 'error' : 'warning',
    packages/lint/src/validate-flow-template-paths.ts:93     export const FLOW_TEMPLATE_UNKNOWN_FIELD = 'flow-template-unknown-field';
    

    ⇒ :396 与 :424 分别属于卡说的两个规则 id,两处都声明了 warning 分支,而两处的 warning 分支在实测里都不可达。⇒ 不是一处疏漏,是同一个 leaves 收集器的同一个缺口喂了两条规则。

    定型 bug

    规则自己声明了非 filter 位置在范围内(severity: inFilter ? 'error' : 'warning',且消息体专门为这一半写了分支文案,解释 token「resolves to an empty string on every run (silently)」),而那一半永不发射。⇒ 声明 ≠ 强制,恢复方向 ⇒ bug。⛔ 不是 enhancement:这不是新增覆盖,是让已声明的覆盖真的存在。

    优先级 p2

    ⭐ 卡把最关键的不对称说透了,我原样采纳并加重:

    会发射的那一半,后果是响的(filter token 解析为空 ⇒ 条件被丢弃 ⇒ 节点在执行期拒绝)。
    静默的那一半,运行期后果也是静默的 —— 按规则自己的消息,token 每次运行都渲染成空字符串。

    ⇒ 一个 hand-off payload 插值了一个被改名的字段,就给计费系统发一列空值,而任何地方都不说话。

    ⭐ 这是 linter 该抓的最典型形状:运行期不会报错的错误,只能在授权期抓。而这条规则恰好在这一半上是哑的。

    不给 p1:需要一次改名/笔误才触发,且没有越权或数据损坏——是数据缺失而非错误数据。

    ⚠️ 顺带一个横向对照,本轮同批三张卡的失败方向各不相同,接卡人排序时可参考:

    车道 domain:devx

    落点 packages/lint/src/validate-flow-template-paths.ts ⇒ devx。⛔ 不是 domain:services(flow 运行时在 service-automation)——被检查的是 flow,改的代码是 linter。

    ⭐ 卡自陈的那次废弃测量,值得单独表扬

    ⚠️ Declared: my first attempt … used {billingAccount.phone_nope} — a variable ref, not a record ref — and so was out of the rule's class, not evidence of anything. It is reported here as a no-op rather than silently replaced。

    ⇒ 一次类外的注入,如果被当作「注入了但没报」,读起来就是一条假的覆盖缺口。卡把它作废并重做,而不是悄悄换掉。⭐ 这与本轮反复出现的那条纪律是同一件事:一个不可能失败的读数,与一个通过了的读数无法区分。

    给接卡人的三条

    1. 修的是 leaves 的收集范围,不是 severity 表。 两处 severity: inFilter ? … 已经写对了;缺的是非 filter 位置的叶子根本没进 leaves。⛔ 别从 severity 那一行下手。
    2. 两条规则一起修、一起 pin。 卡给的四行注入正好是四个 pin:两条 filter 位(必须保持 error + exit 1,是负控制)、两条非 filter 位(修复后应为 warning)。⛔ 只 pin 后两条,下次重构可能把 filter 半边也一起弄坏而测试仍绿。
    3. ⚠️ 注意 warning 不改变 exit code。 修好之后那两行的 exit 仍然是 0(passed: true + warnings)。⇒ 验收标准要看 findings 数组,⛔ 不能只看 exit code——否则修好了也测不出来。

    相邻卡

    #16096(开)——filter-token-unknown 根本不走 flows。卡说得准:同一族的 walk-reach 缺口,方向相反(那条一个 flow 位置都到不了,这条只到 filter 位置)。⛔ 不合并(不同规则、不同文件),但接卡时值得同批读——leaves / walk 的收集边界只想一次比想两次便宜。#16096 也在我的裸卡队列里。

    去重

    卡的定向搜索返回 4 条切题结果(#16096、#4389、#3426、#15793),非零且切题 ⇒ 搜索没有静默返回空。⚠️ 本席位未做穷举枚举,采信卡的判断。


    Generated by Claude Code

  2. claude commented on Sep 6, 2026

    @claude
    Contributor

    Claim: PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T18:12Z — dispatching to an os-dev subagent (mode:subagent) on branch claude/issue-16111-flow-template-leaves-outside-filters. pm:queue → pm:dispatched, assignee = the shared account (identity is this session ID + that branch). The dev inherits both and posts no second claim.

    Clause-②: no

    • Lock read at dispatch (18:10Z): free, queue empty ⇒ arrival depth 1 < LOCK_DEPTH_HOLD (2).
    • Live devs after this claim: 1/5. Two ACCEPTED lint PRs are landing on disjoint files — no two live PRs share a file.

    Generated by Claude Code

  3. claude commented on Sep 6, 2026

    @claude
    Contributor

    Claim addendum — dev interrupted by a container restart; takeover in the same worktree, same claim — PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T18:33Z.

    • The dev dispatched at 18:10Z (Claim 5561159307) was killed at ~18:28Z when the seat's container restarted. Three-state recovery read: remote branch claude/issue-16111-flow-template-leaves-outside-filters at 50f343ad3 (pushed 18:24:58Z); worktree on disk clean at the same commit with node_modules; no PR, no os-dev-report. So the middle state — the work survived; nothing is lost except the unfinished verification.
    • Takeover per the worktree protocol: a second os-dev continues IN the existing worktree on the same branch — no new worktree, no new claim, assignee untouched. Everything inherited from 50f343ad3 is re-verified (red-first on the unmodified walker, the whole lint suite through the lock, the derived gates) and reported as such; the PR body carries a Takeover section.
    • File surface widened, declared here: the measured cause is in the shared walker, not the rule — packages/lint/src/flow-walk.ts (localConfig stripped every region key of any node type from every node, so config.body — loop's region slot AND the http payload key — vanished before any rule read it) plus flow-walk.test.ts; the rule file itself is unchanged. Hot-file check 18:32Z over all 32 open PRs' file lists: none touches flow-walk.* or validate-flow-template-paths.*. Blast radius of a shared-walker change is the takeover dev's first verification item (every localConfig reader keeps its findings).

    Generated by Claude Code

  4. claude commented on Sep 6, 2026

    @claude
    Contributor

    os-dev-report
    {
    "issue": 16111,
    "status": "done",
    "branch": "claude/issue-16111-flow-template-leaves-outside-filters",
    "pr": "#16407",
    "premise_still_valid": true,
    "head_sha": "d5a30458f",
    "summary": "TAKEOVER of the dev killed at ~18:28Z; inherited commit 50f343a, no PR and no report existed. The card's premise holds and is now measured on this tree. Cause: WalkedFlowNode.localConfig stripped the FLAT UNION of every region-holding config key from every node's view, and 'body' is loop's region slot AND the canonical http request-payload key (packages/spec/src/automation/io-node-config.zod.ts says so verbatim), so config.body was deleted before any rule read it. stripRegions now takes the keys to remove and walkFlowNodes passes the slots the node's own type declares. Existing rule ids and severities only; 'severity: inFilter ? error : warning' untouched. The flat union stays the DEFAULT so the one other caller (lint-flow-patterns.ts) is unchanged by this PR. Merged origin/main mid-task: it had landed on lint-flow-patterns.ts, the other stripRegions caller, so the inherited measurements predated it; confirmed the incoming version still calls stripRegions with one argument and re-ran everything on the joint tree.",
    "tests": "All figures from runs in THIS session; the previous dev's logs were read as context only and none of its numbers reused. Heavy runs through scripts/pm/os-verify-lock.sh, verdicts read from its 'VERDICT command-exit' line. RED-FIRST/ABLATION (3 legs, both directions, every leg proving the mutation reached disk via grep -cF anchor counts before/after plus a blob-hash comparison, and restoring under trap ... EXIT INT TERM with absolute paths, restore proven by blob hash + empty 'git diff HEAD'): (A) flow-walk.ts swapped for its origin/main copy (blob b8b8be6ae in place of 8992cf6e5) yields 'Tests 8 failed | 51 passed (59)' - the two FILTER negative controls stayed GREEN (error half already worked), the two payload warning pins and the loop-body pin went RED with 'expected [] to have a length of 1'. Restored to 8992cf6e5; same suites on the fix: 'Tests 59 passed (59)'. (B) targeted single-argument mutation stripRegions(raw.config) yields '5 failed | 54 passed': the 3 rule pins + 2 behavioural walker pins red, the 3 API-existence pins green, isolating the single argument as the cause rather than the refactor. (C) stripRegions(raw.config, []) (strip nothing) yields '5 failed | 54 passed' in the OPPOSITE direction, 'expected [...] to have a length of 1 but got 2', including two PRE-EXISTING card-4380 pins - so the fix is pinned from both sides. Leg C's FIRST attempt was declared VOID by its own guard and is reported, not silently retried: the observation predicate used grep -c with a bracket-bearing pattern (regex error), so the injected-text count came back empty; the mutation had landed but the observation was broken. Re-run with grep -cF; nothing was read from the void run. FLOOR: pnpm --filter @objectstack/lint test yields 'Test Files 100 passed (100) / Tests 3424 passed'; typecheck green with the test-typecheck debt ledger unmoved at 2 files / 6 errors. BLAST RADIUS (read, not asserted): localConfig has exactly ONE reader, validate-flow-template-paths.ts; the other four walkFlowNodes callers read node/path/regionTrail only; stripRegions' one other caller passes one argument and keeps the union default. Dependents: metadata-protocol 2414 passed, example-showcase 379 passed, cli unit tier 2453 passed | 6 expected fail (integration tier declared to CI - the diff touches no integration-layer file, no bin/, no spawn entry, no driver/kernel startup path). Repo-wide 'pnpm lint' (eslint . --no-inline-config, the WHOLE population, no narrowing) exits 0 in 72s. EXAMPLES MEASURED, NOT REASONED: 'objectstack validate' on examples/app-showcase passes, exit 0, ZERO flow-template-* findings; the showcase is the only authored corpus carrying a {record.FIELD} token under a formerly-stripped key (two http payloads, one in a parallel branch and one in a try_catch try) and both name real fields. Made a measurement rather than an assumption with an anti-vacuity control: injecting one typo into that real nested payload produces the warning with the full region trail while validate stays exit 0 - which also directly confirms a warning moves no exit code. Control reverted, blob hash verified. GATES: all 54 families derived by 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' run at head d5a3045 and reconciled with --ran against the tool's own output: '54 derived famil(ies) accounted for - 54 run, 0 NOT-MEASURED'. All 54 exit 0. Two needed a second run and NEITHER was a finding: check:dual-build-cjs-loads first printed 'PREREQUISITE NOT MET' (reads built output; 7 packages had no dist/) and check:type-check-debt first OOMed at 4096MB - re-run at the 6144 ceiling it documents for itself, '55 raw tsc error(s) total, none above its recorded number'. Both NOT MEASURED, not red, green once run properly. Also ran the 4 roster families the derivation flags as non-evidence for my paths (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) - all exit 0. CI convergence not awaited, per the dispatch contract.",
    "mcp_calls": "2 - one search_issues (the single directed dedupe shot) and one issue_write to file card 16405; PR creation, the PR body read-back and this comment all went via repo-scoped REST, which probed open (HTTP 200, core 15000/hr)",
    "takeover_ledger": {
    "inherited_from_50f343ad3_and_kept": "The diagnosis and the fix shape (stripRegions taking the owning node type's slots), the four pins plus the dedupe / clean-flow / loop-body pins, and the changeset.",
    "re_verified": "Everything. No inherited number was reused. Both factual claims the inherited commit rests on were checked against source rather than believed: that 'body' is canonical on an http node (the spec's own error message), and that http-nodes.ts interpolates the raw config wholesale via interpolate(raw, variables, context).",
    "corrected": [
    "Merged origin/main, which had moved onto lint-flow-patterns.ts (the other stripRegions caller) while the branch sat idle; re-ran the whole verification on the joint tree per AGENTS.md section 10. Clean merge, delta vs main still exactly the 4 intended files.",
    "A doc comment in flow-walk.ts that read as though the flat-union default were CORRECT for the caller still taking it. It is not - that caller has the same latent defect. The note now states the default is scope control, names the sibling defect and card 16405, and states the endgame (regionKeys must become required once that caller passes its own slots).",
    "A changeset clause that put the empty-string rendering 'at authoring time and at run time alike'. The rendering is a run-time event; what happens at both ends is that nothing REPORTS it."
    ]
    },
    "open_questions": [
    {
    "question": "The fix leaves the flat union as stripRegions' DEFAULT argument, which keeps lint-flow-patterns.ts on the same latent defect this PR fixes one call site over. Split across two cards, or one?",
    "options": [
    "A - ship as-is: this PR fixes only the two rule ids card 16111 names; the sibling call site lands on card 16405, which then removes the default and makes regionKeys required.",
    "B - widen this PR to both call sites now, making regionKeys required immediately, so the trapping default never exists on main."
    ],
    "recommendation": "A, and it is what is implemented. B changes the reach of two additional rule ids (flow-double-brace-interp, flow-bare-dollar-ref), which is a separate user-visible behaviour change owing its own changeset line, its own pins and its own example-corpus measurement; folding it in would make one changeset promise two different things and put a second blast radius under the same review. A is also the safer contract move rather than a workaround, because card 16405 explicitly ends with the default REMOVED - the trap is scheduled for deletion, not left standing. The cost of A is that main carries the trapping default until card 16405 lands, which the code comment now names in place so no later caller inherits it silently."
    }
    ],
    "out_of_scope_findings": [
    "filed as #16405: lint-flow-patterns.ts:1562 calls stripRegions with one argument, so flow-double-brace-interp and flow-bare-dollar-ref are blind to an http node's request payload for exactly the reason this card fixes - the same defect, one call site over. Searched first (one directed search_issues over open and closed): card 5383 and card 4380 are this walk's closed ancestors and nothing open covers it. No assignee, no labels, left for PM triage."
    ]
    }


    Generated by Claude Code

  5. claude commented on Sep 6, 2026

    @claude
    Contributor

    ACCEPT — PR #16407 (head d5a30458f, read 2026-09-06T19:39Z) — PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8. Reviewed against the three-dot diff origin/main...claude/issue-16111-flow-template-leaves-outside-filters (4 files, +280/−9), every changed line read; the takeover report 5561656574 is the increment I checked, not the evidence.

    • PR shape: draft, base main, first line Fixes #16111; full-body keyword scan reads that one binding and nothing else (related cards spelled "card 16405" / "card 16096"). Four commits (the inherited fix, a merge of main, two doc commits), no card-relation trailer in any — clean under RULE 2. No model identifiers.
    • Scope: packages/lint/src/flow-walk.ts (+58/−9), flow-walk.test.ts (+56), validate-flow-template-paths.test.ts (+155), .changeset/flow-template-leaves-outside-filters.md (@objectstack/lint patch, published package, user's words, states the behaviour change). The rule file itself is unchanged: the measured cause is the shared walker — the widened surface was declared in the claim addendum 5561278396 and no open PR held flow-walk.* (18:32Z scan). check-governed-merges.mjs --test on this file list ⇒ NOT governed.
    • Triage constraint conformance (5556571680): ① the fix is the collection boundary, not the severity table — severity: inFilter ? 'error' : 'warning' untouched; ② both rule ids pinned together with the two filter positions as NEGATIVE controls (error) and the two payload positions as the warning half, plus the both-positions dedupe (error wins), a clean fixture ⇒ zero, and a loop-body payload reported once on the http node with its region trail; ③ every assertion reads the findings array. The PM's mechanism reading (the collector already emitted non-filter leaves) held, and the dev found the real cause one layer down: WalkedFlowNode.localConfig stripped the flat union of every region key of ANY node type, and body is loop's slot AND the http payload key — verified on origin/main: io-node-config.zod.ts:101 says verbatim "body IS canonical on an http node"; localConfig has exactly one reader (validate-flow-template-paths.ts:341); stripRegions has exactly one other caller (lint-flow-patterns.ts:1562, one argument — keeps the union default, so this PR changes nothing there). The "http executor interpolates its raw config wholesale" claim was NOT independently re-read by the seat (my grep of http-nodes.ts reached only parseNodeConfig at :112); it stands as the dev's citation, and the showcase measurement below is the end-to-end evidence that matters.
    • Fix shape: stripRegions(config, regionKeys = REGION_CONFIG_KEYS) + ownRegionKeys(nodeType); walkFlowNodes passes the node's own slots — the same lookup that decides where the walk descends, so localConfig removes exactly what is walked separately. The union stays the default deliberately to bound this PR to the two rule ids on the card; the code comment names the sibling trap and its card (lint: flow-double-brace-interp / flow-bare-dollar-ref never reach an http node's request payload — the same stripRegions union that blinded the flow-template rules #16405) and states the endgame (make regionKeys required once that caller passes its own slots). Open question A — accepted as implemented: widening to lint-flow-patterns.ts changes the reach of two other rule ids, which owes its own changeset, pins and corpus measurement; the trap is scheduled for deletion, not left standing.
    • Tests / evidence (all re-measured by the takeover, none inherited): red-first with the origin/main walker swapped in (blob-proven) — 8 red / 51 green, the two filter controls green, the payload and loop-body pins red with expected [] to have a length of 1; fix ⇒ 59/59; ablation in both directions (one-argument call: 5 red incl. the walker pins; strip-nothing: 5 red the other way incl. two pre-existing card-4380 pins) with a voided first leg disclosed (broken observation predicate, re-run with grep -cF); floor 100 files / 3424 through the lock, typecheck 0 with the debt ledger unmoved; dependents green (metadata-protocol 2414, showcase 379, CLI unit 2453); 54/54 dispatch-gates commands reconciled by --ran, two exit-3 first reads re-measured to 0 and disclosed; whole-repo eslint 0; examples measured: objectstack validate on the showcase passes with zero flow-template-* findings although it carries two real http payloads under the formerly-stripped key (one in a parallel branch, one in a try), and an injected typo there produces the warning with the full region trail while exit stays 0 — the anti-vacuity control. CI at head: every job in_progress at 19:38Z.
    • Takeover ledger read: inherited (diagnosis, fix shape, pins, changeset) / re-verified (everything) / corrected (merge of main onto the other stripRegions caller with re-verification on the joint tree; an overstating doc comment; a changeset clause that put the empty-string rendering at both ends). One cosmetic residue: the test-file comment still carries the corrected phrase ("at authoring time and at run time alike") — recorded, not a REWORK.
    • Out of scope, filed: lint: flow-double-brace-interp / flow-bare-dollar-ref never reach an http node's request payload — the same stripRegions union that blinded the flow-template rules #16405 (the sibling call site; two other rule ids), unrouted for triage, provenance note 5561674277; card 16096 untouched.
    • API reads git could have answered: none — 2 MCP calls (one directed dedupe search, one issue write).

    Landing plan: flip point armed 20:05Z (trig_011em6DYZXMhtYwFr7LnYN42); flip ready when every row at d5a30458f reads completed/success on its own line (each Test Core (k/6), Lint & Repo Gates, TypeScript Type Check, Dogfood Regression Gate (k/3), Build Core, Temporal Conformance, Check Changeset) → auto-merge → probe added_to_merge_queue (expect the shard-5 lap). On MERGED → probe on origin/main: ownRegionKeys ≥1 and regionKeys ≥1 in flow-walk.ts; negative control: the one-argument form localConfig: stripRegions(raw.config), at the walk site = 0; outside a node filter in the rule test ≥1; changeset present; card closes by Fixes, strip pm:dispatched, clear assignee.


    Generated by Claude Code

  6. claude commented on Sep 6, 2026

    @claude
    Contributor

    LANDED — PR #16407 merged into main at 2026-09-06T21:10:33Z as dff0bdd3a via the merge queue on its third group run (34058893500: every Test Core (k/6) row completed/success on its own line, shard 5 at 25m43s). Card closed by Fixes #16111.

    Queue history (queue note 5562057492): run 1 (34057177361) passed but the group base moved; run 2 (34057283911) lost Test Core (5/6) at the 30-minute wall (30m19s) and the fixed aggregate read failure — an honest red, not this PR's; run 3 landed. 64 minutes queued for a 4-file lint patch; #16173's dataset refresh remains the only lever.

    Landing probe on origin/main @ HEAD (21:12Z) — git merge-base --is-ancestor dff0bdd3a origin/main ⇒ true.

    • Firing: ownRegionKeys and regionKeys present in packages/lint/src/flow-walk.ts; outside a node filter in validate-flow-template-paths.test.ts; .changeset/flow-template-leaves-outside-filters.md present.
    • Negative control: the one-argument walk-site form localConfig: stripRegions(raw.config), = 0 on the same grep.

    Delivered by a takeover after the container restart killed the first dev (addendum 5561278396): the diagnosis and fix shape were inherited from 50f343ad3, everything was re-verified from scratch, and two doc corrections plus a merge of main were added. The union default in stripRegions stays for its one other caller until #16405 (triage's) lands and removes it.

    Labels: pm:dispatched stripped (four-step write, read back); assignee cleared.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions