|
| 1 | +--- |
| 2 | +'@objectstack/spec': minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec): an app-declared capability token is not a platform system permission at the `everyone` anchor |
| 6 | + |
| 7 | +`describeHighPrivilegeBits` counted **any** non-empty `systemPermissions` as a |
| 8 | +high-privilege bit, so a permission set carrying the capability token its own |
| 9 | +app declared could not be bound to the `everyone` audience anchor: |
| 10 | + |
| 11 | +``` |
| 12 | +FROM describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }) |
| 13 | + -> 'system permissions' // the app's own navigation gate, refused |
| 14 | +TO describeHighPrivilegeBits({ systemPermissions: ['clm_requester.access'] }, |
| 15 | + { declaredCapabilities: ['clm_requester.access'] }) |
| 16 | + -> null |
| 17 | +``` |
| 18 | + |
| 19 | +One list carries two unlike things: the platform's own powers (`manage_users` |
| 20 | +and friends) and a capability a package **declared for itself** (ADR-0066 D1, |
| 21 | +entering `sys_capability` with `managed_by: 'package'` + `package_id` |
| 22 | +provenance). An app whose navigation gates on its own token therefore could not |
| 23 | +ship the set every employee holds — the set's own gate made it unbindable — and |
| 24 | +authors were pushed toward declaring no gates at all, the opposite of what |
| 25 | +ADR-0066 D1 exists to encourage. |
| 26 | + |
| 27 | +**The discriminator is provenance, not spelling.** Both predicates |
| 28 | +(`describeHighPrivilegeBits`, `describeAnchorForbiddenBits`) take a new optional |
| 29 | +`AnchorBindingContext` naming the capability names *this stack declared*; a |
| 30 | +token on that list is the app's own gate and is not counted. ⛔ A naming-syntax |
| 31 | +rule (dotted ⇒ app token) was considered and rejected: it misjudges in silence |
| 32 | +the first dotted platform permission — `setup.access` is one today — and the |
| 33 | +first undotted app token. |
| 34 | + |
| 35 | +**What is still refused**, each pinned in `high-privilege.test.ts`: |
| 36 | + |
| 37 | +- a platform capability name, **however it is declared** — a package declaring |
| 38 | + `manage_users` cannot launder it past the gate (the platform floor); |
| 39 | +- any token absent from the declared list, and every token when no list is |
| 40 | + passed — omission gets the pre-change verdict, so the narrowing fails closed; |
| 41 | +- a mixed set: one unexcused token still refuses the whole set; |
| 42 | +- the `guest` tier (ADR-0090 D9), which does not honour the excusal at all — |
| 43 | + D5 speaks for authenticated members, and anonymous visitors are not that. |
| 44 | + |
| 45 | +**No shipped behaviour moves in this release.** Every current caller invokes the |
| 46 | +predicates with the old arity, and with no context the code path is identical — |
| 47 | +so this release widens the API, not any live anchor binding. The |
| 48 | +`@objectstack/plugin-security` boot refusal and the `@objectstack/lint` |
| 49 | +`security-anchor-high-privilege` rule pass the declared list in a follow-up, in |
| 50 | +the ruled order (protocol first). |
| 51 | + |
| 52 | +ADR-0090 D5's offending-bit list is revised to match in its own governed PR |
| 53 | +(objectstack#17814), per the ruling's 「ADR-0090 修订单独受管 PR」: the offending |
| 54 | +bit is a `systemPermissions` entry naming a **platform** system permission. |
| 55 | +Both halves are phase ①; ⛔ neither lands without the other following. |
| 56 | + |
| 57 | +**This is shipped, which is why it carries a changeset rather than |
| 58 | +`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and |
| 59 | +the new code reaches it. |
| 60 | + |
| 61 | +Counts below are taken on a **clean full build of this head** — an empty `dist`, |
| 62 | +then `pnpm --filter @objectstack/spec build` with both passes (JS and DTS): exit |
| 63 | +0, `check-dts-emitted` reporting 34/34 declaration files, and |
| 64 | +`dist/.build-input-hash` and `.build-input-hash-dts` both matching `src`. The |
| 65 | +build state is named because it changes the answer: on a JS-only `dist` — one |
| 66 | +still mid-DTS, or built under `OS_SKIP_DTS` — every declaration file is missing |
| 67 | +and each count below that reaches one is halved. |
| 68 | + |
| 69 | +| identifier | built files | where | |
| 70 | +|---|---|---| |
| 71 | +| `declaredCapabilities` | **4** | `security/index.js`, `index.mjs`, `index.d.ts`, `index.d.mts` | |
| 72 | +| `AnchorBindingContext` | **2** | `index.d.ts`, `index.d.mts` — a type, so the declarations are its whole published reach | |
| 73 | +| `appDeclaredCapabilityNames` | **2** | `index.js`, `index.mjs` — module-private, so it has no declaration presence at all | |
| 74 | +| `describeHighPrivilegeBits` | **4** | the positive control: a symbol already known to ship | |
| 75 | + |
| 76 | +Negative control: a sentence occurring **only** in the ADR revision — `As first |
| 77 | +written, the bullet above made` — occurs in **0** built files, and `docs/adr/**` |
| 78 | +is in no package's `files[]`. ⚠️ The control has to be a sentence the source |
| 79 | +does not also carry: `The platform floor is absolute` reads 2, not 0, because |
| 80 | +that sentence is in this predicate's JSDoc as well as in the ADR, and an emitted |
| 81 | +JSDoc reaches `index.d.ts` / `index.d.mts` like any other declaration text. |
0 commit comments