Skip to content

docs(adr): ADR-0090 D5 offending bits are platform system permissions, not any systemPermissions - #17814

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-17189-adr-0090-d5-revision
Sep 13, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-17189-adr-0090-d5-revision

Conversation

@os-bill

@os-bill os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

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/spec predicate, its test, the changeset and the two regenerated surface snapshots. The two were one PR until the seat review of head a21ad008; 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:

ADR-0090 D5 的 offending 清单相应收窄为「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」,ADR 修订走独立受管 PR(draft、请审、人合)

Clause-②: yes(放宽接受集);ADR-0090 修订单独受管 PR

The triage seat had already ruled the same shape: 「那部分必须是独立的受管 PR,⛔ 不得与代码同 diff」.

Measured consequence, not style. scripts/pm/check-governed-merges.mjs --test on 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:

「我们的项目以objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」

D5's last bullet said any systemPermissions was an offending bit, and describeHighPrivilegeBits implemented 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.

⚠️ Merge order. The maintainer's sentence puts the protocol first. This PR is human-merge-only and the sibling is not, so nothing mechanical keeps the code from landing first; if that order matters to you, merge this one before #17811 is enqueued. Flagging rather than deciding — a draft PR cannot enforce it.

What changed

D5's last bullet now reads "a systemPermissions entry naming a platform system permission", and names an app-declared capability token — one a package declared for itself under ADR-0066 D1, entering sys_capability with managed_by: 'package' + package_id provenance — as not an offending bit.

A dated revision note records:

  • the two unlike token kinds in one list, and the cost that motivated the narrowing (a named downstream consumer binding its baseline set to seven positions by hand, plus one manual grant per new hire);
  • three boundaries, each failing closed — the platform floor is absolute (a platform capability name stays high-privilege however it is declared, so manage_users cannot be laundered by declaring it); the discriminator is provenance, never spelling (⛔ the dotted-name rule was considered and rejected: setup.access is a dotted platform capability today); omission refuses (a caller that cannot enumerate the declarations gets the pre-revision verdict);
  • that the D9 guest tier is untouched — D5 speaks for authenticated members;
  • the ruling's provenance and the landing order, quoted verbatim in the original Chinese;
  • that the consuming callers keep the pre-revision behaviour until they supply the declared list.

The revision text is byte-identical to what stood on a21ad008 before 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 the needs:contract-review carrier 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

  • Content: unchanged from the reviewed head, byte-for-byte (see above). The engineering behind it was accepted in the seat review of a21ad008 and 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.
  • ADR gate family on this branch: 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.
  • No changeset: docs/adr/** is in no package's files[], and a phrase unique to this revision note occurs in 0 built files under packages/spec/dist — the negative control measured in the sibling PR's changeset decision.

⚠️ Governed surface — 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

…, 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

Copy link
Copy Markdown
Collaborator

skip-changeset applied by the PM seat — audit note.

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. Check Changeset (job 103520053663, head a8dfc166) exited 1 with its own prescription:

This PR adds no changeset. If it releases nothing (including any 'skills/**' change -- see #5947), apply the 'skip-changeset' label; otherwise run 'pnpm changeset' and name the packages.

Measured rather than assumed: this PR's file list is exactly one path — docs/adr/0090-permission-model-v2-concept-convergence.md. docs/adr/** is in no package's files[], so the diff releases nothing. That is the same reading the sibling PR #17811 took for its own changeset decision, where a phrase unique to this revision note measured 0 occurrences across packages/spec/dist.

⇒ route 1 (skip-changeset) is correct here and route 2 (a changeset) would be wrong — it would name a package this diff does not ship.

Scope of this write. The label set was read, the single addition applied, and the union read back. documentation and size/s are untouched; assignees was empty before and after. ⛔ No other label was written — in particular this seat did not hang needs:contract-review, which the PR body correctly leaves to the owning seat's call.

⛔ Nothing else on this PR moved: still draft, not enqueued, no auto-merge, no approving review. docs/adr/** is governed surface and a human merge remains the review record. The merge-order note in the PR body still stands — this half lands before #17811 is enqueued.

⚠️ If Check Changeset does not re-run on the labeled event, it needs a re-run (or the next push) to turn green; the label alone does not retroactively repaint the old run.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 13, 2026 14:45
@os-zhuang
os-zhuang requested a review from hotlong as a code owner September 13, 2026 14:45
@os-zhuang
os-zhuang enabled auto-merge September 13, 2026 14:46
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit ff1e771 Sep 13, 2026
39 of 40 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-17189-adr-0090-d5-revision branch September 13, 2026 15:12
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants