Skip to content

Commit 21b7c12

Browse files
os-justinclaude
andauthored
fix(security): pass the stack's declared capabilities at every audience-anchor predicate consumer (#18602)
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?)`, where `AnchorBindingContext.declaredCapabilities` excuses a `systemPermissions` name, 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` / `AnchorBindingContext` in `packages/plugins/plugin-security/src` + `packages/lint/src` → 0 hits (control: 3 in `high-privilege.ts`). | site | before | after | |---|---|---| | `plugin-security/src/security-plugin.ts` (boot bind, `bindBaselineToEveryone`) | `const offending = boot ? describeHighPrivilegeBits(boot) : null;` | `:3595` `const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null;` — context read once per pass at `:3592` | | `plugin-security/src/security-plugin.ts` (engine write gate) | `const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' \| 'guest');` | `:5503`–`:5508` the same call with `await declaredCapabilityContext()` as the third argument, memoised at `:5469` | | `plugin-security/src/suggested-audience-bindings.ts` (confirm path) | `const offending = describeAnchorForbiddenBits(setRow, row.anchor as 'everyone' \| 'guest');` | `:968`–`:972` the same call with `await readDeclaredCapabilityContext(ql, deps.metadata)` | | `lint/src/validate-security-posture.ts` (`security-anchor-high-privilege`) | `const offending = describeAnchorForbiddenBits(ps, 'everyone');` | `:795` `describeAnchorForbiddenBits(ps, 'everyone', anchorContext)`, built at `:440`–`:443` from `recordsOf(stack.capabilities)` | New module: `packages/plugins/plugin-security/src/declared-capability-context.ts` — `readDeclaredCapabilityContext(ql, metadataService)`, the registry-first / metadata-service-fallback read the `sys_capability` seeder itself uses, returning `undefined` when 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_capability` rows.** 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`'s `runBootstrap`: - `:3878` `for (const organizationId of catalogPasses) await bindBaselineToEveryone(organizationId);` - `:3917` `const capOutcome = await bootstrapDeclaredCapabilities(ql, this.metadata, …);` - `:3926` `await 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 follow `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and MUST precede `reconcileAudienceBindingSuggestions`. Nothing in the boot sequence was reordered. The same reader serves the engine write gate and `confirmAudienceBindingSuggestion` on 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 through `recordsOf(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) | file:line | case | |---|---| | `packages/plugins/plugin-security/src/security-plugin.test.ts:4372` | boot: a declared token BINDS (row asserted, not just a flag) | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4381` | boot: an UNDECLARED token still refuses (declarations present, naming a different capability) | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4392` | boot: a PLATFORM capability still refuses although the stack declares that name | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4410` | write gate: admits the declared token | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4415` | write gate: refuses the undeclared one — `code: PERMISSION_DENIED`, `statusCode: 403` (ADR-0112 envelope), message names the class | | `packages/plugins/plugin-security/src/security-plugin.test.ts:4426` | write gate: refuses the platform capability, same envelope | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:347` | confirm: binds, and the bound row really carries the token | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:365` | confirm: undeclared still refused, suggestion stays `pending` | | `packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts:378` | confirm: platform capability still refused | | `packages/lint/src/validate-security-posture.test.ts:457` | lint: a declared token lints CLEAN | | `packages/lint/src/validate-security-posture.test.ts:473` | lint: an undeclared token still errors | | `packages/lint/src/validate-security-posture.test.ts:492` | lint: a platform capability still errors | The platform-floor cases reuse `high-privilege.ts`'s own vocabulary (`manage_users` from `PLATFORM_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 judging `systemPermissions` altogether. The lint meta-pins (#5017) were visited deliberately rather than silenced: `stack.capabilities` joined the `stack` read surface and a `cap` receiver entry was added against `ObjectStackSchema.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`: minor `minor`, not `patch`: the PR declares `Clause-②: yes (widening)` and `check:changeset-no-major` requires at least one moved package at `minor` or 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 a `trap … EXIT INT TERM` with absolute paths. The subjects resolve through `src` (same-package relative imports), so no `dist` leg applies. - ablated `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 …`) - ablated `lint`: `Tests 1 failed | 125 passed` — exactly the accepting pin - the six refusal controls (undeclared + platform, at each door) stayed GREEN under the ablation, which is what makes the four reds mean the context and not the predicate - restore leg proven by blob identity, not by an exit code: `git hash-object` of each of the three files equals its `HEAD` blob (`3a8fd520…`, `30c2ad7c…`, `f16fb00e…`), `git status` clean, `git diff HEAD` empty **Suites (merged tree, `1fcf14513`):** - `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security test` → exit 0 — lint `103 files / 3868 tests`, plugin-security `113 files / 2190 tests` - `pnpm --filter @objectstack/lint --filter @objectstack/plugin-security typecheck` → exit 0, 0 `error TS` - `pnpm lint` (repo-wide `eslint . --no-inline-config`) → exit 0 — the whole population, no narrowing claimed - targeted `eslint --format json` over the 7 changed source files → 7 files, 0 errors, 0 warnings **Derived gates** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the merge: 71 families, all run, reconciled with `--ran` carrying 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 commit `e0d05538c` in a separate worktree the gate exits 0 with no `packages/spec/dist/` on disk, and exits 1 with the identical finding the moment one empty `packages/spec/dist/security` directory exists. The finding names `packages/cli/test/init-created-files-summary.e2e.test.ts` descending into `packages/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 no `dist/`). 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` (the `find` double now applies the caller's bound by presence, after the filter) and `check: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/main` moved from `e0d05538c` to `b79fae8fb` during the work and PR #18503 landed in `validate-security-posture.ts`. The one conflict was the `@objectstack/spec` import line; BOTH sides were kept (`referenceCarrierOf` from `/data` and `describeAnchorForbiddenBits, type AnchorBindingContext` from `/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 `everyone` anchor: 1. the runtime anchor-binding accept set (boot bind, engine write gate, suggestion confirm) — a `systemPermissions` token THIS stack declares under `capabilities:` no longer counts as a platform system permission; 2. the lint rule `security-anchor-high-privilege`'s accept set for `isDefault: true` sets — the same names, at authoring time. What did NOT move: the platform floor (`PLATFORM_CAPABILITY_NAMES` is applied inside the predicate, so declaring `manage_users` launders nothing); undeclared names (still refused everywhere); the `guest` tier (the predicate drops the context for `guest` by 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 yield `undefined`, which is the pre-#17811 verdict verbatim. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0b8ec2 commit 21b7c12

10 files changed

Lines changed: 511 additions & 10 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
The `everyone`-anchor doors now pass the stack's declared capabilities, so an app capability token a stack DECLARES no longer makes its `isDefault` set unbindable (#18535).
6+
7+
ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」, and PR #17811 landed the predicate that implements it: `describeHighPrivilegeBits(def, context?)` excuses a `systemPermissions` name when the caller says this stack declared it. No consumer in this package passed a context, so all three doors kept judging an app's own gate exactly like `manage_users` — declared ≠ enforced on a contract both the ADR and the spec had already ruled, and an app that declared a capability its navigation gates on could not ship the "every employee holds this" set those gates need.
8+
9+
All three now read one source — the stack's `capabilities:` declarations, through `readDeclaredCapabilityContext` (registry first, metadata service as the fallback, exactly as the `sys_capability` seeder reads them):
10+
11+
- **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor;
12+
- **the engine write gate** on a `sys_position_permission_set` insert/update, read at most once per pass and only once an anchor row is in play;
13+
- **`confirmAudienceBindingSuggestion`**'s early refusal, which is the friendly rendition of that same gate — one source is what keeps it from answering "confirmed" and then having its own insert refused under it.
14+
15+
**Why the declarations and not the `sys_capability` rows.** The predicate's docblock names the rows at boot, but the boot binding runs BEFORE `bootstrapDeclaredCapabilities` seeds them (the bind must follow `bootstrapBuiltinRoles`, which seeds the anchor, and precede the suggestion reconciliation), so the rows are empty there on a first boot. Reading them would refuse every declared token one layer in.
16+
17+
**Two things do not move.** The platform floor is absolute — declaring a capability named `manage_users` launders nothing, because the predicate applies `PLATFORM_CAPABILITY_NAMES` itself — and an UNDECLARED name still refuses at every door, as does every unreadable or empty declaration list (「omission refuses」). The `guest` tier is untouched: the predicate drops the context for it by contract.
18+
19+
**What changes for a consumer:** a permission set whose `systemPermissions` names only capabilities the stack declares, marked `isDefault: true`, now binds to `everyone` at boot instead of logging `refusing to bind fallback set to everyone`. If you were relying on that refusal to keep such a set unbound, remove the token from the set or stop declaring the capability.
20+
21+
Clause-②: yes (widening)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`security-anchor-high-privilege` now reads the stack's own `capabilities:` declarations, so a declared app capability token on an `isDefault` set lints clean (#18535).
6+
7+
The rule holds an `isDefault: true` set to the `everyone`-anchor tier at authoring time, and ADR-0090 D5 puts 「带 package provenance 的应用声明 capability 令牌」 outside that tier's offending list. The rule called `describeAnchorForbiddenBits(ps, 'everyone')` with no `AnchorBindingContext`, so it reported an error for a set the runtime — once it reads the same declarations — binds without complaint. A lint that refuses what the runtime accepts is the drift ADR-0049 says not to ship, in the direction that is hardest to notice: the author never gets to the runtime.
8+
9+
`validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves:
10+
11+
- an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection;
12+
- a **platform** capability still errors even when the stack declares a capability of that name: the platform floor lives inside the predicate, shared with the runtime gate;
13+
- a stack that declares nothing gets the pre-#17811 verdict verbatim.
14+
15+
**What changes for a consumer:** `os validate` (and any other caller of this rule) stops reporting `security-anchor-high-privilege` on an `isDefault` set whose `systemPermissions` names only capabilities the same stack declares. A stack that was editing its set to silence this rule can declare the capability instead — which is what the ADR asks for, since the declaration is what the runtime reads at boot.
16+
17+
Clause-②: yes (widening)

packages/lint/src/validate-security-posture.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,67 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
447447
).toEqual([]);
448448
});
449449

450+
// ── [#18535] …and the ADR-0090 D5 half of that rule: 「平台系统权限;带
451+
// package provenance 的应用声明 capability 令牌不计」. The predicate has taken
452+
// an `AnchorBindingContext` since PR #17811; this rule passes the stack's own
453+
// `capabilities:` declarations into it, which is what makes an app's
454+
// "every employee holds this" set authorable at all. Three cases, because a
455+
// single one of them is satisfied by both a correct rule and a rule that
456+
// stopped judging `systemPermissions` altogether.
457+
it('accepts an isDefault set whose systemPermissions token THIS stack declares (ADR-0090 D5)', () => {
458+
expect(
459+
rulesOf({
460+
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
461+
permissions: [
462+
{
463+
name: 'app_default',
464+
isDefault: true,
465+
systemPermissions: ['crm.export_pipeline'],
466+
objects: { invoice: { allowRead: true } },
467+
},
468+
],
469+
}),
470+
).toEqual([]);
471+
});
472+
473+
it('still errors on an UNDECLARED systemPermissions token — the control for the case above', () => {
474+
const findings = validateSecurityPosture({
475+
// A real declaration list, naming a DIFFERENT capability: this pins that
476+
// membership is what excuses a token, never the mere presence of a
477+
// `capabilities:` collection on the stack.
478+
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
479+
permissions: [
480+
{
481+
name: 'app_default',
482+
isDefault: true,
483+
systemPermissions: ['crm.settle_ledger'],
484+
objects: { invoice: { allowRead: true } },
485+
},
486+
],
487+
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
488+
expect(findings).toHaveLength(1);
489+
expect(findings[0].message).toContain('system permissions');
490+
});
491+
492+
it('still errors on a PLATFORM capability even when the stack declares a capability of that name', () => {
493+
// The platform floor, applied inside the predicate: declaring
494+
// `manage_users` must not launder it past the anchor gate. Keeping this
495+
// case beside the two above is what stops the lint and the runtime gate
496+
// from drifting — both read the same `PLATFORM_CAPABILITY_NAMES`.
497+
const findings = validateSecurityPosture({
498+
capabilities: [{ name: 'manage_users', label: 'Not Yours' }],
499+
permissions: [
500+
{
501+
name: 'app_default',
502+
isDefault: true,
503+
systemPermissions: ['manage_users'],
504+
objects: { invoice: { allowRead: true } },
505+
},
506+
],
507+
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
508+
expect(findings).toHaveLength(1);
509+
});
510+
450511
// ── Rule: security-role-word (ADR-0090 D3) ──────────────────────────
451512
// [#8310] Its own function (and registry entry) since the rest of the block
452513
// crossed the runtime publish surface — same file, same rule id, same
@@ -1189,7 +1250,11 @@ const NOT_SCHEMA_RECEIVERS: Record<string, string> = {
11891250
const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [
11901251
{
11911252
receiver: 'stack',
1192-
expected: ['apps', 'books', 'data', 'objects', 'permissions', 'positions'],
1253+
// [#18535] `capabilities` joined the list when the ADR-0090 D5 anchor rule
1254+
// started passing the stack's own capability declarations to the predicate
1255+
// as `AnchorBindingContext.declaredCapabilities` — a declared token is the
1256+
// app's own gate, not a platform system permission.
1257+
expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'],
11931258
declaredBy: 'ObjectStackSchema',
11941259
keys: () => Object.keys(ObjectStackSchema.shape),
11951260
},
@@ -1261,6 +1326,16 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
12611326
declaredBy: 'ObjectStackSchema.data[]',
12621327
keys: () => shapeKeysOf(ObjectStackSchema.shape.data),
12631328
},
1329+
// [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it
1330+
// wants their NAMES and hands the declarations themselves to the predicate,
1331+
// which reads `name` and ignores every other field — so nothing is
1332+
// transcribed here and this stays a one-key surface.
1333+
{
1334+
receiver: 'cap',
1335+
expected: ['name'],
1336+
declaredBy: 'ObjectStackSchema.capabilities[]',
1337+
keys: () => shapeKeysOf(ObjectStackSchema.shape.capabilities),
1338+
},
12641339
];
12651340

12661341
/** The `.shape` object itself (not just its keys) of a wrapped collection. */
@@ -1304,6 +1379,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
13041379
'declared', // #16108: one object's sorted field-name list — `.length` / `.slice` / `.join`.
13051380
'entries', // #7503: the rule's own field list — `.find`, a JS method.
13061381
'matched', // #14747: one tier's candidate list — `.length` / `.map`, JS methods.
1382+
'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property.
13071383
]);
13081384
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
13091385
});

packages/lint/src/validate-security-posture.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@
119119
*/
120120

121121
import { referenceCarrierOf } from '@objectstack/spec/data';
122-
import { describeAnchorForbiddenBits } from '@objectstack/spec/security';
122+
import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security';
123123
import { indexObjectGraph, recordsOf, type ObjectGraph } from './object-graph.js';
124124

125125
export const SECURITY_OWD_UNSET = 'security-owd-unset';
@@ -427,6 +427,20 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
427427

428428
const objects = recordsOf(stack.objects);
429429
const permissionSets = recordsOf(stack.permissions);
430+
// [#18535, ADR-0090 D5 / ADR-0066 D1] The stack's own capability
431+
// declarations, handed to the anchor predicate as
432+
// `AnchorBindingContext.declaredCapabilities` — the authoring-time half of
433+
// the source the runtime reads at boot. A `systemPermissions` token this
434+
// stack DECLARES is the app's own gate and does not make an `isDefault` set
435+
// unbindable (the runtime agrees, so the lint and the gate stay one rule);
436+
// an UNDECLARED token still offends, and the platform floor is applied by
437+
// the predicate itself, so declaring `manage_users` excuses nothing.
438+
// No declarations ⇒ `undefined` ⇒ the pre-#17811 verdict verbatim.
439+
const declaredCapabilities = recordsOf(stack.capabilities).filter(
440+
(cap) => typeof cap.name === 'string' && cap.name.length > 0,
441+
);
442+
const anchorContext: AnchorBindingContext | undefined =
443+
declaredCapabilities.length > 0 ? { declaredCapabilities } : undefined;
430444

431445
// ── D1/D4/D11: per-object OWD posture ────────────────────────────────
432446
for (let i = 0; i < objects.length; i++) {
@@ -778,7 +792,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
778792
// this rule to key off, so that binding is outside what a package-time
779793
// linter can see and is judged by the bind-time gate alone (#16110).
780794
if (ps.isDefault === true) {
781-
const offending = describeAnchorForbiddenBits(ps, 'everyone');
795+
const offending = describeAnchorForbiddenBits(ps, 'everyone', anchorContext);
782796
if (offending) {
783797
findings.push({
784798
severity: 'error',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18535, ADR-0090 D5 / ADR-0066 D1] The {@link AnchorBindingContext} the
5+
* audience-anchor predicates need, read from the stack's `capabilities:`
6+
* declarations.
7+
*
8+
* `describeHighPrivilegeBits` / `describeAnchorForbiddenBits`
9+
* (`@objectstack/spec/security`) are pure and synchronous: they read one
10+
* permission-set definition and cannot discover which capability names THIS
11+
* stack declared. That fact belongs to the caller, and until this module
12+
* existed no runtime caller passed it — so a `systemPermissions` token an app
13+
* had DECLARED was judged exactly like `manage_users`, and the app's own
14+
* `isDefault` set was refused at the `everyone` anchor. ADR-0090 D5 rules the
15+
* opposite: 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」.
16+
*
17+
* ## Why the METADATA declarations and not the `sys_capability` rows
18+
*
19+
* The predicate's docblock names two sources — the `sys_capability` rows
20+
* carrying `managed_by:'package'` at boot, the stack's own `capabilities`
21+
* array at authoring time. At the boot moment the anchor binding runs, the
22+
* rows DO NOT EXIST YET: `runBootstrap` binds the baseline to `everyone`
23+
* before it calls `bootstrapDeclaredCapabilities`, and that order is fixed by
24+
* two other constraints (the binding must follow `bootstrapBuiltinRoles`,
25+
* which seeds the anchor, and precede the suggestion reconciliation). Reading
26+
* the rows there would read an empty table on a first boot and refuse every
27+
* declared token — the defect this module removes, reintroduced one layer in.
28+
*
29+
* So all three runtime consumers read the DECLARATIONS, through the same
30+
* two-step the seeder itself reads them by (registry first, metadata service
31+
* as the fallback). One source for the three verdicts is not a convenience:
32+
* `confirmAudienceBindingSuggestion` is the friendly early rendition of the
33+
* gate the engine middleware re-enforces on the insert, so a second source
34+
* there would let a confirm pass its own check and then be refused by the
35+
* write it performs.
36+
*
37+
* ⛔ Never derive this list from the set under test — the predicate's own
38+
* docblock says why: a "declared" list read off `systemPermissions` excuses
39+
* every token by construction and turns the gate off. And nothing here filters
40+
* by platform-ness: {@link describeHighPrivilegeBits} applies the platform
41+
* floor itself, so a capability declared under a curated platform name is
42+
* still high-privilege however it reaches this list.
43+
*
44+
* Fails CLOSED at every step: an unreadable registry, an unreadable metadata
45+
* service, a declaration with no `name` and an empty stack all yield
46+
* `undefined`, which is the pre-#17811 verdict verbatim (「omission refuses」).
47+
*/
48+
49+
import type { AnchorBindingContext } from '@objectstack/spec/security';
50+
import { readDeclared } from './bootstrap-declared-permissions.js';
51+
52+
/**
53+
* Read this stack's declared authorization capabilities as an
54+
* {@link AnchorBindingContext}, or `undefined` when it declares none.
55+
*
56+
* The declarations are handed over as they are — the predicate reads `name`
57+
* off each entry and ignores every other field, so nothing is transcribed and
58+
* a shape change in `CapabilityDeclarationSchema` cannot desynchronize a copy.
59+
*
60+
* @param ql The ObjectQL engine handle (its registry is the primary source).
61+
* @param metadataService The metadata service, read only when the registry
62+
* lists nothing — the same fallback `bootstrapDeclaredCapabilities` uses.
63+
*/
64+
export async function readDeclaredCapabilityContext(
65+
ql: any,
66+
metadataService?: any,
67+
): Promise<AnchorBindingContext | undefined> {
68+
let caps: any[] = readDeclared(ql, 'capability');
69+
if (caps.length === 0) {
70+
try {
71+
const listed = metadataService?.list?.('capability');
72+
caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
73+
} catch { caps = []; }
74+
}
75+
if (!Array.isArray(caps)) return undefined;
76+
const declared = caps.filter(
77+
(c) => c && typeof c === 'object' && typeof (c as { name?: unknown }).name === 'string'
78+
&& (c as { name: string }).name.length > 0,
79+
);
80+
return declared.length > 0 ? { declaredCapabilities: declared } : undefined;
81+
}

0 commit comments

Comments
 (0)