Repository navigation
Commit ad0fbfd
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBLUgtNVtQKV2hgJpusD4LCR)_
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` 按原名匹配)。另外把两份已有的扩展折叠(#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 `extend`s, 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 (#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):
- the controlled-by-parent merge (#23210,
`pushExtensionMergedCbpFindings`) iterates `objectExtensionsByTarget`
instead of its own collector; its target set (bodies' declarations, a
target two bodies declare left unjudged) and its blame logic are
unchanged;
- `validate-membership-key-declarations.ts` drops its private
`mergedObjectsOf` for `objectsAsServed` (same algorithm, same `tied`
output).
- **`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 (#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` (#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`.
<details><summary>Reverse verification (two mutations, each restored by
scripts/ablation-replace.mjs to the HEAD blob)</summary>
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.
</details>
<details><summary>Gates: dispatch-gates --commands, 59 derived at
8aeb321, 59 run, 0 NOT-MEASURED</summary>
- `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.
</details>
## 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
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (CI-owned run).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block
excepted, per project convention).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7d1367 commit ad0fbfd
8 files changed
Lines changed: 468 additions & 113 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
18 | 20 | | |
19 | 21 | | |
20 | 22 | | |
| |||
345 | 347 | | |
346 | 348 | | |
347 | 349 | | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
348 | 437 | | |
349 | 438 | | |
350 | 439 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
365 | 365 | | |
366 | 366 | | |
367 | 367 | | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
368 | 503 | | |
369 | 504 | | |
370 | 505 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
819 | 819 | | |
820 | 820 | | |
821 | 821 | | |
822 | | - | |
| 822 | + | |
| 823 | + | |
823 | 824 | | |
824 | 825 | | |
825 | 826 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| 69 | + | |
69 | 70 | | |
70 | 71 | | |
71 | 72 | | |
| |||
205 | 206 | | |
206 | 207 | | |
207 | 208 | | |
208 | | - | |
209 | | - | |
210 | | - | |
211 | | - | |
212 | | - | |
213 | | - | |
214 | | - | |
215 | | - | |
216 | | - | |
217 | | - | |
218 | | - | |
219 | | - | |
220 | | - | |
221 | | - | |
222 | | - | |
223 | | - | |
224 | | - | |
225 | | - | |
226 | | - | |
227 | | - | |
228 | | - | |
229 | | - | |
230 | | - | |
231 | | - | |
232 | | - | |
233 | | - | |
234 | | - | |
235 | | - | |
236 | | - | |
237 | | - | |
238 | | - | |
239 | | - | |
240 | | - | |
241 | | - | |
242 | | - | |
243 | | - | |
244 | | - | |
245 | | - | |
246 | | - | |
247 | | - | |
248 | | - | |
249 | | - | |
250 | | - | |
251 | | - | |
252 | | - | |
253 | | - | |
254 | | - | |
255 | | - | |
256 | | - | |
257 | | - | |
258 | | - | |
259 | | - | |
260 | | - | |
261 | | - | |
262 | | - | |
263 | | - | |
264 | | - | |
265 | | - | |
266 | | - | |
267 | | - | |
268 | | - | |
269 | | - | |
270 | | - | |
271 | | - | |
272 | | - | |
273 | | - | |
274 | | - | |
275 | | - | |
276 | | - | |
277 | 209 | | |
278 | 210 | | |
279 | 211 | | |
| |||
332 | 264 | | |
333 | 265 | | |
334 | 266 | | |
335 | | - | |
| 267 | + | |
336 | 268 | | |
337 | 269 | | |
338 | 270 | | |
| |||
0 commit comments