From d698804811117ed802192d8b91ca71219c631f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:24:40 +0000 Subject: [PATCH 01/11] wip: delete the declared capability seeder and capability-name-collision --- .../bootstrap-declared-capabilities.test.ts | 935 ------------------ .../src/bootstrap-declared-capabilities.ts | 547 ---------- .../src/capability-name-collision.test.ts | 202 ---- .../src/capability-name-collision.ts | 224 ----- packages/plugins/plugin-security/src/index.ts | 13 - .../plugin-security/src/security-plugin.ts | 44 +- 6 files changed, 10 insertions(+), 1955 deletions(-) delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts delete mode 100644 packages/plugins/plugin-security/src/capability-name-collision.test.ts delete mode 100644 packages/plugins/plugin-security/src/capability-name-collision.ts 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 89f4b88b1e7..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts +++ /dev/null @@ -1,935 +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 { bootstrapSystemCapabilities } from './bootstrap-system-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. `bootstrapSystemCapabilities` - // (called by several cases below) scopes its curated lookup with - // `organization_id: null`, which strict `===` would make 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, measured for the sibling double in - // `bootstrap-system-capabilities.test.ts`; this one 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 () => { - // Simulate the back-compat path: bootstrapSystemCapabilities derived a - // placeholder from a permission set's systemPermissions. - const ql = makeQl([{ name: 'export_data', label: 'Export Data', description: 'Nice.', scope: 'org', _packageId: 'com.acme.reports' }]); - await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }]); - 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('declared name suppresses the implicit derived placeholder (no clobber)', async () => { - // Full boot order: declared first, then system with materializedNames. - const ql = makeQl([{ name: 'export_data', label: 'Export Data', scope: 'org', _packageId: 'com.acme.reports' }]); - const cap = await bootstrapDeclaredCapabilities(ql, null); - await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }], { - materializedCapabilityNames: cap.materializedNames, - }); - const row = ql.rows.find((r) => r.name === 'export_data'); - // The package row is untouched — no humanized placeholder overwrote it. - expect(row).toMatchObject({ managed_by: 'package', 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] A REFUSED declaration must not suppress the back-compat -// derivation. `materializedNames` reports the names this pass CONFIRMED have a -// row — the three refusal paths land on different sides of that line, for -// different reasons, so each gets its own pin (and, where the direction is not -// obvious, the reverse case that shows what the other answer would cost). -// ─────────────────────────────────────────────────────────────────────────── -describe('refused declarations vs. the derived placeholder (#4967 Part 1)', () => { - const OPS_SETS = [{ name: 'showcase_ops', systemPermissions: ['setup.access', 'showcase.export_data'] }]; - - it('NO OWNING PACKAGE + no row: falls through, so the derivation materializes it', async () => { - // The showcase repro: `showcase.export_data` declared without a resolvable - // owner, granted by `showcase_ops`. - const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]); - const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS }); - - // The declaration is refused — no package row, and (the fix) the name is - // NOT reported as materialized. - expect(out.seeded).toBe(0); - expect(out.skippedUnowned).toBe(1); - expect(out.materializedNames).toEqual([]); - - // Second pass — the capability now exists, as the back-compat placeholder - // it would have had if the declaration had never been written. - await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames }); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - name: 'showcase.export_data', managed_by: 'platform', active: true, - }); - // …and it RESOLVES by name, which is what the granting permission set needs - // from the registry (Setup listing, provenance, ADR-0066 ⑨ lint sources). - expect(await ql.find('sys_capability', { where: { name: 'showcase.export_data' } })).toHaveLength(1); - }); - - it('REVERSE: the pre-#4967 list (every declared name) leaves the capability in no row at all', async () => { - // Same fixture, same second pass — only the skip list is the old one, which - // reported a name the first pass refused to write. Nothing derives it and - // nothing declared it: the hole. - const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]); - await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS }); - await bootstrapSystemCapabilities(ql, OPS_SETS, { - materializedCapabilityNames: ['showcase.export_data'], // ← the old `declaredNames` - }); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined(); - }); - - it('is stable across boots: the refusal re-derives nothing and duplicates nothing', async () => { - const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]); - for (let boot = 0; boot < 2; boot += 1) { - const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS }); - await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames }); - } - expect(ql.rows.filter((r) => r.name === 'showcase.export_data')).toHaveLength(1); - // Boot 2 finds the placeholder, so the refusal now reports the name — the - // row exists and must not be re-derived over. - const out2 = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS }); - expect(out2.skippedUnowned).toBe(1); - expect(out2.materializedNames).toEqual(['showcase.export_data']); - }); - - it('NO OWNING PACKAGE + an admin row: still suppresses, so the placeholder cannot clobber it', async () => { - const ql = makeQl([{ name: 'showcase.export_data', label: 'Declared Label' }]); - ql.rows.push({ id: 'cap_admin', name: 'showcase.export_data', label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin' }); - const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: OPS_SETS }); - expect(out.skippedUnowned).toBe(1); - expect(out.materializedNames).toEqual(['showcase.export_data']); - await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: out.materializedNames }); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin', - }); - }); - - it('SECOND LAYER (#5876): dropping that name from the list no longer overwrites the admin row', async () => { - // This pin was written by #5875 (as `REVERSE: dropping that name from the - // list lets the derivation overwrite the admin row`) to justify why the - // unowned path checks for an EXISTING row instead of always falling - // through: back then the derived defaults refreshed label/description on - // ANY row they found, so the skip list was the ONLY thing standing between - // an admin-authored row and a humanized placeholder. That fact is fixed — - // #5876 guards the derived reconcile by `managed_by`, so the write site - // now refuses the clobber even when the call site forgets to suppress it. - // - // The suppression list keeps its job (it is what states the boot-order - // contract, and it stops the derivation doing work on names another pass - // owns), but it is no longer load-bearing for THIS shape — which is the - // point of a second layer. - const ql = makeQl([]); - ql.rows.push({ id: 'cap_admin', name: 'showcase.export_data', label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin' }); - await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] }); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - label: 'Admin Made', description: 'Admin wrote this.', managed_by: 'admin', - }); - }); - - it('DISCRIMINATION (#5876): with the name dropped, a PLATFORM placeholder is still refreshed', async () => { - // The guard above must not read as "the derivation stopped writing". Same - // fixture, same empty skip list, provenance flipped to the one the derived - // pass owns → the reconcile happens exactly as it always did. - const ql = makeQl([]); - ql.rows.push({ id: 'cap_derived', name: 'showcase.export_data', label: 'Stale Placeholder', description: 'Stale.', managed_by: 'platform' }); - await bootstrapSystemCapabilities(ql, OPS_SETS, { materializedCapabilityNames: [] }); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - label: 'Showcase Export Data', description: 'Capability showcase.export_data.', managed_by: 'platform', - }); - }); - - it('FOREIGN owner: suppresses, because the other package authored that row', async () => { - const sets = [{ name: 'ops', systemPermissions: ['shared_cap'] }]; - const ql = makeQl([{ name: 'shared_cap', label: 'Mine', _packageId: 'com.b' }]); - ql.rows.push({ id: 'cap_x', name: 'shared_cap', label: 'Owner Label', description: 'Owner wrote this.', managed_by: 'package', package_id: 'com.a' }); - const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: sets }); - expect(out.skippedForeign).toBe(1); - expect(out.materializedNames).toEqual(['shared_cap']); - await bootstrapSystemCapabilities(ql, sets, { materializedCapabilityNames: out.materializedNames }); - expect(ql.rows.find((r) => r.name === 'shared_cap')).toMatchObject({ - label: 'Owner Label', description: 'Owner wrote this.', package_id: 'com.a', - }); - }); - - it('CURATED platform name: suppresses, and the curated pass seeds the row regardless', async () => { - // A no-op for the skip list (the derived path never reaches a curated name - // — it is already in the curated map), but a truthful answer: the row - // exists after the second pass either way. - const sets = [{ name: 'ops', systemPermissions: ['manage_users'] }]; - const ql = makeQl([{ name: 'manage_users', label: 'Evil', _packageId: 'com.acme.evil' }]); - const out = await bootstrapDeclaredCapabilities(ql, null, { permissionSets: sets }); - expect(out.skippedPlatform).toBe(1); - expect(out.materializedNames).toEqual(['manage_users']); - await bootstrapSystemCapabilities(ql, sets, { materializedCapabilityNames: out.materializedNames }); - expect(ql.rows.find((r) => r.name === 'manage_users')).toMatchObject({ - label: 'Manage Users', managed_by: 'platform', // curated definition, not the package's - }); - }); - - 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 512ea4ef420..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 './per-organization-catalog.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/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/index.ts b/packages/plugins/plugin-security/src/index.ts index c3220bcd134..29eedd8ebe5 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -72,19 +72,6 @@ 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 // door by construction — the author-time one (`@objectstack/lint`, `os build` / diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2c22025483d..c4a20a7edaf 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -87,7 +87,6 @@ import { } from './per-organization-catalog.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.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 { 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 @@ -4660,10 +4659,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); @@ -4983,34 +4981,12 @@ 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 capability registry (sys_capability) in two - // passes. FIRST the EXPLICIT package declarations (`defineCapability` / - // `stack.capabilities`) land with `managed_by:'package'` + package_id - // provenance — the formal replacement for the implicit derive-from- - // systemPermissions back-door. THEN the platform curated set + the - // back-compat derived defaults, SKIPPING any name that already HAS a row - // (so the placeholder never clobbers the authored capability). - // [#4967 Part 1] The skip list is the names the first pass confirmed are - // materialized — not every name it read. A declaration the first pass - // REFUSES (no owning package) writes no row, so skipping its derivation - // too left the capability existing nowhere and every grant naming it - // inert; it now falls through to the placeholder. The permission sets are - // passed to the first pass as well, so a refusal can name the grantor(s) - // it affects (#4967 Part 3). - let materializedCapabilityNames: string[] = []; - try { - // [ADR-0131 D3] Handed the engine WITHOUT this plugin's own curated - // declarations (`builtin-capabilities.ts`): they are not a package's, - // the curated pass below seeds their rows, and read here each would be - // refused as a package claiming a curated name. - const capOutcome = await bootstrapDeclaredCapabilities(withoutPlatformCapabilityDeclarations(ql), this.metadata, { - logger: ctx.logger, - permissionSets: this.bootstrapPermissionSets, - }); - materializedCapabilityNames = capOutcome.materializedNames; - } catch (e) { - ctx.logger.warn('[security] declared-capability seeding failed', { error: (e as Error).message }); - } + // [ADR-0131 D3] A package's declared capabilities (`defineCapability` / + // `stack.capabilities`) are served by the registry, their one home; no + // `sys_capability` row is seeded for them, so no declared name is + // materialized here, and a second holder of a name is refused by the + // registry's one-holder rule (`security-catalog-namespace.ts`). + const materializedCapabilityNames: string[] = []; try { await bootstrapSystemCapabilities(ql, this.bootstrapPermissionSets, { logger: ctx.logger, From 76890c8079b62d628073e8c0e3832e7892dc66e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:28:54 +0000 Subject: [PATCH 02/11] wip: drop the transitional capability view and trim seeder tests --- .../src/bootstrap-seed-round-trips.test.ts | 323 ------------------ .../src/builtin-capabilities.test.ts | 95 +----- .../src/builtin-capabilities.ts | 75 +--- .../src/declared-capability-context.ts | 31 +- 4 files changed, 32 insertions(+), 492 deletions(-) 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 efe84a4d282..baa185ed25a 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'; import { bootstrapSystemCapabilities, KNOWN_CAPABILITIES } from './bootstrap-system-capabilities.js'; /** @@ -458,322 +451,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 c33f18592] `bootstrapSystemCapabilities` — the OTHER capability seeder, whose * definition set is the union of every `systemPermissions[]` string rather than diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts index deca0428bef..ba366ac8110 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 9eae036ed08..5cb988fcf77 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/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'); From b126437a6d2796e2b59c443ed6b23ce6495da517 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:33:48 +0000 Subject: [PATCH 03/11] wip: pin the census without the package row --- .../src/builtin-capabilities.boot.test.ts | 20 +++++++++---------- .../src/builtin-capabilities.test.ts | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) 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 3d8ab46cdd7..bbc9f818206 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 - * one the boot wrote before the curated capabilities were declared — the - * curated pass's rows plus the stack's package row — which is why an - * unchanged census is the pin that the declarations changed no row. + * curated pass's rows and nothing else: the stack's declared capability is + * served by the registry, its one home (ADR-0131 D3), and no row is written + * for it. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -193,10 +193,9 @@ async function census(engine: ObjectQL): Promise { } /** The census the boot writes, read off the producers (module doc). */ -const EXPECTED_CENSUS = [ - ...PLATFORM_CAPABILITIES.map((c) => [c.name, 'platform', '-', c.label, c.description, c.scope, true].join(' | ')), - [STACK_CAPABILITY.name, 'package', STACK_PACKAGE, STACK_CAPABILITY.label, STACK_CAPABILITY.description, STACK_CAPABILITY.scope, true].join(' | '), -].sort(); +const EXPECTED_CENSUS = PLATFORM_CAPABILITIES + .map((c) => [c.name, 'platform', '-', c.label, c.description, c.scope, true].join(' | ')) + .sort(); const SCENARIOS: ReadonlyArray<{ posture: Posture; declared: Declared }> = [ { posture: 'single', declared: 'in the registry' }, @@ -248,9 +247,10 @@ describe.each(SCENARIOS)('a $posture boot, the stack capability $declared', ({ p } }); - it('the boot writes the census it wrote before the declarations, on a fresh and on a seeded database', async () => { + it('the boot writes the curated rows and no row for the stack’s declared capability, 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); + expect(fresh.writes.filter((w) => w.endsWith(` ${STACK_CAPABILITY.name}`))).toEqual([]); // The seeded database needs no write at all: every row already reads as declared. 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 ba366ac8110..a0c47505d3b 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.test.ts @@ -65,12 +65,12 @@ describe('readDeclaredCapabilityContext, beside the curated declarations', () => 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(); + 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(); + expect(describeHighPrivilegeBits(setGranting(APP.name), curatedOnly)) + .toEqual(describeHighPrivilegeBits(setGranting(APP.name), undefined)); + expect(describeHighPrivilegeBits(setGranting(APP.name), undefined)).not.toBeNull(); }); }); From 618a789e980795ee5adf353a8016c69e8abfdff4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:42:18 +0000 Subject: [PATCH 04/11] wip: re-anchor capability liveness evidence; drop the deleted test's double-contract entry --- packages/spec/liveness/capability.json | 22 ++++++++++---------- scripts/engine-double-contract.baseline.json | 8 ------- 2 files changed, 11 insertions(+), 19 deletions(-) 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/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 42685321c78..472269db4ec 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", From 230f51aac0d9b715709b66149be98d46fff6f3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:44:04 +0000 Subject: [PATCH 05/11] wip: changeset, checklist item and false comments re-pointed at the registry --- .../15204-s6b1c-declared-capability-seeder.md | 24 +++++++++++++++++++ .../areas/access-security.json | 18 +++++++------- .../app-showcase/src/security/capabilities.ts | 7 +++--- .../src/validate-capability-references.ts | 2 +- packages/objectql/src/engine.ts | 4 ++-- packages/plugins/plugin-security/src/index.ts | 4 ++-- packages/runtime/src/app-plugin.ts | 4 ++-- 7 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 .changeset/15204-s6b1c-declared-capability-seeder.md diff --git a/.changeset/15204-s6b1c-declared-capability-seeder.md b/.changeset/15204-s6b1c-declared-capability-seeder.md new file mode 100644 index 00000000000..6347eba1fc6 --- /dev/null +++ b/.changeset/15204-s6b1c-declared-capability-seeder.md @@ -0,0 +1,24 @@ +--- +'@objectstack/plugin-security': minor +--- + +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. The curated platform capabilities' rows still seed as before. + +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. + +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/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 0d6e20a8c49..deb58a603df 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -16,7 +16,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 — mirrors * the runtime `bootstrapSystemCapabilities` derived-defaults rule), and diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3177422431a..7b8b1e17718 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3374,8 +3374,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/index.ts b/packages/plugins/plugin-security/src/index.ts index 29eedd8ebe5..7e8133e8928 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -72,8 +72,8 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-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/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 From 7075f97761405a57b269fe329992a7e6e76bdb79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 14:46:27 +0000 Subject: [PATCH 06/11] wip: changeset title --- .changeset/15204-s6b1c-declared-capability-seeder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15204-s6b1c-declared-capability-seeder.md b/.changeset/15204-s6b1c-declared-capability-seeder.md index 6347eba1fc6..2e7be9ae7c2 100644 --- a/.changeset/15204-s6b1c-declared-capability-seeder.md +++ b/.changeset/15204-s6b1c-declared-capability-seeder.md @@ -2,7 +2,7 @@ '@objectstack/plugin-security': minor --- -feat(plugin-security)!: a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted +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 From 651f5cb896d03770341b21b5557a140fb542c958 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 17:41:28 +0000 Subject: [PATCH 07/11] =?UTF-8?q?wip:=20runtime=20pin=20=E2=80=94=20no=20s?= =?UTF-8?q?ys=5Fcapability=20row=20for=20a=20declared=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lone-stack-seeder-declaration-copy.test.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) 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 81418d448fe..6991c1a98b9 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 @@ -352,22 +353,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', () => { From 149f1141c7ecb76df68d59fd46fd0d1d6077094e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:00:49 +0000 Subject: [PATCH 08/11] docs(permissions): a declared capability is a registry item with no sys_capability row, and a second holder is refused by the one-holder rule --- content/docs/permissions/authorization.mdx | 20 ++++--- content/docs/permissions/capabilities.mdx | 62 ++++++++++------------ 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 426e851a9cb..17a991b37d0 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -195,20 +195,18 @@ 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 still runs for back-compat (a reference with no declaration keeps resolving as a `managed_by:'platform'` -placeholder), but an explicit `defineCapability` takes precedence and — for a -pre-existing derived placeholder — **claims** it, upgrading the row to package -provenance with the authored label/description/scope. This is the ADR-0066 D1 +placeholder), but an explicit `defineCapability` is the attributable form: its +label, description, scope and owning package live on the registry item. This is the ADR-0066 D1 direction: retire the implicit `managed_by`-guessing back-doors in favour of explicit, attributable declarations. @@ -543,8 +541,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production seeded by `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`. 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 (which stays for back-compat). 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 f08012ffd35..737e81b9a87 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 @@ -191,36 +193,28 @@ it. Do not rely on it to keep a view or a dashboard from an audience until the platform enforces it. -## 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 readers of the table are the two boot seeders. 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**. A +declared capability has no row at all, and the rows the platform still seeds +for its curated capabilities are a catalogue only: clearing a row's `active` +flag revokes nothing. Withdraw a capability by withdrawing the **grant**. ## `capability` is code-only @@ -265,7 +259,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 From c3ab722106fb16555cf20388a84bcbf087fe36e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:40:15 +0000 Subject: [PATCH 09/11] chore(changeset): name the four packages whose shipped comments or evidence changed; drop the curated-row sentence 6b-1b makes false --- .changeset/15204-s6b1c-declared-capability-seeder.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/15204-s6b1c-declared-capability-seeder.md b/.changeset/15204-s6b1c-declared-capability-seeder.md index 2e7be9ae7c2..2e843a82e11 100644 --- a/.changeset/15204-s6b1c-declared-capability-seeder.md +++ b/.changeset/15204-s6b1c-declared-capability-seeder.md @@ -1,15 +1,21 @@ --- '@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. The curated platform capabilities' rows still seed as before. +`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` From a8c5a6fdb03bae15919c0870ce82a0b7dc5ddb8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:23:03 +0000 Subject: [PATCH 10/11] test(plugin-security): the anchor-binding boot holds the stack's declared capability in the registry, where a real boot holds it --- .../plugin-security/src/security-plugin.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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() }, From b3c8d0daf228a5de710155bdf89841ade7f60f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:19:51 +0000 Subject: [PATCH 11/11] docs(spec): comments that named the declared-capability seeder say the registry is a declared capability's one home --- .../spec/src/kernel/metadata-plugin.zod.ts | 9 ++++--- .../spec/src/kernel/metadata-type-schemas.ts | 4 +-- .../manifest-collection-spelling.ts | 4 +-- packages/spec/src/security/capabilities.ts | 9 +++---- packages/spec/src/security/high-privilege.ts | 25 +++++++------------ 5 files changed, 22 insertions(+), 29 deletions(-) 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