Skip to content

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

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

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-v0u9je

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 #30303

Clause-②: yes (widening: the FLS-key check accepts extension-added fields)
Metadata-protocol: none
Skills-docs: none owed — no skill, eval or doc teaches that a permission set may not name an extension field (git grep -c "security-fls-unknown-field" -- skills content/docs ':!content/docs/releases': 0 hits; content/docs/data-modeling/object-extensions.mdx names no permission or FLS key); the rule's os explain entry is updated here
Prior rulings read: security-fls-unknown-field,objectextensions,indexobjectgraph → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: none; repo: objectstack-ai/objectstack

维护者速读

  • 改了什么:权限集 fields 里的 对象.字段 键,现在按"注册表实际提供的对象"判断字段是否存在,即对象自己声明的字段加上同一制品中任何包通过 objectExtensions 加的字段;真不存在的字段照旧报 security-fls-unknown-field,规则 id、级别、消息不变。
  • 为什么改:HotCRM 的 app 包给模块包扩展出来的 crm_contract.o2c_probe_note 配字段权限,pnpm build / objectstack dev 都被这条规则拒绝,而运行时明明提供这个字段;hotcrm#2315 第 1 阶段因此卡住。
  • 风险与回滚:只放宽拒绝面,不新增拒绝;合并规则照抄 objectql 注册表(按字段名合并、同一包对同一对象只保留最后一条扩展、extend 按原名匹配)。另外把两份已有的扩展折叠(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 的 controlled-by-parent 和 membership-key)收成一份共享实现,两边原有测试全绿。回滚即还原本 PR。
  • 先看:packages/lint/src/object-graph.ts 新增的 objectExtensionsByTarget / objectsAsServed;validate-security-posture.ts 里 flsGraph 那一行和 CBP 段改读共享折叠;新测试 validate-security-posture-fls-extension.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/validate-security-posture-fls-extension.test.ts,预期 8 例全绿(含 per-package 和 union 两种 CLI 运行形态)。
  • 需要你做的:无,席位落地。

What changes

  • packages/lint/src/object-graph.ts: one shared extension fold.
    • objectExtensionsByTarget(stack): every objectExtensions entry by the object it extends, as the registry registers them. With packages[], every body's entries (carrier = body index, the artifact's registration order); without it, the stack's own. One entry per package per target (a later entry of the same package replaces an earlier one whole). Fold order: ascending priority (default 200), then package order. extend names its target verbatim; the registry's short-name to legacy ns__name filing (objectql: a manifest objectExtensions entry or hydrated row whose extend folds to a legacy key opens a second, owner-less object entry #28858, SchemaRegistry.extensionTargetKey) is NOT mirrored, so such an entry folds onto nothing and a key naming its field stays refused (the safe direction), as the docblock says.
    • objectsAsServed(stack): the stack's objects with those entries' fields merged by name (a later writer replacing the whole field), plus the tied keys two packages write at the deciding priority. Only fields move; fieldPatches add no field and are not read.
  • validate-security-posture.ts: the FLS-key check reads the served objects. flsGraph is now indexObjectGraph({ objects: objectsAsServed(stack).objects }). The three skips (an object this stack does not define, an object with no readable field map, a registry-injected column) are unchanged, and the injected-column plan is unchanged too: resolveInjectedSystemColumns reads no field, as the registry plans it over the base layer. The finding's rule id, severity, where, path and message are unchanged; the hint's nearest name now also considers extension fields.
  • The two existing folds now read the shared one (no third copy):
  • rule-explanations.ts: the security-fls-unknown-field entry says the columns are the object's as the registry serves it, extension fields from any package of the artifact included.
  • validate-security-posture.test.ts key-read meta-test (validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017): the scan now also reads the shared fold's code, so the reads that moved out of this rule (stack.objectExtensions, body.objectExtensions, ext.extend/fields/priority) stay held to the same schema tables; the fold's own collections are tabled as plumbing.
  • .changeset/30303-fls-key-extension-fields.md: @objectstack/lint patch.
  • object-graph.test.ts (review round 1): junk-input pins for objectExtensionsByTarget and objectsAsServed beside the indexObjectGraph / packagesOf cases: a null or non-record stack, packages: [null] and other non-record entries or bodies, a non-array objectExtensions, non-record entries and a non-string extend, junk fields (and the array form read by name), and a string priority (read as the default 200; the schema refuses it). Neither export throws; a malformed packages stays packagesOf's refusal, met first by validateSecurityPosture.

The runtime merge mirrored

ObjectQL.registerApp (packages/objectql/src/engine-registration.ts, step 2b) registers each manifest objectExtensions entry as an extend contributor via SchemaRegistry.registerObject(..., 'extend', ext.priority ?? 200) under the name ext.extend spells. The extend arm removes that package's earlier contribution to the same object (contributors.findIndex(c => c.packageId === packageId && c.ownership === 'extend')), contributors sort by ascending priority, and foldExtendersOntoDefinition applies each through mergeObjectDefinitions, whose fields merge is { ...base.fields, ...extension.fields }. fieldPatches (ADR-0146 D10) patch only the owner's own fields. An extension's queue or audit field is governed in the fold, but the name stays served (the injected column), so the served name set is unchanged by it. ADR-0029 D3 confirms any package may extend another's object; ADR-0146 D10's __c reservation is refused at other doors (publish, os validate, install), not by the merge, so this check does not judge it.

Extensions the registry does not merge, still refused (pinned)

  • a field a package's earlier entry adds when a later entry of the same package extends the same object (the twin with the entries reversed is accepted);
  • a field an entry only patches through fieldPatches;
  • a field an entry adds to an object of another spelling (extend: 'crm__crm_contract').

An extension of an object no body declares stays unjudged as before (skip 1: the object may be another installed package's).

Census: rules that resolve object.field through the same index

git grep -n "indexObjectGraph" packages/lint/src (non-test), at this head:

Rule file Index input Folds extensions?
validate-security-posture.ts (FLS keys) objectsAsServed(stack) yes, this PR
validate-membership-key-declarations.ts objectsAsServed(stack) yes, before and after (now through the shared fold)
validate-dataset-measure-aggregates.ts indexObjectGraph(stack) no
validate-dataset-references.ts indexObjectGraph(stack) no
validate-field-consumers.ts indexObjectGraph(stack) no (its header says extensions are not judged)
validate-list-view-field-refs.ts indexObjectGraph(stack) no
validate-object-field-refs.ts indexObjectGraph(stack) no
validate-page-field-bindings.ts indexObjectGraph(stack) no
validate-preset-comparands.ts indexObjectGraph(stack) no
validate-report-runtime-filter.ts indexObjectGraph(stack) no
validate-rls-predicate-enforceability.ts indexObjectGraph(cfg) no
validate-sharing-rule-enforceability.ts indexObjectGraph({ objects: [target] }), the declared anchor no
validate-thank-you-record-fields.ts indexObjectGraph(stack) no
validate-widget-bindings.ts indexObjectGraph(stack) no

The twelve "no" rows read the declared objects only, so a path naming an extension-added field reads as unknown there: the same blind spot, by reading (not reproduced per rule). The shared fold does not fix them for free: each would need its index input switched to objectsAsServed, a widening of its own with its own pins. Carried, not changed here; the seat files or routes them.

Two more rules read extension fields through private readers of their own rather than the index, and are carried with the twelve for consolidation onto the shared fold:

  • validate-related-list-refs.ts: the stack's top-level objectExtensions only (no packages[] bodies);
  • validate-mapping-target-fields.ts (extensionFieldsByTarget): the top level plus every body, additive (no one-entry-per-package replacement).

Why

On a multi-package artifact (HotCRM, platform 18.3.0, hotcrm#2315), the app package owns crm_contract and the permission sets, and a module package adds o2c_probe_note to crm_contract through objectExtensions. The runtime serves the field (GET /api/v1/meta/object/crm_contract lists it), but every app-package permission set naming crm_contract.o2c_probe_note was refused with security-fls-unknown-field, because the FLS-key index read stack.objects only. Both CLI runs saw it: the per-package run over the app body (packageBodyAsStack) and the union run (authoringRuleUnionStack). The maintainer's ruling of 2026-11-05 on hotcrm#2315 puts the module's field access in the app package's permission sets, so the refusal blocked the ruled design.

Verification

Under os-verify-lock, NODE_OPTIONS=--max-old-space-size=4096. Round 1 head 8aeb3212 (the review delta: junk-input pins and the fold's docblock); the ablations below were run at f85110db, whose code the delta does not change.

  • pnpm --filter @objectstack/lint test at 8aeb3212: Test Files 194 passed (194), Tests 8015 passed, 5 skipped (8009 at f85110db; +6 junk-input pins in object-graph.test.ts, 47/47 in that file). pnpm --filter @objectstack/lint typecheck (tsc + check:test-typecheck): VERDICT command-exit 0.
  • The new pins, validate-security-posture-fls-extension.test.ts (8 tests). Every stack is parsed by ObjectStackDefinitionSchema first, and each artifact is judged on both shapes the CLI hands the rule: the per-package run over the app body (packageBodyAsStack) and the union run (authoringRuleUnionStack).
    • the card's artifact (app body owns crm_contract and the set naming crm_contract.o2c_probe_note; a type: 'module' body extends crm_contract with it): no finding; the same artifact without the extension: refused;
    • a key on a field no body declares or extends: still security-fls-unknown-field, error, with where and message byte-equal to a single-object control's;
    • a single-package stack with an in-body objectExtensions: no finding; without it: refused;
    • the hint's nearest name is the extension field;
    • three extensions the registry does not merge (see above): still refused.
  • The existing pins of the two re-pointed folds stay green: validate-security-posture-extension-merge.test.ts (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, parse and lint agree on every merged shape), validate-membership-key-declarations.test.ts (tied-priority pin included), validate-rls-predicate-enforceability.membership-keys.test.ts.
Reverse verification (two mutations, each restored by scripts/ablation-replace.mjs to the HEAD blob)
  1. flsGraph back to indexObjectGraph(stack) in validate-security-posture.ts (anchor 1 → 0, blob cdfe1d906f6a → 5f665455010e): the new file goes 5 failed | 3 passed (8). The five red are the four acceptance pins and the "same package's later entry" twin; the three still green are refusal pins, which hold either way. Restored: blob == HEAD cdfe1d906f6a, git diff HEAD empty.
  2. The one-entry-per-package rule removed in the shared fold (.filter((other) => other.carrier !== carrier) → .filter(() => true), blob 76abadb6999e → 4f04096e54a2): 1 failed | 7 passed (8), the "earlier entry replaced" refusal pin. Restored: blob == HEAD 76abadb6999e, git diff HEAD empty.

The lint tests import the rule from src (relative imports), so no dist rebuild sat between mutation and run.

Gates: dispatch-gates --commands, 59 derived at 8aeb321, 59 run, 0 NOT-MEASURED
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates-ran.list: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0. Record: /mnt/project-files/issue-30303/gates-ran.list (every line exit 0), check-changeset-no-major and check:nul-bytes included.
  • Round 1 (8aeb3212): the derived set is the same 59 families as at f85110db; all 59 re-run after a full turbo run build (VERDICT command-exit 0), every one exit 0 on the first pass.
  • At f85110db, first-pass exit 3 on four, each re-run green on the same head: check-plugin-teardown-shape --self-test (shallow clone; its pinned fixture commit fetched), check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure (no dists; green after a full build).
  • No ! in the title and no (narrowing) arm: no full dogfood run. CI owns the rest of the farm.

Acceptance notes

  • objectExtensionsByTarget keys a package by its raw packages[] index; the old controlled-by-parent collector used its index among the record members (packagesOf filters non-records). They differ only on an artifact whose packages[] holds a non-record member, which the schema refuses; the path then points at the real array position.
  • The removed mergedObjectsOf docblock said a tied field is "left out of the merged map"; its code merged it and reported it in tied. The shared fold keeps what the code did, and its docblock now says so; the membership rule's verdict on a tied field is still "not judged".

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 (CI-owned run).
  • 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_01WGufzfKwxFVA5m6bAjSpxf

… it, extension fields included

security-fls-unknown-field read an object's declared fields only, so a
permission set naming a field that an objectExtensions entry adds (a sibling
package's in a multi-package artifact, or the stack's own) was refused,
although the runtime serves the field and the key masks it.

The FLS-key index is now built over objectsAsServed(stack): the shared
extension fold in object-graph.ts, mirroring the objectql registry
(fields by name, one entry per package per object, extend verbatim). The
controlled-by-parent merge (#23210) and the membership-key check read the
same fold instead of their own copies.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf
@github-actions github-actions Bot added size/m 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 9 documentable anchor(s).

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

  • content/docs/permissions/authorization.mdx (via validateSecurityPosture (symbol, a top-level function))

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

  • content/docs/releases/v13.mdx (via validateSecurityPosture (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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 fdad1786e892270ac3fcc161b3fd4d57c9c47748 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 fdad1786e892270ac3fcc161b3fd4d57c9c47748 → 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 f85110d · 2026-11-05T07:47Z

Full review of the first head at CONTRACT_REVIEW_TIER (a lint refusal loosened: the FLS-key check reads extension-added fields).

@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: f85110db2abcffbc7ebc93977fb58358413e34b1
Local-runs: none
Scope: the PR diff against merge-base cfb602c459 (all 7 files); packages/lint/src/object-graph.ts (head and main; recordsOf :215, packagesOf :264, strName :285, the new fold :368-495); packages/lint/src/validate-security-posture.ts (main :669-800 and head :746-770, :1006-1019); packages/lint/src/validate-membership-key-declarations.ts (main :208-275, head :264-268); packages/lint/src/validate-security-posture-fls-extension.test.ts; packages/lint/src/validate-security-posture-extension-merge.test.ts; packages/lint/src/validate-membership-key-declarations.test.ts (cases :82-254); packages/lint/src/object-graph.test.ts (junk cases :264-344); packages/lint/src/validate-related-list-refs.ts :125-135; packages/lint/src/validate-mapping-target-fields.ts :96-118; packages/objectql/src/engine-registration.ts :116-139; packages/objectql/src/registry.ts at main (registerObject :4401-4698, extensionTargetKey :6530-6560, resolveObjectKey :6302-6318, foldExtendersOntoDefinition :4792-4880, mergeObjectDefinitions :814-866, withoutExtensionQueueField :5008-5025, extendContributionRefusal :2122-2130); packages/objectql/src/registry-extension-legacy-key-fold.test.ts; packages/spec/src/data/object.zod.ts (ObjectExtensionSchema :4965-5125); packages/cli/src/utils/stack-collections.ts :356-392; packages/cli/src/utils/artifact-packages.ts :114-186; packages/cli/src/utils/extension-field-suffix.ts; scripts/pm/clause2-line.mjs (:144, :316, :493-560); scripts/check-changeset-no-major.mjs (:9-15, :1807-1842); card #30303 body and 4 comments (os-dev-report 6652637028 included); PR #30359 body, 2 comments, 0 reviews; check-runs on the head.

① Derived judgments

  1. Fidelity of the fold (judged, no must-fix). objectExtensionsByTarget mirrors registerApp step 2b plus the extend arm exactly where it matters for the served NAME set: (a) one entry per package per target, the later replacing the earlier whole (registry.ts :4666-4669 findIndex(c => c.packageId === packageId && c.ownership === 'extend') then splice; fold object-graph.ts :418 filter(other => other.carrier !== carrier)); (b) ascending priority, default 200 (engine-registration.ts :120 ext.priority ?? 200; registry.ts :4690; fold :435-437), equal priorities in registration order; (c) fields merged by name additively, later writer replacing the whole field (mergeObjectDefinitions :825-827 { ...base.fields, ...extension.fields }; fold :467-476); (d) fieldPatches add no field (applyFieldPatches reaches ownFields only, :856-864) and the fold does not read them; (e) audit (applyAuditFieldGovernance) and queue (withoutExtensionQueueField :5008-5025) governance replace or drop the extender's DEFINITION but the platform column of that name stands, so the served name set is unchanged; (f) extendContributionRefusal (:2122-2130) refuses defaultRowScope / membershipKeys, keys registerApp never copies onto extDef (:122-135) and ObjectExtensionSchema (a strictObject) refuses, so unreachable from a manifest entry; (g) array shape only on both sides (engine-registration.ts :116 Array.isArray(manifest.objectExtensions); fold :416 and :447), map-shaped objects and fields read through recordsOf as the runtime's map branch reads them (:103-110); (h) an extension of an object no body declares is listed by the fold but folded onto nothing, and the FLS check's skip 1 leaves the object unjudged, as before; (i) extend: 'crm__crm_contract' against an object named crm_contract: resolveObjectKey (:6302-6318) finds no short-name match and no FQN, so the registry files it under its own spelling, owner-less and unserved; the lint refusal pin matches. ADR-0146 D10's __c door (extension-field-suffix.ts :8-10) refuses only __c outside a .custom pair and non-__c inside one, so a module package's o2c_probe_note is admitted there, as the card measured. No field the lint now accepts is one the runtime would not serve.
  2. The refactor is behaviour-preserving for 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 and the membership-key check by reading: the CBP target set and blame loop are untouched (head :727-745, :750-770); the old Map<number, VisibleExtension> set-replace and the new filter-push give the same list once sorted by (priority, carrier), carriers being unique per target; the membership check's names.has(entry.extend) pre-filter becomes a no-op because objectsAsServed folds only onto declared objects (:452-456); the tie tracking is the same expression. One acknowledged change: the extension path carrier index is the raw packages[] index, not the packagesOf-filtered one (PR acceptance note), differing only on an artifact the schema refuses; unpinned, acceptable. Existing pins that cover the two folds: validate-security-posture-extension-merge.test.ts :48-122 (array field form, cross-package path packages[0].manifest.objectExtensions[0].fields, undeclared target unjudged) and validate-membership-key-declarations.test.ts :110-136 (merged U, tied priority unjudged). No finding moved on them by reading; the dev reports both files green, CI Test Core shards were in_progress at review time.
  3. Pins: the four the card needs are all present and double-shaped (per-package body with packages[] context and the union, matching packageBodyAsStack :182-186 and authoringRuleUnionStack :356-392): sibling extension accepted (test 1), unknown field still refused with where and message byte-equal to a single-object control (test 3), in-body single-package extension accepted (test 4), and three registry-unmerged shapes refused (same-package replaced entry with its reversed twin, fieldPatches-only, other spelling). Each stack is parsed by ObjectStackDefinitionSchema first. Ablation 1 (indexObjectGraph(stack) back) reddens tests 1, 3, 4, 5 and the reversed twin = 5 of 8, as reported; ablation 2 (drop the per-package filter) reddens only the replaced-entry pin = 1 of 8, as reported. Both are real reds. Gap: no junk-input pin on the two new exports (null stack, packages: [null], non-array objectExtensions, non-record entry, junk fields, string priority); by reading each is tolerated (:412-418, :446-449, recordsOf), but object-graph.test.ts :264-344 is the file that pins this seam's junk tolerance and the new exports are not in it.
  4. Census: git grep indexObjectGraph( at head gives exactly the fourteen non-test call sites in the table; twelve index declared objects only (validate-sharing-rule-enforceability.ts :589 indexes the declared anchor), so they share the blind spot by reading. None is fixed for free: no input moved. Carrying is right; the fix per rule is a switch to objectsAsServed with its own acceptance and refusal pins, each a Clause-② widening of its own. The "no third copy" claim holds for the merged-field-set folds, but two private extension readers remain outside it: validate-related-list-refs.ts :132 (top-level objectExtensions only, no packages[], no per-package rule) and validate-mapping-target-fields.ts :103-118 (top level AND every body, additive, no per-package rule).
  5. Declarations: PR body and changeset both carry Clause-②: yes (widening: …) and Metadata-protocol: none; readClause2Line reads yes with arm widening (clause2-line.mjs :316, first token inside the parenthesis), readMetadataProtocolLine reads none, and the changeset grades @objectstack/lint patch, the level none owes. Check Changeset is green on the head. Body matches code except the known eleven-versus-twelve count (twelve "no" rows, prose says "eleven"). No governed surface in the file list; 485 changed lines.

② Semver level

@objectstack/lint patch. A lint refusal narrows (fewer false security-fls-unknown-field findings); no authorable key, value or default changes; rule id, severity, where, path and message of a true unknown are unchanged (test 3 pins byte-equality). Clause-②: yes (widening) and Metadata-protocol: none are the right pair.

③ Boundary flags

  • Lint imports no runtime; the fold restates the registry by reading, with the registry's own lines cited in its docblock (:385-388). The one divergence is in the safe direction: an extend spelled as a short name that extensionTargetKey folds onto a legacy <ns>__<name> owner (objectql: a manifest objectExtensions entry or hydrated row whose extend folds to a legacy key opens a second, owner-less object entry #28858, registry.ts :6530-6560) is served by the runtime and still refused by lint, a false positive on a deprecated spelling, not a false negative.
  • validate-security-posture.test.ts :1651-1665 now reads object-graph.ts by two string anchors and throws when either is missing, so a later edit of the fold's position reddens the meta-test loudly rather than silently narrowing the scan.
  • Carried tier: the twelve rules and the two private readers above are the seat's follow-up cards, none security-rated in the accepting direction.

④ Design judgment

The fix is at the right layer (lint's object index, not a consumer fallback) and in the right shape: one exported fold next to indexObjectGraph, consumed by the three rules that judge merged field sets, with the registry's semantics named line by line. Should-fix, in order: (1) add the junk-input pins for objectExtensionsByTarget and objectsAsServed to object-graph.test.ts alongside the existing indexObjectGraph / packagesOf cases; (2) correct the body's "eleven" to twelve; (3) soften "extend names its target verbatim" in the fold docblock and body to "as spelled; the #28858 short-name fold onto a legacy key is not mirrored (refused here, served there)"; (4) name validate-related-list-refs.ts and validate-mapping-target-fields.ts in the carried census as extension readers the shared fold could absorb. None blocks landing; landing waits on the still-running required contexts (Build Core, Test Core, Type Check, Lint) going green.

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

VERDICT: PASS

…not mirror the legacy-name fold

objectExtensionsByTarget and objectsAsServed get the junk-input cases their
neighbours have: a null stack, non-record packages entries and bodies, a
non-array objectExtensions, non-record entries, junk fields and a string
priority. The fold's docblock says it names the target verbatim and does not
mirror the registry's short-name to legacy ns__name filing (#28858), so such
an entry folds onto nothing and a key naming its field stays refused.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf
@objectstack-fleet

objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

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

维护者速读

  • 改了什么: 权限集 fields 里的「对象.字段」键,现在按注册表实际提供的对象来判断字段是否存在:对象自己声明的字段,加上同一制品里任何包通过 objectExtensions 加的字段。真不存在的字段照旧报 security-fls-unknown-field。
  • 为什么改: HotCRM 的应用包给模块包扩展出来的字段配权限,构建和 objectstack dev 都被这条规则拒绝,而运行时明明提供这个字段。hotcrm#2315 第 1 阶段因此卡住。
  • 风险与回滚: 只放宽拒绝,不新增拒绝。合并规则照抄注册表。原有的两份扩展合并逻辑(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 的 controlled-by-parent 和 membership-key)收成一份共享实现,原测试全绿。回滚即还原本 PR。
  • 先看: packages/lint/src/object-graph.ts 的 objectExtensionsByTarget / objectsAsServed;validate-security-posture-fls-extension.test.ts。
  • 两分钟验证: pnpm --filter @objectstack/lint exec vitest run src/validate-security-posture-fls-extension.test.ts,8 例全绿。
  • 需要你做的: 无,席位落地。

席位意见:fable 契约复审 PASS,没有必须修的。四条建议都已采纳:补了 6 个异常输入的钉,注释写明不跟注册表做短名折叠(这样偏向拒绝,是安全方向),正文的普查也改正了。另有 12 条规则有同样的盲点,已另开卡 #30383。

席位核对 — ACCEPT · domain:spec#3 · head 8aeb321249e63c295e811b973421491e1871d3c9

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 08:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit ad0fbfd Nov 5, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-v0u9je branch November 5, 2026 10:04
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