docs(adr): ADR-0090 D5 offending bits are platform system permissions, not any systemPermissions - #17814
Conversation
…, not any systemPermissions D5's last bullet made any non-empty `systemPermissions` an offending bit for an `everyone` anchor binding, and the shared predicate implemented it literally. That list conflates two unlike tokens: the platform's own powers, and a capability a package declared for itself under ADR-0066 D1. The bullet is narrowed to a `systemPermissions` entry naming a PLATFORM system permission, with a dated revision note recording the two token kinds, the three fail-closed boundaries (the absolute platform floor, provenance rather than spelling, and omission refusing), that the D9 `guest` tier is untouched, and that the consuming callers keep the pre-revision behaviour until they supply the declared list. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
|
Acting on a maintainer instruction given in live PM chat, 2026-09-13, verbatim: 「红的你要帮我解决啊」 (a direct instruction to clear the red CI on the stale open PRs, this one among them). Why this label and not a changeset.
Measured rather than assumed: this PR's file list is exactly one path — ⇒ route 1 ( Scope of this write. The label set was read, the single addition applied, and the union read back. ⛔ Nothing else on this PR moved: still draft, not enqueued, no auto-merge, no approving review.
Generated by Claude Code |
… permission at the everyone anchor (objectstack-ai#17811) Part of objectstack-ai#17189 — step ① of the ruled order only. ⛔ This PR does not discharge the card and ⛔ carries no closing keyword: the `plugin-security` boot refusal and the `@objectstack/lint` `security-anchor-high-privilege` rule are step ②, deliberately untouched here. **Sibling**: objectstack-ai#17814 carries the ADR-0090 D5 revision alone, as its own governed draft PR. The two were one PR until the seat review of head `a21ad008`; they are split on the ruling's own instruction — 「ADR-0090 修订单独受管 PR」 and 「ADR 修订走独立受管 PR(draft、请审、人合)」 (objectstack-ai#17189 comment `5615806616`), which the triage seat had already spelled 「⛔ 不得与代码同 diff」. Measured: `check-governed-merges --test` on this PR's five-file list exits **0 (NOT governed)**; add the ADR back and the same predicate exits **3 (GOVERNED)**. Bundled, one governed path made the predicate change human-merge-only too. ⭐ Both halves are phase ① and ⛔ neither is dropped.⚠️ **Merge order.** The maintainer's ordering note (objectstack-ai#17189 comment `5617614086`) puts the protocol first: 「协议不正确的应该先修改协议。」 The ADR half is human-merge-only and this half is not, so nothing mechanical keeps this one from landing first. If that order matters, merge objectstack-ai#17814 before this is enqueued — flagging rather than deciding, since a draft PR cannot enforce it. - **Clause-②: yes** — 放宽接受集. Corrected on seat review: the ruling (objectstack-ai#17189 comment `5615806616`) declared this value in advance, in its 执行 line, and a declaration made in advance is not overruled by a reading of the diff. My earlier `no` applied a narrower test ("no new key on a published payload") than clause ② asks. The diff agrees with the ruling three ways: a permission set that was refused is now accepted; `packages/spec/api-surface/security.json` and `export-origins/security.json` were regenerated, so the published-surface snapshots moved; and the changeset's own rationale says "a widened accept set". ## The defect `describeHighPrivilegeBits` counted **any** non-empty `systemPermissions` as a high-privilege bit, so a permission set carrying the capability token its own app declared could not be bound to the `everyone` audience anchor. ``` describeHighPrivilegeBits({ systemPermissions: ['manage_users'] }) -> 'system permissions' describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }) -> 'system permissions' ``` Re-confirmed on this branch's own base (`482d34d60c`) against a freshly built `dist`, with a lit control: `{ objects: { a: { allowRead: true } } }` and `{ systemPermissions: [] }` both returned `null` in the same run, so the instrument could have come back the other way. The shipped predicate's arity was `1` — there was no channel through which the distinction could have arrived. ## The change Both predicates take a new optional `AnchorBindingContext` naming the capability names **this stack declared** (ADR-0066 D1: `defineCapability`, entering `sys_capability` with `managed_by: 'package'` + `package_id` provenance). A token on that list is the app's own gate and is not counted as a system permission. **The discriminator is provenance, not spelling, and that is the point rather than a convenience.** The rejected alternative was a naming-syntax rule (dotted ⇒ app token). It misjudges in silence in both directions: `setup.access` is a *platform* capability that is dotted today, and nothing stops an app declaring an undotted token. A syntax rule guesses; the declared list is a fact the caller can read, and only the caller can read it — the predicate is pure and synchronous by contract, and a set may never vouch for its own tokens. Two properties keep the widening honest, both fail-closed: - **The platform floor is absolute.** A name in `PLATFORM_CAPABILITY_NAMES` stays high-privilege however it is declared, so a package cannot launder `manage_users` past the gate by declaring a capability of that name. - **Omission refuses.** With no context — or with a token absent from it — the verdict is byte-identical to the pre-change one. Every current caller uses the old arity, so **no live anchor binding moves in this PR**; the narrowing reaches a binding only once step ② supplies the list. `guest` (ADR-0090 D9) does not honour the excusal at all: D5 speaks for authenticated members, and conferring an app's own gate on anonymous visitors is a different act that ruling (i) did not decide. ## Both directions tested `packages/spec/src/security/high-privilege.test.ts`, 15 cases. Newly **accepted**: a set whose only system permission is a declared token, in the authored shape, the `sys_permission_set` JSON-string column shape, declaration/registry row entries, and the filing consumer's real shape. Still **refused**: a platform permission even when a package declares that name; `setup.access` — dotted, declared, still refused; any undeclared token; every token when no list is passed; a mixed set (one unexcused token refuses the whole set); non-string entries; every other D5 bit (VAMA, delete/transfer, bulk export); and the whole `guest` tier. **Ablation** (both legs: mutate, prove it reached disk with an occurrence count on the mutated text, run, restore; restore verified byte-identical to the `HEAD` blob with `git diff HEAD` empty): | mutation | expected | observed | |---|---|---| | delete the excusal (`unexcused = sys`) | the acceptance pins go red | **6 red / 9 green** | | delete the platform floor | only the floor pins go red | **3 red / 12 green** — exactly the three named | The first leg reddened one test more than predicted: `leaves every other D5 bit refusing` carries both a declared token and an object bit, so without the excusal the `systemPermissions` branch answers before the object branch is reached. Reported as observed, not as predicted. ## ADR-0090 D5 The revision is not optional here. D5's last bullet said **any** `systemPermissions`, and the code implemented that literally — so the protocol, not the implementation, was the thing that was wrong. Per the maintainer's ordering, verbatim: > 「我们的项目以objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」 The bullet now reads "a `systemPermissions` entry naming a **platform** system permission", with a dated revision note recording the two token kinds, the three fail-closed boundaries, why the spelling rule was rejected, that `guest` is untouched, and that the callers keep the pre-revision behaviour until they supply the list. **That change now lives in objectstack-ai#17814**, byte-identical to what stood here on `a21ad008` — verified by diffing the two branches' copies of the file. Removing the code hunks from it made no sentence of it false: nothing in the note ever claimed the predicate ships alongside it.⚠️ Until objectstack-ai#17814 merges, this file's own JSDoc describes a D5 list narrower than the published ADR still states; that window is the split's cost, and the merge-order note above is how to close it. ## Verification - `pnpm --filter @objectstack/spec test` — 472 files / 13365 tests passed. - `pnpm --filter @objectstack/spec typecheck` — passed (`tsc --noEmit`, scripts, and the test-layer ledger; the new test file compiles clean and is **not** added to `test-typecheck-debt.json`). - Consumers of the changed surface, **unedited**: `plugin-security` `audience-anchors.test.ts` + `audience-anchor-set-claims.pin.test.ts` (20 passed — including the pin that reads this very JSDoc block), `@objectstack/lint` `validate-security-posture*.test.ts` (139 passed). - Derived gate family, **re-derived on the split head** (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 83 commands — 79 exit 0, and four exit **3 = PREREQUISITE NOT MET** (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`, `check:doc-formula-expressions`). All four read a whole-repo build; the closure build was OOM-killed on this shared box at 11m52s. They are **NOT MEASURED**, ⛔ not green, and are declared to CI. Exactly six commands left this PR's family when the ADR did — `check-adr-links`, `check-adr-symbol-anchors` (each with its `--self-test`), `check:adr-anchors` and `check:pm-governed-merges` — and all six are run green on objectstack-ai#17814 instead, which derives 18 commands of its own (17 green, the same `check:doc-formula-expressions` at 3). - The engineering the seat accepted is untouched by the split, proven rather than asserted: `git diff a21ad00 HEAD -- packages/spec` is **empty**. Only the ADR file and one changeset sentence moved (the sentence said the revision landed "in the same PR", which the split made false; it now names objectstack-ai#17814). - `eslint . --no-inline-config` over the whole repo — exit 0, measured on `a21ad008`, whose `packages/spec` tree is byte-identical to this head. No narrowing claimed. - Generated baselines regenerated on a fresh build, not by hand: `api-surface/security.json`, `export-origins/security.json` (one line each, the new interface). `check:generated` green. - Changeset owed, **measured — and re-measured after the at-tier review found the first reading wrong** (F3). The counts are taken on a **clean full build of this head**: empty `dist`, then `pnpm --filter @objectstack/spec build` with both passes, exit 0, `check-dts-emitted` 34/34, and both `dist/.build-input-hash*` matching `src`. `declaredCapabilities` reaches **4** files (`security/index.js`, `index.mjs`, `index.d.ts`, `index.d.mts`) and the positive control `describeHighPrivilegeBits` **4**; `AnchorBindingContext` **2** (declarations only - it is a type) and `appDeclaredCapabilityNames` **2** (JS only - it is module-private). Negative control: a sentence occurring only in the ADR revision reads **0**, and `docs/adr/**` is in no package's `files[]`.⚠️ my first reading said "2" for everything. Every figure in it was the **JS-only** reading: a background build was rebuilding the package while I grepped, and it had emitted JS but not DTS. That also silently broke the negative control - `The platform floor is absolute` is in this predicate's own JSDoc as well as in the ADR, so it was never ADR-unique and reads **2**, not 0, once declarations exist. Both the number and the control are corrected, and the changeset now names the build state, because the build state is what changes the answer. The conclusion it supports - that a changeset is owed - never moved. - Step ② is byte-unchanged: `git diff` over `packages/plugins/plugin-security` and `packages/lint` against the merge base is empty. ## 维护者速读(草稿) **改了什么** — 应用自己声明的「门牌」不再被当成平台系统权限。应用现在可以把「全体员工都持有」的权限集绑到 `everyone`,即使这个集合带着它自己导航要读的那张门牌。平台权限(`manage_users` 一类)的保护一点没松。本 PR 同时按裁决修订了 ADR-0090 D5 的清单——协议先改,实现跟上。 **为什么改** — 具名下游 `objectstack-ai/hotclm` 被这条规则挡住:它无法表达「全体员工」,只能把权限集逐一绑到七个岗位,再由管理员为每个没有岗位的员工手工授予;每一个新入职都是一次手工步骤,永远。该仓维护者已裁定保留这个 workaround 等本修。 **风险与代价(含回滚)** — 本次发布**没有任何已有行为变化**:所有调用方仍用旧参数调用,不传名单时判定与改前逐字一致。风险集中在第二步(`plugin-security` 与 `lint` 开始传名单)落地时,而不是现在。误判方向是「多拒」不是「多放」:名单缺失即拒绝。平台权限有绝对下限——应用声明一个叫 `manage_users` 的能力也洗不白它。`guest`(匿名访客)这一档完全不放宽。回滚成本低:本 PR 是一个可选参数加一份文档修订,`git revert` 即可,无数据迁移、无存储格式变化。 **席位意见** — (留给维护者) **你要做的** — 一、确认 D5 修订的措辞就是您要的协议(这是受管面,需要您人工合并)。二、确认「应用声明过的能力可以发给全体员工」这条安全姿态判断——本 PR 只按已声明的出处区分,**不**按 `scope: 'org'` 再收窄一层;若您要求更窄的判据,说一声,第二步的调用方过滤一下名单即可,谓词不用改。 ---⚠️ **Governed surface — this footer described the PRE-SPLIT PR and is now false; corrected in place rather than deleted, because the reading it reports is the reason the split happened.** When this PR still carried `docs/adr/0090-…`, `check-governed-merges --test` on its file list exited **3 (GOVERNED)**. It no longer carries it: re-measured on the five-file list at 2026-09-12T09:5xZ the same predicate exits **0 — NOT governed**, with the ADR appended as the control exiting **3**. ⇒ ordinary queue landing applies to this PR, and the human-merge rule applies to **objectstack-ai#17814** instead. ⛔ Still not armed, for a different reason: the maintainer's ordering note puts the protocol first, so the seat holds this PR until objectstack-ai#17814 is merged by hand. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #17189 — the protocol half of phase ①. ⛔ This PR does not discharge the card, and ⛔ carries no closing keyword: #17189 still owes step ②.
Sibling: #17811 carries the
packages/specpredicate, its test, the changeset and the two regenerated surface snapshots. The two were one PR until the seat review of heada21ad008; they are split here on the ruling's own instruction.Why this is its own PR
The ruling (#17189 comment
5615806616, director seat, batch #110) says it twice, verbatim:The triage seat had already ruled the same shape: 「那部分必须是独立的受管 PR,⛔ 不得与代码同 diff」.
Measured consequence, not style.
scripts/pm/check-governed-merges.mjs --teston this PR's one-file list exits 3 = GOVERNED; on the sibling's five-file list it exits 0 = NOT governed. Bundled, one governed path made the predicate change human-merge-only too. Split, this half waits for its human and the code half takes ordinary landing.Why the revision is required
Both halves are phase ①, per the ordering note (#17189 comment
5617614086) carrying the maintainer verbatim:D5's last bullet said any
systemPermissionswas an offending bit, anddescribeHighPrivilegeBitsimplemented that literally — so the protocol, not the implementation, was the half that was wrong. A predicate change without this revision would leave the ADR describing a rule the code no longer applies.What changed
D5's last bullet now reads "a
systemPermissionsentry naming a platform system permission", and names an app-declared capability token — one a package declared for itself under ADR-0066 D1, enteringsys_capabilitywithmanaged_by: 'package'+package_idprovenance — as not an offending bit.A dated revision note records:
manage_userscannot be laundered by declaring it); the discriminator is provenance, never spelling (⛔ the dotted-name rule was considered and rejected:setup.accessis a dotted platform capability today); omission refuses (a caller that cannot enumerate the declarations gets the pre-revision verdict);guesttier is untouched — D5 speaks for authenticated members;The revision text is byte-identical to what stood on
a21ad008before the split — verified by diffing this branch's file against that branch's. Removing the code hunks made no sentence of it false: nothing in the note claims the predicate ships in this PR.Clause ②
Recorded, not re-declared here: the card's declaration is
Clause-②: yes(放宽接受集)per the ruling's 执行 line, and theneeds:contract-reviewcarrier is already hung by the seat on card #17189 and on the code PR #17811. This half is prose only — it puts no key on any published payload and moves no accept set by itself. Whether the carrier is also owed on this PR is the seat's call; ⛔ this round writes no labels.Verification
a21ad008and is ⛔ not re-litigated here.check-governed-merges.mjs --test docs/adr/0090-permission-model-v2-concept-convergence.md→ exit 3 (GOVERNED). Control, the sibling's five-file list → exit 0 (NOT governed), so the 3 is a verdict about this file rather than an instrument that only ever says 3.check-adr-links,check-adr-symbol-anchors,check:adr-anchors,check-adr-0087-registration --base origin/main,check:nul-bytes,check:pm-governed-prose,check:doc-authoring— exit codes captured before any pipe, reported in the round's report.docs/adr/**is in no package'sfiles[], and a phrase unique to this revision note occurs in 0 built files underpackages/spec/dist— the negative control measured in the sibling PR's changeset decision.docs/adr/**. Draft only, and it stays draft: ⛔ not flipped ready, ⛔ not enqueued, ⛔ no auto-merge, ⛔ no approving review. A human merge is the review record.Generated by Claude Code