Repository navigation
fix(security): pass the stack's declared capabilities at every audience-anchor predicate consumer - #18602
Conversation
… predicate consumer WIP: the four consumer sites of describeHighPrivilegeBits / describeAnchorForbiddenBits now hand over AnchorBindingContext. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…-predicate consumer ADR-0090 D5's `everyone`-anchor excusal for app-declared capability tokens landed as a spec predicate in PR #17811 and no consumer passed it a context, so a declared token still made an `isDefault` set unbindable at boot, at the engine write gate, at the suggestion confirm path and in the lint rule. All four now hand over `AnchorBindingContext.declaredCapabilities`, read from the stack's `capabilities:` declarations; the platform floor and the undeclared-name refusal are unchanged, and the `guest` tier is untouched. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
The `find` double added for the #18535 anchor pins refuses a WHERE combinator it does not implement instead of comparing it as a field name, applies the caller's bound by presence after the filter, and its grown seam counts are ratcheted into the engine-double ledger. Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu Co-authored-by: Claude <noreply@anthropic.com>
…clared-capabilities-consumers # Conflicts: # packages/lint/src/validate-security-posture.ts
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 46575539988c9e11bebd9c978eb568b335967589 && git checkout 46575539988c9e11bebd9c978eb568b335967589
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ad067addec3731c4da61fe1de75d2212029fa8ef 1fcf14513ca02114214c315e9c59e1f8693e6cfa && git checkout -B drift-repro ad067addec3731c4da61fe1de75d2212029fa8ef && git merge --no-ff 1fcf14513ca02114214c315e9c59e1f8693e6cfa
node scripts/docs-audit/affected-docs.mjs --json ad067addec3731c4da61fe1de75d2212029fa8ef
|
Contract reviewServed-tier: In-seat review by the dispatching ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ility rule in objectstack-data security.md (objectstack-ai#18531) Fixes objectstack-ai#17359 **Status 2026-09-17 — the paragraph below is historical and discharged**: the follow-up it names landed as objectstack-ai#18535 (PR objectstack-ai#18602, `origin/main` `21b7c12b4`), the ceiling was ruled, and this branch merged `origin/main`; see "Patch round" at the end. ⛔ **Blocked-by: the objectstack-ai#17189 step-② follow-up (the `plugin-security` boot refusal and the `packages/lint` `security-anchor-high-privilege` rule passing the declared-capability list) — not landed on `origin/main` `2496415`, and no open card names it.** Do not land this PR before that follow-up: today the bullet's second sentence describes the protocol (`packages/spec/src/security/high-privilege.ts` + the ADR-0090 D5 revision, landed by PR objectstack-ai#17814 and PR objectstack-ai#17811) and not yet the running lint/boot — see "Premise check" below. ## What One 3-line bullet in `skills/objectstack-data/rules/security.md`, inserted after the `Source:` line of "## Object-level permissions (RBAC)" — the construct accepted on the card (assessment 5616225081, ACCEPT 5616301504) with the ACCEPT's boot-wording correction applied (「fails lint and boot」 overstated the boot side; boot refuses the binding with a warning, it does not fail). Every other line of the file is byte-identical (`git diff --numstat` = `3 0`); `skills/objectstack-platform/SKILL.md` unchanged; no other file. Lines 35–37, widths 81 / 82 / 91 characters (the file's widest line is 93): ```markdown - **`isDefault: true` = the `everyone` baseline (ADR-0090 D5).** It may carry app capabilities declared under `capabilities:` (`defineCapability`) and granted via `systemPermissions`; lint and boot refuse a platform capability or undeclared name there. ``` Wording deviation, declared: the dispatch's suggested passive form 「is refused by lint and at boot」 puts line 3 at 101 characters, over the 93 cap; the active form above keeps every noun of the accepted text and the ACCEPT's own verb ("refuse") at 91. No other word moved. ## Premise check on `origin/main` `2496415` - **A1 holds**: `security.md` is 211 lines, widest line 93, lines 31–34 verbatim as quoted in the assessment. Its last touch is `7d350a4` (objectstack-ai#17476, the Multi-tenancy section), not `6a3bcd8` as the dispatch read; the neighbours are unaffected. - **A2 holds in substance**: `git grep -F` over `skills/**` at `2496415` — `defineCapability` 0, `systemPermissions` 0, `isDefault` 1 (a list-view example), `capabilities:` 5 — and all five are the data-hook VM tokens (`capabilities: ['api.read', …]` in `objectstack-data/references/data-hooks.md` ×4 and `objectstack-ui/rules/actions.md` ×1), a third registry, not the ADR-0066 D1 stack key. Positive control `definePermissionSet` = 5 hits. The bullet duplicates nothing. - **A3 holds, and the boot side is narrower than the dispatch assumed**: `bindBaselineToEveryone` (`packages/plugins/plugin-security/src/security-plugin.ts:3581`) logs `ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { set, offending })` and `continue`s — a warning and no binding, never a failure.⚠️ Its call is `describeHighPrivilegeBits(boot)` (:3585) with NO `AnchorBindingContext`, and so are the other three consumers: `security-plugin.ts:5475`, `suggested-audience-bindings.ts:961`, `packages/lint/src/validate-security-posture.ts:771`. The predicate's own contract (`high-privilege.ts:134`): 「Omission refuses」. PR objectstack-ai#17811's changeset says it in so many words: 「No shipped behaviour moves in this release. Every current caller invokes the predicates with the old arity … The `@objectstack/plugin-security` boot refusal and the `@objectstack/lint` `security-anchor-high-privilege` rule pass the declared list in a follow-up」. ⇒ Today a DECLARED app token on the `isDefault: true` set is still refused by lint (error) and at boot (warn, no binding) — the shape hotclm hit. The bullet is correct for the protocol and premature for the runtime; per 「文档应该以实际实现为准」 this PR waits for the follow-up. The 「or undeclared name」 clause stays: the implicit placeholder derivation in `bootstrap-declared-capabilities.ts` is alive, and `describeHighPrivilegeBits` excuses only names on `context.declaredCapabilities`, platform floor absolute. - **Keys and anchor, all present**: `capabilities` on `ObjectStackDefinitionSchema` (`packages/spec/src/stack.zod.ts:467`, `.describe('[ADR-0066 D1] …')`); `defineCapability` (`packages/spec/src/security/capabilities.ts:214`); `systemPermissions` on `PermissionSetSchema` (`packages/spec/src/security/permission.zod.ts:691`, schema at :587); `isDefault` at :682 with `.describe('[ADR-0090 D5] App baseline for the everyone position …')`; ADR-0090 D5 at `docs/adr/0090-permission-model-v2-concept-convergence.md:46`, its objectstack-ai#17189 revision block at :249. Control: a nonexistent key greps 0 in `permission.zod.ts`. ## Line readings (the `skills/**` rule) | reading | before (`2496415`) | after (`4ea43892e`) | net | |:--|--:|--:|--:| | `skills/objectstack-data/rules/security.md` | 211 | 214 | +3 | | package: every `SKILL.md` under `skills/` (10) + `skills/objectstack-data/{rules,references}/*` (10) — 20 files | 8959 | 8962 | +3 | Token reading (the sister gate `scripts/check-skills-token-ratchet.mjs`, convention ceil(utf8 bytes / 4)): `security.md` is **2543** tokens against a ceiling of **2480** (`CEILINGS` row at `scripts/check-skills-token-ratchet.mjs:424`, pinned at the landed count with zero headroom) — **over by 63, the gate is RED at this head**. Verdict line, verbatim: 「✗ check-skills-token-ratchet: skills/objectstack-data/rules/security.md is 2543 tokens; the ratchet ceiling is 2480 (over by 63). … The other direction lands only in a PR whose body quotes a maintainer ruling authorizing it. ⛔ MAINTAINER-ONLY」. Not raised here and not paid by deletion (the accepted construct pins every other line of the file). Landing needs that one row moved to 2543 under the maintainer's word, or an equivalent deletion in the same file directed by the PM. ## Changeset `skip-changeset`, by measurement: a walk over every tracked `package.json` finds **0** manifests whose `files[]` names a `skills` path (positive control: 70 manifests name `dist`); no `skills/*/package.json` exists; the catalog ships from the GitHub tree by `npx skills add objectstack-ai/objectstack/skills` (`skills/README.md`; `packages/create-objectstack/src/skills-install.ts:62` `SKILLS_CATALOG = 'objectstack-ai/objectstack/skills'`), never inside an npm tarball. `.github/workflows/pr-automation.yml:758` lists `skills/` among the releases-nothing paths. ## Gates (local, at `4ea43892e`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 22 commands; every one was run with redirect-then-`$?` capture; `--ran` reconcile: 「22 derived, 22 run, 0 NOT-MEASURED, 0 UNRUN」, exit 0. - 21 × exit 0 — among them `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:skill-compatibility`, `check:nul-bytes`, `check:doc-authoring`, `check:role-word`, `check:corpus-claim-drift`, `check:cross-package-test-inputs`, `check:pm-governed-merges`, `check-skills-token-ratchet --self-test`. - `node scripts/check-skills-token-ratchet.mjs` → **exit 1** (the +63 above). - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` → first run exit 3 `PREREQUISITE NOT MET` (`@objectstack/formula` and `@objectstack/lint` unbuilt — not a measurement); built both under `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`, held 142s), re-run → exit 0. - Not applicable: the 14 pending-changeset families (no changeset, by the measurement above); the 2 workflow-valued families and the 1 path-scheduled CI job are CI's own. - No ① dependency-closure build and no ② package test: the diff touches no package. - Control-character self-scan of the file: none. ## Acceptance notes - noted, not filed — 承接者: the PM, relayed in the dev report as a class (b) contract finding with dedupe words: ADR-0090 D5 [ruled] and `describeHighPrivilegeBits` say a declared app token is not an offending bit, while the four consumers listed under A3 still refuse it. PR objectstack-ai#17811's changeset promises the follow-up; no open card in `domain:services` (48), `domain:devx` (103), `domain:spec` (144), `security` (19) or `finding` (78) names it (lists read 2026-09-16, keyword set `17189 / bindBaselineToEveryone / declaredCapabilities / AnchorBindingContext / security-anchor-high-privilege`). - noted, not filed — 承接者: 无: with this bullet the word "capabilities" names three registries across the published bundle (data-hook VM tokens, `requires:` platform service tokens, ADR-0066 D1 stack declarations). Not a defect; the assessment placed the stack key beside its GRANT key on purpose. ## 维护者速读(草稿) **改了什么**:在 `skills/objectstack-data/rules/security.md` 的「Object-level permissions (RBAC)」键列表里加一条三行要点:`isDefault: true` 的权限集就是 `everyone` 基线(ADR-0090 D5);它可以携带本应用在 `capabilities:` 下用 `defineCapability` 声明、再经 `systemPermissions` 授予的应用能力;平台能力或未声明的名字放在那里会被 lint 与启动拒绝。文件其余各行一字未动,`skills/objectstack-platform/SKILL.md` 不动。 2026-09-17 补丁轮:合入 `origin/main`(合并提交 `68e1b07e1`;objectstack-ai#18535 已落地,这三行描述的 lint 与启动行为已成真,三行本身一字未改),并按维护者裁定把 `scripts/check-skills-token-ratchet.mjs` 里 `security.md` 的 token 上限行从 2480 抬到 2543,上限行旁按该文件自己的抬限格式逐字引用裁定「security.md 允许增加到 2543」;合并后实测恰为 2543(`ceil(utf8 bytes / 4)`),余量 0,其它上限行不动。 **为什么改**:objectstack-ai#17189 裁定 (i) 之后,「默认权限集携带本应用自己的门牌令牌」这一组合从被拒变为合法(前提是先声明),而已发布的 skills 里没有任何一处写到这三个键;AI 作者照 schema 直接写 `systemPermissions` 就会写出 hotclm 踩过的那种形状。已接受的评估(5616225081)裁定只加这一条、不加反例、`+3` 行。 **风险与代价(含回滚)**:① 时序——objectstack-ai#17811 只落了协议这一半(spec 谓词 + ADR 修订),启动侧与 lint 侧尚未把声明清单传给谓词,今天照这条要点写出的默认集仍会被 lint 报错、启动只警告不绑定;所以本 PR ⛔ 不应先于那一半落地(正文顶部已标 Blocked-by)。② 已发布 skills 的 token 棘轮:`security.md` 上限 2480、余量 0,本次 +63 使 `check-skills-token-ratchet` 变红;上限行(`scripts/check-skills-token-ratchet.mjs:424`)按门禁自述只有维护者裁定可抬,本 PR 未抬。回滚 = revert 这一个提交(单文件 +3 行,无发布物)。 2026-09-17 更新:① 已解除——objectstack-ai#18535 落地,启动侧与 lint 侧都已把声明清单传给谓词;② 已解除——维护者裁定抬到 2543,本 PR 抬行,`check-skills-token-ratchet` 在新 head 上为绿。回滚 = revert 两个非合并提交(`4ea43892` 三行 + `dfe355143` 上限行),仍无发布物。 **席位意见**: **你要做的**:① 确认 objectstack-ai#17189 第 ② 步(`plugin-security` 启动拒绝 + `packages/lint` 规则传入声明清单)是否已有卡;没有则立卡,并让本 PR 排在它之后合并。② 决定 token 上限:把 `security.md` 那一行抬到 2543(在本 PR 正文引用你的裁定),或指示在同一文件删等量内容(这会动已接受评估钉死的其他行)。③ 之后由 skills 席四件套复核,你点合并。 2026-09-17 更新:① ② 已完成(objectstack-ai#18535 落地;上限已按你的裁定抬到 2543 并在正文与上限行旁引用),只剩 ③。 ## Patch round — merge main + ceiling raise (2026-09-17) Both park conditions discharged (park note 5704585750, update 5710537792, unpark 5711767613). The `Blocked-by:` paragraph at the top of this body is historical: objectstack-ai#18535 landed on `origin/main` as `21b7c12b4` (PR objectstack-ai#18602). Same branch, commits added on top — no rebase, no force-push, no new PR. **Merge**: `68e1b07e1` = `git merge --no-ff origin/main` (`21b7c12b4`) into the branch. Clean (`git merge-tree --write-tree` exit 0; 37 commits behind at merge time); no `os-regen-pending` recorded (the branch touches no generated artifact). Three-dot delta vs `origin/main` after the round: `skills/objectstack-data/rules/security.md` +3/−0 (byte-identical to `4ea43892`), `scripts/check-skills-token-ratchet.mjs` +10/−1. **Re-count** (the ratchet's convention, `ceil(utf8 bytes / 4)`): `security.md` on `origin/main` `21b7c12b4` = 9913 bytes → 2479 tokens (main did not touch the file since the branch forked); branch head before the round `4ea43892` = 10170 bytes → 2543; after the merge (`68e1b07e1`, and the head `dfe355143`) = 10170 bytes → **2543**. Not more than 2543, so the raise is exactly the ruling. Line readings unchanged: file 211 → 214 (+3), widest line 93, package (20 files) 8959 → 8962. **Ratchet row** (`CEILINGS` in `scripts/check-skills-token-ratchet.mjs`): - before: `['skills/objectstack-data/rules/security.md', 2480],` (line 424 on `21b7c12b4`) - after: `['skills/objectstack-data/rules/security.md', 2543],` (line 433 on `dfe355143`), with the raise recorded beside the row in the file's own raise-ritual form (before → after, the surface it authorizes, the arithmetic +63 / headroom 0 / ceiling +63, the ruling verbatim with its record id). No other row moves; the ceiling equals the measurement. **Maintainer ruling**, verbatim and untranslated (maintainer, 2026-09-17, recorded on objectstack-ai#17359 as comment 5710537499): > 「security.md 允许增加到 2543」 That is the authorization the ratchet's own rule requires: 「the other direction lands only in a PR whose body quotes a maintainer ruling authorizing it」. **Re-read of the three lines against the landed code** (`origin/main` `21b7c12b4`): | clause | verdict | evidence | |:--|:--|:--| | `isDefault: true` = the `everyone` baseline (ADR-0090 D5) | still true | `packages/spec/src/security/permission.zod.ts:682` `.describe('[ADR-0090 D5] App baseline for the everyone position …')`; ADR-0090 D5 at `docs/adr/0090-permission-model-v2-concept-convergence.md:46` | | it may carry app capabilities declared under `capabilities:` (`defineCapability`) and granted via `systemPermissions` | now true at boot and in lint (protocol-only before objectstack-ai#18535) | `packages/plugins/plugin-security/src/declared-capability-context.ts` (`readDeclaredCapabilityContext` reads the stack's `capabilities:` declarations, registry first, metadata service as fallback); boot bind `security-plugin.ts:3592–3595` `describeHighPrivilegeBits(boot, anchorContext)`; engine write gate `:5503–5508`; confirm path `suggested-audience-bindings.ts:968–972`; lint `packages/lint/src/validate-security-posture.ts:439–443` builds `anchorContext` from `stack.capabilities`, `:795` passes it to `describeAnchorForbiddenBits` | | lint and boot refuse a platform capability or undeclared name there | still true | `packages/spec/src/security/high-privilege.ts:70–74` the platform floor (`PLATFORM_CAPABILITY_NAMES` is never excused), `:134` 「Omission refuses」 — a token absent from `declaredCapabilities` stays offending; lint emits `security-anchor-high-privilege` at severity `error` (`validate-security-posture.ts:795–808`); boot logs `[security] refusing to bind fallback set to everyone — high-privilege bits` and skips the binding (`security-plugin.ts:3595–3600`) | No clause corrected; the three lines are unchanged. **Ablation of the row** (from the committed head `dfe355143`; trap-restored, absolute paths; no `dist/` involved — the gate reads its own source): with the row reverted to 2480 on disk (grep counts: 2480-row 1, 2543-row 0) the gate exits 1 — 「✗ check-skills-token-ratchet: skills/objectstack-data/rules/security.md is 2543 tokens; the ratchet ceiling is 2480 (over by 63). … ⛔ MAINTAINER-ONLY」; restored with `git checkout HEAD -- …`: `git hash-object` = HEAD blob `5b984866`, `git diff HEAD` empty, `git status --porcelain` empty, gate exits 0 again with 「is 2543 tokens (ceiling 2543; headroom 0)」. **Gates** (worktree at `dfe355143`; `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, change set derived from the merge base `21b7c12b4`, 2 paths): 40 commands derived, all 40 run with redirect-then-`$?` capture; `--ran` reconcile: 「40 derived, 40 run, 0 NOT-MEASURED, 0 UNRUN」, exit 0. `pnpm --filter @objectstack/lint run check:doc-formula-expressions` measured after building `@objectstack/lint...` under `scripts/pm/os-verify-lock.sh` (「VERDICT command-exit 0 · held the lock 136s · waited 0s」). `pnpm check:pm-dispatch-gates` took 626 s under contention. The list, byte-for-byte as derived, with exit codes: ```text node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-skills-token-ratchet.mjs :: exit 0 node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pm-governed-merges :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-compatibility :: exit 0 pnpm check:skill-frame-sync :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:watch-hint-literal :: exit 0 ``` Named verdict lines: `check-skills-token-ratchet` 「✓ … security.md is 2543 tokens (ceiling 2543; headroom 0)」 and 「34 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted」; its `--self-test` 「65 cases pass」; `check-ratchet-remedy-authority` 「255 scripts swept … 15 mark the expanding remedy ⛔ MAINTAINER-ONLY」 (unchanged); `check-skill-frame-sync` self-test 14 cases + the frame coherent. Not owed locally: the 51 artifact-roster, 11 wide-population and 14 pending-changeset families, the path-scheduled CI job and the always-runs tail (CI's own); no ① dependency-closure build or ② package test is owed — the diff touches no package. **Governed**: `node scripts/pm/check-governed-merges.mjs --test skills/objectstack-data/rules/security.md scripts/check-skills-token-ratchet.mjs` → exit 3, 「GOVERNED — a human merge is the review record for this PR」 (1 of 2 paths hit the register; `skills/**` is the rules layer). Still draft; not for any seat to land. **Changeset**: still `skip-changeset` — `scripts/check-skills-token-ratchet.mjs` is a repo-root gate script (private root package; no `files[]` ships it), and the `skills/**` measurement above stands. Label set read back after the round: `documentation`, `size/s`, `skip-changeset` (no label written this round). **Control characters**: `grep -naP` over both touched files → no match (exit 1). --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…lity DECLARATIONS, not the not-yet-seeded sys_capability rows (objectstack-ai#18767) Clause-②: no Fixes objectstack-ai#18603 Comment text only, in one file: the `AnchorBindingContext` docblock in `packages/spec/src/security/high-privilege.ts`. No predicate, type, export or accept set moves. ## What the sentence said, and why a literal follower is refused The docblock named two sources for `declaredCapabilities`: at boot 「the `sys_capability` rows carrying `managed_by: 'package'` provenance」, at authoring time the stack's own `capabilities` array. The boot half carried an ordering precondition the sentence never stated. The ADR-0090 D5 anchor binding runs BEFORE the seeder that writes those rows, so on a first boot the table is empty at exactly the moment the docblock said to read it — and this docblock's own 「omission refuses」 property then turns that emptiness into a silent refusal of every declared token: the app's own `isDefault` set unbindable at the `everyone` anchor, which is the defect objectstack-ai#17811 introduced the input to remove, reproduced one layer in. The boot half now names the DECLARATIONS, read through the seeder's own two-step — the ObjectQL registry first, the metadata service as the fallback — which is exactly what `readDeclaredCapabilityContext` (`@objectstack/plugin-security`, PR objectstack-ai#18602) already implements, so the contract text and its one runtime consumer corroborate each other instead of contradicting. The `sys_capability` rows stay a valid source, qualified: only once the seeder has written them. ## LIT — the ordering was READ, by symbol, on this branch's base The card's line numbers were taken on PR objectstack-ai#18602's head and were carried forward unverified. They were re-derived here by SYMBOL on `origin/main` `95b21b33be` (this branch's merge base), `packages/plugins/plugin-security/src/security-plugin.ts`: | symbol | line | inside | | :-- | :-- | :-- | | `const runBootstrap` | `:3655` | the boot sweep itself | | `await seedCatalogBuiltins(...)` | `:3866` | `runBootstrap` — reaches `bootstrapBuiltinRoles` at `:3572` (defined in `seedCatalogBuiltins`, `:3570`), which seeds the `everyone` anchor | | `await bindBaselineToEveryone(...)` | `:3888` | `runBootstrap` — the ADR-0090 D5 bind; defined at `:3583`, consults `describeHighPrivilegeBits` at `:3595` | | `await reconcileAudienceBindingSuggestions(...)` | `:3905` | `runBootstrap` | | `await bootstrapDeclaredCapabilities(...)` | `:3927` | `runBootstrap` — the seeder that WRITES the `managed_by: 'package'` rows | `:3888` and `:3927` sit in one straight-line `try` body of one function with no branch between them, so the bind precedes the seeder. **The card's conclusion holds.** Three line attributions in the card's table are worth correcting for the next reader, and none of them moves the conclusion: - `:3572` is `bootstrapBuiltinRoles`'s call site inside the helper `seedCatalogBuiltins` (`:3570`), not a line of `runBootstrap`; `runBootstrap` reaches it at `:3866`. - `:3639` is a SECOND `bindBaselineToEveryone` call, inside `seedCatalogForOrganization` (`:3635`) — the organization-creation hook, not the boot sweep. Only `:3888` is `runBootstrap`'s. - `:3742` is `reconcileAudienceBindingSuggestions` inside the publish-materializer callback `runBootstrap` registers — a runtime publish path, not a boot step. The boot step is `:3905`. ## DARK — a reading that must be ZERO, with a control proving it fires Predicate: take `git diff -U0` over `packages/spec/src/security/high-privilege.ts`, keep the `+`/`-` lines that are not the `+++`/`---` headers, and drop every one that is blank or begins with `*`, `//` or `/*`. What remains is CODE. | leg | input | reading | | :-- | :-- | :-- | | this change | `git diff -U0 95b21b3 HEAD -- packages/spec/src/security/high-privilege.ts` | `NON_COMMENT_CHANGED_LINES=0` | | control | the same file's own `d5c91dd681` (objectstack-ai#17811), same predicate, same input shape | `NON_COMMENT_CHANGED_LINES=33` — it names the added `import`, the `export interface AnchorBindingContext`, its member and the whole of `appDeclaredCapabilityNames` | The zero is a measurement, not an absence of input: the same instrument reads 33 on a real code change to the same file. `git diff --stat` for this change is 17 insertions / 2 deletions, all of them comment. ## Changeset — measured, not assumed `skip-changeset` would be wrong: published content moves. - `packages/spec/src/security/high-privilege.ts` is NOT shipped as source. `@objectstack/spec`'s `files[]` takes `src/**/*.zod.ts` and this file is not one — `npm pack --dry-run --json` lists 2021 shipped paths and does not include it, with the sibling `src/security/permission.zod.ts` present in the same listing as the lit control. - Its published reach is the EMITTED declarations, and they move. After `pnpm --filter @objectstack/spec build`, the new clause is present in `dist/security/index.d.ts` and `dist/security/index.d.mts` — both in that same shipped listing — the superseded spelling is absent from every built declaration file (0 files), and the docblock's unchanged neighbouring sentence (「Never synthesize this from the set under test」) is present in the same two files as the lit control. Hence `.changeset/18603-anchor-binding-declared-capabilities.md`, `@objectstack/spec: patch`. ## Verification, at `2387ad9a5c` - `pnpm --filter @objectstack/spec build` — green. - `pnpm --filter @objectstack/spec test` — 486 test files, 14017 tests, all passed. - `pnpm --filter @objectstack/spec typecheck` — green. - `pnpm --filter @objectstack/spec check:generated` — all 15 generated artifacts up to date; nothing needed regenerating. - `pnpm build` — 73/73 tasks successful. - `pnpm lint` (`eslint . --no-inline-config`, the repo-wide population) — green, exit 0. - The gate families derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: all 75 run, all exit 0, reconciled with `--ran` (75 derived / 75 run / 0 NOT-MEASURED, derived from recorded exit codes). Three of them (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) first answered `exit 3` PREREQUISITE NOT MET on an unbuilt tree, which is not a finding; they were re-run green after `pnpm build`. ## Acceptance notes Nothing filable was found alongside this change. The three line-attribution corrections above are reported here rather than filed: they are a nuance in a card's evidence table, not a defect in the code, and the ordering they describe is correct. Landing is the owning seat's — left as a draft, auto-merge not armed. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18535
ADR-0090 D5 rules the
everyone-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」. PR #17811 landed the predicate that implements it —describeHighPrivilegeBits(def, context?)/describeAnchorForbiddenBits(def, anchor, context?), whereAnchorBindingContext.declaredCapabilitiesexcuses asystemPermissionsname, the platform floor stays absolute and an omitted context refuses — and its own changeset named this follow-up: 「the plugin-security boot refusal and the lint security-anchor-high-privilege rule pass the declared list in a follow-up」. This is that follow-up.packages/spec/**is untouched.What changed, per site
Premise re-verified on the branch before editing: four consumer sites, none passing a context;
declaredCapabilities/AnchorBindingContextinpackages/plugins/plugin-security/src+packages/lint/src→ 0 hits (control: 3 inhigh-privilege.ts).plugin-security/src/security-plugin.ts(boot bind,bindBaselineToEveryone)const offending = boot ? describeHighPrivilegeBits(boot) : null;:3595const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null;— context read once per pass at:3592plugin-security/src/security-plugin.ts(engine write gate)const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' | 'guest');:5503–:5508the same call withawait declaredCapabilityContext()as the third argument, memoised at:5469plugin-security/src/suggested-audience-bindings.ts(confirm path)const offending = describeAnchorForbiddenBits(setRow, row.anchor as 'everyone' | 'guest');:968–:972the same call withawait readDeclaredCapabilityContext(ql, deps.metadata)lint/src/validate-security-posture.ts(security-anchor-high-privilege)const offending = describeAnchorForbiddenBits(ps, 'everyone');:795describeAnchorForbiddenBits(ps, 'everyone', anchorContext), built at:440–:443fromrecordsOf(stack.capabilities)New module:
packages/plugins/plugin-security/src/declared-capability-context.ts—readDeclaredCapabilityContext(ql, metadataService), the registry-first / metadata-service-fallback read thesys_capabilityseeder itself uses, returningundefinedwhen the stack declares nothing.Where the declared list is read, and why that moment is safe
Boot (the three runtime doors) reads the DECLARATIONS, not the
sys_capabilityrows. The predicate's docblock names the rows at boot; the ordering forbids it, so the card's ruled fallback applies and this is the "say so" half of it.Ordering evidence, all in
security-plugin.ts'srunBootstrap::3878for (const organizationId of catalogPasses) await bindBaselineToEveryone(organizationId);:3917const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, …);:3926await bootstrapSystemCapabilities(ql, …)The binding runs 39 lines and one awaited pass BEFORE the seeder that writes
managed_by:'package'rows, so on a first boot that table is empty at bind time; reading it there would refuse every declared token one layer in. The position is pinned by two other constraints stated in the code at:3866–:3868: the bind MUST followbootstrapBuiltinRoles(which seeds theeveryoneanchor) and MUST precedereconcileAudienceBindingSuggestions. Nothing in the boot sequence was reordered.The same reader serves the engine write gate and
confirmAudienceBindingSuggestionon purpose: the confirm check is the friendly early rendition of the gate that re-enforces the predicate on the insert it performs, so a second source there could answer "confirmed" and then have its own write refused under it.Lint reads the stack's own
capabilities:collection throughrecordsOf(stack.capabilities)— the authoring-time source the predicate's docblock names, indexed by the same helper every other collection in the rule uses. No second declaration source was invented.Pins (each beside the consumer it guards, three cases per door)
packages/plugins/plugin-security/src/security-plugin.test.ts:4372packages/plugins/plugin-security/src/security-plugin.test.ts:4381packages/plugins/plugin-security/src/security-plugin.test.ts:4392packages/plugins/plugin-security/src/security-plugin.test.ts:4410packages/plugins/plugin-security/src/security-plugin.test.ts:4415code: PERMISSION_DENIED,statusCode: 403(ADR-0112 envelope), message names the classpackages/plugins/plugin-security/src/security-plugin.test.ts:4426packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:347packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:365pendingpackages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:378packages/lint/src/validate-security-posture.test.ts:457packages/lint/src/validate-security-posture.test.ts:473packages/lint/src/validate-security-posture.test.ts:492The platform-floor cases reuse
high-privilege.ts's own vocabulary (manage_usersfromPLATFORM_CAPABILITY_NAMES), so the two layers cannot drift. The boot pins drive the METADATA-SERVICE door of the reader and the confirm pins drive the REGISTRY door, so both halves of the fallback are exercised. Three cases per door and not one: "the declared token binds" alone is equally satisfied by a door that stopped judgingsystemPermissionsaltogether.The lint meta-pins (#5017) were visited deliberately rather than silenced:
stack.capabilitiesjoined thestackread surface and acapreceiver entry was added againstObjectStackSchema.capabilities[], so the new read is held to the same "reads only keys the spec declares" rule as every other.Changesets
.changeset/18535-anchor-declared-capabilities-consumers.md—@objectstack/plugin-security: minor.changeset/18535-lint-anchor-declared-capabilities.md—@objectstack/lint: minorminor, notpatch: the PR declaresClause-②: yes (widening)andcheck:changeset-no-majorrequires at least one moved package atminoror above under that declaration. Both bodies carry the arm and the consumer-facing FROM → TO sentence.Measurements
Red-then-green, with the control lit. Reverse verification ran from the COMMITTED fix, mutating the four call sites back to their pre-fix argument lists, proving the mutation reached the disk (anchored occurrence counts 1 → 0 for each fixed spelling, plus
git diff --stat), and restoring under atrap … EXIT INT TERMwith absolute paths. The subjects resolve throughsrc(same-package relative imports), so nodistleg applies.plugin-security(both files):Tests 3 failed | 293 passed— exactly the three accepting pins (binds an isDefault set …,binds the isDefault set …,write gate: admits …)lint:Tests 1 failed | 125 passed— exactly the accepting pingit hash-objectof each of the three files equals itsHEADblob (3a8fd520…,30c2ad7c…,f16fb00e…),git statusclean,git diff HEADemptySuites (merged tree,
1fcf14513):pnpm --filter @objectstack/lint --filter @objectstack/plugin-security test→ exit 0 — lint103 files / 3868 tests, plugin-security113 files / 2190 testspnpm --filter @objectstack/lint --filter @objectstack/plugin-security typecheck→ exit 0, 0error TSpnpm lint(repo-wideeslint . --no-inline-config) → exit 0 — the whole population, no narrowing claimedeslint --format jsonover the 7 changed source files → 7 files, 0 errors, 0 warningsDerived gates —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the merge: 71 families, all run, reconciled with--rancarrying each exit code →71 derived, 68 run, 3 NOT-MEASURED, 0 UNRUN. 67 green. The four non-zero:pnpm check:cross-package-test-inputs→ exit 1. NOT caused by this diff, proven with a control: at the base commite0d05538cin a separate worktree the gate exits 0 with nopackages/spec/dist/on disk, and exits 1 with the identical finding the moment one emptypackages/spec/dist/securitydirectory exists. The finding namespackages/cli/test/init-created-files-summary.e2e.test.tsdescending intopackages/spec/dist/— a file this PR does not touch, in a package it does not touch. Reported for filing, not fixed here.pnpm check:dual-build-cjs-loads,pnpm check:i18n,pnpm check:type-check-debt→ exit 3,PREREQUISITE NOT MET: each refuses to measure without a full workspace build (53 packages with nodist/). NOT MEASURED locally, not a pass and not a finding; CI builds first and runs them for real.Three gates DID go red on this diff and were fixed, all in the new boot double:
check:engine-double-contract(grown seam counts ratcheted with--write),check:objectql-double-limit(thefinddouble now applies the caller's bound by presence, after the filter) andcheck:where-matcher(the matcher now REFUSES a$-prefixed combinator instead of comparing it as a field name — the refusal had to live INSIDE the matcher callback, since that gate probes the extracted matcher behaviourally).Merge:
origin/mainmoved frome0d05538ctob79fae8fbduring the work and PR #18503 landed invalidate-security-posture.ts. The one conflict was the@objectstack/specimport line; BOTH sides were kept (referenceCarrierOffrom/dataanddescribeAnchorForbiddenBits, type AnchorBindingContextfrom/security), neither dropped, and every measurement above was re-taken on the merged tree.Note for the contract-tier reviewer (Clause-② yes)
Exactly two accept sets widen, both by the same ruled rule and both only for the
everyoneanchor:systemPermissionstoken THIS stack declares undercapabilities:no longer counts as a platform system permission;security-anchor-high-privilege's accept set forisDefault: truesets — the same names, at authoring time.What did NOT move: the platform floor (
PLATFORM_CAPABILITY_NAMESis applied inside the predicate, so declaringmanage_userslaunders nothing); undeclared names (still refused everywhere); theguesttier (the predicate drops the context forguestby contract, and no call site overrides that); the VAMA / delete / transfer / bulk-export / wildcard arms of the predicate; the boot sequence's order; and the failure direction when the declarations cannot be read — an unreadable registry, an unreadable metadata service, or an empty list all yieldundefined, which is the pre-#17811 verdict verbatim.Generated by Claude Code