Skip to content

fix(lint): field-no-consumers reads the synthesized layout and the upsert identity - #18381

Merged
os-try-charles merged 5 commits into
mainfrom
claude/issue-17135-field-consumers-synthesized-layout
Sep 16, 2026
Merged

os-try-charles merged 5 commits into
mainfrom
claude/issue-17135-field-consumers-synthesized-layout

Conversation

@os-try-charles

Copy link
Copy Markdown
Collaborator

Fixes #17135

Clause-②: yes

The rule's first run on a real application reported 12 fields, and all 12 were on screen or load-bearing that day. Triage ruled the direction (#17135 comment 5620725440): "⇒ Teach the rule about both consumer paths", with the guardrail "⛔ Do not fix it by exempting hidden fields wholesale — a hidden field with genuinely no consumer is still a finding; the point is that a seeder-only identity column has one."

Both consumers are now read off the spec, the way the rule's existing two exemptions (injectedColumnsFor, resolveDisplayField) already are.

1 · The synthesized layout is a display consumer

deriveFieldGroupLayout (@objectstack/spec/data, ADR-0085 §5) is the single derivation every renderer applies — form, detail, drawer, designer. It places a field by its group membership in the object's declared fieldGroups, so no fields: [...] array anywhere names it. A field the derivation puts in a keyed section is drawn, and is now credited as a display site.

This follows the previews-count-as-consumers ruling (2026-08-10, #7131 comment 5237224148), which #17135 pointed at through #7427: "A designer preview that renders a key to a human is a runtime consumer."

The trailing untitled bucket is deliberately NOT credited, and this is the load-bearing line in the change. deriveFieldGroupLayout collects every visible field that named no declared group into a trailing keyless section, and returns null — "callers fall back to their existing flat/auto layout" — for an object declaring no groups at all. So every visible field renders somewhere. Crediting "renders on the synthesized layout" in full would leave field-no-consumers able to report hidden fields and nothing else, which is switching the gate off by another name. What is credited is the author's placement; what is not is the fallback that would draw anything.

⚠️ Reach, measured, for the maintainer. On hotcrm@965933b, 326 of 331 declared fields (98%) sit in a declared group. The 12 findings were 11 of them, so the acceptance set widens by 11 fields on this application — but on an app that groups everything, the display half of this rule now rests on the author's grouping decision. That is the price of the ruled direction and it is stated rather than buried.

2 · An upsert identity inside a carrier root is a read

bucketFor returned carrier for a carrier root before anything else could ask. But a seed's externalId (SeedSchema: "Field (or composite list of fields) matched for the uniqueness check") and an import mapping's upsertKey name the column the loader matches on — it reads that column on every row to decide insert from update. A seeded value is still a carrier; the identity is the one read a carrier root holds.

crm_forecast.seed_key is exactly the shape triage described: hidden: true, readonly: true, "Written only by the seed loader; empty on every real snapshot", and src/data/forecast.seed.ts declares externalId: 'seed_key'. It is consumed by being an identity.

Nothing exempts hidden as a category. The derivation never draws a hidden field, so path 1 gives it nothing; path 2 rescues it only when an upsert actually matches on it. The matched-pair control below is the proof.

Acceptance evidence — the false-positive rate on a real app

⭐ Triage: "The acceptance evidence is the false-positive rate on a real app, ⛔ not that the unit tests pass — the unit tests passed when it shipped."

Instrument. hotcrm has no checkout here, so one was cloned (objectstack-ai/hotcrm, public, read-only) and its authored stack dumped to JSON, with functions replaced by their source text — which is exactly what the rule does with a function node (Function.prototype.toString.call(node)scanText), so the dump is behaviourally equivalent for this rule. The instrument is calibrated, not assumed: at hotcrm@965933b the app's own npx objectstack lint --json (its pinned @objectstack/lint@17.4.0) answers errors: 0 · warnings: 13 · suggestions: 12 — the card's reading byte for byte — with 12 field-no-consumers rows, and the harness over the same tree reproduces the same 12, same objects, same declaration paths.

Tree. objectstack-ai/hotcrm@965933bfeat(platform): migrate onto the @objectstack/* 17.4.0 line (hotcrm#1814), the commit the 12 were reported on. (Not v3.0.0: the app removed crm_product.tax_rate and later gave four fields consumers and the contact form its mailing block, so neither the tag nor main is the tree the card measured.)

12 → 0, per field

# object · field before cleared by the consumer it really has
1 crm_account.logo carrier-only, 4 layout group: 'branding'
2 crm_article_feedback.comment carrier-only, 4 layout group: 'basic'
3 crm_campaign.description carrier-only, 11 layout group: 'basic'
4 crm_campaign_member.added_date carrier-only, 57 layout group: 'basic'
5 crm_contact.mailing_street carrier-only, 5 layout group: 'mailing_address'
6 crm_contact.mailing_city carrier-only, 5 layout group: 'mailing_address'
7 crm_contact.mailing_state carrier-only, 5 layout group: 'mailing_address'
8 crm_contact.mailing_postal_code carrier-only, 5 layout group: 'mailing_address'
9 crm_contact.mailing_country carrier-only, 5 layout group: 'mailing_address'
10 crm_contract.description carrier-only, 9 either group: 'basic', and the seed matches on it (externalId: 'description')
11 crm_forecast.seed_key carrier-only, 12 identity only data seed externalId: 'seed_key'; hidden, so the layout gives it nothing
12 crm_quote_line_item.line_number carrier-only, 23 layout group: 'basic'

Nothing remains: 12 → 0. Every one of the 12 carries a group naming a declared fieldGroups entry, which is what the app's own CHANGELOG says about them — "every one of them still renders to users through its fieldGroup on the synthesized detail layout, which the rule does not resolve."

The per-field column is not asserted, it is ablated. Each path was removed from the committed file by anchored replacement (absent or non-unique anchor exits non-zero), the mutation was proven on disk by literal counts before reading any verdict, and each leg restored with git checkout HEAD -- PATH proven by blob identity against the HEAD blob plus an empty git diff HEAD:

leg live paths findings
main neither 12
ABLATED-A identity only 10 (the layout accounts for 11, overlapping on #10)
ABLATED-B layout only 1crm_forecast.seed_key (the identity accounts for 2)
HEAD both 0

⭐ The other half: does a real defect still get reported?

⛔ Showing the 12 disappear proves nothing on its own — switching the gate off does that too. Six deliberately unconsumed fields were injected into the same real hotcrm stack, nothing reading, displaying, seeding, translating or granting any of them:

control shape reported?
crm_contact.ctl_ungrouped visible, no group → trailing flat bucket ✅ reported
crm_contact.ctl_undeclared_group group naming a group the object never declared ✅ reported
crm_contact.ctl_hidden_grouped hidden, in a declared group ✅ reported
crm_contact.ctl_hidden_readonly hidden + readonly, in a declared group ✅ reported
ctl_plain.ctl_orphan visible, on an object declaring no fieldGroups ✅ reported
ctl_seeded.ctl_not_seed_key hidden + readonly, no upsert matches on it ✅ reported
ctl_seeded.ctl_seed_key byte-identical to the row above, matched by a seed externalId silent — correctly

The last two are a matched pair on the same object: identical declarations, one variable between them. Only the one an upsert matches on goes quiet. hidden is exempt nowhere.

Tests, gates

  • pnpm --filter @objectstack/lint test103 files, 3831 passed, 5 skipped, including 11 new pins on both paths and on each boundary that keeps the rule judging (undeclared group, flat bucket, hidden field, an object with no groups, array-shaped field maps, composite externalId, mapping upsertKey, the matched pair).
  • pnpm --filter @objectstack/lint typecheck — exit 0.
  • pnpm --filter '@objectstack/lint^...' build — exit 0 (dependency closure).
  • Changeset: patch on @objectstack/lint. Measured, not assumed — the new message text is present in all four published bundles named by files[] (dist/index.js, dist/index.cjs, dist/runtime.js, dist/runtime.cjs) alongside a pre-existing-text positive control.

Acceptance notes

Observed while measuring, not filed — no PR or author is heading for either file:

  • SeedSchema aliases key / externalkey / naturalkey onto externalId, and MappingSchema aliases key / matchOn / externalId / externalIdField onto upsertKey. On a pre-parse stack an author using an alias gets no credit from path 2. key in particular is in LITERAL_KEYS and is skipped outright, for good reasons unrelated to this rule. Post-parse — which is what os lint feeds — aliases are already normalized, so the live surface is covered.
  • SeedSchema.externalId defaults to 'name'. On a post-parse stack every seeded object therefore credits its name column, which is usually already exempt as the ADR-0079 title field and is in any case genuinely matched by the loader.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk


Generated by Claude Code

…psert identity

The rule's first run on a real app reported 12 fields, all of them on screen
or load-bearing. Two consumers name the field nowhere in metadata:

- a declared field group places it on the SYNTHESIZED layout, so no
  `fields: [...]` anywhere mentions it;
- a seed's `externalId` / an import mapping's `upsertKey` names the column the
  loader MATCHES ON, which a carrier root never got to ask about because the
  root decided the bucket first.

Both are read off the spec (`deriveFieldGroupLayout`, ADR-0085 §5) rather than
listed by hand, as the rule's other two exemptions already are.

Co-authored-by: Claude <noreply@anthropic.com>
… the rule judging

The synthesized-layout credit is pinned to a DECLARED group only — an
undeclared key, the trailing flat bucket, a hidden field and an object with
no `fieldGroups` at all each keep their verdict.

The upsert-identity credit is pinned against a matched pair: two identical
`hidden` + `readonly` declarations, one matched by a seed `externalId` and one
not. Only the matched one goes quiet, so `hidden` is nowhere exempt.

Co-authored-by: Claude <noreply@anthropic.com>
… the upsert identity

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/data-modeling/field-types.mdx (via externalId (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
  • content/docs/data-modeling/fields.mdx (via externalId (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
  • content/docs/data-modeling/import-mappings.mdx (via upsertKey (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
  • content/docs/data-modeling/seed-data.mdx (via externalId (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
  • content/docs/data-modeling/validation-rules.mdx (via externalId (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
  • content/docs/getting-started/examples.mdx (via externalId (literal, a string literal in CARRIER_IDENTITY_SEGMENTS))
What this run could not see
  • 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 8b81ab60d7b02e5540e8a36917cc86478bfbf899packageMentionDocs.

Which tree this was computed on

This run read content/docs from 638d139ef1e7f206546737545b185fd2fc14fbe9 — the merge of head 73bd3ff28ff7216cd93ac8062babeb8ed93698a9 into base 8b81ab60d7b02e5540e8a36917cc86478bfbf899, 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 638d139ef1e7f206546737545b185fd2fc14fbe9 && git checkout 638d139ef1e7f206546737545b185fd2fc14fbe9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8b81ab60d7b02e5540e8a36917cc86478bfbf899 73bd3ff28ff7216cd93ac8062babeb8ed93698a9 && git checkout -B drift-repro 8b81ab60d7b02e5540e8a36917cc86478bfbf899 && git merge --no-ff 73bd3ff28ff7216cd93ac8062babeb8ed93698a9

node scripts/docs-audit/affected-docs.mjs --json 8b81ab60d7b02e5540e8a36917cc86478bfbf899

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

…eclaration takes at least minor

AGENTS.md: "The declaration is `Clause-②: yes|no` plus at most one arm from the
closed pair `(widening)`/`(narrowing)`: `yes` takes at least `minor`".

This PR declares `Clause-②: yes` — the acceptance set widens, as a precision
fix to an advisory rule — so `patch` was below the floor.

Co-authored-by: Claude <noreply@anthropic.com>

os-try-charles commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Served-tier: CONTRACT_REVIEW_TIER

Contract review

73bd3ff28ff7216cd93ac8062babeb8ed93698a9

Implemented-by: claude/issue-17135-field-consumers-synthesized-layout
Reviewed-by: session_017ef78bLdybu3AffehKkhfk

① Derived judgments —— diff 引出的接受集与公开面变化,逐条点名判对错

# 变化
1 接受集放宽 A:一个字段的 group 指向对象已声明fieldGroups 条目 ⇒ 由 deriveFieldGroupLayout(@objectstack/spec/data,ADR-0085 §5)判定为显示位,记为 consumer 。这是 2026-08-10「previews-count-as-consumers」裁定在合成布局上的应用,与规则既有的 injectedColumnsFor / resolveDisplayField 两个豁免同一条纪律 —— 从 spec 派生,⛔ 不是手工名单
2 接受集放宽 B:seed 的 externalId 与 import mapping 的 upsertKey 由「carry」改判为「read」 。loader 在每一行上读该列以决定 insert 还是 update ⇒ 它确实被消费。bucketFor 此前让 carrier root 抢先答了,是分类错误而非策略选择
3 ⛔ 未放宽(申报为边界):deriveFieldGroupLayout末尾无标题桶与「对象完全未声明 group 时返回 null」两种情形 ⛔ 不记 consumer 对,而且是本 PR 的要害。dev 实测:credit 它们会使规则退化到只能报 hidden 字段 —— 那是关掉门禁。credit 的是作者的 placement,不是渲染兜底
4 公开面 未动。无新导出符号、无已发布载荷上的新键;三个文件为 .changeset/*packages/lint/src/validate-field-consumers.{ts,test.ts}

⇒ 接受集确有放宽(第 1、2 条),Clause-②: yes 直读成立;⛔ 不是人工地板 —— 地板管门禁削弱,本卡修的是误报(那 12 个字段当日在产品里可见可编辑)。

② semver 定级与 changeset 声明一致

.changeset/17135-field-consumers-synthesized-layout.md   "@objectstack/lint": minor
AGENTS.md:1067-1068   「`yes` takes at least `minor`」

一致⚠️ 初版为 patch,CI 的 Check Changeset(即 check-changeset-no-major.mjs,其级别轴由 PR #18346 / 卡 #164212fc092b1c 带入)判红;dev 核树后改为 minor,重跑 exit 0。⭐ 本 PR 是该级别轴判的第一个 diff

③ 边界旗与 open_questions —— 逐旗答复

处置
98% 到达面(hotcrm 331 个声明字段中 326 个落在已声明 group 里 ⇒ 显示半边实质依赖作者的分组决定) 裁 A,按已落形态发(裁定全文见卡 #17135 评论 5693562007)。⛔ 不作否决:分诊给的验收判据是真实应用误报率,读数为 12 → 0 且 6/6 注入的真死字段仍被报⚠️ 该数已在 PR 正文保留为给维护者的范围说明,并在轮次报告中点名
SeedSchema / MappingSchema 别名在 pre-parse 栈上得不到 credit 同意不立卡:os lint 吃 post-parse 栈,别名已归一化 ⇒ 无活表面够得到,carrier 为 none
SeedSchema.externalId 默认 'name' ⇒ 每个 seeded 对象 credit 自己的 name 列 同意不立卡:该列通常已按 ADR-0079 标题字段豁免,且确实被 loader 匹配 ⇒ credit 落在对的地方
check:cross-package-test-inputs 在建过/没建过的树上给出不同裁决 已立 #18348(四小时前,由另一张卡的 dev 发现、本席独立复现并做了换门禁版本的回归对照)。本 dev 的对照(同一提交、纯净 worktree @ bce527080)更硬,已归入该卡。⛔ 不开第二张

⇒ 三旗答复,一旗已转出并有卡;open_questions 现为空。

独立性

Implemented-by: 是产 diff 的分支(mode:subagent),Reviewed-by: 是渲染本裁决的席位 session ⇒ 两者不同,非 SELF-REVIEW⚠️ ⛔ 据实申报:本复核为同席默认档自审加门禁(2026-09-10 的档位裁定把 contract-review 档保留给 skills 席、spec 席的条款②与维护者召集的 director;本 diff 不触 packages/spec ⇒ 车道席自审即为 review of record)。本 PR 不属独立性件:无契约真分叉、dev 未挂旗要求隔离、派发后无跨车道面。

判定

PASS

⛔ 本记录是在 PR 被队列弹出之后补写的 —— 见下一条 provenance 评论。

⚠️ 本记录编辑过一次:初版的 head 码段写的是缩写 73bd3ff28(9 位,取自本席自己截断的打印),而 C6 读的是 73bd3ff28f。⇒ 改为完整 40 位。⛔ 又一次「写了自己的缩写,而不是它读的那个字面」。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing provenance —— 清标同笔的出处记录

domain:devx 执行席 · 座位贴 #6023 · 取数时刻 2026-09-16T08:16Z

references/contract-review.md:38「清标即落地:PASS ⇒ 同席同笔剥双载体;清标同笔落 PR provenance 评论,引记录 id 与所判 head」:

复核记录 id     5694274823        (本 PR 线程,`## Contract review`,PASS)
所判 head       73bd3ff28ff7216cd93ac8062babeb8ed93698a9
双载体          needs:contract-review 已自 PR #18381 与卡 #17135 同笔摘除
机读            node scripts/pm/check-clause2-carriers.mjs --pair 18381  → exit 0
                「both carriers agree, and a review of record names …」
落地前检 ③      38 runs → 按 check 名去重 34 → NOT-GREEN 0

⛔ 本席在此之前犯的两个错,记在案上

  1. 本 PR 于 07:47:56Z 入队,07:49:01Z 被 github-merge-queue[bot] 弹出。 成因:Governed Surface Guard 步骤 [WIP] Create a new release version #9「Governed surfaces may not enter the merge queue unreviewed」exit 6 —— 本席写了 ACCEPT 就武装,而 ACCEPT 不是复核记录。C6 说得更直白:「a dev's own report, or an ACCEPT paragraph with the head and the line but no heading, is not one」。⇒ 闸门判对了,⛔ 本席漏了整条席内契约复核。
  2. 补写记录之后 C6 仍红:本席的 head 码段写成 73bd3ff28(9 位,取自自己截断的打印),而它读的是 73bd3ff28f。⇒ 改成完整 40 位才过。又一次写了自己的缩写而不是它读的那个字面。

⚠️ 本班此前三个受管 PR 走的是两位授权批准人 + 人工合并,不经本席武装 ⇒ 不受这条闸门约束。#18381 是本班第一个由本席自己武装的 Clause-②: yes PR —— 这条路径的前置本席今天才第一次走完。


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

Projects

None yet

2 participants