Repository navigation
Commit d434481
fix(lint): the controlledByParent owner arm prescribes ownerMayEdit only for an owner_id-keyed policy (#26658)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XG6dM1MS6FbmME9jADHuk89)_
Fixes #26591
Clause-②: no (lint advice is not a contract face; no accepted metadata
shape moves)
Metadata-protocol: none
## 维护者速读
- **改了什么**:`os lint` 的
`security-controlled-by-parent-owner-policy-without-opt-in` 只在策略按
`owner_id == current_user.id` 写、且明细表确有 `owner_id` 列时,才建议加 `ownerMayEdit:
true`;按别的人员字段(如 hotclm 的 `owner`)写的策略,不论加没加开关都会提示,并改为建议「把负责人放进
`owner_id`」,不再建议这个开关。
- **为什么改**:运行时的写入门只认行上存的 `owner_id`。以前 lint 对任何人员字段都建议加开关,hotclm 照做后
lint 不响了,但负责人照样被 403 拒绝,等于 lint 在误导。
- **风险与回滚**:只改 lint 的提示文字和触发条件,不动运行时权限门,也不动任何元数据契约;回滚就是 revert 本 PR。
- **先看**:`packages/lint/src/validate-security-posture.ts`(新函数
`carriesCbpOwnerColumn`、`pushCbpCallerPolicyOffOwnerColumn` 和规则循环里的
`admittable`
一行);`packages/lint/src/validate-security-posture-owner-may-edit.test.ts`(新的
#26591 用例组);`packages/lint/src/rule-explanations.ts`。
- **两分钟验证**:`pnpm --filter @objectstack/lint exec vitest run
src/validate-security-posture-owner-may-edit.test.ts`,预期全部通过,其中「an
other-field policy with the opt-in: still flagged」一条证明加了开关的 `owner`
策略仍会被提示。
- **需要你做的**:无,席位落地。
## What changes
`sharing.controlledByParent.ownerMayEdit: true` admits one caller past a
`controlled_by_parent` detail's master-write gate. That caller is the
user in the stored row's `owner_id` (`assertControlledByParentWrite`),
and only on a detail whose registered schema carries that column
(`resolveCbpRelation` resolves the opt-in `false` otherwise). The lint
arm prescribed the opt-in for any `FIELD == current_user.id` update
policy. This PR narrows the advice to the shape the runtime honours. The
runtime gate does not change.
| detail | predicate keys the caller on | before | after |
|---|---|---|---|
| carries `owner_id`, no opt-in | `owner_id` (alone or beside another
field) | flagged, opt-in prescribed | same |
| carries `owner_id`, opted in | `owner_id` (alone or beside another
field) | silent | same |
| carries `owner_id`, no opt-in | only another user field (`owner`) |
flagged, **opt-in prescribed** | flagged, the verdict says the opt-in
admits only the stored `owner_id`, and the hint says to key the
accountable person on `owner_id`. The hint never names the opt-in |
| carries `owner_id`, opted in | only another user field (`owner`) |
**silent** | flagged, as above, and the verdict names the declared
opt-in |
| no `owner_id` (`ownership: 'business_unit' \| 'org' \| 'none'`, no own
`owner_id` field) | anything, `owner_id` included | flagged and opt-in
prescribed (no opt-in), or silent (opted in) | flagged, the verdict says
it is "a column the detail does not carry", and the hint says to give
the detail an `owner_id` column. The hint never names the opt-in |
- **`packages/lint/src/validate-security-posture.ts`.**
`carriesCbpOwnerColumn(obj)` answers whether the detail carries
`owner_id` the way the runtime does. It reads the spec's injection plan
(`injectedColumnsFor`, `resolveInjectedSystemColumns`) or a field the
author declared under that name. The arm keeps `{ optedIn, ownerColumn
}` per resolving detail instead of a set of details without the opt-in.
A predicate whose keyed fields include `owner_id` on a detail that
carries it keeps today's behaviour. Any other caller-keyed predicate
goes to the new push site, `pushCbpCallerPolicyOffOwnerColumn`, under
the same rule id and the same `warning` tier.
`callerIdentityKeyedFields` keeps its shape: it already returns field
names, and this arm is its only caller.
- **`packages/lint/src/rule-explanations.ts`.** The `os explain` entry
now says that the opt-in reads only the stored `owner_id`, only on a
detail that carries it. It gives a separate remedy for each case.
- **`packages/spec/liveness/object.json`.** The `ownerMayEdit` evidence
string described the arm as flagging "an owner-keyed update policy on a
detail that has not declared `ownerMayEdit`". After the narrowing that
undersells it, so it now describes the narrowed arm, and its
`verifiedAt` moves to 2026-10-27. This is an evidence string, not a
contract change. The `object.zod.ts` docblock near :3089 ("so the gate
and the lint ask one question one way") is still true, because both read
the opt-in through `controlledByParentOwnerMayEdit`, so it is unchanged.
- **Changeset:** `@objectstack/lint` patch, plus `@objectstack/spec`
patch because `liveness/` ships in spec's `files[]`.
## Tests
- `validate-security-posture-owner-may-edit.test.ts` adds a `#26591`
group on hotclm's shape (a `controlled_by_parent` detail with its own
`owner` user field and `using: owner == current_user.id`):
- `owner_id` policy without the opt-in: flagged, opt-in prescribed.
- `owner_id` policy with it: silent.
- `owner` policy without the opt-in: flagged with the `owner_id` remedy.
The hint does not contain `ownerMayEdit`.
- `owner` policy with the opt-in: still flagged.
- Mixed `owner || owner_id` policy: prescribed without the opt-in,
silent with it.
- `ownership: org | none | business_unit`, with and without the opt-in:
flagged with the owner-column remedy and no opt-in.
- An author's own `owner_id` field on `ownership: 'none'`: prescribed,
then silent.
- The fixture is key-legal under `ObjectStackSchema`.
- `validate-security-posture.test.ts` meta-tests:
- The receiver table excuses `detail` (the arm's own fold) and `sharing`
(the opt-in's spelling inside the verdict string).
- `cbpDetails` replaces `cbpDetailsWithoutOptIn` in the plumbing list.
- The `findings.push` count goes from 18 to 19.
- A reachability-corpus row drives the new push site with key-legal
metadata.
## Acceptance notes
- **ADR-0055 :176 is now partly false.** It says an `update` / `all`
policy that keys a field on `current_user.id`, on a detail that has not
opted in, "is flagged with the opt-in as the fix". After this PR that
holds only for a policy keyed on `owner_id`. The order keeps
`docs/adr/**` out of this PR. The report names the line for the seat.
- **Overlap with other PRs.** #26599, the only other PR on
`validate-security-posture.ts`, has merged since this branch was cut. It
edits the OWD-alias docblocks near :101–:170, and `git merge-tree HEAD
origin/main` is clean. The open #26337 and #26628 edit other entries of
`rule-explanations.ts`. No hunk is shared.
## Verification
All readings below are on head `816501243`.
<details><summary>Lint package: tests, typecheck, scoped
eslint</summary>
- `pnpm --filter @objectstack/lint test`: `Test Files 166 passed (166)`
· `Tests 7395 passed | 5 skipped (7400)`. This run was on the tree
before the corpus row and the `detailName` typecheck fix. The two
touched suites re-ran on the final tree: `Test Files 2 passed (2)` ·
`Tests 210 passed (210)`.
- `pnpm --filter @objectstack/lint typecheck` (`tsc --noEmit && pnpm
check:test-typecheck`): exit 0.
- `eslint --no-inline-config` on the four touched lint files: exit 0.
- `pnpm --filter @objectstack/spec exec vitest run` (for the liveness
string): `Test Files 810 passed (810)` · `Tests 23353 passed | 1 todo
(23354)` · `VERDICT command-exit 0`.
</details>
<details><summary>Ablation (one, of the narrowing)</summary>
`node scripts/ablation-replace.mjs` replaced `const admittable =
detail.ownerColumn && fields.includes(CBP_OWNER_COLUMN);` with `const
admittable = true || (…); // ABL26591`. That restores the old behaviour:
every caller-keyed predicate takes the prescriptive path, and an
opted-in detail is silent.
- The mutation landed: anchor x1 to x0, replacement x0 to x1, blob
`f52007d93faf` to `3b0a2d4cd9cb`.
- The two suites went red as expected: `Tests 5 failed | 205 passed
(210)`. The 5 failures are both other-field cases (without and with the
opt-in) and the three no-`owner_id` cases (org / none / business_unit).
Every `owner_id` case stayed green.
- The restore was proven: the blob after restore equals HEAD
(`f52007d93faf`), and `git diff HEAD` is empty.
- The lint suites import `./validate-security-posture.js` from `src/`,
so no build sat between the mutation and the run.
</details>
<details><summary>Derived gates: 65 / 65, reconciled</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 65 commands. All 65 ran, every exit
code was captured before any pipe, and all 65 exited 0.
- `--ran`: `✓ dispatch-gates --ran: 65 derived famil(ies) accounted for
— 65 run, 0 NOT-MEASURED`.
- Four commands first answered exit 3 (PREREQUISITE NOT MET) and were
re-run after their prerequisite. Their second readings are the ones
counted:
- `check-plugin-teardown-shape.mjs --self-test`, after `git fetch origin
621a487` (the self-test's positive control).
- `check:docs-transcript-drift`, `check:lean-entry-closure` and
`check:dual-build-cjs-loads`, after `pnpm --filter
'@objectstack/lint...' --filter '@objectstack/objectql...' build`.
- Dogfood battery: not owed. The title has no `!`, and `Clause-②` has no
`(narrowing)` arm.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5oX5j1RjbyiKQpdeiF
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5oX5j1RjbyiKQpdeiF)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8222fa8 commit d434481
6 files changed
Lines changed: 243 additions & 25 deletions
File tree
- .changeset
- packages
- lint/src
- spec/liveness
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
713 | 713 | | |
714 | 714 | | |
715 | 715 | | |
716 | | - | |
717 | | - | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
718 | 722 | | |
719 | | - | |
720 | | - | |
721 | | - | |
722 | | - | |
723 | | - | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
724 | 730 | | |
725 | 731 | | |
726 | 732 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
167 | 167 | | |
168 | 168 | | |
169 | 169 | | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 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 | + | |
170 | 253 | | |
171 | 254 | | |
172 | 255 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1579 | 1579 | | |
1580 | 1580 | | |
1581 | 1581 | | |
| 1582 | + | |
| 1583 | + | |
| 1584 | + | |
| 1585 | + | |
| 1586 | + | |
| 1587 | + | |
| 1588 | + | |
| 1589 | + | |
1582 | 1590 | | |
1583 | 1591 | | |
1584 | 1592 | | |
| |||
1781 | 1789 | | |
1782 | 1790 | | |
1783 | 1791 | | |
1784 | | - | |
| 1792 | + | |
1785 | 1793 | | |
1786 | 1794 | | |
1787 | 1795 | | |
| |||
2010 | 2018 | | |
2011 | 2019 | | |
2012 | 2020 | | |
| 2021 | + | |
| 2022 | + | |
| 2023 | + | |
| 2024 | + | |
| 2025 | + | |
| 2026 | + | |
| 2027 | + | |
| 2028 | + | |
| 2029 | + | |
| 2030 | + | |
| 2031 | + | |
| 2032 | + | |
| 2033 | + | |
| 2034 | + | |
| 2035 | + | |
| 2036 | + | |
| 2037 | + | |
| 2038 | + | |
| 2039 | + | |
| 2040 | + | |
| 2041 | + | |
| 2042 | + | |
| 2043 | + | |
2013 | 2044 | | |
2014 | 2045 | | |
2015 | 2046 | | |
| |||
2033 | 2064 | | |
2034 | 2065 | | |
2035 | 2066 | | |
2036 | | - | |
| 2067 | + | |
| 2068 | + | |
2037 | 2069 | | |
2038 | 2070 | | |
2039 | 2071 | | |
| |||
0 commit comments