Skip to content

fix(lint): field-path rules judge an object as served, so a field an objectExtensions entry adds is not refused (#30383) - #30432

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-tbzgwm
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-tbzgwm

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Requested by Yibo Wang · project thread

Fixes #30383

Clause-②: yes (widening: field-path rules accept fields a sibling package adds through objectExtensions, which the runtime already serves; any rule that deliberately stays on declared fields says why in its header)
Metadata-protocol: none (lint judgements, no authorable key)
Skills-docs: none owed — no skill, eval or doc teaches that a view, report, dataset, page, widget, mapping, related list or row policy may not name an extension field (git grep -c -E "dataset-field-unknown|list-view-field-unknown|rls-predicate-unknown-field|related-list-ref-unknown|mapping-target-field-unknown|page-field-unknown|widget-filter-field-unknown" -- skills content/docs ':!content/docs/releases': 1 hit, content/docs/deployment/validating-metadata.mdx:267, which names no extension; skills/objectstack-data/rules/security.md:156 refuses a membership key on an extension, a rule this PR does not touch)
Prior rulings read: objectextensions,indexobjectgraph,objectsasserved → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: none; repo: objectstack-ai/objectstack

维护者速读

  • 改了什么:卡上 12 条字段路径 lint 规则(数据集、报表筛选、仪表盘部件、列表视图、对象自身字段列表、页面绑定、预设比较值、RLS、共享规则、感谢页、度量聚合、字段消费者)和 2 个私有扩展读取器(相关列表、导入映射),全部改读 fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) #30359 落地的同一份共享折叠:对象按注册表实际提供的样子判断,即声明字段加上同一制品中任何包通过 objectExtensions 加的字段。
  • 为什么改:多包制品(HotCRM 的模块包给 app 包的对象加字段)里,视图、报表、数据集、页面、部件只要引用扩展字段就被 lint 判为未知字段并拒绝,而运行时明明提供这个字段。
  • 风险与回滚:只放宽拒绝面;真不存在的字段照旧报原规则 id、级别、消息(逐字节钉住)。按类型判断的规则现在会按扩展字段的实际类型判断;两个包在同一优先级写同一字段时,胜者取决于注册顺序,这些类型判断对该字段不下结论,感谢页则只在所有写法都是引擎生成类型时才放行。相关列表对本栈未声明的对象不当作子对象判断(逐包检查时它的声明在别的包里)。showcase 全量实测 14 条规则前后发现完全一致。回滚即还原本 PR。
  • 先看:packages/lint/src/object-graph.ts 文件末尾新增的 indexServedObjectGraph 与 objectsWithoutTiedFields;validate-related-list-refs.ts 与 validate-mapping-target-fields.ts 两个私有读取器的删除;object-graph.test.ts 末尾的 [#30383] describe。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts,预期 89 例全绿(其中 42 例为本 PR 新增)。
  • 需要你做的:无,席位落地。

What changes

  • object-graph.ts, two helpers at the end of the file: indexServedObjectGraph(stack, { tied }), indexObjectGraph over objectsAsServed(stack).objects; and objectsWithoutTiedFields(served), the served objects less every field in served.tied. Neither is exported from the package entry. They sit below the fold's section on purpose: foldCode() in validate-security-posture.test.ts (:1656-1665) appends object-graph.ts's text from export interface VisibleObjectExtension to /** What a path resolved to to the posture rule's code, and holds every receiver read there to its tables; these two read only the fold's output. The shared fold (objectExtensionsByTarget, objectsAsServed) itself is unchanged.
  • Twelve rules now resolve through the served objects (table below). Rules that build a second index beside the graph (list-view option values, widget field types, page field names) build it from the same served objects, so the two indexes of one rule cannot disagree.
  • Tied fields (objectsAsServed(...).tied: two packages write one field at the deciding priority). The registry registers packages in dependency-topological order (resolveArtifactPackageOrder), not packages[] order, so which definition is served cannot be read off the stack. Every rule still accepts the tied field's name. The type arms leave it unjudged: measure aggregates and preset comparands index { tied: 'drop' }, the sharing rule's condition graph and the widget field-type map and kanban option map are built from objectsWithoutTiedFields. The thank-you rule fails closed instead: a tied field passes only when every writer at the deciding priority has an engine-generated type.
  • Two private readers retired onto the shared fold:
    • validate-related-list-refs.ts: its fold read the stack's top-level objectExtensions only, and only the map form of fields. It now reads objectsAsServed for the children this stack declares, every field read by name in either shape. An object this stack does not declare is not a child even when an extension targets it: on the per-package leg its declaration and foreign keys may live in a package the leg does not carry, so it falls to the unknown-child rule as before (header says so).
    • validate-mapping-target-fields.ts: extensionFieldsByTarget (top level plus every body, every entry kept) is deleted; the object is judged as served. The ruled refusal of a present non-array packages (ruling A on finding(spec): the option-B readers disagree with @objectstack/core about whether a non-array packages is a refusal #15293) stays, asked through packagesOf once, since the fold reads such a value as absent.
  • validate-field-consumers.ts header: extension-added fields are still not judged themselves, but a dataset or cube path through an extension-added relationship now credits the declared field it reaches.
  • Tests: one top-level describe in object-graph.test.ts, the fold's harness (42 tests): for each existence rule an extension-added field passes, the same stack without the extension refuses, and a field nothing adds is refused with byte-equal severity, where, path and message to a declared-only control; the per-package leg (packageBodyAsStack's shape) for the existence rules and for related lists; a nested describe for a field two packages tie on, one test per tie-dropping arm, each tied pair listing last the definition the arm would refuse (the one the fold keeps), with a single-writer control of that definition refused; validate-mapping-target-fields.test.ts's extension fixture rewritten to the two shapes the registry reads (see Acceptance notes).
  • .changeset/30383-field-path-rules-extension-fields.md: @objectstack/lint patch.

Per rule

All on one artifact: an app package declares xs_deal, a module package extends it with region, score, closed_on, ref_no, partner, and extends xs_note with a lookup to xs_deal.

Rule Moved or kept Why Pin
validate-dataset-references.ts moved (indexServedObjectGraph) the dataset compiler queries the served object ref_no and partner.tier pass; without the extension dataset-field-unknown; nowhere, partner.nowhere still dataset-field-unknown
validate-report-runtime-filter.ts moved same compiler ref_no passes, refused without the extension; nowhere still report-filter-field-unknown
validate-widget-bindings.ts moved (graph, field-type map without ties) same ref_no passes, refused without; nowhere still widget-filter-field-unknown; SUM over a number/percent tie is not measure-aggregate-incoherent, over percent alone it is
validate-list-view-field-refs.ts moved (graph, kanban option map without ties) the list door queries the served object ref_no passes, refused without; nowhere still list-view-field-unknown; a lane b on a select tied between options a,b and a,c is not judged, on a,c alone it is list-view-kanban-lane-unknown
validate-object-field-refs.ts moved the record page reads the served object ref_no passes, refused without; nowhere still object-field-ref-unknown
validate-page-field-bindings.ts moved, last (graph and field-name index) the renderer binds the served object ref_no passes, refused without; nowhere still page-field-unknown
validate-rls-predicate-enforceability.ts moved the RLS compiler compiles against the served object; the maintainer's hotcrm#2315 ruling gives a module's extension fields their access in the app package's permission sets ref_no passes, refused without; nowhere still rls-predicate-unknown-field
validate-thank-you-record-fields.ts moved, ties fail closed the panel renders the inserted served record an extension autonumber passes, refused without; nowhere still thank-you-record-field; a tie between autonumber and text is refused
validate-mapping-target-fields.ts moved, private reader retired the import door writes the served object ref_no passes, refused without; nowhere still mapping-target-field-unknown; one entry per package per target
validate-related-list-refs.ts moved, private reader retired the record page derives related lists from served fields an FK a sibling package adds passes, bare and as object.field; refused without; xs_note.nowhere still related-list-ref-unknown; one entry per package per target; on the per-package leg a body extending another package's undeclared object raises nothing
validate-dataset-measure-aggregates.ts moved (type arm, ties dropped) judges the served type SUM over extension score passes, over extension region is measure-aggregate-field-type-refused; nowhere stays the existence rule's; a tied score is not judged
validate-preset-comparands.ts moved (type arm, ties dropped) judges the served type closed_on: 'last_30_days' on an extension date is filter-preset-comparand; on extension text it passes
validate-sharing-rule-enforceability.ts moved (type arm without ties, anchor served) the driver judges the criteria query against the served object; a controlled_by_parent anchor's master is read as the security posture rule's merge (#23210) reads it record.region == record.name passes; record.closed_on == record.name is sharing-rule-unlowerable-condition
validate-field-consumers.ts moved for resolution; extension fields still not judged crediting a path the runtime serves partner.tier credits xs_account.tier; without that path tier is field-no-consumers; no finding on any extension-added field

No rule stays on declared fields. The two security rules were weighed: the RLS rule's only verdict on an extension field was a false rls-predicate-unknown-field, and the sharing rule's type arm judges what the driver judges at query time, so neither has a security reason to read the declared anchor.

Why

The runtime serves each object with every body's objectExtensions folded in, but these rules indexed stack.objects only, so on a multi-package artifact (HotCRM, hotcrm#2315: a module package adds fields to an app package's object) a view, report, dataset, page or widget naming an extension-added field was refused as unknown. #30303 (PR #30359) added the shared fold and moved the FLS-key check onto it; this PR moves the rest of the census onto the same fold.

Verification

All at head 806dd06b, under os-verify-lock, NODE_OPTIONS=--max-old-space-size=4096:

  • pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck: Test Files 194 passed (194), Tests 8063 passed (8063); check:test-typecheck: OK; VERDICT command-exit 0. object-graph.test.ts: 89 passed (89).
  • npx eslint --no-inline-config over the 17 changed .ts files: exit 0.
  • Before/after on the showcase (examples/app-showcase, which carries top-level objectExtensions), measured at e27387e2: each of the 14 rules run on the loaded config with the origin/main copy and with that head. Every rule's finding set (rule, path, where, message) is identical, 49 field-no-consumers warnings and 0 of every other id on both sides. Probe files deleted. The rework narrows only what that head accepted (an undeclared related-list child, a tied field's type), and the showcase has neither.
Reverse verification (restored to the HEAD blob each time)
  • Round 1, scripts/ablation-replace.mjs: indexServedObjectGraph's body back to indexObjectGraph(stack) (anchor 1 → 0): object-graph.test.ts went 9 failed | 54 passed (63), exactly the pins of the eight rules routed through the helper. The other six rules read objectsAsServed directly; each of their pins asserts the same stack without the extension still refuses, so none is vacuous.
  • Rework, related lists (at 31eef372): validate-related-list-refs.ts set back to its e27387e2 blob: the new per-package-leg pin goes red (1 failed | 84 passed (85), a false related-list-ref-unknown on the undeclared target); restored.
  • Rework, ties: objectsWithoutTiedFields's first line made to return served.objects unconditionally: 5 failed | 84 passed (89), one per arm: measure aggregates, preset comparands, sharing-rule criteria, the widget field-type map, the kanban option map. The thank-you pin stays green, as it should: that rule reads tied itself and fails closed. Restored, git diff HEAD empty. (ablation-replace.mjs refuses a replacement that keeps its anchor, so this one was applied and restored by hand, the blob checked equal to HEAD after.)
  • The lint tests import src, so no dist sat between mutation and run.
Gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 806dd06b: 59 derived families, the same set as at a7e08193. Each run serially with NODE_OPTIONS=--max-old-space-size=4096; record /mnt/project-files/issue-30383/gates-ran.list, every line exit 0 on the first pass, check-changeset-no-major included.
  • dispatch-gates --ran: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0.
  • No ! in the title and no (narrowing) arm: no full dogfood run. CI owns the rest of the farm.

Acceptance notes

  • Measured differences of the two retired readers. (1) validate-mapping-target-fields.ts read the top-level objectExtensions beside packages[]; the registry reads only the bodies when an artifact carries packages[] (its top level repeats them). The old test fixture put sla_tier at the top level only, beside a packages[], a shape no CLI leg produces (the per-package leg hands the body's own entries in its packages[] entry too); it is rewritten to the two shapes the registry reads. (2) Both readers kept every entry; the fold keeps one per package per target, so a field only a package's earlier, replaced entry adds is now refused, as the registry serves it (pinned). (3) The related-list reader read only the map form of fields; the array form is now read too. (4) The related-list reader made an undeclared object reached by a top-level extension a judged child; that is dropped, so such an object falls to the unknown-child rule, as it does for any undeclared object. On the showcase none of these changes a finding.
  • Tied fields inside existence rules. The existence rules accept a tied field's name. Where an existence rule also asks a type question of the graph (RLS list-holding and cross-class checks, a list view's dotted head, a page count's multi-value check, a dataset hop), it reads the definition the fold kept, the one objectsAsServed returns; only the type arms named above drop ties. Narrowing those too would need the graph to carry a tie per field, a change to the shared index this card leaves alone.
  • Census, wider than the card (by reading; not reproduced per rule, ⛔ not changed here; carried to the seat):
    • validate-translation-references.ts keeps a third private objectExtensionsByTarget (top level plus every body, every entry kept). It also folds validation rules, which the shared fold does not carry, so it was not moved.
    • Field-name indexes outside indexObjectGraph still read declared objects only: validate-expressions.ts (buildFieldIndex, the record.FIELD reads of validations, formulas, flows, hooks and sharing-rule conditions), indexObjectFields in validate-react-page-props.ts, validate-flow-node-writes.ts and validate-action-body-writes.ts, and the own copy in validate-hook-body-writes.ts. Open PR fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 moves page sorts to validate-sortable-fields.ts, which at its head reads declared objects only (recordsOf(stack.objects), line 508); carried with the others.
    • defineStack's twin, collectRelatedListNameErrors in packages/spec/src/stack.zod.ts, folds every entry additively; lint is now the stricter of the two on the replaced-entry case only.
  • validate-page-field-bindings.ts was moved last; open draft PR fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 edits the same file in hunks this PR does not touch.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (eslint over the changed files: exit 0; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block excepted, per project convention).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG

Twelve field-path rules indexed declared objects only, and two rules folded
objectExtensions through private readers that differed from the registry.
All now read the one shared fold in object-graph.ts (objectsAsServed /
objectExtensionsByTarget): a view, report, dataset, page or widget naming a
field a sibling package adds through objectExtensions resolves as it does at
runtime. Adds indexServedObjectGraph, the graph over objectsAsServed.

Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG
Co-authored-by: Claude <noreply@anthropic.com>
One artifact for all fourteen rules: a module package adds fields to the app
package's object. Each existence rule passes the extension-added field,
refuses it without the extension, and still refuses a field nothing adds
under the same rule id; the type arms judge the served definition; the two
retired readers keep one entry per package per target. Adds the changeset.

Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Nov 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/deployment/validating-metadata.mdx (via validateRlsPredicateEnforceability (symbol, a top-level function), validateSharingRuleEnforceability (symbol, a top-level function))

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

  • content/docs/releases/v16.mdx (via validateWidgetBindings (symbol, a top-level function))
  • content/docs/releases/v17/17-1.mdx (via validateWidgetBindings (symbol, a top-level function))

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: master_detail (literal, 40 pages)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 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 c58008187fb14259cae52461aff9971a1b4b15e9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c58008187fb14259cae52461aff9971a1b4b15e9

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head e27387e · 2026-11-05T11:09Z

Full review of the first head at CONTRACT_REVIEW_TIER (lint refusals loosened across fourteen field-path rules: each reads extension-added fields through the shared fold landed by #30359).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e27387e27c0a40f723c1c31bb08d1570a37838e1
Local-runs: probe — the must-fix needed a measured reproduction (the per-package leg is unpinned), so the reviewer ran targeted vitest in a detached scratch worktree at the head, restored afterwards. Details: targeted vitest in a detached scratch worktree at the head (/tmp/claude-0/-home-claude-objectstack/efcf9dc3-8706-504f-ba08-68ded05f26c9/scratchpad/seat/wt-30432, pnpm install --filter "@objectstack/lint..." --ignore-scripts, workspace deps built with OS_SKIP_DTS=1): (a) src/object-graph.test.ts + src/validate-mapping-target-fields.test.ts at head: 84 passed; (b) ablation of validate-list-view-field-refs.ts :967 (objectsAsServed(stack).objects back to recordsOf(stack.objects)): 1 failed | 62 passed, the red being its own pin; (c) the same ablation on validate-sharing-rule-enforceability.ts :580: 1 failed | 62 passed, the red being its own pin; (d) a review probe src/zz-review-30432.test.ts (kept in the worktree, not in the PR) of the related-list rule on the per-package leg: at head 1 failed | 3 passed; with origin/main's copy of validate-related-list-refs.ts swapped in, 4 passed. Files restored after each run; git status of the worktree shows only the probe.
Scope: the PR diff against merge-base 7d7babb127 (18 files, 291 insertions, 93 deletions, 384 changed lines; the content/docs/permissions/authentication.mdx hunk that appears against a stale local main is #30382 on main, not this PR); packages/lint/src/object-graph.ts (head: objectExtensionsByTarget :417-458, its packages[] branch :446-452, objectsAsServed :470-500, indexServedObjectGraph :510-512); the fourteen rules at head (validate-dataset-measure-aggregates.ts :654; validate-dataset-references.ts :185; validate-field-consumers.ts :1200 and the declared ledger :1257; validate-list-view-field-refs.ts :963-968, :1071, :1441; validate-mapping-target-fields.ts :111-117; validate-object-field-refs.ts :469, :507; validate-page-field-bindings.ts :458-461, :607-620; validate-preset-comparands.ts :746; validate-related-list-refs.ts :126-141, :180, and main :124-135; validate-report-runtime-filter.ts :87; validate-rls-predicate-enforceability.ts :1907-1919, :1970; validate-sharing-rule-enforceability.ts :405, :486-499, :572-598; validate-thank-you-record-fields.ts :181; validate-widget-bindings.ts :817-837); packages/lint/src/validate-security-posture.ts :748, :1019; packages/lint/src/validate-membership-key-declarations.ts :267-268; packages/lint/src/reference-integrity-suite.ts :436-444; packages/lint/src/object-graph.test.ts :556-700; packages/lint/src/validate-mapping-target-fields.test.ts :183-247; packages/lint/src/validate-translation-references.ts :1135-1152; validate-expressions.ts :202, :2231; indexObjectFields callers (validate-react-page-props.ts :1139, validate-flow-node-writes.ts :240, validate-action-body-writes.ts :382, validate-hook-body-writes.ts :505, :1106); packages/cli/src/utils/artifact-packages.ts :182-186; packages/cli/src/utils/stack-collections.ts :356-392; packages/spec/src/stack.zod.ts (:3198-3209, :3212-3240, :4063-4100); packages/plugins/plugin-security/src/rls-compiler.ts :172-178; packages/plugins/plugin-sharing/src/declared-sharing-rules.ts :141; scripts/pm/clause2-line.mjs :558-572; PR #30270 at 647a1c1f (its validate-page-field-bindings.ts and validate-sortable-fields.ts :508 hunks, git merge-tree --write-tree pr-30432 pr-30270 clean); card #30383 body and 4 comments (os-dev-report 6656458650 included); PR #30432 body, 2 comments, 0 reviews; check-runs on the head.

① Derived judgments

  1. Per rule, the move is correct and each now accepts exactly the served name set, with one exception (item 2). Eight rules take indexServedObjectGraph (:510-512), which is indexObjectGraph over objectsAsServed, so the three skips, the injected columns and the FieldPathVerdict family are unchanged by construction; the four that keep a second index beside the graph (list-view option map :1071, widget field types :819, page field names :611-612, sharing anchor graph :580-596) build both from the same served array, so one rule cannot hold two accounts of an object. No finding-emitting line changed in any of the fourteen files (the diff is index swaps and comments), so rule id, severity, where, path and message of a true unknown are the same code path as before. The declared-only reads that remain are the right ones: the walks over what the stack OWNS (validate-object-field-refs.ts :507 own field lists, validate-list-view-field-refs.ts :1441 built-in list views, validate-rls-predicate-enforceability.ts :1970 defaultRowScope, validate-field-consumers.ts :1257 the ledger) stay on stack.objects while resolution goes through the served graph, which is the per-package leg's own rule (artifact-packages.ts :182-186: resolution context changes what a rule can RESOLVE, never what it JUDGES). Security posture, both rules, judged safe in the direction they moved. RLS: the graph answers existence and type for judgeClause / judgePolicy (:1942, :1953, :1976) and the membership trace (:1919); the runtime compiler reads the object's columns from the live SchemaRegistry (rls-compiler.ts :172-178), i.e. the merged definition, so a predicate on an extension-added field compiles and is enforced there, and lint's only prior verdict on it was a false rls-predicate-unknown-field; no ownership or anchor semantics read fields (owner_id is injected over the base layer, which the fold leaves alone, docblock :462-466). Sharing: the anchor arm reads sharingModel (:499) and effectiveSharingModelOf, neither of which the fold touches (only fields move, :497); masterOf (:405) now sees an extension-added master_detail, which changes only the hint's master name and matches the finding(security): an objectExtensions entry that adds a second required master_detail to a controlled_by_parent object passes defineStack and the posture lint, then fails closed at runtime — neither door judges the merged shape #23210 merge validate-security-posture.ts :748 already reads; the list-holding and cross-class arms judge the criteria filter the seeder compiles with the same compileCelToFilter call (declared-sharing-rules.ts :141) against the registry's object, and on an extension-added list-holding or cross-class field they now REFUSE where they were silent (unknowable), the strict direction. Neither rule needed to stay on declared fields; the dev's weighing in the body is right.
  2. Must-fix, measured: validate-related-list-refs.ts now refuses a valid related list on the per-package leg. Head :139-141 lists every target of objectExtensionsByTarget that objects does not declare as a judged child carrying its extensions' fields alone; main :132-135 did the same only for the stack's TOP-LEVEL entries, which on the per-package leg (packageBodyAsStack :186, objects = this package's own, packages[] = every body) are this package's own. So a sibling package's extension of a child a third package owns now makes that child judged on fields that cannot hold its FK, and judge :180 refuses instead of skipping (wholeCollection is off on that leg, reference-integrity-suite.ts :441-443). Probe: packages a (owns a_x, relatedLists: ['d_obj']), d (owns d_obj with x_id lookup to a_x), b (extends d_obj with a text field); the per-package leg of a gives related-list-ref-unknown at head and nothing at main (Local-runs d); the union leg is clean on both. The runtime serves d_obj with its FK, so this is a false refusal at error on os build, on exactly the multi-package shape the card exists to admit, and the header line at :26-31 documents it as intended rather than weighing it. The pins do not reach it because every stack in the new describe is union-shaped (artifact :573-581 puts all three objects at the top level).
  3. The two retired readers. Mapping (main extensionFieldsByTarget, top level plus every body, additive): the measured deltas are (a) top-level entries beside packages[] no longer read, a shape no CLI leg produces (the union repeats its bodies, authoringRuleUnionStack :356-392; the per-package leg carries the body's own entries in its packages[] entry too), and the old fixture that relied on it is rewritten to the two served shapes (:183-247, both parsed as the registry reads them); (b) one entry per package per target, pinned (:680-700). Related list (main: top level only, map-form fields only): (a) sibling bodies' extensions now seen, the card's defect, pinned (:670-684); (b) array-form fields now read, a widening (fewer refusals); (c) one entry per package, pinned; (d) the per-package-leg regression of item 2, unpinned. On (b) of both readers: defineStack refuses two objectExtensions entries for one target within a stack (stack.zod.ts :3198-3209 identity by extend, :3212-3240, top level only), so the replacement-versus-additive difference bites only on an input that door already refuses, in the refusing direction here. Showcase before/after identical as reported; not reproduced.
  4. Pins. The nine-row it.each (:627-633) pins, per rule, an extension-added ref_no passing, the same stack with objectExtensions: [] refusing (the non-vacuity control), and nowhere refused under the same id; the type arms (:643-667) pin a served-type verdict that an ablation cannot fake; field-consumers (:661-668) pins the credit and the no-finding-on-extension-fields property; both retired readers' replacement (:680-700). Real: the dev's ablation of the helper reddens the nine routed pins; my ablations of two direct readers redden exactly their own pin (Local-runs b, c), and by the same reading the other four direct readers' pins redden the same way. Gaps: (a) ids only; severity, where and message of the nowhere refusal are not compared against a declared-only control, so "same message" is by reading of the untouched emit sites, not pinned (PR fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) #30359's test 3 pinned byte-equality); (b) no per-package-leg shape anywhere in the new describe, which is how item 2 escaped; (c) the type arms (validate-dataset-measure-aggregates.ts, validate-preset-comparands.ts, the sharing cross-class arm) ignore objectsAsServed(...).tied, which the membership-key rule (:267) consults; a field two packages define at equal priority is judged on carrier order, which the fold docblock (:466-468) says a stack does not state. Narrow, unpinned.
  5. Declarations and serial constraint. PR body and changeset carry Clause-②: yes (widening: …) and Metadata-protocol: none (lint judgements, no authorable key); readMetadataProtocolLine (:558-572) reads the first value token, so the parenthesis is tolerated; the changeset grades @objectstack/lint patch, the level none owes; Check Changeset, Check PR Size, PR-body guards and Lint · PM dispatch-gates are green on the head, Test Core (8 shards), Build Core, Type Check and Lint contexts still in_progress. Prior rulings read: line present (2 hits, ADR-0029 D3, ADR-0146 D10). fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270: this PR edits validate-page-field-bindings.ts at :125 (import) and :607-620; fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 edits its header (:52), inserts isPageSortRef before the function (:600-615), and touches :643 and :690; git merge-tree --write-tree pr-30432 pr-30270 produces a tree with no conflict, and the two changes are semantically independent (served index versus which sort refs the walk reads). fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270's base predates ad0fbfd1, and its validate-sortable-fields.ts :508 reads recordsOf(stack.objects), so that rule's blind spot is measured, not "not measured" as the body says.
  6. Body versus code. The per-rule table matches the diff and the pins row for row; the retired-reader notes match the measurement in item 3; "no rule stays on declared fields" is true; 384 changed lines and 17 .ts files are right. The three carried findings are real and correctly scoped out: (a) validate-expressions.ts buildFieldIndex (:202, :2231) and the indexObjectFields readers (validate-react-page-props.ts :1139, validate-flow-node-writes.ts :240, validate-action-body-writes.ts :382 all call the page rule's export, whose body :458-461 still reads stack.objects; validate-hook-body-writes.ts :505 is its own copy) index declared objects only, and this PR moved the page rule's CALL (:612) without moving the export, so the three siblings stay blind by design; (b) validate-translation-references.ts :1135-1152 keeps a third private fold (top level plus every body, every entry kept, and it reads validation rules the shared fold does not carry); (c) stack.zod.ts collectRelatedListNameErrors :4063-4100 folds every entry additively and reads every body's objects, so it differs from lint on the replaced-entry case (moot at the stack door, item 3) and, after the item 2 fix, should be the model lint follows for a child a sibling owns.

② Semver level

@objectstack/lint patch. Refusals narrow on the union leg (extension-added fields pass under fourteen rules); ids, severities and messages of a true unknown are unchanged by reading of the untouched emit sites. Clause-②: yes (widening) and Metadata-protocol: none are the right pair. The item 2 regression is a narrowing on the per-package leg that the fix removes; it does not change the level.

③ Boundary flags

④ Design judgment

The shape is right: one helper beside the fold, every field-path rule resolving through it, the per-rule table honest about what each judges, and the two security rules weighed rather than exempted. Must-fix, in order: (1) validate-related-list-refs.ts :139-141: for a target objects does not declare, either leave it unjudged (drop the loop; on the union leg every declared target is already folded at :136-138, and a target no body declares is a platform object whose FK set this rule cannot know) or fold the sibling bodies' declared objects for it as collectRelatedListNameErrors does; rewrite the header line :26-31 to say which; pin it with a per-package-shaped stack (objects = one package's own, packages[] = every body, a sibling child a third package extends) that must be clean, plus the whole-collection control that a child nowhere is still refused. Should-fix, in order: (2) add one per-package-leg case to the [#30383] describe for the moved rules, the shape PR #30359's pins used, so the leg that ships is pinned too; (3) pin severity, where and message of the nowhere refusal byte-equal to a declared-only control for at least one rule per helper path; (4) consult tied in the three type arms, or state in each header that a tied definition is judged on package order; (5) correct the body's "whose index was not measured" for validate-sortable-fields.ts to "reads declared objects only (:508 at #30270's head)". Landing waits on (1) and on the running required contexts.

Implemented-by: claude/project-thread-tbzgwm
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: FAIL

…s skip tied fields

Contract review r1 on the served-fold PR:
- validate-related-list-refs no longer builds a child from extensions alone:
  on the per-package leg a child another body owns read as having only a
  sibling's extension fields, a false related-list-ref-unknown.
- A field two packages write at the deciding priority is served, but which
  definition wins is registration order. The type arms (measure aggregates,
  preset comparands, sharing comparisons, widget field types, kanban option
  values) leave it unjudged; the thank-you panel fails closed on it.
- Pins: the per-package leg for every existence rule, byte-equal refusals
  against a declared-only control, the related-list per-package case, ties.

Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head a7e0819 · 2026-11-05T12:02Z

Re-review at CONTRACT_REVIEW_TIER of the rework after the round-1 FAIL (6656702755 on e27387e2): the related-list must-fix, the four should-fixes, and the new tie handling.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a7e081935144d36f0b0c4094061e04a8dd511ed5
Local-runs: none
Scope: delta review against the round-1 FAIL record (PR comment 6656702755, head e27387e2); the delta e27387e2..a7e08193 (rework 31eef372, changeset sentence a7e08193: 10 files, 194 insertions, 34 deletions) read in full, and the whole PR against merge-base 7d7babb127 re-measured (18 files, 455 insertions, 97 deletions, 552 changed lines); packages/lint/src/object-graph.ts at head (fold objectExtensionsByTarget :417-458 and objectsAsServed :470-501 unchanged; objectsWithoutTiedFields :809-817, indexServedObjectGraph :827-830, placement comment :796-797); validate-related-list-refs.ts :26-35, :139-141, :179; validate-dataset-measure-aggregates.ts :656, :568; validate-preset-comparands.ts :748, :346; validate-list-view-field-refs.ts :968-969, :1074, :1106; validate-widget-bindings.ts :820-831, :846, :853; validate-sharing-rule-enforceability.ts :314, :342, :579-591, :600, :611; validate-thank-you-record-fields.ts :170-193, :211-214, :222; validate-rls-predicate-enforceability.ts :1907-1916; validate-membership-key-declarations.ts :217-274, :306-308, :335-375; packages/objectql/src/registry.ts :427, :4268-4278, :4556-4558, :4634, :4741-4795; packages/core/src/artifact-packages.ts :234-250; packages/lint/src/reference-integrity-suite.ts :436-444; packages/lint/src/index.ts :773-786; packages/lint/src/validate-security-posture.test.ts :1639-1650 and :1941-1970; packages/lint/src/collection-coercion-single-copy.test.ts :103, :147; packages/lint/src/object-graph.test.ts :627-776; the changeset at head; the PR body revision r1 (/mnt/project-files/seat3-orders/30383-body-r1.md); check-runs on the head via REST.

① Derived judgments

  1. Must-fix of round 1, fixed. validate-related-list-refs.ts :139-141 now merges only objectsAsServed(stack).objects; the loop that listed every objectExtensionsByTarget target as a judged child is deleted and the objectExtensionsByTarget import is gone (:67). A child the stack does not declare therefore has no fieldsByObject entry and reaches :179, where it is skipped unless wholeCollection is set, which the suite sets only for a runtime write with no package scope (reference-integrity-suite.ts :438), never on either CLI leg. The header :26-35 now says which option was taken and why (the per-package leg holds one body's objects, so an extension alone is not a field set). The pin at object-graph.test.ts :692-701 is the per-package shape round 1 asked for (app body as the stack, lib owning xs_note and its FK, mod extending xs_note with mood, every body under packages[]) and must be clean; at e27387e2 that stack produced xs_note in fieldsByObject with mood alone and namedBy empty, so the dev's "1 failed | 84 passed" ablation is what the reading gives. The union-leg control (an FK a sibling adds passes, xs_note.nowhere refused) is unchanged at :703-710.
  2. (a) Per-package-leg cases. perPackage :630-633 builds packageBodyAsStack's shape (the body at the top level and under manifest, the artifact's packages[] beside it, the extensions in a second body); it.each(existence) :642-646 runs all nine rows on it with the three verdicts (extension field passes, the same leg with objectExtensions: [] refuses, nowhere refused). Plus the related-list case of item 1. Closed.
  3. (b) Byte-equal refusal. :648-656 projects severity, where, path, message of the one zq_absent finding under each rule id on the served stack and toEquals it against the same projection on the declared-only control, for all nine existence rules, not the one per helper path round 1 asked for. Closed.
  4. (c) Ties, correctness against the registry. The registry sorts contributors by priority ascending with a stable sort (:4556-4558), merges fields by spread so the last contributor applied wins (:4741-4749), and at equal priority the last applied is the one registered last, which for an artifact is resolveArtifactPackageEntries' dependency-topological order (artifact-packages.ts :248-250), not packages[] order. The fold breaks equal priorities by packages[] index (carrier, :458), so for a field whose deciding priority has two or more writers the fold's winner is a guess; objectsAsServed names exactly those fields in tied (:487-492: a key is tied when the writer that lands on it has the same priority as the previous one, and un-tied when a higher one lands), the same computation mergedObjectsOf makes in the membership-key rule (:259-271), where columnOf :308 returns skip for a tied column, that is, unjudged. The two new helpers follow that rule: objectsWithoutTiedFields :809-817 strips the tied keys from the served objects (array-form fields, which is what the fold emits for every extended object; an unextended object comes back by reference), and indexServedObjectGraph(stack, { tied: 'drop' }) :827-830 indexes that. Each direction, measured by reading the consumer: measure aggregates :656 index with ties dropped, and a path that does not resolve is Skip 3 (:568, undefined), so a tied measure field is unjudged and dataset-field-unknown still belongs to the existence rule; preset comparands :748 the same graph, :346 returns false for a non-ok verdict, unjudged; list-view option map :1074 built without ties, consumer :1106 reads undefined and the lanes are not judged, while the existence graph :969 keeps ties; widget field-type map :825 without ties, consumer :853 skips a missing type, existence graph unchanged; sharing rule :587-591 judgedByName feeds only anchorGraph :600, which only the two comparison arms read, both of which return null on zero resolvable comparisons (:314, :342), while the anchor arm :611 keeps objectsByName with ties, so sharingModel and masterOf are unchanged; thank-you :178-193 admits a tied name only when every writer at the deciding priority has an admitted type, otherwise names the first that has not, and the check runs only after refusalReason on the kept definition passes (:222), so both orders refuse. Safety: no consumer turns an absent tied field into a refusal, so nothing valid is refused; the arms that go silent on a tie are all ones whose runtime consequence on the wrong guess is a refused query (driver INVALID_FILTER, CRITERIA_QUERY_REFUSED :299) or an advisory, so a sharing rule on a tied field can under-share, never over-share, and the one surface shown to anonymous visitors fails closed. The registry also warns at boot on every redeclared field (reportRedeclaredFields :4780-4795), so the author is told. The RLS rule keeps indexServedObjectGraph(cfg) with ties (:1916) and answers its list-holding and cross-class questions on the kept definition; the body's acceptance note states this and why (a per-field tie in the shared graph is out of scope); it is the narrow gap round 1 graded narrow, now stated in the body but not in that rule's header (should-fix 3).
  5. (c) Ties, pinned. describe('a field two packages tie on') :731-776 ties mod and mod2 at the default priority. The existence pin :743-746 is real (served name passes, zq_absent refused). The thank-you pin :758-775 is real in both orders and non-vacuous for the new code (['text','autonumber'] passes refusalReason on the kept autonumber and is refused only by tiedFieldRefusal; both-autonumber must pass). The type-arm pin :748-756 is non-vacuous for measure aggregates only: numberOrText :740 lists the text writer last, so sum over the kept text would be measure-aggregate-field-type-refused if judged, and the control at :751-752 shows it. The other two assertions are vacuous: dateOrText :741 also lists text last, and last_30_days on text passes the preset arm and record.closed_on == record.name on text against text passes the cross-class arm whether or not ties are dropped. The dev's ablation reading "1 failed | 84 passed" is consistent with this: the measure-aggregates assertion fails first and ends the it, so it says nothing about the preset and sharing wiring, and the widget type map (:825) and kanban option map (:1074) have no tie pin at all. The helper itself is pinned once; the per-rule wiring of four of the six consumers is not (should-fix 1).
  6. Scan-region placement. validate-security-posture.test.ts reads only validate-security-posture.ts (RULE_SOURCE :1639) and strips its import block (:1650); it never scans object-graph.ts, so the placement of the two helpers cannot affect it and the justification at object-graph.ts :796-797 and body line 24 misdescribes the mechanism (the fold's own :420-422 comment, from fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) #30359, is the likely source). The one test that does read object-graph.ts by string, collection-coercion-single-copy.test.ts :103, asks that the file declare exactly one recordsOf; the new helper calls it (:815) and declares nothing. Test Core (8 shards) is green on the head, so no string anchor broke. Placement at the end of the file is fine; the comment should say why truthfully or say nothing (should-fix 2).
  7. (d) Carried findings. Body line 91 now says validate-sortable-fields.ts at fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270's head reads declared objects only (recordsOf(stack.objects), line 508), carried with the buildFieldIndex / indexObjectFields family and the translation reader; this is round-1 should-fix 5, done. Still a carry, not a change.
  8. Declarations and size. Changeset: '@objectstack/lint': patch, Clause-②: yes (widening: …), Metadata-protocol: none (lint judgements, no authorable key), and the new sentence on tied fields matches the code (type arms unjudged, thank-you admits only when every deciding definition is engine-generated). The PR body carries the same pair. 552 changed lines across 18 files, within the order's ~600. Check-runs on a7e08193: Lint & Repo Gates, TypeScript Type Check, Test Core (8 shards), Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI all success; Lint UI skipped by its path filter, as on every PR that does not touch ui/; Check Changeset, Check PR Size, PR-body guards, Lint · PM dispatch-gates success. No governed surface in the file list.
  9. Body r1 versus code. The per-rule table, the two retired readers' notes, the helper description (two helpers at the end of the file, neither exported from the entry: index.ts :773-786 lists neither), the "38 tests" (three it.each over nine rows plus eleven single its in the describe) and the "18 files, 552 lines" all match. Two sentences are off: the meta-test mechanism (item 6), and the reverse-verification sentence on ties, which is literally true but reads as covering the three type arms when it covers one assertion (item 5). The maintainer digest (维护者速读) matches the code.

② Semver level

@objectstack/lint patch, unchanged from round 1. The rework narrows what e27387e2 accepted (an undeclared related-list child on the per-package leg, a tied field's type) back to unjudged, which is a narrowing of the PR's own widening, not of main; against main the delta is still widening only, with a true unknown refused byte-equal (now pinned). Clause-②: yes (widening) and Metadata-protocol: none remain the right pair.

③ Boundary flags

④ Design judgment

The rework takes the first of the two fixes round 1 offered and documents it in the header; the per-package leg is now pinned for every moved rule and for related lists; the byte-equal pin exceeds what was asked; the tie handling is the membership-key rule's rule applied where a type is judged, with the one anonymous-facing surface failing closed, and every direction read is safe. No must-fix. Should-fix, in order: (1) make the preset and sharing tie assertions non-vacuous by listing the text writer first in dateOrText (:741) so the kept definition is the refusing one, split :748-756 so one failing arm does not hide the others, and add a tie case for the widget type map and the kanban option map; (2) reword object-graph.ts :796-797 and body line 24: the security-posture meta-test scans its own rule file only (:1639), so say the helpers sit after the fold to keep #30359's section byte-identical, or drop the sentence; (3) add one line to the RLS rule's header near :1907-1916 saying a tied field's type questions are answered on the fold's kept definition, as the body's acceptance note already does. None blocks landing; all required contexts are green on the head.

Implemented-by: claude/project-thread-tbzgwm
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

Each tied pair now lists the definition the arm would refuse last, which
is the one the fold keeps, so every arm that read it instead of dropping
the tie goes red. Adds the widget field-type map and the kanban option
map, and says in the RLS rule and above the helpers how a tie is read.

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

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

维护者速读

  • 改了什么: 另外 14 条 lint 字段路径规则(数据集、列表视图、页面、报表、组件、RLS、共享规则、感谢页等)改为按运行时实际提供的对象判断字段,即把同一制品里各包通过 objectExtensions 加的字段也算进去,和 lint: an app-package permission set's field key on a field a sibling package adds through objectExtensions is refused as security-fls-unknown-field, though the runtime serves the merged field #30303 用的是同一个共享折叠。两个私有扩展读取器也收进共享折叠。两个包在同一优先级写同一个字段(平局)时,判断类型的规则对它不下结论,不会误拒也不会误放。
  • 为什么改: HotCRM 这类多包应用里,视图、报表、页面引用模块包扩展出来的字段会被 lint 误拒,而运行时明明提供这些字段。
  • 风险与回滚: 只放宽误拒;真不存在的字段照旧被拒,规则 id、严重度、位置和消息逐字节不变(已钉)。RLS 和共享规则按运行时编译时的合并对象判断,方向安全。回滚即还原本 PR。
  • 先看: packages/lint/src/object-graph.ts(indexServedObjectGraph、objectsWithoutTiedFields);object-graph.test.ts 的 [#30383] 部分;validate-related-list-refs.ts 的头注释。
  • 两分钟验证: pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts,全绿。
  • 需要你做的: 无,席位落地。

席位意见:fable 契约复审三轮。首轮 FAIL:相关列表规则在单包视角下把兄弟包对第三个包对象的扩展当成子对象来判,产生误报。返工后复审 PASS,平局处理也核对过注册表的合并顺序。最后一轮补了三条建议:平局钉原来有两处空转(现在每个分支单独一个测试,消融 5 条全红),注释措辞,以及 RLS 注释。更宽的同类读取器另开 #30471。

席位核对 — ACCEPT · domain:spec#3 · head 806dd06b0fe3f915f32a7b022d5f505166a0169d

  • Shape: 18 files (+492 −97, 589 changed lines), all under packages/lint, on merge-base 7d7babb1:
    • nine existence rules index the served objects through indexServedObjectGraph (indexObjectGraph over objectsAsServed);
    • the type arms (measure aggregates, preset comparands, the sharing condition graph, the widget field-type map, kanban options) read objectsWithoutTiedFields, so a tied field is unjudged on its type; the thank-you rule reads tied itself and fails closed;
    • validate-related-list-refs.ts judges only the children the stack declares, read as served; validate-mapping-target-fields.ts' private reader is retired onto the shared fold;
    • pins for every moved rule on both CLI legs, a patch changeset.
  • Premise measured: at merge-base, the twelve rules indexed declared objects only and the two private readers folded extensions unlike the registry (fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) #30359's census).
  • Gates: at 806dd06b, lint 194 files, 8063 passed; type check, check:test-typecheck and eslint clean; 59/59 derived gates exit 0 and --ran reconciles with 0 NOT-MEASURED (dev report, record /mnt/project-files/issue-30383/gates-ran.list). Check-runs on this head: 54 success, 11 skipped, 0 red; the body edit re-runs the body guards.
  • Contract review:
    • Round 1, 6656702755 on e27387e2: FAIL. Must-fix: on the per-package leg, validate-related-list-refs.ts listed every extension target as a judged child, so a sibling package's extension of a child a third package owns gave a false related-list-ref-unknown (reproduced by a probe).
    • Round 2, 6657658276 on a7e08193: PASS. The must-fix and should-fixes (a) to (d) hold; the tie handling matches the registry (stable priority sort, last writer wins), and every consumer treats a dropped tie as unjudged, never as a refusal. Three should-fixes remained.
    • The seat read the round-3 delta (3 files, +45 −8; no rule logic changed): the tie pins now list last the definition each arm would refuse, one test per arm with a single-writer control, plus pins for the widget map and kanban options; the dev's tie ablation turns 5 of 89 red, one per arm. On should-fix 2 the dev is right and the review misread: validate-security-posture.test.ts :1656 appends foldCode() (:1659), which reads object-graph.ts between two markers, and the reworded comment names it. The RLS comment now says a tied field's type questions read the fold's kept definition.
  • Declarations: Clause-②: yes (widening: …), Metadata-protocol: none, @objectstack/lint patch. ADR-0087 not needed.
  • Accepted deviations: where an existence rule also asks a type question of the graph (RLS list-holding and cross-class checks, a list view's dotted head, a page count's multi-value check, a dataset hop), it reads the fold's kept definition of a tied field; narrowing those needs a per-field tie on the shared index, outside this card. Stated in the body.
  • Carried: lint: the field-name indexes outside indexObjectGraph (validate-expressions, indexObjectFields readers, hook-body-writes, sortable-fields, translation-references) still read declared objects only, refusing extension-added fields #30471 (p2, Blocked-by lint: twelve field-path rules and two private readers judge object fields without the objectExtensions fold, so a view, report, dataset or page naming an extension-added field is refused #30383): the field-name indexes outside indexObjectGraph (validate-expressions.ts, the indexObjectFields readers, validate-hook-body-writes.ts, validate-sortable-fields.ts after fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270), validate-translation-references.ts' private fold, and defineStack's additive collectRelatedListNameErrors.
  • Landing: not governed (check-governed-merges --pr 30432: 589 lines). Open draft PR fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) #30270 edits validate-page-field-bindings.ts in other hunks and merges clean (round 2, git merge-tree). The hand-off is pr_ready plus automerge_enable, through the merge queue.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 12:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit e760204 Nov 5, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-tbzgwm branch November 5, 2026 13:10
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant