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

Draft
objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-tbzgwm
Draft

objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-tbzgwm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

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

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;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,预期 63 例全绿(其中 16 例为本 PR 新增)。
  • 需要你做的:无,席位落地。

What changes

  • object-graph.ts: indexServedObjectGraph(stack), indexObjectGraph over objectsAsServed(stack).objects. Not exported from the package entry. 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.
  • 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 declared children and objectExtensionsByTarget for a child only an extension reaches (another package's object), every field read by name in either shape.
    • 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 (16 tests); 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 and field-type map) same ref_no passes, refused without; nowhere still widget-filter-field-unknown
validate-list-view-field-refs.ts moved (graph and kanban option map) the list door queries the served object ref_no passes, refused without; nowhere still list-view-field-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 the panel renders the inserted served record an extension autonumber passes, refused without; nowhere still thank-you-record-field
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
validate-dataset-measure-aggregates.ts moved (type arm) judges the served type SUM over extension score passes, over extension region is measure-aggregate-field-type-refused; nowhere stays the existence rule's
validate-preset-comparands.ts moved (type arm) 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, 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 e27387e2, 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 8037 passed (8037); check:test-typecheck: OK; VERDICT command-exit 0.
  • 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): each of the 14 rules run on the loaded config with the origin/main copy and with this 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.
Reverse verification (scripts/ablation-replace.mjs, restored to the HEAD blob)

indexServedObjectGraph's body back to indexObjectGraph(stack) (anchor 1 → 0, blob 6701b63b281e → 1c21f154c760): object-graph.test.ts goes 9 failed | 54 passed (63). The nine red are exactly the pins of the eight rules routed through the helper (dataset references ×2, report filter, object field refs, RLS, thank-you, measure aggregates, preset comparands, field consumers). The other six rules read objectsAsServed directly; each of their pins asserts the same stack without the extension still refuses, so none of them is vacuous. Restored: anchor count back to 1, git diff HEAD empty. 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 e27387e2: 59 derived families. Each run serially with NODE_OPTIONS=--max-old-space-size=4096; record /mnt/project-files/issue-30383/gates-ran.list, every line exit 0, check-changeset-no-major included.
  • dispatch-gates --ran: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0.
  • First pass exit 3 on two, each re-run green on the same head: check-plugin-teardown-shape --self-test (shallow clone; its pinned fixture commit fetched), check:dual-build-cjs-loads (no dists; green after a full turbo run build, VERDICT command-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. On the showcase none of these changes a finding.
  • 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, whose index was not measured.
    • 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 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 f18adce2f65b9de8ce3b7de1c3ba870bebb53420 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 f18adce2f65b9de8ce3b7de1c3ba870bebb53420 → 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.

This branch has not been deployed

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant