diff --git a/.changeset/15204-s6b1c-declared-capability-seeder.md b/.changeset/15204-s6b1c-declared-capability-seeder.md
new file mode 100644
index 00000000000..2e843a82e11
--- /dev/null
+++ b/.changeset/15204-s6b1c-declared-capability-seeder.md
@@ -0,0 +1,30 @@
+---
+'@objectstack/plugin-security': minor
+'@objectstack/spec': patch
+'@objectstack/objectql': patch
+'@objectstack/runtime': patch
+'@objectstack/lint': patch
+---
+
+feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted
+
+Clause-②: no
+
+`SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served.
+
+A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run.
+
+In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged.
+
+Removed from `@objectstack/plugin-security`'s public exports, with no replacement:
+
+- `CAPABILITY_NAME_COLLISION`
+- `capabilityNameCollisionDiagnostic`
+- `formatCapabilityNameCollisionDiagnostic`
+- `reportCapabilityNameCollisions`
+- `CapabilityNameCollisionDiagnostic` (type)
+
+FROM → TO:
+
+- FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`.
+- FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place.
diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx
index 261ccf329ea..b0eb31534ee 100644
--- a/content/docs/permissions/authorization.mdx
+++ b/content/docs/permissions/authorization.mdx
@@ -195,20 +195,19 @@ export default defineStack({
});
```
-At boot `bootstrapDeclaredCapabilities`
-(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds
-each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`**
-provenance — idempotent, re-seeded on upgrade, and it **never clobbers**
-admin-authored rows, refuses to hijack a curated platform capability, and refuses
-to write into another package's capability.
+At boot each declaration becomes a registry item owned by its package — the
+capability's one home (ADR-0131 D3). No `sys_capability` row is written for it.
+The registry's one-holder rule refuses a second holder of a name at boot with a
+`422`, so a package can neither take a curated platform capability nor take
+over another package's.
This **replaces the implicit back-door** where a capability existed only as an
untitled placeholder derived from whatever a permission set happened to reference
in `systemPermissions[]`. That derivation is retired: a reference with no
declaration still matches by name at runtime but gets no `sys_capability` row.
-An explicit `defineCapability` takes precedence and — for a derived placeholder
-an older database still holds — **claims** it, upgrading the row to package
-provenance with the authored label/description/scope. This is the ADR-0066 D1
+An explicit `defineCapability` is the attributable form: its label,
+description, scope and owning package live on the registry item, and it gets
+no row either. This is the ADR-0066 D1
direction: retire the implicit `managed_by`-guessing back-doors in favour of
explicit, attributable declarations.
@@ -553,8 +552,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production
`sys_capability` row.
A **package** now DEFINES its own capabilities explicitly via
[`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1),
- seeded with `managed_by:'package'` + `package_id` provenance by
- `bootstrap-declared-capabilities.ts` — replacing the implicit
+ served by the registry with owning-package provenance (no row is written for
+ it) — replacing the implicit
derive-from-`systemPermissions` back-door (now retired).
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a
diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx
index cc5483063ab..2ac0247ee4d 100644
--- a/content/docs/permissions/capabilities.mdx
+++ b/content/docs/permissions/capabilities.mdx
@@ -49,8 +49,8 @@ capability name travels through five stations:
② REGISTER AppPlugin → metadata.registerInMemory('capability', name)
│ (also: **/*.capability.ts files)
▼
- ③ SEED bootstrapDeclaredCapabilities → sys_capability row
- │ managed_by: 'package' + package_id provenance
+ ③ SERVE registry item (capability kind) → catalog read, /meta door
+ │ owning-package provenance, one holder per name
▼
④ GRANT permission set: systemPermissions: ['export_data']
│ assigned to positions / users
@@ -62,12 +62,13 @@ capability name travels through five stations:
1. **Declare** — `defineCapability(...)` on the stack's `capabilities` array,
or a `*.capability.ts` / `*.capability.yml` file the filesystem loader globs.
2. **Register** — at boot, `AppPlugin` puts each stack-declared capability into
- the metadata registry under the `capability` kind, so boot seeders and
- runtime resolvers can list it.
-3. **Seed** — `bootstrapDeclaredCapabilities` (in `@objectstack/plugin-security`)
- reads them back and upserts each one into `sys_capability` with
- `managed_by: 'package'` and `package_id` provenance. This is what makes the
- capability *attributable* and package uninstall well-defined.
+ the metadata registry under the `capability` kind, so the security catalog
+ and runtime resolvers can list it.
+3. **Serve** — the registry is the capability's one home. It records the
+ owning package as provenance, which is what makes the capability
+ *attributable*. The security catalog read and
+ `GET /api/v1/meta/capability/:name` answer its definition. No
+ `sys_capability` row is written for it.
4. **Grant** — an administrator (or a permission set your package ships) lists
the name in [`systemPermissions`](/docs/permissions/permission-metadata#system-permissions).
5. **Check** — a resource lists the name in `requiredPermissions`, and the
@@ -157,7 +158,8 @@ plain names. Three consequences worth internalising before you pick one:
You cannot take a platform name. A declaration whose `name` collides with a
curated platform capability (`manage_users`, `manage_metadata`,
-`setup.access`, …) is refused loudly at boot — those are platform-owned.
+`setup.access`, …) is refused at boot by the registry's one-holder rule
+(`422`) — those are platform-owned, and the platform declares them itself.
## "Or" is one capability, granted twice
@@ -193,36 +195,29 @@ the same way; a user who may edit the object still reads all of its list views,
so a save of what they loaded keeps them.
-## What the seeder does, and what it refuses
+## One holder per name
-`bootstrapDeclaredCapabilities` runs on `kernel:ready` and is idempotent: it
-re-seeds on every boot, so the row always reflects the shipped declaration. Four
-outcomes are refusals rather than writes, and each one leaves the capability
-behaving differently:
+Each capability name has exactly one holder in the registry. When a package
+registers a declaration, the registry records that package as its owner.
+Package uninstall then removes it with the rest of the package. Two cases are
+refused at boot, with a `422` that names the name and both holders:
| Situation | Outcome |
|:--|:--|
-| No resolvable owning package (`_packageId` and `packageId` both absent) | **Refused — no row is written.** The declaration is inert |
-| Name collides with a curated platform capability | Refused; the platform keeps its own definition |
-| A row already exists owned by a **different** package | Skipped loudly; a package never writes into a foreign record |
-| A row was authored by an administrator (`managed_by: 'admin'`) | Never clobbered |
-
-A pre-existing `managed_by: 'platform'` row for a *non-curated* name is a
-different case: that is the untitled placeholder the old implicit back-door
-derived from whatever a permission set happened to reference, and an explicit
-declaration **claims** it — upgrading the row to package provenance with your
-authored label, description and scope. The provenance and composition rules
-behind that behaviour are set out in
-[Package capability declaration](/docs/permissions/authorization#package-capability-declaration-adr-0066-d1).
+| The name is a curated platform capability | Refused; the platform holds its own definition |
+| Another installed package already declares the name | Refused; a package never takes over another package's capability |
+
+Declaring a capability writes no `sys_capability` row. Its label, description,
+scope and provenance live on the registry item, and that is what the catalog
+read and the metadata door serve.
-**The `sys_capability` row is a catalogue, not a gate.** It carries the label,
-the scope and the provenance that make a capability reviewable, attributable and
-uninstallable — but no authorization check reads it. Permission-set grants and
-resource `requiredPermissions` match capability names **as strings**; the only
-production reader of the table is the declared-capability boot seeder. The row's `active`
-flag is a catalogue/visibility flag with no authorization effect: clearing it
-revokes nothing. Withdraw a capability by withdrawing the **grant**.
+**No authorization check reads a `sys_capability` row.** Permission-set grants
+and resource `requiredPermissions` match capability names **as strings**. The
+boot writes no row for a capability, declared or curated: both are served by
+the registry. A row an older database still holds is a catalogue only, and
+clearing its `active` flag revokes nothing. Withdraw a capability by
+withdrawing the **grant**.
## `capability` is code-only
@@ -267,7 +262,7 @@ to store arbitrary JSON on the authorization surface.
- [ ] `label` and `description` are written for the administrator who will grant it in Setup
- [ ] `scope` is `org` unless the privilege really is platform-wide
- [ ] The declaration is reachable from the stack (`capabilities: [...]`) or a `*.capability.ts` file
-- [ ] The package resolves an owning package id — otherwise the seeder writes no row
+- [ ] No other installed package declares the same name — the registry refuses a second holder at boot
- [ ] Something actually **requires** the name (`requiredPermissions`), and something **grants** it (`systemPermissions`)
## See also
diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx
index eef1295c08f..a7cf1778785 100644
--- a/content/docs/permissions/permission-sets.mdx
+++ b/content/docs/permissions/permission-sets.mdx
@@ -150,9 +150,9 @@ the anchor decides *which* unit `unit*` means.
- **Capabilities** are named privileges such as `manage_users`,
`manage_metadata`, `setup.access`, `studio.access`. The platform's curated
ones are declared `capability` metadata served by the registry
- (`GET /api/v1/meta/capability`) and have no `sys_capability` row; a
- capability a package declares with `defineCapability` is also seeded as a
- `sys_capability` row.
+ (`GET /api/v1/meta/capability`); a capability a package declares with
+ `defineCapability` is served the same way, under its owning package. Neither
+ has a `sys_capability` row.
Grant them via `systemPermissions`; declare them as prerequisites via an
object/field/app `requiredPermissions` (an **AND**-gate checked before any
CRUD grant).
diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json
index f483a0c2e42..e2f87796949 100644
--- a/docs/qa/platform-checklist/areas/access-security.json
+++ b/docs/qa/platform-checklist/areas/access-security.json
@@ -2149,15 +2149,15 @@
]
},
"variants": [
- "DEFINE — capability metadata authored via defineCapability, bootstrapped to a sys_capability row",
+ "DEFINE — capability metadata authored via defineCapability, served by the registry (no sys_capability row is written for it — ADR-0131 D3)",
"GRANT — a permission set's systemPermissions carries the capability name",
"REQUIRE — an action's requiredPermissions lists it; invocation on the platform action path is denied unless a granted set carries it (ADR-0066 D4 actionPermissionError)",
"REGRANT — the grant is withdrawn from the permission set and restored; the gate must follow both edits, not cache the first answer",
- "shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES is refused loudly"
+ "shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES, or with a name another package declares, is refused loudly by the registry's one-holder rule"
],
"steps": [
- "boot showcase isolated; confirm the declared capability bootstrapped: GET the sys_capability row for showcase.export_data (bootstrap-declared-capabilities.ts upsertPackageCapability writes name/label/description/scope)",
- "read its fields — label falls back to humanize(name), description to 'Capability .' when absent (capabilityRowFields)",
+ "boot showcase isolated; confirm the declared capability is served: GET /api/v1/meta/capability/showcase.export_data answers its declared name/label/description/scope, and no sys_capability row names it",
+ "read its fields — they are the declaration's own; nothing is transcribed into a row",
"identify which platform action-invoke surface this build exposes (dispatcher /actions domain · MCP run_action · in-process ql.executeAction/invokeBusinessAction) and record it; do NOT probe /api/v1/actions/... or /api/v1/data/:object/actions/... — @objectstack/rest mounts no action-invoke route by design (#7680), so a 404 there scores nothing",
"on that surface, INVOKE showcase_zoo_perm_held (requires showcase.export_data) as the GRANTED member holding OpsPermissionSet, then as a NON-granted member: granted executes, non-granted is refused with the actionPermissionError text naming the action and the missing capability",
"REGRANT leg: withdraw showcase.export_data from OpsPermissionSet's systemPermissions, re-invoke as the (formerly) granted member → now refused; restore the grant, re-invoke → executes again",
@@ -2167,10 +2167,10 @@
],
"acceptance": [
{
- "clause": "the package-declared capability materializes as a sys_capability row at boot with its authored identity (name is the upsert key; label/description apply their documented fallbacks)",
+ "clause": "the package-declared capability is served by the registry with its authored identity, and no sys_capability row is written for it (ADR-0131 D3)",
"oracle": "api",
- "verify": "the sys_capability read for showcase.export_data matches the declaration; label/description fallbacks hold when omitted",
- "evidence": "the row read + the declaration source"
+ "verify": "GET /api/v1/meta/capability/showcase.export_data matches the declaration; the sys_capability read finds no row for it",
+ "evidence": "the metadata read + the empty row read + the declaration source"
},
{
"clause": "the three-way resolves by NAME on the surface that actually enforces it — the PLATFORM action path (ADR-0066 D4 actionPermissionError, reached via the dispatcher /actions domain, the MCP run_action bridge, or ql.executeAction): invoking an action that REQUIREs the capability is refused for a member whose granted sets do not carry it and executes for one that does, the refusal follows a grant WITHDRAWN from the permission set, and execution returns when it is re-granted — grant / deny / regrant, all three legs",
@@ -2181,7 +2181,7 @@
{
"clause": "a declaration whose name shadows a curated PLATFORM_CAPABILITY_NAME is refused LOUDLY — a package cannot hijack a platform-owned capability name",
"oracle": "build",
- "verify": "the shadow declaration is rejected at authoring/boot with a located error naming the collision (bootstrap-declared-capabilities.ts guard)",
+ "verify": "the shadow declaration is rejected at authoring/boot with a located error naming the collision (the registry's one-holder refusal, SecurityCatalogNameConflictError)",
"evidence": "the refusal text"
},
{
@@ -2205,7 +2205,7 @@
],
"source": [
"packages/spec/liveness/capability.json (ADR-0066 D1, #5961 — the kind's own liveness ledger)",
- "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#upsertPackageCapability (upsertPackageCapability, capabilityRowFields, PLATFORM_CAPABILITY_NAMES guard)",
+ "packages/objectql/src/registry.ts#SecurityCatalogNameConflictError (the one-holder refusal: a second holder of a capability name, a curated platform name included)",
"packages/lint/src/validate-capability-references.ts#known (authoring lint known-name set)",
"examples/app-showcase/src/security/{capabilities.ts,permission-sets.ts} (DEFINE + GRANT fixtures)",
"packages/runtime/src/action-execution.ts#actionPermissionError (actionPermissionError — ADR-0066 D4, the single-sourced REQUIRE-side gate every invoke surface calls)",
diff --git a/examples/app-showcase/src/security/capabilities.ts b/examples/app-showcase/src/security/capabilities.ts
index 16ee6086e67..caa05ac3d67 100644
--- a/examples/app-showcase/src/security/capabilities.ts
+++ b/examples/app-showcase/src/security/capabilities.ts
@@ -29,9 +29,10 @@ import { defineCapability } from '@objectstack/spec';
* ['showcase.export_data']`.
*
* `packageId` is the ADR-0086 D3 AUTHOR-DECLARED provenance — the documented
- * fallback the seeder reads when the registry has not stamped `_packageId`
- * (`cap._packageId ?? cap.packageId` in `bootstrapDeclaredCapabilities`).
- * Without an owning package the declaration is NOT materialized into
+ * fallback the declared-capability seeder read when the registry had not
+ * stamped `_packageId` (`cap._packageId ?? cap.packageId`; that seeder is
+ * retired by ADR-0131 D3, and no row is written for a declared capability).
+ * Without an owning package the declaration was NOT materialized into
* `sys_capability` (a `managed_by:'package'` row with no `package_id` would
* make uninstall undefined), which left `OpsPermissionSet` granting a
* capability that never existed — one boot `warn` and an inert security
diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts
index e299ac5882e..8e0101f6c15 100644
--- a/packages/lint/src/validate-capability-references.ts
+++ b/packages/lint/src/validate-capability-references.ts
@@ -17,7 +17,7 @@
* 1. the built-in platform capabilities (`PLATFORM_CAPABILITY_NAMES`),
* 2. every capability the stack DECLARES via `defineCapability`
* (`stack.capabilities`) — the explicit, package-provenanced declaration
- * (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`,
+ * (ADR-0066 D1), served at runtime by the registry (ADR-0131 D3),
* 3. every capability a permission set in this stack GRANTS via
* `systemPermissions` (granting a capability also declares it), and
* 4. any `sys_capability` row shipped as seed data.
diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts
index 5b44438985c..ebc9e19e633 100644
--- a/packages/objectql/src/engine.ts
+++ b/packages/objectql/src/engine.ts
@@ -3429,8 +3429,8 @@ const METADATA_ARRAY_KEYS = [
// `permissions` (#5870, #4967 Part 2): the ONLY seam that stamps
// ADR-0010 provenance is `registerItem` → `applyProtection`, so a
// collection missing from this list reaches no registry with a
- // `_packageId`. `bootstrapDeclaredCapabilities` resolves the owning
- // package as `cap._packageId ?? cap.packageId`; while `capabilities`
+ // `_packageId`. The declared-capability seeder (retired by ADR-0131 D3)
+ // resolved the owning package as `cap._packageId ?? cap.packageId`; while `capabilities`
// sat outside this list the first half could never be satisfied and
// `readDeclared(ql, 'capability')` returned nothing, which made the
// author-side `packageId` — documented as the FALLBACK — mandatory,
diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts
deleted file mode 100644
index bcdd1ecad8c..00000000000
--- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts
+++ /dev/null
@@ -1,792 +0,0 @@
-// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { readFileSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-import { describe, it, expect, vi } from 'vitest';
-import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
-import { CAPABILITY_NAME_COLLISION } from './capability-name-collision.js';
-import {
- CAPABILITY_DECLARATION_UNOWNED,
- CAPABILITY_PLATFORM_NAME_REFUSED,
- CAPABILITY_ROWS_UNREADABLE,
-} from './seed-refusal-diagnostics.js';
-
-/** [#18091] Seeded from this file, for the class pin at the bottom. */
-const HERE = dirname(fileURLToPath(import.meta.url));
-
-/** Minimal in-memory ql for sys_capability seeding with a registry stub. */
-function makeQl(declared: any[] = []) {
- const rows: any[] = [];
- return {
- rows,
- // readDeclared() reads engine.registry.listItems(type); stub it so
- // capabilities are surfaced without a metadata service.
- //
- // [#8378] Items are surfaced as the real engine surfaces them — the
- // document itself, not a `{ content: - }` box that nothing produces.
- registry: {
- listItems(type: string) {
- return type === 'capability' ? [...declared] : [];
- },
- },
- async find(object: string, q: any) {
- if (object !== 'sys_capability') return [];
- const where = q?.where ?? {};
- // [#8470] A `null` comparand is IS NULL, not `=== null`: `driver-sql`
- // compiles `{ field: null }` to `IS NULL`, `driver-memory`'s query path
- // matches null-or-missing through mingo (its reference matcher, retired by
- // commit `8fec76a2b`, used `value == condition`), and MongoDB matches
- // null-or-missing — none of them
- // is strict equality against an ABSENT key; strict `===` would make an
- // `organization_id: null` lookup unsatisfiable here while it works in
- // production.
- // [#11096] `$in` is supported because the real engine supports it — the
- // seeder now issues ONE batched `{ name: { $in: [...] } }` existence read
- // for the whole declaration. ⛔ A double that did not match it would
- // return `[]`, which this seeder is REQUIRED to trust as the answer "none
- // of these names exist" — so every provenance case below would silently
- // become a first-boot insert while the suite reported green. That is the
- // double's limits masquerading as the seeder's behaviour.
- const matched = rows.filter((r) =>
- Object.entries(where).every(([k, v]) => {
- if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
- if (v && typeof v === 'object' && !Array.isArray(v)) {
- const inList = (v as any).$in;
- if (Array.isArray(inList)) return inList.includes(r[k]);
- throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`);
- }
- return (v === null ? r[k] == null : r[k] === v);
- }),
- );
- // [commit e1d773eb7] `limit` is HONOURED, and a paged read is ordered by `id`
- // ascending (#4363's pagination tie-breaker). Both are properties of the
- // shipped drivers; this double once ignored `limit`
- // entirely, which made the whole class of page-cap defect INEXPRESSIBLE
- // here — including the cap commit e1d773eb7 fixed, whose consequence lands on THIS seeder.
- if (q?.limit === undefined) return matched;
- return [...matched]
- .sort((a, b) => (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0))
- .slice(0, q.limit);
- },
- async insert(object: string, data: any) {
- if (object !== 'sys_capability') return null;
- rows.push({ ...data });
- return { id: data.id };
- },
- async update(object: string, data: any) {
- if (object !== 'sys_capability') return;
- const r = rows.find((x) => x.id === data.id);
- if (r) Object.assign(r, data);
- },
- };
-}
-
-describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () => {
- it('seeds an explicit package declaration with package provenance', async () => {
- const ql = makeQl([
- { name: 'export_data', label: 'Export Data', description: 'Bulk export.', scope: 'org', _packageId: 'com.acme.reports' },
- ]);
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.seeded).toBe(1);
- expect(out.materializedNames).toEqual(['export_data']);
- const row = ql.rows.find((r) => r.name === 'export_data');
- expect(row).toMatchObject({
- name: 'export_data',
- label: 'Export Data',
- description: 'Bulk export.',
- scope: 'org',
- managed_by: 'package',
- package_id: 'com.acme.reports',
- active: true,
- });
- });
-
- it('is idempotent + upgrade-aware for its own rows (re-seed, no dup)', async () => {
- const ql = makeQl([{ name: 'billing.refund', label: 'Refund', scope: 'platform', _packageId: 'com.acme.billing' }]);
- await bootstrapDeclaredCapabilities(ql, null);
- // Ship a new label on the next boot.
- (ql as any).registry.listItems = (t: string) =>
- t === 'capability' ? [{ name: 'billing.refund', label: 'Issue Refund', _packageId: 'com.acme.billing' }] : [];
- const out2 = await bootstrapDeclaredCapabilities(ql, null);
- expect(out2.seeded).toBe(0);
- expect(out2.updated).toBe(1);
- expect(ql.rows.filter((r) => r.name === 'billing.refund')).toHaveLength(1);
- expect(ql.rows.find((r) => r.name === 'billing.refund')?.label).toBe('Issue Refund');
- });
-
- it('defaults label/description/scope when omitted', async () => {
- const ql = makeQl([{ name: 'approve_invoice', _packageId: 'com.acme.inv' }]);
- await bootstrapDeclaredCapabilities(ql, null);
- const row = ql.rows.find((r) => r.name === 'approve_invoice');
- expect(row.label).toBe('Approve Invoice');
- expect(row.description).toBe('Capability approve_invoice.');
- expect(row.scope).toBe('platform');
- });
-
- it('refuses to hijack a curated platform capability', async () => {
- const ql = makeQl([{ name: 'manage_users', label: 'Evil', _packageId: 'com.acme.evil' }]);
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.skippedPlatform).toBe(1);
- expect(out.seeded).toBe(0);
- expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined();
- });
-
- it('skips a declaration with no owning package', async () => {
- const ql = makeQl([{ name: 'orphan_cap', label: 'Orphan' }]);
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.seeded).toBe(0);
- expect(out.skippedUnowned).toBe(1);
- expect(ql.rows.find((r) => r.name === 'orphan_cap')).toBeUndefined();
- });
-
- it('refuses to write into a capability owned by a different package', async () => {
- const ql = makeQl([{ name: 'shared_cap', _packageId: 'com.b' }]);
- ql.rows.push({ id: 'cap_x', name: 'shared_cap', managed_by: 'package', package_id: 'com.a' });
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.skippedForeign).toBe(1);
- expect(ql.rows.find((r) => r.name === 'shared_cap')?.package_id).toBe('com.a');
- });
-
- it('never clobbers an admin-authored row', async () => {
- const ql = makeQl([{ name: 'admin_cap', label: 'Ship', _packageId: 'com.a' }]);
- ql.rows.push({ id: 'cap_a', name: 'admin_cap', label: 'Admin Made', managed_by: 'admin' });
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.skippedAdmin).toBe(1);
- expect(ql.rows.find((r) => r.name === 'admin_cap')?.label).toBe('Admin Made');
- });
-
- it('CLAIMS a derived-from-systemPermissions platform placeholder', async () => {
- // A platform placeholder row, as the retired derive-from-systemPermissions
- // seeder left one on an existing database.
- const ql = makeQl([{ name: 'export_data', label: 'Export Data', description: 'Nice.', scope: 'org', _packageId: 'com.acme.reports' }]);
- ql.rows.push({ id: 'cap_derived', name: 'export_data', label: 'Export Data', description: 'Capability export_data.', managed_by: 'platform' });
- const derived = ql.rows.find((r) => r.name === 'export_data');
- expect(derived.managed_by).toBe('platform');
- // Now the explicit declaration claims it.
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out.claimed).toBe(1);
- const claimed = ql.rows.find((r) => r.name === 'export_data');
- expect(claimed).toMatchObject({ managed_by: 'package', package_id: 'com.acme.reports', label: 'Export Data', scope: 'org' });
- expect(ql.rows.filter((r) => r.name === 'export_data')).toHaveLength(1);
- });
-
- it('returns an empty outcome when nothing is declared', async () => {
- const ql = makeQl([]);
- const out = await bootstrapDeclaredCapabilities(ql, null);
- expect(out).toMatchObject({ seeded: 0, updated: 0, claimed: 0, skippedUnowned: 0, materializedNames: [] });
- });
-});
-
-// ───────────────────────────────────────────────────────────────────────────
-// [#4967 Part 1] `materializedNames` reports the names this pass CONFIRMED have
-// a row — the refusal paths land on different sides of that line. The cases
-// that paired it with the back-compat derivation went with that seeder.
-// ───────────────────────────────────────────────────────────────────────────
-describe('refused declarations vs. the derived placeholder (#4967 Part 1)', () => {
- it('materializedNames reconciles with the outcome counters', async () => {
- const ql = makeQl([
- { name: 'a.new', _packageId: 'com.a' }, // → seeded
- { name: 'a.own', label: 'Fresh', _packageId: 'com.a' }, // → updated
- { name: 'a.derived', _packageId: 'com.a' }, // → claimed
- { name: 'a.admin', _packageId: 'com.a' }, // → skippedAdmin
- { name: 'a.foreign', _packageId: 'com.a' }, // → skippedForeign
- { name: 'manage_users', _packageId: 'com.a' }, // → skippedPlatform
- { name: 'a.orphan' }, // → skippedUnowned, NO row
- ]);
- ql.rows.push({ id: 'c1', name: 'a.own', managed_by: 'package', package_id: 'com.a' });
- ql.rows.push({ id: 'c2', name: 'a.derived', managed_by: 'platform' });
- ql.rows.push({ id: 'c3', name: 'a.admin', managed_by: 'admin' });
- ql.rows.push({ id: 'c4', name: 'a.foreign', managed_by: 'package', package_id: 'com.z' });
-
- const out = await bootstrapDeclaredCapabilities(ql, null);
-
- expect(out).toMatchObject({
- seeded: 1, updated: 1, claimed: 1,
- skippedAdmin: 1, skippedForeign: 1, skippedPlatform: 1, skippedUnowned: 1,
- });
- // Every named declaration lands in exactly one counter…
- const counted = out.seeded + out.updated + out.claimed
- + out.skippedAdmin + out.skippedForeign + out.skippedPlatform + out.skippedUnowned;
- expect(counted).toBe(7);
- // …and `materializedNames` is that set minus the refusal that found no row.
- expect(out.materializedNames).toEqual(['a.new', 'a.own', 'a.derived', 'a.admin', 'a.foreign', 'manage_users']);
- expect(out.materializedNames).toHaveLength(counted - out.skippedUnowned);
- expect(out.materializedNames).not.toContain('a.orphan');
- });
-});
-
-// ───────────────────────────────────────────────────────────────────────────
-// [#4967 Part 3] The refusal diagnostic names the GRANTOR permission set(s)
-// and the actual consequence. Level stays `warn` per #4632 (functional
-// degradation, not a durability failure).
-// ───────────────────────────────────────────────────────────────────────────
-describe('unowned-declaration diagnostic (#4967 Part 3)', () => {
- function spyLogger() {
- const warns: Array<{ msg: string; meta?: Record }> = [];
- const errors: string[] = [];
- return {
- warns,
- errors,
- logger: {
- warn: (msg: string, meta?: Record) => { warns.push({ msg, meta }); },
- error: (msg: string) => { errors.push(msg); },
- },
- };
- }
-
- it('names every permission set that grants the capability, and stays a warn', async () => {
- const { warns, errors, logger } = spyLogger();
- const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
- await bootstrapDeclaredCapabilities(ql, null, {
- logger,
- permissionSets: [
- { name: 'showcase_ops', systemPermissions: ['setup.access', 'showcase.export_data'] },
- { name: 'showcase_admin', systemPermissions: ['showcase.export_data'] },
- { name: 'unrelated', systemPermissions: ['setup.access'] },
- ],
- });
- const w = warns.find((x) => x.msg.includes('showcase.export_data'));
- expect(w).toBeDefined();
- expect(w!.msg).toContain('has no owning package');
- expect(w!.msg).toContain('showcase_ops');
- expect(w!.msg).toContain('showcase_admin');
- expect(w!.msg).not.toContain('unrelated');
- // The consequence, not just the name: it still exists, without provenance.
- expect(w!.msg).toContain('derived placeholder');
- expect(w!.meta?.grantedBy).toEqual(['showcase_ops', 'showcase_admin']);
- // [#4632] functional degradation → warn, never error.
- expect(errors).toEqual([]);
- });
-
- it('says so plainly when NOTHING grants the capability (it exists nowhere)', async () => {
- const { warns, logger } = spyLogger();
- const ql = makeQl([{ name: 'never_granted' }]);
- await bootstrapDeclaredCapabilities(ql, null, { logger, permissionSets: [{ name: 'ops', systemPermissions: ['setup.access'] }] });
- const w = warns.find((x) => x.msg.includes('never_granted'));
- expect(w!.msg).toContain('granted by no bootstrap permission set');
- expect(w!.msg).toContain('materialized nowhere');
- expect(w!.meta?.grantedBy).toEqual([]);
- });
-
- it('reports the existing row when one already resolves the name', async () => {
- const { warns, logger } = spyLogger();
- const ql = makeQl([{ name: 'showcase.export_data' }]);
- ql.rows.push({ id: 'cap_p', name: 'showcase.export_data', managed_by: 'platform' });
- await bootstrapDeclaredCapabilities(ql, null, {
- logger,
- permissionSets: [{ name: 'showcase_ops', systemPermissions: ['showcase.export_data'] }],
- });
- const w = warns.find((x) => x.msg.includes('showcase.export_data'));
- expect(w!.msg).toContain('left as-is');
- expect(w!.meta?.grantedBy).toEqual(['showcase_ops']);
- });
-
- it('falls back to a placeholder label for an unnamed permission set', async () => {
- const { warns, logger } = spyLogger();
- const ql = makeQl([{ name: 'orphan_cap' }]);
- await bootstrapDeclaredCapabilities(ql, null, { logger, permissionSets: [{ systemPermissions: ['orphan_cap'] }] });
- const w = warns.find((x) => x.msg.includes('orphan_cap'));
- expect(w!.meta?.grantedBy).toEqual(['(unnamed permission set)']);
- });
-});
-
-/**
- * [commit e1d773eb7] THE CONSEQUENCE THIS SEEDER PAYS FOR A TRUNCATED EXISTENCE PAGE.
- *
- * This is one of the two callers on `main` that read UNSCOPED (the other is
- * `permission-set-projection`'s overlay pass), and `seed-name-lookup.ts` capped
- * an unscoped page at `names.length` — exact only while one row can exist per
- * name. Since #8461 / ADR-0120 D1 `sys_capability.name` is unique PER
- * ORGANIZATION and ADR-0066 D1 encourages admins to EXTEND the registry inside
- * their own organization, so a healthy install carries a row per organization
- * plus the package's. The rows that fall off a full page are the highest `id`s
- * (#4363's `ORDER BY id ASC`), so whole names vanish — and a vanished name reads
- * as `absent`, which sends THIS loop to its INSERT branch.
- *
- * That is the whole severity of the card: not a read that under-reports, but a
- * read that under-reports and then WRITES. Measured here on the seeder rather
- * than argued, which needs the double above to honour `limit` — it did not, and
- * that is why nothing in this file could see the defect.
- */
-describe('#11518 — a truncated existence page must never route this seeder to INSERT', () => {
- const NAMES = Array.from({ length: 8 }, (_, i) => `acme.cap_${i}`);
- const PACKAGE_ID = 'com.acme.suite';
- const declared = NAMES.map((name, i) => ({
- name, label: `Cap ${i}`, description: `Capability ${i}.`, scope: 'org', _packageId: PACKAGE_ID,
- }));
-
- /**
- * A REBUILD on a healthy install: every declared name already has this
- * package's row, and two organizations have extended the first one. Their ids
- * sort first, so they take the head of the page and the package's own rows are
- * what falls off the end.
- */
- function fixture() {
- const ql = makeQl(declared);
- ql.rows.push(
- { id: 'aaa_org_jia', organization_id: 'org_jia', name: NAMES[0], label: 'Jia copy', description: 'jia', scope: 'org', managed_by: 'admin', active: true },
- { id: 'aab_org_yi', organization_id: 'org_yi', name: NAMES[0], label: 'Yi copy', description: 'yi', scope: 'org', managed_by: 'admin', active: true },
- );
- NAMES.forEach((name, i) => ql.rows.push({
- id: `cap_${i}`, name, label: `Cap ${i}`, description: `Capability ${i}.`, scope: 'org',
- managed_by: 'package', package_id: PACKAGE_ID, organization_id: null, active: true,
- }));
- return ql;
- }
-
- it('POSITIVE CONTROL: at the cap that was live before this fix, two declared names have no row on the page', async () => {
- // Pins the DOUBLE, so it holds before and after the repair — without it,
- // "the seeder wrote nothing" could be green because the trap was never set.
- const ql = fixture();
- const page: any[] = await ql.find('sys_capability', {
- where: { name: { $in: NAMES } },
- limit: NAMES.length, // ← the UNSCOPED cap this card repairs
- });
- const onThePage = new Set(page.map((r) => r.name));
- expect(page).toHaveLength(NAMES.length);
- expect([...onThePage].sort()).toEqual(NAMES.slice(0, 6).sort());
- for (const lost of [NAMES[6], NAMES[7]]) {
- expect(onThePage.has(lost), `${lost} fell off a full page`).toBe(false);
- expect(ql.rows.some((r: any) => r.name === lost && r.managed_by === 'package')).toBe(true);
- }
- });
-
- it('re-seeds a healthy multi-organization install WITHOUT writing anything', async () => {
- const ql = fixture();
- const rowsBefore = ql.rows.length;
- const out = await bootstrapDeclaredCapabilities(ql, null);
-
- // ⛔ The load-bearing assertion. Before the repair this was `seeded: 2` and
- // two DUPLICATE rows for names whose package rows were sitting in the table
- // — every boot, on an install that is simply using ADR-0066 D1.
- expect(out.seeded).toBe(0);
- expect(ql.rows).toHaveLength(rowsBefore);
- // Nothing else was written either: seven of the names resolve to this
- // package's own unchanged row…
- expect(out.updated).toBe(0);
- expect(out.unchanged).toBe(NAMES.length - 1);
- // …and the eighth resolves to an organization's authored copy, which this
- // seeder never clobbers. Unscoped, the first row by id is the row — the
- // pre-#10946 per-item answer, unchanged by this repair.
- expect(out.skippedAdmin).toBe(1);
- expect(out.unreadable).toBe(0);
- });
-});
-
-/**
- * [#18023] The foreign-owner refusal must REACH THE AUTHOR.
- *
- * ## What these pins are about, and what they deliberately do not touch
- *
- * ⛔ Not the skip. Refusing to write into a `sys_capability` row another
- * package owns is correct under ADR-0086 D4 and is asserted UNCHANGED in every
- * case below (`skippedForeign` still counts, the foreign row is never mutated,
- * the name is still reported as materialized). The defect was that the refusal
- * was invisible: `logger?.warn?.(…)` at that branch is optionally chained
- * TWICE, so a caller passing no logger produced no output at all and an entire
- * declared capability vanished with one internal counter moved.
- *
- * ## The measurement these replace
- *
- * On the pre-fix tree, driven through `bootstrapDeclaredCapabilities` with a
- * foreign-owned row and NO logger, the first case below measured ZERO console
- * lines across every channel and `undefined` for the outcome's diagnostic
- * records, while `skippedForeign` was 1. That is the card's reading, reproduced.
- *
- * ## The discriminating half
- *
- * A pin that only asserts "something was printed on a collision" passes just as
- * well against a seeder that prints on EVERY seeded capability, which is a
- * different defect (#12015's: a diagnostic that fires always is as unreadable
- * as one that never fires). So the no-collision control asserts SILENCE on all
- * five console channels over a pass that really does seed, really does re-seed
- * its own drifted row, and really does claim a derived placeholder.
- *
- * ⛔ These reuse this file's `makeQl` rather than standing up a second engine
- * double: a new double would be a new row in `engine-double-contract`'s ledger
- * for a card that names no engine seam.
- */
-
-/**
- * Capture EVERY console channel — "author-visible output" is not
- * channel-specific, and a pin watching only `warn` could be satisfied by a
- * change that merely MOVED the silence.
- */
-function captureAllConsole() {
- const seen: string[] = [];
- const spies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((m) =>
- vi.spyOn(console, m).mockImplementation((...args: unknown[]) => {
- seen.push(`${m}: ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`);
- }),
- );
- return { seen, restore: () => spies.forEach((s) => s.mockRestore()) };
-}
-
-describe('[#18023] a capability-name collision reaches the author', () => {
- it('prints, WITH NO LOGGER INJECTED, and returns the diagnostic record', async () => {
- const sets = [{ name: 'ops', systemPermissions: ['shared_cap'] }];
- const ql = makeQl([{ name: 'shared_cap', label: 'Mine', description: 'B wrote this.', scope: 'org', _packageId: 'com.example.b' }]);
- ql.rows.push({
- id: 'cap_owned_by_a',
- name: 'shared_cap',
- label: 'Owner Label',
- description: 'Owner wrote this.',
- scope: 'platform',
- managed_by: 'package',
- package_id: 'com.example.a',
- });
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- // No logger — the exact call shape under which the old branch was mute.
- out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: sets });
- } finally {
- cap.restore();
- }
-
- // ── The reading this card exists to move: 0 → 1 ──────────────────────────
- expect(out.skippedForeign).toBe(1);
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
- expect(cap.seen[0]).toContain(CAPABILITY_NAME_COLLISION);
- expect(cap.seen[0]).toContain('shared_cap');
-
- // ── The record travels back, for a caller that reads no log at all ──────
- expect(out.collisions).toHaveLength(1);
- expect(out.collisions![0]).toMatchObject({
- event: CAPABILITY_NAME_COLLISION,
- severity: 'warning',
- name: 'shared_cap',
- declaredBy: 'com.example.b',
- ownedBy: 'com.example.a',
- grantedBy: ['ops'],
- });
-
- // ── ⛔ The skip itself is UNCHANGED (ADR-0086 D4) ────────────────────────
- expect(ql.rows.find((r) => r.name === 'shared_cap')).toMatchObject({
- label: 'Owner Label', description: 'Owner wrote this.', scope: 'platform', package_id: 'com.example.a',
- });
- expect(ql.rows.filter((r) => r.name === 'shared_cap')).toHaveLength(1);
- // …and the name is still reported materialized, so the back-compat
- // derivation does not clobber the owner's authored row (#4967 Part 1).
- expect(out.materializedNames).toEqual(['shared_cap']);
- });
-
- it('a package-managed row with NO package_id is FOREIGN — the shared predicate, not a local `===`', async () => {
- const ql = makeQl([{ name: 'unowned_row_cap', _packageId: 'com.example.b' }]);
- ql.rows.push({ id: 'cap_no_owner', name: 'unowned_row_cap', managed_by: 'package' });
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null);
- } finally {
- cap.restore();
- }
-
- // ADR-0086 D3: a row that cannot prove its owner is not ours to write.
- expect(out.skippedForeign).toBe(1);
- expect(out.updated).toBe(0);
- expect(out.collisions![0]!.ownedBy).toBeNull();
- // The RECORD words it; the pass line summarises and carries the record in
- // its structured meta. ⛔ Neither prints `undefined` at an author.
- expect(out.collisions![0]!.message).toContain('(a package-managed row with no package_id)');
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]).toContain('"ownedBy":null');
- expect(cap.seen[0]).not.toContain('undefined');
- expect(ql.rows.find((r) => r.name === 'unowned_row_cap')!.package_id).toBeUndefined();
- });
-
- it('says it ONCE per pass, with every dropped declaration in the record list', async () => {
- const ql = makeQl([
- { name: 'cap_one', _packageId: 'com.example.b' },
- { name: 'cap_two', _packageId: 'com.example.b' },
- { name: 'cap_three', _packageId: 'com.example.b' },
- ]);
- for (const [id, name] of [['c1', 'cap_one'], ['c2', 'cap_two'], ['c3', 'cap_three']] as const) {
- ql.rows.push({ id, name, managed_by: 'package', package_id: 'com.example.a' });
- }
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null);
- } finally {
- cap.restore();
- }
-
- expect(out.skippedForeign).toBe(3);
- // ⛔ Three lines would bury the remedy under the finding.
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]).toContain('3 declared capabilities were NOT applied');
- expect(out.collisions!.map((d) => d.name)).toEqual(['cap_one', 'cap_two', 'cap_three']);
- });
-
- it('reports through an INJECTED logger and leaves the console alone', async () => {
- const warn = vi.fn();
- const ql = makeQl([{ name: 'shared_cap', _packageId: 'com.example.b' }]);
- ql.rows.push({ id: 'cap_x', name: 'shared_cap', managed_by: 'package', package_id: 'com.example.a' });
-
- const cap = captureAllConsole();
- try {
- await bootstrapDeclaredCapabilities(ql, null, { logger: { warn } });
- } finally {
- cap.restore();
- }
-
- expect(warn).toHaveBeenCalledTimes(1);
- expect(warn.mock.calls[0]![0]).toContain(CAPABILITY_NAME_COLLISION);
- expect(cap.seen).toEqual([]);
- });
-
- it('CONTROL: a pass that really works and collides on nothing is SILENT on all five channels', async () => {
- // ⛔ The discriminating half. Without this, a seeder that warned on every
- // declaration would satisfy every assertion above.
- const ql = makeQl([
- { name: 'b.fresh', _packageId: 'com.example.b' }, // → seeded
- { name: 'b.drifted', label: 'New Label', _packageId: 'com.example.b' }, // → updated
- { name: 'b.derived', _packageId: 'com.example.b' }, // → claimed
- { name: 'b.steady', _packageId: 'com.example.b' }, // → unchanged
- ]);
- ql.rows.push({ id: 'd1', name: 'b.drifted', label: 'Old Label', description: 'Capability b.drifted.', scope: 'platform', managed_by: 'package', package_id: 'com.example.b' });
- ql.rows.push({ id: 'd2', name: 'b.derived', managed_by: 'platform' });
- // ⚠️ Exactly what a re-seed would write (`humanize('b.steady')` = 'B Steady'),
- // so this row takes the `unchanged` arm — an EQUALITY test, not a presence
- // test. A label that merely looked plausible would take `updated` instead
- // and silently delete this control's fourth kind of work.
- ql.rows.push({ id: 'd3', name: 'b.steady', label: 'B Steady', description: 'Capability b.steady.', scope: 'platform', managed_by: 'package', package_id: 'com.example.b' });
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null);
- } finally {
- cap.restore();
- }
-
- // The pass really did all four kinds of work…
- expect(out).toMatchObject({ seeded: 1, updated: 1, claimed: 1, unchanged: 1, skippedForeign: 0 });
- // …and said nothing at all.
- expect(cap.seen).toEqual([]);
- // The key stays ABSENT rather than empty: `undefined` and `[]` would
- // otherwise be the same fact to a caller inspecting the outcome.
- expect(out.collisions).toBeUndefined();
- });
-});
-
-// ───────────────────────────────────────────────────────────────────────────
-// [#18091] The three refusals #18023 left behind in this seeder. Same defect,
-// same reading: driven with NO logger the author-visible line count was 0 at
-// every one of them while the already-repaired collision path in the harness
-// above read 1. Each case below asserts THAT SITE'S OWN sentence, so a future
-// regression to one generic "declaration skipped" line is caught by name.
-// ───────────────────────────────────────────────────────────────────────────
-
-/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */
-function unreadableQl(declared: any[]) {
- const ql = makeQl(declared);
- (ql as any).find = async () => { throw new Error('sys_capability is unreachable'); };
- return ql;
-}
-
-describe('[#18091] the three remaining capability refusals reach the author', () => {
- it('CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost', async () => {
- const ql = makeQl([{ name: 'manage_users', label: 'Evil', description: 'Hijack.', _packageId: 'com.acme.evil' }]);
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null, {
- permissionSets: [{ name: 'acme_ops', systemPermissions: ['manage_users'] }],
- });
- } finally {
- cap.restore();
- }
-
- // ── The reading this card moves: 0 → 1 ──────────────────────────────────
- expect(out.skippedPlatform).toBe(1);
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
- expect(cap.seen[0]).toContain(CAPABILITY_PLATFORM_NAME_REFUSED);
- expect(cap.seen[0]).toContain('manage_users');
- expect(cap.seen[0]).toContain('com.acme.evil');
- // ── THIS site's consequence, not the foreign-owner one. The curated row
- // answers for the name, so nothing is denied — an author sent hunting
- // for a broken grant is the failure this wording prevents.
- expect(cap.seen[0]).toContain('CURATED PLATFORM capability');
- expect(cap.seen[0]).toContain('The name still resolves');
- expect(cap.seen[0]).toContain('acme_ops');
- // ⛔ And the remedy is rename-only: a curated name is not co-ownable, so
- // the ADR-0130 D1 escape the collision diagnostic offers must NOT appear.
- expect(cap.seen[0]).toContain('not co-ownable');
-
- // ── ⛔ The refusal itself is UNCHANGED ───────────────────────────────────
- expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined();
- expect(out.seeded).toBe(0);
- // …and the name is still reported materialized: the curated pass owns it.
- expect(out.materializedNames).toEqual(['manage_users']);
- });
-
- it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence', async () => {
- const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null, {
- permissionSets: [{ name: 'showcase_ops', systemPermissions: ['showcase.export_data'] }],
- });
- } finally {
- cap.restore();
- }
-
- expect(out.skippedUnowned).toBe(1);
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
- expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
- expect(cap.seen[0]).toContain('has no owning package');
- // [#4967 Part 3] The grantor and the ACTUAL consequence, both preserved —
- // this arm is "a row will be derived", which is not the other two arms.
- expect(cap.seen[0]).toContain('showcase_ops');
- expect(cap.seen[0]).toContain('derived placeholder');
- expect(cap.seen[0]).not.toContain('materialized nowhere');
- // ⛔ Unchanged: no row is written for an unowned declaration.
- expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined();
- });
-
- it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => {
- const ql = unreadableQl([
- { name: 'a.one', _packageId: 'com.a' },
- { name: 'a.two', _packageId: 'com.a' },
- ]);
-
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(ql, null);
- } finally {
- cap.restore();
- }
-
- expect(out.unreadable).toBe(2);
-
- // [#18570] TWO lines now, and the second one is the point of that card.
- // This assertion used to read `toHaveLength(1)`, and that ONE was the
- // measurement #18570 was filed on: the same unreadable pass, with no logger
- // injected, printed this seeder's summary while the batched existence read
- // that failed FIRST — `seed-name-lookup.ts`, a doubly-optional
- // `logger?.warn?.(…)` — said nothing at all. Both sites now deliver through
- // `reportThroughSink`, so both reach an author who injected nothing.
- //
- // ⛔ Selected by CONTENT, never by index: the read fails before the pass
- // summarises, so the #18570 line arrives first.
- expect(cap.seen).toHaveLength(2);
- const summary = cap.seen.find((l) => l.includes(CAPABILITY_ROWS_UNREADABLE));
- const batchedRead = cap.seen.find((l) => l.includes('batched seed existence read failed'));
- expect(batchedRead).toBeDefined();
- expect(batchedRead!.startsWith('warn: ')).toBe(true);
-
- expect(summary).toBeDefined();
- expect(summary!.startsWith('warn: ')).toBe(true);
- // The count, and the consequence "unreadable" alone does not state.
- expect(summary).toContain('2 of 2');
- expect(summary).toContain('keeps the stale value');
- expect(summary).toContain('nothing is lost');
- // ⛔ Unchanged: a name whose row could not be read is left ENTIRELY alone,
- // and stays out of `materializedNames` so the derivation gets its attempt.
- expect(ql.rows).toHaveLength(0);
- expect(out.materializedNames).toEqual([]);
- });
-
- it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => {
- // The discriminating case for R3: two different refusals in ONE pass.
- const ql = makeQl([
- { name: 'manage_users', _packageId: 'com.acme.evil' },
- { name: 'orphan_cap' },
- ]);
-
- const cap = captureAllConsole();
- try {
- await bootstrapDeclaredCapabilities(ql, null);
- } finally {
- cap.restore();
- }
-
- expect(cap.seen).toHaveLength(2);
- const curated = cap.seen.find((l) => l.includes(CAPABILITY_PLATFORM_NAME_REFUSED));
- const unowned = cap.seen.find((l) => l.includes(CAPABILITY_DECLARATION_UNOWNED));
- expect(curated).toBeDefined();
- expect(unowned).toBeDefined();
- // ⛔ Two tokens, two consequences. A generic sentence would make these two
- // assertions pass against ONE wording, so each names a phrase only its own
- // site can produce.
- expect(curated).toContain('The name still resolves');
- expect(curated).not.toContain('derived placeholder');
- expect(unowned).toContain('materialized nowhere');
- expect(unowned).not.toContain('CURATED PLATFORM capability');
- });
-
- it('an INJECTED logger takes all three, and the console stays clean', async () => {
- const warn = vi.fn();
- const cap = captureAllConsole();
- try {
- await bootstrapDeclaredCapabilities(makeQl([{ name: 'manage_users', _packageId: 'com.a' }]), null, { logger: { warn } });
- await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: { warn } });
- await bootstrapDeclaredCapabilities(unreadableQl([{ name: 'a.one', _packageId: 'com.a' }]), null, { logger: { warn } });
- } finally {
- cap.restore();
- }
-
- // ⚠️ FOUR, not three: the third pass ALSO trips the batched existence
- // oracle's own read-failure line, which is a different diagnostic in a
- // different module (`seed-name-lookup.ts`) and outside this card. Pinning
- // three here would have made that line's removal invisible; filtering by
- // this card's own tokens keeps the assertion about this card.
- expect(warn).toHaveBeenCalledTimes(4);
- const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
- expect(events).toEqual([
- CAPABILITY_PLATFORM_NAME_REFUSED,
- CAPABILITY_DECLARATION_UNOWNED,
- CAPABILITY_ROWS_UNREADABLE,
- ]);
- expect(cap.seen).toEqual([]);
- });
-
- it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => {
- // ⚠️ `ProjectionLogger.warn` is non-optional, but the type cannot reach a
- // plain-JS embedder or a cast. The old `logger?.warn?.()` bought safety here
- // with silence; `if (logger) logger.warn()` would buy noise with a throw
- // inside a seeding pass. The `typeof` guard buys neither.
- const liar = { info: () => {} } as any;
- const cap = captureAllConsole();
- let out: Awaited>;
- try {
- out = await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: liar });
- } finally {
- cap.restore();
- }
- expect(out.skippedUnowned).toBe(1);
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
- });
-
- it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => {
- // The reason this card exists: the shape was repaired one instance at a
- // time twice before. A grep that reds when a sixth call site appears is the
- // difference between a third instance repair and a class that is closed.
- const source = readFileSync(resolve(HERE, 'bootstrap-declared-capabilities.ts'), 'utf8');
- // Positive control — the pin is reading the file it thinks it is.
- expect(source).toContain('export async function bootstrapDeclaredCapabilities');
- const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.('));
- for (const line of hits) {
- expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true);
- }
- // ⚠️ The INFO channel keeps its outer `?.` deliberately and is NOT part of
- // this class: a pass that refused nothing must stay silent on every console
- // channel with no sink, which is the control above.
- expect(source).toContain("options.logger?.info?.(");
- });
-});
diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts
deleted file mode 100644
index bab9324934e..00000000000
--- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts
+++ /dev/null
@@ -1,547 +0,0 @@
-// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * bootstrapDeclaredCapabilities — seed stack-declared `capabilities` into
- * `sys_capability` with PACKAGE provenance (ADR-0066 D1; the exact sibling of
- * `bootstrapDeclaredPermissions`).
- *
- * A package's authorization capabilities used to reach the registry only
- * IMPLICITLY: `bootstrapSystemCapabilities` derived an untitled placeholder for
- * any capability string a permission set happened to reference in
- * `systemPermissions[]`, seeded `managed_by:'platform'` with a humanized label.
- * That back-door gave the registry no way to attribute a capability to the
- * package that owns it, nor to carry a real label/description.
- *
- * `defineCapability` + `stack.capabilities` is the EXPLICIT declaration entry
- * point (ADR-0066 D1: "packages DEFINE capabilities"). This seeder materializes
- * those declarations:
- *
- * - each declared capability is upserted by `name` with `managed_by:'package'`
- * and `package_id` = the registering package (`_packageId` stamped by the
- * SchemaRegistry / ADR-0010, with the spec-level `packageId` (ADR-0086 D3)
- * as the author-declared fallback);
- * - a name that collides with a CURATED platform capability
- * (`PLATFORM_CAPABILITY_NAMES`) is refused loudly — those are platform-owned
- * and a package must not hijack them;
- * - a pre-existing `managed_by:'platform'` row for a NON-curated name is a
- * derived-from-systemPermissions placeholder — the explicit declaration
- * CLAIMS it (upgrades it to package provenance with the authored metadata),
- * which is the whole point: retire the implicit back-door;
- * - IDEMPOTENT + UPGRADE-AWARE: a row this seeder owns (`managed_by:'package'`,
- * same `package_id`) is re-seeded on every boot so the record always
- * reflects the shipped declaration. A row owned by a DIFFERENT package is
- * skipped, and [#18023] the skip now REACHES THE AUTHOR — one
- * `capability_name_collision` line per pass through
- * {@link reportCapabilityNameCollisions}, which prints even with no logger
- * injected, plus the records themselves on
- * {@link CapabilitySeedOutcome.collisions};
- * - admin-authored rows (`managed_by:'admin'`) are NEVER clobbered.
- *
- * Runs on `kernel:ready` in `@objectstack/plugin-security` alongside the other
- * declared-metadata seeders. {@link CapabilitySeedOutcome.materializedNames} is
- * returned so the caller can tell `bootstrapSystemCapabilities` to SKIP
- * re-deriving (and thus clobbering) a capability that already has a row.
- *
- * ## Cost shape (#11096)
- *
- * This pass is a read-then-write reconciler over a set known IN FULL before its
- * loop starts, so it pays ONE batched existence read for the whole declaration
- * (`buildExistingByName`, added by #10946) and writes only where the stored row
- * actually differs. It used to issue a `SELECT … WHERE name = ? LIMIT 1` per
- * declared capability plus an `UPDATE` that fired whether or not anything had
- * changed — the same shape whose cost was measured at 4.0000 round trips per
- * item (R² = 1.000000) on the two sibling loops in #10946. That is a COUNT, and
- * the count is what `bootstrap-seed-round-trips.test.ts` pins; the hosted
- * latency rig lives in `objectstack-ai/cloud` and this axis' own slope has
- * never been measured.
- *
- * ⛔ The write-skip is an EQUALITY test and must stay one. A reconciler that
- * skipped writes outright would show a perfect round-trip curve while
- * reconciling nothing at all, which is why every counting test in that file is
- * paired one-for-one with a drift test over the same fixture.
- *
- * [#4967 Part 1] That list reports names this pass CONFIRMED have a row — NOT
- * every name it read. The two are different facts, and conflating them turned a
- * REFUSED declaration into a hole: the refusal writes no row, the reported name
- * suppressed the back-compat derivation too, and the capability then existed in
- * no row at all, leaving every `systemPermissions` grant naming it inert. Adding
- * an explicit declaration was therefore strictly WORSE than omitting one. The
- * suppression rule is now uniform and stated per refusal path in
- * {@link upsertPackageCapability}: a name suppresses the derivation IFF
- * `sys_capability` holds a row for it after this pass — protecting an authored
- * row from a humanized placeholder is the whole purpose of the list, and a
- * refusal with no row is not an authored row there is anything to protect.
- */
-
-import {
- genId,
- tryInsert,
- tryUpdate,
- type ProjectionLogger,
-} from './permission-set-projection.js';
-import {
- createSeedWriteRefusals,
- reportSeedWriteRefusals,
- type SeedWriteRefusals,
-} from './write-refusals.js';
-import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js';
-import { readDeclared } from './bootstrap-declared-permissions.js';
-// [#18023] THE owner-comparison predicate, REUSED rather than re-derived. It
-// carries the axis it was first written for in its NAME, and is
-// axis-INDEPENDENT in its question — "is the standing row's owner a DIFFERENT
-// package from the one declaring this?" — which is exactly what this seeder's
-// foreign branch asks. One name for one derivation beats two names for one
-// derivation; re-spelling the comparison here is the drift
-// `permission-set-name-collision.ts` exists to prevent.
-import { permissionSetNameIsForeign } from './permission-set-name-collision.js';
-import {
- capabilityNameCollisionDiagnostic,
- reportCapabilityNameCollisions,
- type CapabilityNameCollisionDiagnostic,
-} from './capability-name-collision.js';
-// [#18091] The three remaining refusals of this seeder, each with its OWN
-// wording, token and record. They share exactly one thing — `reportThroughSink`,
-// the delivery rule — because the alternative measured here was five more
-// hand-written copies of the two lines #17516 and #18023 each wrote out.
-import {
- capabilityDeclarationUnownedDiagnostic,
- capabilityPlatformNameRefusedDiagnostic,
- reportCapabilityDeclarationUnowned,
- reportCapabilityPlatformNameRefused,
- reportCapabilityRowsUnreadable,
-} from './seed-refusal-diagnostics.js';
-import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
-
-/** The only shape this seeder reads off a permission set: who grants what. */
-type GrantingPermissionSet = { name?: string; systemPermissions?: readonly string[] };
-
-interface SeedOptions {
- logger?: ProjectionLogger;
- /**
- * [#4967 Part 3] The bootstrap permission sets that GRANT capabilities via
- * `systemPermissions[]` — the SAME array `bootstrapSystemCapabilities` derives
- * its placeholders from, so "granted by X" and "will be derived" are read off
- * one source. Used only to name the grantor(s) in the refusal diagnostics: a
- * seeder-side warn that names the capability but not the permission set(s)
- * that grant it does not tell the reader the consequence. Threaded as an
- * argument on every call — never module-level state.
- */
- permissionSets?: readonly GrantingPermissionSet[];
-}
-
-/** Aggregated outcome of a declared-capability seeding pass. */
-export interface CapabilitySeedOutcome {
- seeded: number;
- updated: number;
- claimed: number;
- skippedAdmin: number;
- skippedForeign: number;
- skippedPlatform: number;
- /**
- * [#4967 Part 1] Declarations refused for want of an owning package
- * (`_packageId`/`packageId` both absent) — previously the one refusal path
- * with no counter at all, which is why `declaredNames` and the counters could
- * not be reconciled.
- */
- skippedUnowned: number;
- /**
- * [#11096] Rows this seeder OWNS whose stored `label`/`description`/`scope`
- * already equalled what a re-seed would write, so no `UPDATE` was issued.
- *
- * ⚠️ Read together with {@link updated}, never instead of it — "the row now
- * reflects the declaration" is `seeded + updated + claimed + unchanged`, while
- * `updated` alone means "a write was needed AND landed". The unconditional
- * re-write this counter replaces made the two questions accidentally
- * identical; they are not.
- */
- unchanged: number;
- /**
- * [#11096] Declarations this pass DECLINED to touch because `sys_capability`
- * could not be READ for them. Distinct from every other counter here:
- * nothing was compared, nothing was written, and whether a row exists is
- * simply unknown — which is why such a name is also kept OUT of
- * {@link materializedNames} (suppressing the derivation for a name that may
- * have no row is the #4967 hole).
- *
- * ⛔ It is NOT a variety of `skippedUnowned`. Before the batched read, a
- * declaration whose read failed fell through to an INSERT that the `name`
- * unique index refused — a database constraint standing in for a decision
- * this seeder should be making. It now declines on its own; on a deployment
- * where that index is absent the old shape DUPLICATED rows instead.
- */
- unreadable: number;
- /**
- * [#4967 Part 1] Names this pass CONFIRMED are materialized in
- * `sys_capability`, i.e. the names `bootstrapSystemCapabilities` must NOT
- * re-derive a placeholder for. A name lands here when this pass wrote its row
- * (seeded / updated / claimed) or found a row it must not clobber
- * (admin-authored, another package's, or a curated platform name the curated
- * pass owns) — and NOT when a refusal left the name with no row anywhere.
- *
- * Accounting: every named declaration falls into exactly one counter, so
- * `seeded + updated + claimed + unchanged + skippedAdmin + skippedForeign +
- * skippedPlatform + skippedUnowned + unreadable` is the number of named
- * declarations read,
- * and `materializedNames` is that same set minus the refusals that found no
- * row. (Pre-existing caveat, unchanged: a write the engine rejects increments
- * no counter — and a rejected INSERT deliberately keeps its name out of this
- * list, so the derivation gets its own attempt.)
- */
- materializedNames: string[];
- /**
- * [#18023] The declarations this pass DROPPED because another package owns
- * the capability name — one record per dropped declaration, the structured
- * half of what {@link reportCapabilityNameCollisions} prints.
- *
- * ⚠️ Present only when the pass found at least one, exactly like its
- * permission-set sibling: the absent key keeps a caller from reading an empty
- * array as "this pass looked" on an outcome that predates the key.
- *
- * Here so a caller that reads NO LOG AT ALL — a boot report, a test — can
- * still ask what happened. ⛔ It is not a substitute for the print: a record
- * nobody reads is the defect this card removed, so the pass reports through
- * the sink whether or not anyone inspects this.
- */
- collisions?: CapabilityNameCollisionDiagnostic[];
-}
-
-function humanize(name: string): string {
- return name.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
-}
-
-/** Normalize a declaration body into the `sys_capability` platform-owned fields. */
-function capabilityRowFields(cap: any): { label: string; description: string; scope: 'platform' | 'org' } {
- return {
- label: typeof cap.label === 'string' && cap.label ? cap.label : humanize(cap.name),
- description: typeof cap.description === 'string' && cap.description ? cap.description : `Capability ${cap.name}.`,
- scope: cap.scope === 'org' ? 'org' : 'platform',
- };
-}
-
-/**
- * [#11096] True when the stored row differs from what a re-seed would write.
- *
- * ⚠️ EQUALITY decides, not presence: a capability whose stored label,
- * description or scope drifted from the declaration STILL gets its `UPDATE`.
- * The comparison covers exactly the columns {@link capabilityRowFields}
- * writes and no others — `active`, `managed_by` and `package_id` are never
- * touched by an own-row re-seed, so they can neither cause nor suppress one.
- * Widening this set without widening the write (or the reverse) is how a
- * "batched" reconciler turns into one that reconciles nothing.
- */
-function capabilityRecordDiffers(row: any, fields: ReturnType): boolean {
- return (row?.label ?? null) !== (fields.label ?? null)
- || (row?.description ?? null) !== (fields.description ?? null)
- || (row?.scope ?? null) !== (fields.scope ?? null);
-}
-
-/**
- * [#4967 Part 3] Index `capability name → granting permission set name(s)` over
- * the bootstrap permission sets, so a refusal can name the grantor(s) — and,
- * because this is the same array the back-compat derivation reads, can state
- * the ACTUAL consequence rather than a generic "not materialized".
- */
-function indexGrantors(sets: readonly GrantingPermissionSet[] = []): Map {
- const byCapability = new Map();
- for (const ps of sets) {
- const setName = typeof ps?.name === 'string' && ps.name ? ps.name : '(unnamed permission set)';
- for (const cap of ps?.systemPermissions ?? []) {
- if (typeof cap !== 'string' || !cap) continue;
- const grantors = byCapability.get(cap);
- if (!grantors) byCapability.set(cap, [setName]);
- else if (!grantors.includes(setName)) grantors.push(setName);
- }
- }
- return byCapability;
-}
-
-// [#4967 Part 3 / #18091] The unowned-declaration wording — the grantor names
-// and the three-way consequence — moved WHOLE into
-// `capabilityDeclarationUnownedDiagnostic`, so the record and the sentence are
-// one derivation instead of a message here and a record somewhere else.
-
-/**
- * Upsert ONE declared capability into `sys_capability` under the owning
- * `packageId`, applying the ADR-0066 D1 provenance rules (own-row re-seed,
- * derived-platform-row claim, curated/foreign/admin refuse-or-skip).
- *
- * Returns whether the name is MATERIALIZED — i.e. whether `sys_capability`
- * holds a row for it after this call, and so whether the caller must suppress
- * the back-compat derived placeholder for it (#4967 Part 1). The three refusal
- * paths differ, and the difference is the whole bug:
- *
- * - CURATED platform name → `true`. The curated pass of
- * `bootstrapSystemCapabilities` seeds every curated name unconditionally, so
- * the row exists regardless of this refusal; the derived path cannot reach a
- * curated name in the first place (it is already in the curated map), which
- * makes the answer a no-op here — but a truthful one.
- * - FOREIGN owner (and admin-authored rows) → `true`. A row exists and its
- * label/description are AUTHORED; re-deriving would overwrite them with a
- * humanized placeholder, which is exactly what the suppression list is for.
- * - NO OWNING PACKAGE → `true` only if a row already exists. With no row this
- * is the #4967 hole: suppressing the derivation left the capability existing
- * nowhere, so it falls through and the placeholder is derived as it would
- * have been had the declaration never been written.
- */
-async function upsertPackageCapability(
- ql: any,
- cap: any,
- packageId: string | null | undefined,
- out: CapabilitySeedOutcome,
- grantors: readonly string[],
- /**
- * [#11096] The ONE batched existence read for the whole declaration, built
- * before the loop. Replaces this function's own per-item `SELECT`; see
- * `seed-name-lookup.ts` for why its third outcome (`unknown`) exists and must
- * never be collapsed into `absent`.
- */
- existingByName: ExistingByNameIndex,
- logger?: ProjectionLogger,
- /**
- * Collects writes the database REFUSED, so the pass reports them once
- * instead of returning a zero that reads as "nothing to do".
- */
- refusals?: SeedWriteRefusals,
-): Promise {
- if (!cap?.name) return false;
-
- // Curated platform capabilities are platform-owned — a package must never
- // claim one (that would let it silently redefine `manage_users`, `setup.access`, …).
- if (PLATFORM_CAPABILITY_NAMES.has(cap.name)) {
- out.skippedPlatform += 1;
- // [#18091] ⛔ The refusal is unchanged; what changed is that it is no longer
- // invisible. The old line was optionally chained TWICE, so a caller that
- // injected no logger got no output at all — measured on the pre-fix tree at
- // this exact site: `skippedPlatform = 1`, author-visible console lines = 0
- // across all five channels. Its own wording, because this consequence is
- // not the foreign-owner one: the curated row answers for the name, so
- // nothing is denied and the remedy is rename-only.
- reportCapabilityPlatformNameRefused(logger, capabilityPlatformNameRefusedDiagnostic({
- name: String(cap.name),
- declaredBy: packageId ?? null,
- grantedBy: grantors,
- }));
- return true;
- }
-
- const fields = capabilityRowFields(cap);
- // [#11096] ⛔ THREE outcomes, not two. `unknown` is the absence of any fact —
- // the read did not answer — and is not the fact "no such capability". The
- // provenance branches below every one of them turn on WHETHER A ROW WAS
- // FOUND (the refusal messages state a different consequence for each), so a
- // batched read that reported "not found" for a read it never got an answer
- // to would make every one of those diagnostics lie.
- const lookup = await existingByName.get(String(cap.name));
- if (lookup.status === 'unknown') {
- out.unreadable += 1;
- return false;
- }
- const existing = lookup.status === 'present' ? lookup.row : undefined;
-
- // A `managed_by:'package'` row without a `package_id` makes uninstall
- // undefined (the ambiguity ADR-0086 D3 removes) — skip an unowned declaration.
- //
- // ⚠️ The `hasRow` argument below is why the lookup happens BEFORE this
- // refusal rather than after it: the diagnostic's consequence clause differs
- // by whether a row already resolves the name. It is a definite answer here,
- // never a swallowed failure — `unknown` returned above.
- if (!packageId) {
- out.skippedUnowned += 1;
- // [#18091] Same refusal, now delivered: measured mute at this site on the
- // pre-fix tree (`skippedUnowned = 1`, author-visible lines = 0 with no
- // logger). The three-way consequence is preserved verbatim — `hasRow` is
- // why the existence read happens above this branch.
- reportCapabilityDeclarationUnowned(logger, capabilityDeclarationUnownedDiagnostic({
- name: String(cap.name),
- grantedBy: grantors,
- hasRow: Boolean(existing?.id),
- }));
- return Boolean(existing?.id);
- }
-
- if (!existing?.id) {
- const row = {
- id: genId('cap'),
- name: cap.name,
- ...fields,
- managed_by: 'package',
- package_id: packageId,
- active: true,
- };
- const created = await tryInsert(ql, 'sys_capability', row, undefined, refusals);
- if (created) {
- out.seeded += 1;
- // [#11096] The oracle is a SNAPSHOT taken before the loop, so it cannot
- // see this insert. The per-item read could, and a name declared twice in
- // one batch therefore resolved as present on the second pass and took the
- // loud `skippedForeign` refusal below. Without this write-back the second
- // declaration would attempt its own insert, the `name` unique index would
- // refuse it, and a refusal that used to be REPORTED would become a silent
- // nothing.
- existingByName.remember(String(cap.name), row);
- }
- // A rejected insert leaves no row — fall through so the derivation gets its
- // own attempt rather than the name landing in a hole.
- return Boolean(created);
- }
-
- if (existing.managed_by === 'package') {
- // [#18023] ⛔ NOT a hand-written `existing.package_id === packageId`. The
- // owner comparison is THE shared predicate (see the import note): a second
- // spelling of it is free to drift from the one a compile-time door asks,
- // and a nullish owner in particular must read FOREIGN — a package-managed
- // row with no `package_id` is the exact ADR-0086 D3 ambiguity, and adopting
- // it on a name match would be a package writing into a record it cannot
- // prove it owns.
- if (!permissionSetNameIsForeign(existing.package_id, packageId)) {
- // Our own row — re-seed so it always reflects the shipped declaration.
- //
- // [#11096] ⚠️ Only when the stored row ACTUALLY DIFFERS. An unconditional
- // UPDATE here cost one remote round trip per declared capability on every
- // boot to store the values already there — and the capability set is the
- // union of every capability every package declares, so it is typically
- // the largest of the identity axes.
- //
- // ⛔ The skip is an EQUALITY test, never a presence test. Deleting the
- // `else if` and always taking the `unchanged` arm would produce a
- // perfect round-trip count while this seeder silently stopped
- // reconciling anything — the failure shape the paired drift tests in
- // `bootstrap-seed-round-trips.test.ts` exist to make impossible.
- if (!capabilityRecordDiffers(existing, fields)) {
- out.unchanged += 1;
- } else if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields }, undefined, refusals)) {
- out.updated += 1;
- }
- } else {
- // [#18023] The SKIP is unchanged and correct — ADR-0086 D4: a package
- // never writes into a foreign record. What changed is that it is no
- // longer invisible.
- //
- // ⛔ The old line did not refuse loudly, whatever "skipped loudly" in
- // this module's header claimed: `logger?.warn?.(…)` is optionally chained
- // TWICE, so a caller passing no logger produced NO OUTPUT AT ALL and an
- // entire declared capability vanished with one internal counter moved.
- // Measured on the pre-fix tree with no logger: `skippedForeign = 1`,
- // author-visible console lines = 0 across all five channels.
- //
- // The report now goes through `reportCapabilityNameCollisions` at the end
- // of the pass — ONCE, and loud with no sink injected (#10556:
- // silent-by-declaration is rejected) — and the diagnostic RECORD travels
- // back on the outcome so a caller that reads no log at all can still ask
- // what happened. ⛔ The record is not a substitute for the print; it is
- // the second half of it.
- out.skippedForeign += 1;
- (out.collisions ??= []).push(capabilityNameCollisionDiagnostic({
- name: String(cap.name),
- declaredBy: packageId,
- ownedBy: typeof existing.package_id === 'string' ? existing.package_id : null,
- grantedBy: grantors,
- }));
- }
- // Either way a package-authored row exists and must not be re-derived over.
- return true;
- }
-
- if (existing.managed_by === 'platform') {
- // Non-curated (curated excluded above) platform row = a derived-from-
- // systemPermissions placeholder. The explicit declaration CLAIMS it:
- // upgrade to package provenance with the authored label/description/scope.
- if (await tryUpdate(ql, 'sys_capability', { id: existing.id, ...fields, managed_by: 'package', package_id: packageId }, undefined, refusals)) {
- out.claimed += 1;
- }
- return true;
- }
-
- // `admin` (or any other) — environment/admin-authored. Never clobbered.
- out.skippedAdmin += 1;
- return true;
-}
-
-export async function bootstrapDeclaredCapabilities(
- ql: any,
- metadataService: any,
- options: SeedOptions = {},
-): Promise {
- const out: CapabilitySeedOutcome = {
- seeded: 0, updated: 0, claimed: 0, unchanged: 0, unreadable: 0,
- skippedAdmin: 0, skippedForeign: 0, skippedPlatform: 0, skippedUnowned: 0,
- materializedNames: [],
- };
- if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out;
-
- let caps: any[] = readDeclared(ql, 'capability');
- if (caps.length === 0) {
- try {
- const listed = metadataService?.list?.('capability');
- caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
- } catch { caps = []; }
- }
- if (!Array.isArray(caps) || caps.length === 0) return out;
-
- const grantorsByCapability = indexGrantors(options.permissionSets);
-
- // One log per pass, not per refused row: a legacy platform-wide unique index
- // refuses EVERY declared capability, and a line each would bury the remedy.
- const refusals = createSeedWriteRefusals();
-
- // [#11096] ONE existence read for the whole declaration, hoisted out of the
- // loop below. Each declared capability used to cost its own sequential
- // `SELECT … WHERE name = ? LIMIT 1` — invisible on a local file database, one
- // separate awaited HTTP request per name on the remote libsql/Turso database
- // every hosted environment runs, competing for the same request budget as the
- // rest of boot.
- //
- // ⚠️ NOT threaded with an organization, deliberately: this seeder's read, its
- // insert and its update are all organization-less today (unlike the position
- // and permission-set doors), so an unscoped lookup is EXACTLY the question the
- // per-item read asked — `resolveOwnOrganizationRow` returns the first row when
- // no organization is given, which is what `tryFind(…, 1)[0]` returned. Making
- // this catalog per-organization is a separate change with its own migration,
- // not a rider on a round-trip fix.
- const existingByName = await buildExistingByName(
- ql,
- 'sys_capability',
- caps.map((c) => c?.name),
- options.logger,
- );
-
- for (const cap of caps) {
- if (!cap?.name) continue;
- // Registry provenance first (ADR-0010 `_packageId`), author-declared
- // spec `packageId` (ADR-0086 D3) as fallback.
- const packageId: string | undefined = cap._packageId ?? cap.packageId ?? undefined;
- const grantors = grantorsByCapability.get(cap.name) ?? [];
- const materialized = await upsertPackageCapability(ql, cap, packageId, out, grantors, existingByName, options.logger, refusals);
- // [#4967 Part 1] Report the name ONLY once this pass knows a row exists for
- // it. Reporting it before the upsert decided anything is what let a refused
- // declaration suppress the derivation it needed.
- if (materialized) out.materializedNames.push(cap.name);
- }
-
- // Before the counts, so an operator reads WHY the count is zero beside it.
- // This seeder is organization-less today (see the lookup note above), so the
- // report carries no organization either.
- reportSeedWriteRefusals(options.logger, refusals);
- // [#18023] Said once per pass, and said even when no logger was injected —
- // the whole defect was that this refusal reached nobody. The records are
- // already on the outcome (`out.collisions`), for a caller that reads no log.
- reportCapabilityNameCollisions(options.logger, out.collisions ?? []);
- if (out.unreadable > 0) {
- // [#11096] Said ONCE with the count, like the sibling seeders: a per-name
- // warn on a database that is down is a log flood that buries its own
- // meaning. The consequence is spelled out because "unreadable" alone does
- // not state one — these names were left ENTIRELY alone, so a genuinely new
- // declaration among them has not been created and a drifted one has not
- // been healed; the next boot with a readable database does both.
- // [#18091] …and said even when no logger was injected. An unreadable
- // database that reports nothing reads exactly like a healthy boot.
- reportCapabilityRowsUnreadable(options.logger, {
- unreadable: out.unreadable,
- total: caps.length,
- });
- }
- options.logger?.info?.('[security] declared capabilities seeded into sys_capability (ADR-0066 D1)', {
- ...out, total: caps.length,
- });
- return out;
-}
diff --git a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts
index 8ff408fb832..a1cea6b126f 100644
--- a/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts
+++ b/packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts
@@ -11,12 +11,6 @@
* round trips on every kernel boot (2 × existence `SELECT`, 1 × `UPDATE`,
* 1 × `SELECT`), of which the `UPDATE` fired even when nothing had changed.
*
- * [#11096] The declared-CAPABILITY seeder next door had the same shape and is
- * pinned here too. ⚠️ Its own slope has never been measured — the hosted rig's
- * axes are permission sets / positions / objects — so nothing below claims one;
- * what is established is that the code shape is identical, and the capability
- * set is typically the LARGEST of the identity axes because it is the union of
- * every capability every declared package contributes.
* On a local file database that loop is invisible; on a remote libsql/Turso
* database — every hosted environment — each leg is its own sequential HTTP
* request.
@@ -44,7 +38,6 @@ import { describe, it, expect } from 'vitest';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js';
-import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
/**
* The engine registry as the security catalog read uses one — its list, its
@@ -457,322 +450,6 @@ describe('#10946 — a name declared twice in one batch keeps its loud refusal',
});
});
-// ── #11096 — declared capabilities ─────────────────────────────────────────
-
-/**
- * `scope` is typed rather than inferred: the "declared SCOPE changed" drift
- * fixture below widens `'platform'` to `'org'` to simulate a package version
- * bump, which an inferred `'platform'` literal type rejects — the same reason
- * `DeclaredSet` above types `objects` explicitly.
- */
-interface DeclaredCapability {
- name: string;
- label: string;
- description: string;
- scope: 'platform' | 'org';
- _packageId: string;
-}
-
-const declaredCaps = (n: number): DeclaredCapability[] =>
- Array.from({ length: n }, (_, i) => ({
- // ⚠️ Never a curated `PLATFORM_CAPABILITY_NAMES` entry: those are refused
- // before the existence read is even consulted, so a curated fixture would
- // measure the refusal path and report zero round trips for the wrong reason.
- name: `crm.cap.${i}`,
- label: `Capability ${i}`,
- description: `desc ${i}`,
- scope: 'platform',
- _packageId: 'com.example.crm',
- }));
-
-const capabilityQl = (declared: any[], behaviour = {}) =>
- makeCountingQl('sys_capability', 'capability', declared, behaviour);
-
-describe('#11096 — steady-state rebuild is O(1) round trips (declared capabilities)', () => {
- it('does not grow the rebuild round-trip count with the number of declared capabilities', async () => {
- const measure = async (n: number) => {
- const ql = capabilityQl(declaredCaps(n));
- await bootstrapDeclaredCapabilities(ql, undefined); // first boot: seeds
- ql.reset();
- const r = await bootstrapDeclaredCapabilities(ql, undefined); // REBUILD
- expect(r.seeded).toBe(0);
- expect(r.updated).toBe(0);
- expect(r.unchanged).toBe(n);
- // The suppression list is the whole reason this seeder reports names, and
- // an unchanged row is still a materialized one (#4967 Part 1).
- expect(r.materializedNames).toHaveLength(n);
- return ql.roundTrips();
- };
-
- const [n1, n5, n20, n40] = [await measure(1), await measure(5), await measure(20), await measure(40)];
- // The count is asserted, never the wall time.
- expect([n1, n5, n20, n40]).toEqual([1, 1, 1, 1]);
- });
-
- it('issues ONE batched `$in` existence read for the whole declaration', async () => {
- const ql = capabilityQl(declaredCaps(12));
- await bootstrapDeclaredCapabilities(ql, undefined);
- ql.reset();
- await bootstrapDeclaredCapabilities(ql, undefined);
- expect(ql.calls.find).toBe(1);
- expect(ql.wheres[0]).toEqual({ name: { $in: declaredCaps(12).map((c) => c.name) } });
- });
-
- it('first boot costs one batched read plus one INSERT per genuinely new capability', async () => {
- const ql = capabilityQl(declaredCaps(10));
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.seeded).toBe(10);
- expect(ql.calls.find).toBe(1);
- expect(ql.calls.insert).toBe(10);
- expect(ql.calls.update).toBe(0);
- expect(ql.rows).toHaveLength(10);
- });
-});
-
-/**
- * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write
- * would pass every capability count above while reconciling nothing at all —
- * the exact failure shape a round-trip suite alone cannot see.
- */
-describe('#11096 — drift STILL reconciles (declared capabilities)', () => {
- it('a capability row whose stored label/description differ still gets its UPDATE', async () => {
- const ql = capabilityQl(declaredCaps(20));
- await bootstrapDeclaredCapabilities(ql, undefined);
-
- // The package ships new copy for exactly ONE of the 20.
- const upgraded = declaredCaps(20);
- upgraded[7] = { ...upgraded[7], label: 'Renamed', description: 'new text' };
- (ql as any).registry = { listItems: (t: string) => (t === 'capability' ? upgraded : []) };
-
- ql.reset();
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.updated).toBe(1);
- expect(r.unchanged).toBe(19);
- expect(ql.calls.update).toBe(1);
- const row = ql.rows.find((x) => x.name === 'crm.cap.7');
- expect(row.label).toBe('Renamed');
- expect(row.description).toBe('new text');
- });
-
- it('a capability whose declared SCOPE changed still gets its UPDATE', async () => {
- const ql = capabilityQl(declaredCaps(3));
- await bootstrapDeclaredCapabilities(ql, undefined);
-
- const upgraded = declaredCaps(3);
- upgraded[1] = { ...upgraded[1], scope: 'org' as const };
- (ql as any).registry = { listItems: (t: string) => (t === 'capability' ? upgraded : []) };
-
- ql.reset();
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.updated).toBe(1);
- expect(ql.rows.find((x) => x.name === 'crm.cap.1').scope).toBe('org');
- });
-
- it('a capability row a hand-edit drifted is healed back to the declaration', async () => {
- const ql = capabilityQl(declaredCaps(3));
- await bootstrapDeclaredCapabilities(ql, undefined);
- // Someone wrote straight at the row.
- ql.rows[1].description = 'hand-edited';
-
- ql.reset();
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.updated).toBe(1);
- expect(r.unchanged).toBe(2);
- expect(ql.rows[1].description).toBe('desc 1');
- });
-
- it('a re-seed still never touches the provenance columns', async () => {
- const ql = capabilityQl([{
- name: 'crm.cap.0', label: 'Capability v2', description: 'new', scope: 'platform',
- _packageId: 'com.example.crm',
- }]);
- ql.rows.push({
- id: 'cap_1', name: 'crm.cap.0', label: 'Capability', description: 'old', scope: 'platform',
- active: false, managed_by: 'package', package_id: 'com.example.crm',
- });
- await bootstrapDeclaredCapabilities(ql, undefined);
- const row = ql.rows[0];
- expect(row.label).toBe('Capability v2');
- expect(row.active).toBe(false);
- expect(row.managed_by).toBe('package');
- expect(row.package_id).toBe('com.example.crm');
- });
-});
-
-describe('#11096 — a genuinely NEW declaration is still created', () => {
- it('the batched read does not turn "absent" into "present" (capabilities)', async () => {
- const ql = capabilityQl(declaredCaps(5));
- await bootstrapDeclaredCapabilities(ql, undefined);
-
- const grown = [...declaredCaps(5), {
- name: 'crm.cap.new', label: 'New', description: 'brand new', scope: 'platform' as const,
- _packageId: 'com.example.crm',
- }];
- (ql as any).registry = { listItems: (t: string) => (t === 'capability' ? grown : []) };
-
- ql.reset();
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.seeded).toBe(1);
- expect(r.unchanged).toBe(5);
- expect(ql.rows.map((x) => x.name)).toContain('crm.cap.new');
- // one batched read + one insert — the other five cost nothing at all
- expect(ql.roundTrips()).toBe(2);
- });
-});
-
-/**
- * ⛔ The provenance half of triage's clause ②: the refusal diagnostics at the
- * top of `upsertPackageCapability` state a DIFFERENT consequence depending on
- * whether a row was found, so the batched read has to preserve the found /
- * not-found distinction, not merely return rows.
- */
-describe('#11096 — the batched read preserves the provenance branches', () => {
- it('still refuses a capability owned by ANOTHER package, loudly', async () => {
- const ql = capabilityQl([{
- name: 'crm.cap.0', label: 'Mine', description: 'd', scope: 'platform',
- _packageId: 'com.example.b',
- }]);
- ql.rows.push({
- id: 'cap_1', name: 'crm.cap.0', label: 'Theirs', description: 'd', scope: 'platform',
- managed_by: 'package', package_id: 'com.example.a', active: true,
- });
- const warns: string[] = [];
- const r = await bootstrapDeclaredCapabilities(ql, undefined, {
- logger: { info: () => {}, warn: (m) => warns.push(m) },
- });
- expect(r.skippedForeign).toBe(1);
- expect(r.materializedNames).toEqual(['crm.cap.0']);
- expect(ql.rows[0].package_id).toBe('com.example.a'); // untouched
- // [#18023] The loudness is pinned on the stable grep TOKEN, not on prose:
- // the wording moved when the refusal grew a diagnostic record, and a pin on
- // a sentence re-reds on every rewording without ever asking the question it
- // was written to ask. The record on the outcome is the other half.
- expect(warns.some((w) => w.includes('capability_name_collision'))).toBe(true);
- expect(r.collisions?.map((d) => d.name)).toEqual(['crm.cap.0']);
- });
-
- it('still CLAIMS a derived platform placeholder for an explicit declaration', async () => {
- const ql = capabilityQl(declaredCaps(1));
- ql.rows.push({
- id: 'cap_1', name: 'crm.cap.0', label: 'Crm Cap 0', description: 'Capability crm.cap.0.',
- scope: 'platform', managed_by: 'platform', active: true,
- });
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.claimed).toBe(1);
- expect(ql.rows[0].managed_by).toBe('package');
- expect(ql.rows[0].package_id).toBe('com.example.crm');
- });
-
- it('never clobbers an admin-authored row', async () => {
- const ql = capabilityQl(declaredCaps(1));
- ql.rows.push({
- id: 'cap_1', name: 'crm.cap.0', label: 'Admin Copy', description: 'admin wrote this',
- scope: 'platform', managed_by: 'admin', active: true,
- });
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.skippedAdmin).toBe(1);
- expect(r.materializedNames).toEqual(['crm.cap.0']);
- expect(ql.rows[0].label).toBe('Admin Copy');
- expect(ql.calls.update).toBe(0);
- });
-
- it('an UNOWNED declaration still reports the consequence for a name WITH a row', async () => {
- const ql = capabilityQl([{ name: 'crm.cap.0', label: 'L', description: 'd', scope: 'platform' }]);
- ql.rows.push({
- id: 'cap_1', name: 'crm.cap.0', label: 'L', description: 'd', scope: 'platform',
- managed_by: 'platform', active: true,
- });
- const warns: string[] = [];
- const r = await bootstrapDeclaredCapabilities(ql, undefined, {
- logger: { info: () => {}, warn: (m) => warns.push(m) },
- });
- expect(r.skippedUnowned).toBe(1);
- // A row resolves it — so the derivation must be suppressed, and the
- // diagnostic must say so rather than promising a placeholder.
- expect(r.materializedNames).toEqual(['crm.cap.0']);
- expect(warns.some((w) => w.includes('already resolves it and is left as-is'))).toBe(true);
- });
-
- it('an UNOWNED declaration with NO row reports the OTHER consequence, and stays unsuppressed', async () => {
- const ql = capabilityQl([{ name: 'crm.cap.0', label: 'L', description: 'd', scope: 'platform' }]);
- const warns: string[] = [];
- const r = await bootstrapDeclaredCapabilities(ql, undefined, {
- logger: { info: () => {}, warn: (m) => warns.push(m) },
- permissionSets: [{ name: 'crm_rep', systemPermissions: ['crm.cap.0'] }],
- });
- expect(r.skippedUnowned).toBe(1);
- expect(r.materializedNames).toEqual([]); // ⛔ #4967: no row ⇒ no suppression
- expect(warns.some((w) => w.includes('falls back to the back-compat derived placeholder'))).toBe(true);
- expect(warns.some((w) => w.includes('crm_rep'))).toBe(true);
- });
-
- it('a name declared twice in one batch keeps its loud refusal', async () => {
- const ql = capabilityQl([
- { name: 'crm.shared', label: 'A', description: 'a', scope: 'platform', _packageId: 'com.example.a' },
- { name: 'crm.shared', label: 'B', description: 'b', scope: 'platform', _packageId: 'com.example.b' },
- ]);
- const warns: string[] = [];
- const r = await bootstrapDeclaredCapabilities(ql, undefined, {
- logger: { info: () => {}, warn: (m) => warns.push(m) },
- });
- expect(r.seeded).toBe(1);
- expect(r.skippedForeign).toBe(1);
- expect(ql.rows).toHaveLength(1);
- expect(ql.rows[0].package_id).toBe('com.example.a');
- // [#18023] Token, not prose — see the sibling case above.
- expect(warns.some((w) => w.includes('capability_name_collision'))).toBe(true);
- expect(r.collisions?.map((d) => d.declaredBy)).toEqual(['com.example.b']);
- });
-});
-
-/**
- * ⛔ #3807's conflation class on the capability axis. Note the second half: a
- * name whose row could not be read must ALSO stay out of `materializedNames`,
- * because suppressing the back-compat derivation for a name that may have no
- * row is precisely the #4967 hole.
- */
-describe('#11096 — a read that CANNOT ANSWER is not the answer "none exist"', () => {
- it('a throwing read does NOT re-create capabilities that are already seeded', async () => {
- const ql = capabilityQl(declaredCaps(4));
- await bootstrapDeclaredCapabilities(ql, undefined);
- expect(ql.rows).toHaveLength(4);
-
- const broken = capabilityQl(declaredCaps(4), { findThrows: true });
- broken.rows.push(...ql.rows.map((r) => ({ ...r })));
- const warns: string[] = [];
- const r = await bootstrapDeclaredCapabilities(broken, undefined, {
- logger: { info: () => {}, warn: (m) => warns.push(m) },
- });
-
- expect(r.seeded).toBe(0);
- expect(r.unreadable).toBe(4);
- expect(broken.calls.insert).toBe(0); // ⛔ no blind insert
- expect(broken.rows).toHaveLength(4); // ⛔ nothing re-created
- expect(r.materializedNames).toEqual([]); // ⛔ unknown ⇒ never suppress
- expect(warns.some((w) => w.includes('batched seed existence read failed'))).toBe(true);
- expect(warns.some((w) => w.includes('could not be read'))).toBe(true);
- });
-
- it('a read returning a non-result (undefined) is not read as "none exist"', async () => {
- const ql = capabilityQl(declaredCaps(4), { findReturnsNonArray: true });
- ql.rows.push(...declaredCaps(4).map((c, i) => ({
- id: `cap_${i}`, name: c.name, label: c.label, description: c.description,
- scope: 'platform', managed_by: 'package', package_id: 'com.example.crm', active: true,
- })));
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.seeded).toBe(0);
- expect(r.unreadable).toBe(4);
- expect(ql.calls.insert).toBe(0);
- expect(ql.rows).toHaveLength(4);
- });
-
- it('an EMPTY result set is still trusted as "none exist" — the first boot depends on it', async () => {
- const ql = capabilityQl(declaredCaps(3));
- const r = await bootstrapDeclaredCapabilities(ql, undefined);
- expect(r.seeded).toBe(3);
- });
-});
-
/**
* [commit 1a6855226] `buildExistingByName` gained an optional bucket predicate for
* the curated capability read. The fence below holds the callers that pass
diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts
index 58b27f58eae..e81d0383373 100644
--- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts
+++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts
@@ -19,16 +19,16 @@
*
* - `in the registry` — a package manifest's `capabilities`, the way a stack
* reaches the engine registry;
- * - `in the metadata service only` — what the declared-capability seeder
- * reads when the registry holds no package declaration.
+ * - `in the metadata service only` — what the catalog read falls back to
+ * when the registry holds no package declaration.
*
* ## The expected values
*
* Read off the producers, never off a registry: the curated entries from
- * `PLATFORM_CAPABILITIES`, the stack's from its declaration. The census is the
- * stack's package row and nothing else: the curated capabilities live in the
- * registry, and the boot writes no `sys_capability` row for them (the curated
- * seeder is retired), on a fresh database or on one it opened again.
+ * `PLATFORM_CAPABILITIES`, the stack's from its declaration. The
+ * census is empty: neither the curated capabilities nor the stack's declared
+ * capability gets a row. Both are served by the registry, their one home
+ * (ADR-0131 D3), on a fresh database or on one the boot opened again.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
@@ -192,10 +192,8 @@ async function census(engine: ObjectQL): Promise {
.sort();
}
-/** The census the boot writes, read off the producers (module doc): no curated row. */
-const EXPECTED_CENSUS = [
- [STACK_CAPABILITY.name, 'package', STACK_PACKAGE, STACK_CAPABILITY.label, STACK_CAPABILITY.description, STACK_CAPABILITY.scope, true].join(' | '),
-].sort();
+/** The census the boot writes, read off the producers (module doc): no row at all. */
+const EXPECTED_CENSUS: string[] = [];
const SCENARIOS: ReadonlyArray<{ posture: Posture; declared: Declared }> = [
{ posture: 'single', declared: 'in the registry' },
@@ -247,10 +245,11 @@ describe.each(SCENARIOS)('a $posture boot, the stack capability $declared', ({ p
}
});
- it('the boot writes no curated row — only the stack’s package row — on a fresh and on a seeded database', async () => {
+ it('the boot writes no `sys_capability` row — neither curated nor the stack’s declared one — on a fresh and on a seeded database', async () => {
expect(await census(fresh.engine)).toEqual(EXPECTED_CENSUS);
expect(await census(reseeded.engine)).toEqual(EXPECTED_CENSUS);
- // The seeded database needs no write at all: the package row already reads as declared.
+ expect(fresh.writes).toEqual([]);
+ // The seeded database needs no write at all either.
expect(reseeded.writes).toEqual([]);
});
diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts
index deca0428bef..a0c47505d3b 100644
--- a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts
+++ b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts
@@ -7,14 +7,8 @@
*/
import { describe, it, expect } from 'vitest';
-import { CapabilityDeclarationSchema, PLATFORM_CAPABILITIES } from '@objectstack/spec/security';
-import {
- isPlatformCapabilityDeclaration,
- registerBuiltinCapabilities,
- securityBuiltinCapabilities,
- withoutPlatformCapabilityDeclarations,
- withoutPlatformCapabilityItems,
-} from './builtin-capabilities.js';
+import { CapabilityDeclarationSchema, PLATFORM_CAPABILITIES, describeHighPrivilegeBits } from '@objectstack/spec/security';
+import { registerBuiltinCapabilities, securityBuiltinCapabilities } from './builtin-capabilities.js';
import { SECURITY_PLUGIN_ID } from './manifest.js';
import { readDeclaredCapabilityContext } from './declared-capability-context.js';
@@ -59,85 +53,24 @@ describe('registerBuiltinCapabilities', () => {
const curated = PLATFORM_CAPABILITIES[0]!.name;
const OWN = { name: curated, _packageId: SECURITY_PLUGIN_ID };
-const FOREIGN_CURATED = { name: curated, _packageId: 'com.example.other' };
-const PACKAGE_DECLARED = { name: 'field.export', _packageId: 'com.example.other' };
-const OWN_NON_CURATED = { name: 'field.export', _packageId: SECURITY_PLUGIN_ID };
-
-describe('isPlatformCapabilityDeclaration', () => {
- it('is this plugin’s declaration of a curated name, and nothing else', () => {
- expect(isPlatformCapabilityDeclaration(OWN)).toBe(true);
- for (const item of [FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED, { name: curated }, null, undefined, curated]) {
- expect(isPlatformCapabilityDeclaration(item)).toBe(false);
- }
- });
-
- it('drops only those from a list', () => {
- expect(withoutPlatformCapabilityItems([OWN, FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED])).toEqual(
- [FOREIGN_CURATED, PACKAGE_DECLARED, OWN_NON_CURATED],
- );
- });
-});
-
-describe('withoutPlatformCapabilityDeclarations', () => {
- class FakeRegistry {
- readonly items: Record = {
- capability: [OWN, FOREIGN_CURATED, PACKAGE_DECLARED],
- permission: [OWN],
- };
- listItems(type: string): unknown[] {
- return this.items[type] ?? [];
- }
- getItem(type: string, name: string): unknown {
- return (this.items[type] ?? []).find((i) => (i as { name?: unknown }).name === name);
- }
- }
- class FakeEngine {
- registry = new FakeRegistry();
- #inserted: unknown[] = [];
- async insert(object: string, row: unknown): Promise {
- this.#inserted.push([object, row]);
- return row;
- }
- inserted(): unknown[] {
- return this.#inserted;
- }
- }
-
- it('lists every capability but this plugin’s curated declarations, and every other type as it is', () => {
- const engine = new FakeEngine();
- const view = withoutPlatformCapabilityDeclarations(engine);
- expect(view.registry.listItems('capability')).toEqual([FOREIGN_CURATED, PACKAGE_DECLARED]);
- expect(view.registry.listItems('permission')).toEqual([OWN]);
- expect(view.registry.getItem('capability', curated)).toBe(OWN);
- // The engine itself is untouched.
- expect(engine.registry.listItems('capability')).toEqual([OWN, FOREIGN_CURATED, PACKAGE_DECLARED]);
- });
-
- it('forwards every other member to the engine itself, private state included', async () => {
- const engine = new FakeEngine();
- const view = withoutPlatformCapabilityDeclarations(engine);
- await view.insert('sys_capability', { name: 'x' });
- expect(engine.inserted()).toEqual([['sys_capability', { name: 'x' }]]);
- });
-
- it('returns an engine with no listing registry as it is', () => {
- const bare = { insert: async () => undefined };
- expect(withoutPlatformCapabilityDeclarations(bare)).toBe(bare);
- expect(withoutPlatformCapabilityDeclarations(undefined)).toBeUndefined();
- });
-});
describe('readDeclaredCapabilityContext, beside the curated declarations', () => {
const APP = { name: 'field.export', _packageId: 'com.example.field' };
const engineWith = (capabilities: unknown[]) => ({ registry: { listItems: (type: string) => (type === 'capability' ? capabilities : []) } });
+ const setGranting = (...systemPermissions: string[]) => ({ name: 'baseline', systemPermissions });
- it('still falls back to the metadata service when the registry holds only the platform’s curated declarations', async () => {
- const metadata = { list: async (type: string) => (type === 'capability' ? [APP] : []) };
- expect(await readDeclaredCapabilityContext(engineWith([OWN]), metadata)).toEqual({ declaredCapabilities: [APP] });
- expect(await readDeclaredCapabilityContext(engineWith([OWN]), { list: async () => [] })).toBeUndefined();
+ it('hands over the registry’s capabilities as they are, the curated ones included', async () => {
+ expect(await readDeclaredCapabilityContext(engineWith([OWN, APP]), undefined)).toEqual({ declaredCapabilities: [OWN, APP] });
});
- it('reads the registry’s package declarations without the curated ones', async () => {
- expect(await readDeclaredCapabilityContext(engineWith([OWN, APP]), undefined)).toEqual({ declaredCapabilities: [APP] });
+ it('excuses a package-declared token and never a curated one: the platform floor discards the curated declarations', async () => {
+ const context = await readDeclaredCapabilityContext(engineWith([OWN, APP]), undefined);
+ expect(describeHighPrivilegeBits(setGranting(APP.name), context)).toBeNull();
+ expect(describeHighPrivilegeBits(setGranting(curated), context)).not.toBeNull();
+ // A registry holding only the curated declarations excuses nothing, as no declaration did.
+ const curatedOnly = await readDeclaredCapabilityContext(engineWith([OWN]), undefined);
+ expect(describeHighPrivilegeBits(setGranting(APP.name), curatedOnly))
+ .toEqual(describeHighPrivilegeBits(setGranting(APP.name), undefined));
+ expect(describeHighPrivilegeBits(setGranting(APP.name), undefined)).not.toBeNull();
});
});
diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.ts b/packages/plugins/plugin-security/src/builtin-capabilities.ts
index ed40a6d4315..775c9c0cde6 100644
--- a/packages/plugins/plugin-security/src/builtin-capabilities.ts
+++ b/packages/plugins/plugin-security/src/builtin-capabilities.ts
@@ -34,19 +34,13 @@
* reason. Existing `sys_capability` rows are rows, not registry holders, so a
* database the seeder already populated registers the same as a fresh one.
*
- * ## The one adapter, and why it lives at the call site
+ * ## Beside the package declarations
*
- * `bootstrapDeclaredCapabilities` and `readDeclaredCapabilityContext`
- * (`declared-capability-context.ts`) read the registry's capabilities as PACKAGE declarations. Handed the curated
- * declarations too, the seeder would refuse each one as a package hijacking a
- * curated name (one false `capability_platform_name_refused` line per name,
- * every boot), and both would stop falling back to the metadata service on a
- * registry that holds no package declaration. The seeders are retired, not
- * edited (ADR-0131 cutover), so the plugin hands that seeder an engine view
- * without this plugin's own curated declarations
- * ({@link withoutPlatformCapabilityDeclarations}), and the context read drops
- * the same items ({@link withoutPlatformCapabilityItems}); both readers then
- * see exactly what they saw before. The view goes with the seeder.
+ * The registry lists these beside every package-declared capability, and
+ * `readDeclaredCapabilityContext` (`declared-capability-context.ts`) hands
+ * them to the anchor predicates with the rest: the predicates' platform floor
+ * (`@objectstack/spec/security`, `high-privilege.ts`) keeps a curated name
+ * high-privilege whoever declares it, so they excuse nothing there.
*
* ## What a declaration here carries
*
@@ -55,8 +49,7 @@
* catalog row.
*/
-import { PLATFORM_CAPABILITIES, PLATFORM_CAPABILITY_NAMES, type PlatformCapability } from '@objectstack/spec/security';
-import { SECURITY_PLUGIN_ID } from './manifest.js';
+import { PLATFORM_CAPABILITIES, type PlatformCapability } from '@objectstack/spec/security';
/**
* The engine registry's registration seam, as this module uses it — ObjectQL's
@@ -89,57 +82,3 @@ export function registerBuiltinCapabilities(registry: unknown, packageId: string
}
return securityBuiltinCapabilities.length;
}
-
-/**
- * Is `item` this plugin's own declaration of a curated capability — a curated
- * name stamped with this plugin's package id? Exact match on both. Another
- * package's item under a curated name is not, so it still reaches the readers
- * (and their refusal) as before.
- */
-export function isPlatformCapabilityDeclaration(item: unknown): boolean {
- if (!item || typeof item !== 'object') return false;
- const { name, _packageId } = item as { name?: unknown; _packageId?: unknown };
- return _packageId === SECURITY_PLUGIN_ID && typeof name === 'string' && PLATFORM_CAPABILITY_NAMES.has(name);
-}
-
-/** Read `prop` off `target`, binding a method to `target` itself. */
-function forward(target: object, prop: PropertyKey): unknown {
- const value = Reflect.get(target, prop, target);
- return typeof value === 'function' ? value.bind(target) : value;
-}
-
-/**
- * The engine as the declared-capability seeder must see it: every member is
- * the engine's own, except that its registry lists no capability this plugin
- * declared from the curated list ({@link isPlatformCapabilityDeclaration}).
- * Every other type, and every other capability, is listed as the registry
- * lists it. An engine without a listing registry is returned as it is.
- */
-export function withoutPlatformCapabilityDeclarations(engine: T): T {
- const registry = (engine as { registry?: unknown } | null | undefined)?.registry;
- if (!engine || typeof engine !== 'object' || !registry || typeof registry !== 'object'
- || typeof (registry as { listItems?: unknown }).listItems !== 'function') {
- return engine;
- }
- const registryView = new Proxy(registry, {
- get(target, prop) {
- if (prop !== 'listItems') return forward(target, prop);
- return (type: string, ...rest: unknown[]) => {
- const items = (target as { listItems(type: string, ...rest: unknown[]): unknown }).listItems(type, ...rest);
- return type === 'capability' && Array.isArray(items)
- ? items.filter((item) => !isPlatformCapabilityDeclaration(item))
- : items;
- };
- },
- });
- return new Proxy(engine as object, {
- get(target, prop) {
- return prop === 'registry' ? registryView : forward(target, prop);
- },
- }) as T;
-}
-
-/** Drop this plugin's own curated declarations from a list of capability declarations. */
-export function withoutPlatformCapabilityItems(items: readonly T[]): T[] {
- return items.filter((item) => !isPlatformCapabilityDeclaration(item));
-}
diff --git a/packages/plugins/plugin-security/src/capability-name-collision.test.ts b/packages/plugins/plugin-security/src/capability-name-collision.test.ts
deleted file mode 100644
index 598a290753d..00000000000
--- a/packages/plugins/plugin-security/src/capability-name-collision.test.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * [#18023] Pins for the capability-name collision diagnostic's PURE half —
- * the token, the record and the report channel, with no seeder and no engine
- * double in the way.
- *
- * ⛔ Not the skip. Refusing to write into a `sys_capability` row another
- * package owns is correct under ADR-0086 D4; the seeder-side pins in
- * `bootstrap-declared-capabilities.test.ts` assert it UNCHANGED. What this file
- * pins is that the refusal REACHES somebody, and says something true.
- *
- * ## The three trap shapes these discriminate against
- *
- * 1. `logger?.warn?.(…)` — the defect. A sink-less call must still print, so
- * the no-sink case asserts `console.warn` fired, not merely "did not throw".
- * 2. `(logger?.warn ?? console.warn)(…)` — evaluates to a bare function and
- * calls it with `this === undefined`. A class-based host sink that reaches
- * for `this` throws, so one case below injects exactly that sink.
- * 3. A diagnostic that fires ALWAYS is as unreadable as one that never fires
- * (#12015), so the empty-collection case asserts total silence.
- */
-
-import { describe, it, expect, vi } from 'vitest';
-
-import {
- CAPABILITY_NAME_COLLISION,
- capabilityNameCollisionDiagnostic,
- formatCapabilityNameCollisionDiagnostic,
- reportCapabilityNameCollisions,
-} from './capability-name-collision.js';
-
-/**
- * Capture EVERY console channel. "Author-visible output" is not
- * channel-specific — the pre-fix failure was that NONE of them carried
- * anything — so a pin watching only `warn` could be satisfied by a change that
- * merely MOVED the silence.
- */
-function captureConsole() {
- const seen: string[] = [];
- const spies = (['log', 'info', 'warn', 'error', 'debug'] as const).map((m) =>
- vi.spyOn(console, m).mockImplementation((...args: unknown[]) => {
- seen.push(`${m}: ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`);
- }),
- );
- return { seen, restore: () => spies.forEach((s) => s.mockRestore()) };
-}
-
-const collision = (over: Record = {}) => capabilityNameCollisionDiagnostic({
- name: 'shared_cap',
- declaredBy: 'com.example.b',
- ownedBy: 'com.example.a',
- ...over,
-});
-
-describe('[#18023] capabilityNameCollisionDiagnostic — the record', () => {
- it('is stamped with the token, is a warning, and carries both owners', () => {
- const d = collision();
- expect(d.event).toBe(CAPABILITY_NAME_COLLISION);
- expect(CAPABILITY_NAME_COLLISION).toBe('capability_name_collision');
- expect(d.severity).toBe('warning');
- expect(d.name).toBe('shared_cap');
- expect(d.declaredBy).toBe('com.example.b');
- expect(d.ownedBy).toBe('com.example.a');
- });
-
- it('states the CAPABILITY-axis consequence, not the permission-set one', () => {
- const d = collision();
- // ⛔ The load-bearing discrimination against a mechanical copy of the
- // sibling axis: on THIS axis the name still resolves through the owning
- // package's row, so a message claiming the grant is dead would send the
- // author hunting a break that is not there.
- expect(d.message).toContain('The name still resolves');
- expect(d.message).toContain('ADR-0086 D4');
- expect(d.message).toContain('label, description and scope');
- expect(d.message).toContain('attributes the capability to com.example.a');
- // ⛔ And it does not borrow the sibling's wording, which is about a set
- // whose permissions are not in effect at all.
- expect(d.message).not.toContain('none of its object, field, tab');
- });
-
- it('names the granting permission sets — the blast radius — when there are any', () => {
- const d = collision({ grantedBy: ['ops', 'support'] });
- expect(d.grantedBy).toEqual(['ops', 'support']);
- expect(d.message).toContain('Permission set(s) granting it: ops, support');
- });
-
- it('says so explicitly when NOTHING grants it, rather than going quiet', () => {
- const d = collision();
- expect(d.grantedBy).toEqual([]);
- expect(d.message).toContain('No bootstrap permission set grants it');
- // Still a finding: an inert declaration is the thing being reported.
- expect(d.message).toContain('the declaration is still inert');
- });
-
- it('reads a NULLISH owner as an unowned row, never as `undefined`', () => {
- // The predicate half of this is pinned on the seeder (a package-managed row
- // with no `package_id` is FOREIGN, ADR-0086 D3). This is the WORDING half:
- // printing `undefined` at an author is worse than saying what is true.
- for (const ownedBy of [null, undefined]) {
- const d = collision({ ownedBy });
- expect(d.ownedBy).toBeNull();
- expect(d.message).toContain('(a package-managed row with no package_id)');
- expect(d.message).not.toContain('undefined');
- }
- });
-
- it('offers both remedies — rename, or single-declarer co-ownership', () => {
- const d = collision();
- expect(d.fix).toContain('Rename the capability in package "com.example.b"');
- expect(d.fix).toContain('ADR-0130 D1');
- });
-});
-
-describe('[#18023] formatCapabilityNameCollisionDiagnostic — one line, whole finding', () => {
- it('carries the grep token, the message and the fix', () => {
- const line = formatCapabilityNameCollisionDiagnostic(collision());
- expect(line.startsWith(`[security] [${CAPABILITY_NAME_COLLISION}] `)).toBe(true);
- expect(line).toContain(collision().message);
- expect(line).toContain(`Fix: ${collision().fix}`);
- });
-});
-
-describe('[#18023] reportCapabilityNameCollisions — the refusal reaches somebody', () => {
- it('PRINTS with NO SINK INJECTED — the exact call shape that used to be mute', () => {
- const cap = captureConsole();
- try {
- reportCapabilityNameCollisions(undefined, [collision()]);
- } finally {
- cap.restore();
- }
- // ⛔ Not "did not throw": `logger?.warn?.(…)` did not throw either, and
- // that is precisely how an entire declared capability disappeared.
- expect(cap.seen).toHaveLength(1);
- expect(cap.seen[0]).toContain(CAPABILITY_NAME_COLLISION);
- expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
- });
-
- it('says it ONCE for a whole pass, with every record in the structured meta', () => {
- const warn = vi.fn();
- reportCapabilityNameCollisions({ warn }, [
- collision({ name: 'a_cap' }),
- collision({ name: 'b_cap', grantedBy: ['ops'] }),
- ]);
- // One line, not one per dropped capability: a package that collides on its
- // whole declaration would otherwise bury its own remedy.
- expect(warn).toHaveBeenCalledTimes(1);
- const [message, meta] = warn.mock.calls[0]!;
- expect(message).toContain('2 declared capabilities were NOT applied');
- expect(meta.event).toBe(CAPABILITY_NAME_COLLISION);
- expect(meta.collisions.map((c: any) => c.name)).toEqual(['a_cap', 'b_cap']);
- expect(meta.collisions[1].grantedBy).toEqual(['ops']);
- expect(meta.collisions[0].fix).toContain('Rename the capability');
- });
-
- it('uses the SINGULAR wording for one, so the line reads as English', () => {
- const warn = vi.fn();
- reportCapabilityNameCollisions({ warn }, [collision()]);
- expect(warn.mock.calls[0]![0]).toContain('1 declared capability was NOT applied');
- });
-
- it('prefers an injected sink and leaves the console alone', () => {
- const warn = vi.fn();
- const cap = captureConsole();
- try {
- reportCapabilityNameCollisions({ warn }, [collision()]);
- } finally {
- cap.restore();
- }
- expect(warn).toHaveBeenCalledTimes(1);
- expect(cap.seen).toEqual([]);
- });
-
- it('KEEPS THE RECEIVER — a class-based sink that reaches for `this` must not throw', () => {
- // ⛔ The `(logger?.warn ?? console.warn)(…)` trap: that form evaluates to a
- // bare function and calls it with `this === undefined`. `@objectstack/core`'s
- // `ObjectLogger` is exactly this shape.
- const seen: string[] = [];
- class HostSink {
- private readonly prefix = '[host] ';
- warn(message: string): void {
- seen.push(this.prefix + message);
- }
- }
- expect(() => reportCapabilityNameCollisions(new HostSink(), [collision()])).not.toThrow();
- expect(seen).toHaveLength(1);
- expect(seen[0]!.startsWith('[host] ')).toBe(true);
- });
-
- it('is SILENT on an empty pass — a diagnostic that always fires is unreadable', () => {
- const warn = vi.fn();
- const cap = captureConsole();
- try {
- reportCapabilityNameCollisions(undefined, []);
- reportCapabilityNameCollisions({ warn }, []);
- } finally {
- cap.restore();
- }
- expect(cap.seen).toEqual([]);
- expect(warn).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/plugins/plugin-security/src/capability-name-collision.ts b/packages/plugins/plugin-security/src/capability-name-collision.ts
deleted file mode 100644
index 1495f2bf3d5..00000000000
--- a/packages/plugins/plugin-security/src/capability-name-collision.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * [#18023] The capability-name collision diagnostic — the CAPABILITY axis of
- * the refusal `permission-set-name-collision.ts` words for permission sets.
- *
- * ## What was wrong
- *
- * `bootstrapDeclaredCapabilities` refuses to write into a `sys_capability` row
- * another package owns (ADR-0086 D4: a package never writes into a foreign
- * record). That refusal is CORRECT and is unchanged here. What was wrong is
- * that it was INVISIBLE: the whole declaration vanished with
- * `out.skippedForeign += 1` and one `logger?.warn?.(…)` line — optionally
- * chained TWICE, so a caller that passed no logger produced no output at all.
- *
- * Measured on the pre-fix tree, driven through the seeder with a foreign-owned
- * row and no logger: `skippedForeign = 1`, author-visible console lines = 0
- * across all five channels, and `undefined` for the outcome's diagnostic
- * records. The permission-set axis, same harness, after its own repair landed:
- * 1 line. This module moves the capability axis to that lit reading.
- *
- * ## Why a PARALLEL module, and what it deliberately REUSES
- *
- * Four of the six things a collision report is made of are axis-SPECIFIC here:
- * the token, the record type, the wording and the consequence. Folding them
- * into a file named `permission-set-name-collision.ts` would make that name
- * lie, and renaming that file churns pins that landed one card ago.
- *
- * What is axis-INDEPENDENT is reused rather than re-derived — re-deriving it is
- * the drift the sibling module exists to prevent:
- *
- * - {@link CollisionReportSink} — the minimal sink, imported from the sibling
- * and re-exported here so a consumer of this axis never declares a second
- * structural copy of it.
- * - `permissionSetNameIsForeign` — THE owner-comparison predicate. The seeder
- * calls it directly at the branch (see `bootstrap-declared-capabilities.ts`);
- * it carries the axis it was first written for in its NAME, and one name for
- * one derivation beats two names for one derivation.
- *
- * ⛔ What is NOT reused is `reportPermissionSetNameCollisions`: its message
- * states a permission-set consequence, and the consequence here is different
- * (below). Its sink discipline is restated in {@link reportCapabilityNameCollisions}
- * because that discipline IS the contract this card buys — not a derivation
- * that can drift, but two lines whose behaviour both axes pin independently.
- *
- * ## Why the consequence differs — the reason this is not a mechanical copy
- *
- * On the permission-set axis the ENTIRE declared set is not materialized: none
- * of its object, field, tab or system permissions are in effect. Here the name
- * still RESOLVES, because the standing row — the other package's — answers for
- * it, and `upsertPackageCapability` therefore reports the name as materialized
- * so the back-compat derivation does not clobber that row (#4967 Part 1).
- *
- * So what is lost is narrower and needs saying precisely, or the author is sent
- * hunting for a broken grant that is not broken:
- *
- * - the declaring package's authored `label`, `description` and `scope` are
- * NOT applied — the registry keeps the OWNING package's;
- * - `sys_capability.package_id` attributes the capability to the owner, so the
- * declaring package's ADR-0086 D3 provenance claim over it does not exist;
- * - grants naming the capability keep working, which is why nothing else in
- * the system ever reports this.
- *
- * ⛔ This module states nothing about uninstall taking the row away:
- * `cleanupPackagePermissions` covers `sys_permission_set` and does not touch
- * `sys_capability` today, so a diagnostic claiming a disappearing capability
- * would be words the runtime does not back.
- *
- * ## Why `event` and not `code`
- *
- * {@link CAPABILITY_NAME_COLLISION} is a snake_case DATA VALUE, not an ADR-0112
- * error code: it is never routed to `error.code`, never reaches a wire refusal,
- * and travels as the structured half of a warning about an artifact — the same
- * discrimination `PERMISSION_SET_NAME_COLLISION` and the sibling
- * `position_name_fold_grant` token already make in this package.
- */
-
-import type { CollisionReportSink } from './permission-set-name-collision.js';
-
-export type { CollisionReportSink };
-
-/**
- * The stable token stamped on every capability-name-collision report, so an
- * operator can grep ONE string for every dropped capability declaration.
- *
- * Exported for the same reason its permission-set sibling is: the compile-time
- * door (`os build` / `os validate`, and the author-time capability rule in
- * `@objectstack/lint`) must consume this rather than re-spell the literal.
- */
-export const CAPABILITY_NAME_COLLISION = 'capability_name_collision';
-
-/** One declared capability whose declaration was NOT applied because another package owns its name. */
-export interface CapabilityNameCollisionDiagnostic {
- /** Always {@link CAPABILITY_NAME_COLLISION}. */
- readonly event: typeof CAPABILITY_NAME_COLLISION;
- /**
- * Never `error`: the platform refuses the write and carries on, and the
- * refusal direction is CLOSED for the DECLARATION — nothing of the declaring
- * package's is written anywhere. ⚠️ Were a path ever measured in which the
- * owner's retained `scope` ALLOWS an access the declaration would have
- * refused, that is a different severity and a different card.
- */
- readonly severity: 'warning';
- /** The declared capability's name — the `sys_capability.name` that collided. */
- readonly name: string;
- /** The package whose declaration was dropped. */
- readonly declaredBy: string;
- /**
- * The package that owns the standing row. `null` when the row is
- * package-managed but carries no `package_id` — an unowned row is still not
- * ours to write, and saying so is more useful than printing `undefined`.
- */
- readonly ownedBy: string | null;
- /**
- * The bootstrap permission set(s) that GRANT this capability via
- * `systemPermissions[]` — the blast radius, named rather than left for the
- * author to find. Empty when nothing grants it, which is itself the useful
- * fact: the sibling `unownedRefusalMessage` in the seeder makes the same
- * distinction, off the same index.
- */
- readonly grantedBy: readonly string[];
- readonly message: string;
- readonly fix: string;
-}
-
-/**
- * Word ONE collision. Callers decide reachability (see the seeder's
- * package-managed branch); this only words the finding, so every door that
- * raises it prints the same sentence.
- */
-export function capabilityNameCollisionDiagnostic(input: {
- name: string;
- declaredBy: string;
- ownedBy?: string | null;
- grantedBy?: readonly string[];
-}): CapabilityNameCollisionDiagnostic {
- const ownedBy = input.ownedBy ?? null;
- const owner = ownedBy ?? '(a package-managed row with no package_id)';
- const grantedBy = [...(input.grantedBy ?? [])];
- const blastRadius = grantedBy.length > 0
- ? `Permission set(s) granting it: ${grantedBy.join(', ')} — those grants keep resolving, ` +
- `against a capability this package does not own.`
- : `No bootstrap permission set grants it, so nothing is denied today — but the ` +
- `declaration is still inert.`;
- return {
- event: CAPABILITY_NAME_COLLISION,
- severity: 'warning',
- name: input.name,
- declaredBy: input.declaredBy,
- ownedBy,
- grantedBy,
- message:
- `Package "${input.declaredBy}" declares capability "${input.name}", but that capability ` +
- `name is already owned by ${owner}. The declaration was NOT applied — a package never ` +
- `writes into a foreign record (ADR-0086 D4) — so the registry keeps the owning package's ` +
- `label, description and scope, and sys_capability attributes the capability to ${owner}, ` +
- `not to "${input.declaredBy}". The name still resolves, which is why nothing else reports ` +
- `this. ${blastRadius}`,
- fix:
- `Rename the capability in package "${input.declaredBy}" to a name it owns (prefix it with ` +
- `the package's own module prefix), or — if the two packages genuinely co-own this ` +
- `namespace (ADR-0130 D1) — have exactly one of them declare it and let the other depend ` +
- `on it.`,
- };
-}
-
-/** One line carrying the whole finding — the text every door prints. */
-export function formatCapabilityNameCollisionDiagnostic(
- d: CapabilityNameCollisionDiagnostic,
-): string {
- return `[security] [${d.event}] ${d.message} Fix: ${d.fix}`;
-}
-
-/**
- * Report every collision one seeding pass found — the half that makes the
- * refusal reach the author.
- *
- * ⛔ NOT `logger?.warn?.(…)`. That is the defect this module exists to remove:
- * with no sink injected it evaluates to nothing at all, and the declaration
- * disappears with only a counter moved. A caller that passes no sink gets
- * `console.warn`, for the reason #10556's ruling gives — loud until a host
- * injects one.
- *
- * ⛔ NOT `(logger?.warn ?? console.warn)(…)` either: that evaluates to a bare
- * function and calls it with `this === undefined`, and a class-based host sink
- * (`@objectstack/core`'s `ObjectLogger`) reaches for `this` and throws. The
- * property-access call form below keeps the receiver — the same measured
- * conclusion `logSeedDurabilityFailure` and `reportPermissionSetNameCollisions`
- * both record.
- *
- * ONE line per pass, not one per dropped capability: a package that collides on
- * its whole declaration would otherwise bury its own remedy. Every diagnostic
- * travels in the structured meta beside it, and the caller gets the records
- * themselves on the pass outcome, so nothing is summarised away.
- *
- * `warn`, not `error`: this is FUNCTIONAL degradation — the deployment is
- * visibly less attributable than it was authored to be. Nothing claimed to be
- * persisted silently failed to land; the write was deliberately never attempted.
- */
-export function reportCapabilityNameCollisions(
- logger: CollisionReportSink | undefined,
- collisions: readonly CapabilityNameCollisionDiagnostic[],
-): void {
- if (collisions.length === 0) return;
- const n = collisions.length;
- const message =
- `[security] [${CAPABILITY_NAME_COLLISION}] ${n} declared ` +
- `capabilit${n === 1 ? 'y was' : 'ies were'} NOT applied — another package already owns ` +
- `${n === 1 ? 'that capability name' : 'those capability names'} (ADR-0086 D4). ` +
- `The declaring package's authored label, description and scope are NOT in effect, and the ` +
- `capabilit${n === 1 ? 'y is' : 'ies are'} attributed to the owning package.`;
- const meta = {
- event: CAPABILITY_NAME_COLLISION,
- collisions: collisions.map((d) => ({
- name: d.name,
- declaredBy: d.declaredBy,
- ownedBy: d.ownedBy,
- grantedBy: [...d.grantedBy],
- fix: d.fix,
- })),
- };
- if (logger) logger.warn(message, meta);
- else console.warn(message, meta);
-}
diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts
index 56e5713fcd3..37b9dbfb40b 100644
--- a/packages/plugins/plugin-security/src/declared-capability-context.ts
+++ b/packages/plugins/plugin-security/src/declared-capability-context.ts
@@ -18,17 +18,13 @@
*
* The predicate's docblock names two sources — the `sys_capability` rows
* carrying `managed_by:'package'` at boot, the stack's own `capabilities`
- * array at authoring time. At the boot moment the anchor binding runs, the
- * rows DO NOT EXIST YET: `runBootstrap` binds the baseline to `everyone`
- * before it calls `bootstrapDeclaredCapabilities`, and that order is fixed by
- * two other constraints (the binding must follow `bootstrapBuiltinRoles`,
- * which seeds the anchor, and precede the suggestion reconciliation). Reading
- * the rows there would read an empty table on a first boot and refuse every
- * declared token — the defect this module removes, reintroduced one layer in.
+ * array at authoring time. No such row is written: a package's declared
+ * capability lives in the registry, its one home (ADR-0131 D3). Reading the
+ * rows would read an empty table and refuse every declared token — the defect
+ * this module removes, reintroduced one layer in.
*
- * So all three runtime consumers read the DECLARATIONS, through the same
- * two-step the seeder itself reads them by (registry first, metadata service
- * as the fallback). One source for the three verdicts is not a convenience:
+ * So all three runtime consumers read the DECLARATIONS, through one two-step
+ * (registry first, metadata service as the fallback). One source for the three verdicts is not a convenience:
* `confirmAudienceBindingSuggestion` is the friendly early rendition of the
* gate the engine middleware re-enforces on the insert, so a second source
* there would let a confirm pass its own check and then be refused by the
@@ -38,8 +34,9 @@
* docblock says why: a "declared" list read off `systemPermissions` excuses
* every token by construction and turns the gate off. And nothing here filters
* by platform-ness: {@link describeHighPrivilegeBits} applies the platform
- * floor itself, so a capability declared under a curated platform name is
- * still high-privilege however it reaches this list.
+ * floor itself, so a capability declared under a curated platform name — the
+ * platform's own curated declarations (`builtin-capabilities.ts`) included —
+ * is still high-privilege however it reaches this list.
*
* Fails CLOSED at every step: an unreadable registry, an unreadable metadata
* service, a declaration with no `name` and an empty stack all yield
@@ -48,7 +45,6 @@
import type { AnchorBindingContext } from '@objectstack/spec/security';
import { readDeclared } from './bootstrap-declared-permissions.js';
-import { withoutPlatformCapabilityItems } from './builtin-capabilities.js';
/**
* Read this stack's declared authorization capabilities as an
@@ -60,18 +56,13 @@ import { withoutPlatformCapabilityItems } from './builtin-capabilities.js';
*
* @param ql The ObjectQL engine handle (its registry is the primary source).
* @param metadataService The metadata service, read only when the registry
- * lists nothing — the same fallback `bootstrapDeclaredCapabilities` uses.
+ * lists nothing.
*/
export async function readDeclaredCapabilityContext(
ql: any,
metadataService?: any,
): Promise {
- // [ADR-0131 D3] The registry also holds the platform's curated capabilities,
- // which this plugin declares itself (`builtin-capabilities.ts`). They are not
- // a package's declaration, and the platform floor would discard them anyway;
- // dropping them keeps the metadata-service fallback below answering on a
- // registry that holds no package declaration, as it did before.
- let caps: any[] = withoutPlatformCapabilityItems(readDeclared(ql, 'capability'));
+ let caps: any[] = readDeclared(ql, 'capability');
if (caps.length === 0) {
try {
const listed = metadataService?.list?.('capability');
diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts
index c3220bcd134..7e8133e8928 100644
--- a/packages/plugins/plugin-security/src/index.ts
+++ b/packages/plugins/plugin-security/src/index.ts
@@ -72,21 +72,8 @@ export type {
CollisionReportSink,
PermissionSetNameCollisionDiagnostic,
} from './permission-set-name-collision.js';
-// [#18023] The CAPABILITY axis of the same refusal, exported for the same
-// reason: the runtime door (`bootstrapDeclaredCapabilities`) raises it today,
-// and the author-time door — `@objectstack/lint`'s capability rule, `os build`
-// / `os validate` — must consume these rather than re-derive the wording or
-// re-spell the token. ⛔ The owner-comparison predicate is NOT duplicated for
-// it: both axes call `permissionSetNameIsForeign` above.
-export {
- CAPABILITY_NAME_COLLISION,
- capabilityNameCollisionDiagnostic,
- formatCapabilityNameCollisionDiagnostic,
- reportCapabilityNameCollisions,
-} from './capability-name-collision.js';
-export type { CapabilityNameCollisionDiagnostic } from './capability-name-collision.js';
-// [#18091] The seeders' remaining refusals. EXPORTED on the criterion the two
-// blocks above state and no wider: a refusal an AUTHOR can cause has a second
+// [#18091] The seeders' remaining refusals. EXPORTED on the criterion the
+// block above states and no wider: a refusal an AUTHOR can cause has a second
// door by construction — the author-time one (`@objectstack/lint`, `os build` /
// `os validate`) — and a door that re-spells the token or re-derives the
// wording is the drift these modules exist to prevent. A package declaring a
diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts
index a49f9ed47b3..497c951738d 100644
--- a/packages/plugins/plugin-security/src/security-plugin.test.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.test.ts
@@ -4409,11 +4409,11 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185
/**
* Boot the plugin far enough to run `runBootstrap` to completion, with the
- * declarations served off the METADATA SERVICE — the seeder's own fallback
- * source, and the one that answers at this moment in the boot: the anchor
- * binding runs BEFORE `bootstrapDeclaredCapabilities` writes any
- * `sys_capability` row. (The registry door is pinned on the other consumer,
- * `suggested-audience-bindings.test.ts`.)
+ * stack's declarations held where a real boot holds them: in the engine
+ * registry, under the declaring package (`registerApp` stamps `_packageId`),
+ * beside the platform's own curated declarations. That registry is a declared
+ * capability's one home (ADR-0131 D3), and no `sys_capability` row is written
+ * for it.
*/
async function boot(token: string, declaredCapabilities: any[]) {
const tables: Record = {
@@ -4435,6 +4435,9 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185
listItems: (type: string) => [...items.entries()].filter(([k]) => k.startsWith(`${type}:`)).map(([, v]) => v),
isPackageDisabled: () => false,
};
+ for (const capability of declaredCapabilities) {
+ registry.registerItem('capability', capability, 'name', 'com.example.app');
+ }
const ql: any = {
registry,
registerMiddleware: () => {},
@@ -4476,7 +4479,7 @@ describe('audience-anchor bindings read the stack\'s declared capabilities (#185
};
const metadata = {
get: async () => null,
- list: async (type: string) => (type === 'capability' ? declaredCapabilities : []),
+ list: async () => [],
};
const services: Record = {
manifest: { register: vi.fn() },
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index 08de4091d4c..29fdb7f24d6 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -87,7 +87,6 @@ import {
seedCtx,
} from './organization-scope.js';
import { normalizeManagedByVocab } from './normalize-managed-by.js';
-import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
import { readDeclaredCapabilityContext } from './declared-capability-context.js';
import { RLSCompiler, RLS_DENY_FILTER, compiledPolicyNameOf, policyDeclaresClause } from './rls-compiler.js';
import {
@@ -179,7 +178,7 @@ import {
SECURITY_PLUGIN_ID,
} from './manifest.js';
import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js';
-import { registerBuiltinCapabilities, withoutPlatformCapabilityDeclarations } from './builtin-capabilities.js';
+import { registerBuiltinCapabilities } from './builtin-capabilities.js';
/**
* [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the
@@ -4700,10 +4699,9 @@ export class SecurityPlugin implements Plugin {
// [#18535, ADR-0090 D5] The stack's declared `capabilities:` — the half
// of the anchor question the predicate cannot discover for itself. Read
// ONCE per pass, from the DECLARATIONS rather than from `sys_capability`:
- // this binding runs before `bootstrapDeclaredCapabilities` seeds those
- // rows (see `declared-capability-context.ts` for why that order is
- // fixed), so the rows are empty here on a first boot. Unreadable or
- // absent ⇒ `undefined` ⇒ the pre-#17811 verdict, which refuses.
+ // the registry is a package capability's one home (ADR-0131 D3), and no
+ // row is seeded for it. Unreadable or absent ⇒ `undefined` ⇒ the
+ // pre-#17811 verdict, which refuses.
const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata);
for (const baselineName of this.baselinePermissionSets) {
const boot = this.bootstrapPermissionSets.find((p) => p.name === baselineName);
@@ -5023,27 +5021,6 @@ export class SecurityPlugin implements Plugin {
} catch (e) {
ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message });
}
- // [ADR-0066 D1] Seed the EXPLICIT package capability declarations
- // (`defineCapability` / `stack.capabilities`) into `sys_capability` with
- // `managed_by:'package'` + package_id provenance. The permission sets are
- // passed as well, so a refusal can name the grantor(s) it affects (#4967
- // Part 3).
- // [ADR-0131 D3] The platform's curated capabilities get no row here: they
- // are served by the registry (`registerBuiltinCapabilities`,
- // `GET /api/v1/meta/capability`), and the curated + derived-default
- // capability seeder is retired.
- try {
- // [ADR-0131 D3] Handed the engine WITHOUT this plugin's own curated
- // declarations (`builtin-capabilities.ts`): they are not a package's,
- // and read here each would be refused as a package claiming a curated
- // name.
- await bootstrapDeclaredCapabilities(withoutPlatformCapabilityDeclarations(ql), this.metadata, {
- logger: ctx.logger,
- permissionSets: this.bootstrapPermissionSets,
- });
- } catch (e) {
- ctx.logger.warn('[security] declared-capability seeding failed', { error: (e as Error).message });
- }
// [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped
// 'user'; older positions stamped system/config/user) onto the unified
// platform/package/admin vocab. Runs after the seeders so their canonical
diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts
index 2e0e2cd0fde..7e9b07d8b1d 100644
--- a/packages/runtime/src/app-plugin.ts
+++ b/packages/runtime/src/app-plugin.ts
@@ -1082,8 +1082,8 @@ export class AppPlugin implements Plugin {
['positions', 'position'],
['permissions', 'permission'],
// [ADR-0066 D1] Package-declared authorization capabilities —
- // read back by bootstrapDeclaredCapabilities to seed
- // sys_capability with package provenance.
+ // read back by the security catalog read and the anchor
+ // predicates' declared-capability context (ADR-0131 D3).
['capabilities', 'capability'],
['sharingRules', 'sharing_rule'],
// `['policies', 'policy']` removed at #12894, together with
diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts
index 728fe978a21..a33e457181a 100644
--- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts
+++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts
@@ -14,7 +14,8 @@
// one FIRST:
//
// bootstrap-declared-permissions.ts readDeclared(ql, 'permission') then metadataService.list
-// bootstrap-declared-capabilities.ts readDeclared(ql, 'capability') then metadataService.list
+// (bootstrap-declared-capabilities.ts did the same for 'capability' until
+// ADR-0131 D3 retired it: a declared capability now has no row at all)
// bootstrap-declared-sharing-rules.ts readDeclared(engine,'sharing_rule') then metadataService.list
//
// Triage (14491#issuecomment-5507909226) ruled the card down to four questions
@@ -353,22 +354,10 @@ describe('#14491 Q3 — whether a `capability` arrives without its `scope` defau
});
});
- it('ABSORBED: `capabilityRowFields` supplies its OWN `platform` default, so the row is indistinguishable from the door-fed one', () => {
- // `scope: cap.scope === 'org' ? 'org' : 'platform'` — the seeder never
- // reads the door's value, it re-derives an equal one. The equality is a
- // coincidence of two independent defaults, not a normalisation of the
- // door's copy; this row records that it currently holds.
- const row = byName(rows.sys_capability, 'probe.export');
- expect(row).toMatchObject({
- name: 'probe.export',
- label: 'Export probe data',
- description: 'Capability probe.export.', // also the seeder's own default
- scope: 'platform',
- managed_by: 'package',
- package_id: 'com.test.issue-14491',
- active: true,
- });
+ it('no row: a declared capability is served by the registry and no `sys_capability` row is written for it (ADR-0131 D3)', () => {
+ expect(rows.sys_capability.filter((r: any) => r.name === 'probe.export')).toEqual([]);
});
+
});
describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins in the persisted row', () => {
diff --git a/packages/spec/liveness/capability.json b/packages/spec/liveness/capability.json
index 9c3a6a9850c..b052f032c3e 100644
--- a/packages/spec/liveness/capability.json
+++ b/packages/spec/liveness/capability.json
@@ -1,35 +1,35 @@
{
"type": "capability",
- "_note": "CapabilityDeclarationSchema (ADR-0066 D1). The DECLARATION side of the three-way separation — packages DEFINE a capability here, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. Seeded 2026-08-08 with #5961, the PR that made `capability` a registered metadata kind; every property was call-graph-closed at that time against `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`, which is the one consumer that turns a declaration into a `sys_capability` row. The ADR-0010 envelope keys carry the same `null` verdict as `permission`/`position`: they are loader-stamped, not authored. 2026-08-28 (commit 8f10a79f7): all six `path:NNN` citations in this file were re-anchored to their consuming symbols. Five of the six were wrong and every one was IN RANGE; the three row-field pointers had drifted as a block onto three consecutive lines of one unrelated docblock, which reads as precision right up until you open the file.",
+ "_note": "CapabilityDeclarationSchema (ADR-0066 D1). The DECLARATION side of the three-way separation — packages DEFINE a capability here, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. Seeded 2026-08-08 with #5961, the PR that made `capability` a registered metadata kind; every property was call-graph-closed at that time against `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`, then the one consumer that turned a declaration into a `sys_capability` row. 2026-10-10 (ADR-0131 D3): that seeder is deleted — a package's declared capability is served by the registry, its one home, and no row is written for it — so the five authored properties were re-anchored to the readers that remain. The ADR-0010 envelope keys carry the same `null` verdict as `permission`/`position`: they are loader-stamped, not authored. 2026-08-28 (commit 8f10a79f7): all six `path:NNN` citations in this file were re-anchored to their consuming symbols. Five of the six were wrong and every one was IN RANGE; the three row-field pointers had drifted as a block onto three consecutive lines of one unrelated docblock, which reads as precision right up until you open the file.",
"props": {
"name": {
"status": "live",
- "verifiedAt": "2026-08-28",
- "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#upsertPackageCapability (the name is the `sys_capability` upsert key and is matched against `PLATFORM_CAPABILITY_NAMES` to refuse a package shadowing a curated capability; it is also the string `systemPermissions` / `requiredPermissions` resolve a grant by); packages/lint/src/validate-capability-references.ts#validateCapabilityReferences (`known.add(cap.name)` — the authoring lint's known-name set)",
+ "verifiedAt": "2026-10-10",
+ "evidence": "packages/objectql/src/security-catalog-namespace.ts#declaredSecurityCatalogNames (the name is the key the one-holder rule refuses a second registry holder of, a curated platform name included); packages/plugins/plugin-security/src/declared-capability-context.ts#readDeclaredCapabilityContext (the anchor predicates excuse a package-declared `systemPermissions` token by this name); packages/lint/src/validate-capability-references.ts#validateCapabilityReferences (`known.add(cap.name)` — the authoring lint's known-name set)",
"note": "The whole identity of a capability. This is also why the kind's write door matters: the name is resolved by string from both the grant side and the requirement side, so an unvalidated row lands directly in the authorization namespace (#5961). 2026-08-28: RE-ANCHORED (commit 8f10a79f7), and REPOINTED on the bootstrap leg — `:203-210` had rotted onto the opening `/**` of the unowned-declaration diagnostic's docblock; `upsertPackageCapability` begins ~43 lines below it. The lint leg was ACCURATE (`:99` is still `known.add(cap.name)`) and is migrated, not repaired. Re-closed by hand against 8cb96ec41."
},
"label": {
"status": "live",
- "verifiedAt": "2026-08-28",
- "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#capabilityRowFields (`label: typeof cap.label === 'string' && cap.label ? cap.label : humanize(cap.name)` — written to `sys_capability.label`)",
+ "verifiedAt": "2026-10-10",
+ "evidence": "objectui: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx (the default `label` / `description` columns of every metadata type's list, the capability list from `GET /api/v1/meta/capability` included; read at the pinned `.objectui-sha` 20c6d351ad)",
"note": "display (Setup's capability list/detail, rendered from the sys_capability row). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:122` had rotted onto a docblock sentence about the `unchanged` outcome counter, ~45 lines above `capabilityRowFields`. Its two siblings cited `:123` and `:124`, the next two lines of that same docblock: the three row-field pointers had drifted as ONE block, which is why none of them looked wrong beside the others. Re-closed by hand against 8cb96ec41."
},
"description": {
"status": "live",
- "verifiedAt": "2026-08-28",
- "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#capabilityRowFields (`description: … ? cap.description : `Capability ${cap.name}.`` — written to `sys_capability.description`)",
+ "verifiedAt": "2026-10-10",
+ "evidence": "objectui: packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx (the default `label` / `description` columns of every metadata type's list, the capability list from `GET /api/v1/meta/capability` included; read at the pinned `.objectui-sha` 20c6d351ad)",
"note": "display. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:123` had rotted onto the closing `*/` of that docblock (see the `label` entry: all three of this file's row-field pointers moved together). Re-closed by hand against 8cb96ec41."
},
"scope": {
"status": "live",
- "verifiedAt": "2026-08-28",
- "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#capabilityRowFields (`scope: cap.scope === 'org' ? 'org' : 'platform'` — normalized on an exact `org` match, else `platform`)",
+ "verifiedAt": "2026-10-10",
+ "evidence": "objectui: packages/app-shell/src/views/metadata-admin/SchemaForm.tsx (the item page renders each property of the type's served schema, `scope` included, for a capability read from `GET /api/v1/meta/capability/:name`; read at the pinned `.objectui-sha` 20c6d351ad) — display",
"note": "platform vs org decides whether holding the capability is a global power or one scoped to the caller's organization — the same distinction PLATFORM_CAPABILITIES carries on the curated side. Not display: it is the field that would be escalated if a tenant could overlay a package-shipped declaration, which is why the #5961 registry entry sets `supportsOverlay: false` / `allowOrgOverride: false`. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:124` had rotted onto `unchanged: number;`, the counter field itself (see the `label` entry). Re-closed by hand against 8cb96ec41."
},
"packageId": {
"status": "live",
- "verifiedAt": "2026-08-28",
- "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#bootstrapDeclaredCapabilities (`const packageId: string | undefined = cap._packageId ?? cap.packageId ?? undefined` — the ADR-0086 D3 author-declared FALLBACK provenance, consulted when the registry stamp is absent); packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts#upsertPackageCapability (with neither, this takes the no-owning-package refusal and the capability is materialized with no package provenance)",
+ "verifiedAt": "2026-10-10",
+ "evidence": "packages/metadata/src/metadata-manager.ts#unregisterPackage (an item whose `packageId` names the package is unregistered with it); packages/metadata/src/metadata-manager.ts#collectPackageMembers (the same key makes it a member of the package's publish and revert)",
"note": "Deliberately a fallback and not the primary: #5870 added `capabilities` to the ObjectQL engine's stamped-collection list (packages/objectql/src/engine.ts:2393), so `_packageId` now reaches a declaration and takes precedence. The key stays live because the fallback branch is still read and still decides materialization for any declaration that arrives unstamped. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:294` had rotted onto a `//` continuation line inside the unowned-declaration refusal's comment block, and the read itself had moved OUT of `upsertPackageCapability` into its caller, which is the distinction the two anchors now hold apart. The note's `engine.ts:2393` pointer was stale in the same way; the stamped-collection list is `packages/objectql/src/engine.ts#METADATA_ARRAY_KEYS`. Re-closed by hand against 8cb96ec41."
},
"_lock": null,
diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts
index 47f68e76cb5..78c1b68d3e9 100644
--- a/packages/spec/src/kernel/metadata-plugin.zod.ts
+++ b/packages/spec/src/kernel/metadata-plugin.zod.ts
@@ -169,7 +169,8 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([
// `declared ≠ enforced` that #5271 closed for `api`. `PLURAL_TO_SINGULAR`
// has mapped `capabilities` → `capability` since #5870, `AppPlugin`
// registers stack-declared capabilities under that exact name, and
- // `bootstrapDeclaredCapabilities` reads them back — but the kind was absent
+ // the declared-capability seeder (since retired, ADR-0131 D3) read them back —
+ // but the kind was absent
// from this enum, from `BUILTIN_METADATA_TYPE_SCHEMAS` and from
// `DEFAULT_METADATA_TYPE_REGISTRY`, so it resolved no schema and
// `PUT /api/v1/meta/capability/:name` stored ANY JSON on an authorization
@@ -1134,9 +1135,9 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] =
//
// The package-declaration channel is untouched: `AppPlugin` registers stack
// `capabilities[]` through `registerInMemory`, and the filesystem loader
- // globs `filePatterns` — neither goes through `saveMetaItem`, so
- // `bootstrapDeclaredCapabilities` still seeds `sys_capability` exactly as
- // before. `OS_METADATA_WRITABLE=capability` remains the ONE documented
+ // globs `filePatterns` — neither goes through `saveMetaItem`, so a package's
+ // declarations still reach the registry, their one home (ADR-0131 D3).
+ // `OS_METADATA_WRITABLE=capability` remains the ONE documented
// operator escape hatch (ADR-0005), and behind it the write is now judged by
// `CapabilityDeclarationSchema` (422) instead of being stored unvalidated.
//
diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts
index 962956ac0b8..7e106638c0c 100644
--- a/packages/spec/src/kernel/metadata-type-schemas.ts
+++ b/packages/spec/src/kernel/metadata-type-schemas.ts
@@ -169,8 +169,8 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> =
// authorization capabilities (ADR-0066 D1). The kind was produced and read
// back long before it resolved a schema: `PLURAL_TO_SINGULAR` maps
// `capabilities` → `capability` (#5870), `AppPlugin` registers stack-declared
- // ones under that name, and `bootstrapDeclaredCapabilities` reads them back to
- // seed `sys_capability`. Yet `getMetadataTypeSchema('capability')` answered
+ // ones under that name, and the declared-capability seeder (since retired,
+ // ADR-0131 D3) read them back to seed `sys_capability`. Yet `getMetadataTypeSchema('capability')` answered
// `undefined`, so `saveMetaItem` took its documented "unregistered type →
// store without validation" branch and `PUT /meta/capability/:name` accepted
// ANY JSON — on an AUTHORIZATION surface, where `systemPermissions` /
diff --git a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts
index d86acfad8a2..4bbab747f92 100644
--- a/packages/spec/src/meta-spelling/manifest-collection-spelling.ts
+++ b/packages/spec/src/meta-spelling/manifest-collection-spelling.ts
@@ -76,8 +76,8 @@ export const PLURAL_TO_SINGULAR: Record = {
positions: 'position',
permissions: 'permission',
// [ADR-0066 D1, #5870] Package-declared authorization capabilities. The
- // singular form is what `bootstrapDeclaredCapabilities` reads back
- // (`readDeclared(ql, 'capability')`) and what `AppPlugin` already registers
+ // singular form is what the registry and the security catalog read back
+ // (`listItems('capability')`) and what `AppPlugin` already registers
// under; without the mapping the registration seam stored them as
// `'capabilities'`, a store nothing reads.
capabilities: 'capability',
diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts
index 076c4518e3e..3b9af37f00d 100644
--- a/packages/spec/src/security/capabilities.ts
+++ b/packages/spec/src/security/capabilities.ts
@@ -93,9 +93,8 @@ export const PLATFORM_CAPABILITY_NAMES: ReadonlySet = new Set(
* capability of its own — the counterpart, on the declaration side, of the
* curated {@link PLATFORM_CAPABILITIES}. Authored via {@link defineCapability}
* and collected on a stack's `capabilities` array, these declarations flow
- * EXPLICITLY into the `sys_capability` registry at boot with `managed_by:
- * 'package'` + `package_id` provenance (seeded by
- * `@objectstack/plugin-security`'s `bootstrapDeclaredCapabilities`).
+ * EXPLICITLY into the registry at boot under their owning package (ADR-0131
+ * D3); no `sys_capability` row is written for them.
*
* This replaces the old implicit "derive an untitled capability from whatever a
* permission set happens to reference in `systemPermissions[]`" back-door: a
@@ -127,8 +126,8 @@ export const PLATFORM_CAPABILITY_NAMES: ReadonlySet = new Set(
* - **The shape is `.strict()`.** Unlike `api` (which is on that suite's
* `STILL_STRIP` list because `ApiEndpointSchema` doubles as the parser for
* STORED rows carrying `packageId` / `state`), nothing re-parses a stored
- * `sys_capability` row through this schema — `bootstrapDeclaredCapabilities`
- * reads named fields off the body (`capabilityRowFields`). So closing it
+ * `sys_capability` row through this schema — no row is written for a
+ * declared capability (ADR-0131 D3). So closing it
* costs nothing and buys the declared = enforced posture an AUTHORIZATION
* surface should have had from the start.
*/
diff --git a/packages/spec/src/security/high-privilege.ts b/packages/spec/src/security/high-privilege.ts
index 07b0383d7ab..5cfa29d8344 100644
--- a/packages/spec/src/security/high-privilege.ts
+++ b/packages/spec/src/security/high-privilege.ts
@@ -31,23 +31,16 @@ function coerceRecord(v: unknown): Record | undefined {
* The predicates are pure and synchronous — they read one permission-set
* definition and nothing else — so the one fact they cannot discover for
* themselves is which capability names this stack DECLARED. The caller holds
- * it: at boot from the stack's own `capabilities:` DECLARATIONS — and from
- * the `sys_capability` rows carrying `managed_by: 'package'` provenance only
- * ONCE THE SEEDER HAS WRITTEN THEM — at authoring time from the stack's own
- * `capabilities` array.
+ * it: at boot from the stack's own `capabilities:` DECLARATIONS in the
+ * registry, at authoring time from the stack's own `capabilities` array.
*
- * ⚠️ [#18603] Why the boot half names the DECLARATIONS and not the rows: the
- * ADR-0090 D5 anchor binding (`bindBaselineToEveryone` in
- * `@objectstack/plugin-security`) runs BEFORE `bootstrapDeclaredCapabilities`,
- * the seeder that WRITES those `managed_by: 'package'` rows, and that order is
- * fixed by two other constraints — the binding must follow the seeding of the
- * `everyone` anchor it binds to, and precede the audience-binding suggestion
- * reconciliation. On a first boot the table is therefore EMPTY at the bind
- * moment, and "omission refuses" below turns that emptiness into a silent
- * refusal of every declared token. A boot caller reads the declarations
- * through the seeder's own two-step — the ObjectQL registry first, the
- * metadata service as the fallback — as `readDeclaredCapabilityContext`
- * (`@objectstack/plugin-security`) does.
+ * ⚠️ [#18603] Why the boot half names the DECLARATIONS and not the rows: no
+ * `sys_capability` row is written for a declared capability — the registry is
+ * its one home (ADR-0131 D3) — so a row read would find nothing, and
+ * "omission refuses" below would turn that emptiness into a silent refusal of
+ * every declared token. A boot caller reads the declarations from the ObjectQL
+ * registry first, the metadata service as the fallback, as
+ * `readDeclaredCapabilityContext` (`@objectstack/plugin-security`) does.
*
* ⛔ Never synthesize this from the set under test. The point of the input is
* that a set cannot vouch for its own tokens; a "declared" list derived from
diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json
index 1bf69c0399c..04cbe231c31 100644
--- a/scripts/engine-double-contract.baseline.json
+++ b/scripts/engine-double-contract.baseline.json
@@ -392,14 +392,6 @@
"why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.",
"closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)"
},
- {
- "file": "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts",
- "verb": "update",
- "unguarded": 1,
- "kind": "DEBT",
- "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 10. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.",
- "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)"
- },
{
"file": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts",
"verb": "update",