diff --git a/.changeset/15204-s6b1b-system-capability-seeder.md b/.changeset/15204-s6b1b-system-capability-seeder.md new file mode 100644 index 00000000000..a59ca8bb25f --- /dev/null +++ b/.changeset/15204-s6b1b-system-capability-seeder.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/spec': patch +'@objectstack/lint': patch +--- + +feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + +Clause-②: no + +The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + +No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + +In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 7af47d16388..294b88ac722 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -204,10 +204,10 @@ to write into another package's capability. 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 +in `systemPermissions[]`. That derivation is retired: a reference with no +declaration still matches by name at runtime but gets no `sys_capability` row. +An explicit `defineCapability` takes precedence and — for a derived placeholder +an older database still holds — **claims** it, upgrading the row to package provenance with the authored label/description/scope. This is the ADR-0066 D1 direction: retire the implicit `managed_by`-guessing back-doors in favour of explicit, attributable declarations. @@ -543,15 +543,17 @@ The complete, prioritized gap map lives in issue **#2561** (the production - **Deny/muting layer** (ADR-0066 ⑦⑧) — union-only grants can't take access away; needed for large-org governance and packaged-set adjustment. -- **Capability registry** (ADR-0066 D1) — **landed**: capabilities are seeded - as first-class `sys_capability` records from the canonical list in +- **Capability registry** (ADR-0066 D1, ADR-0131 D3) — **landed**: the + platform's curated capabilities, from the canonical list in `@objectstack/spec` (`security/capabilities.ts` → `PLATFORM_CAPABILITIES`), - seeded by `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts`. + are declared `capability` metadata served by the registry + (`registerBuiltinCapabilities`, `GET /api/v1/meta/capability`), with no + `sys_capability` row. A **package** now DEFINES its own capabilities explicitly via [`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1), seeded with `managed_by:'package'` + `package_id` provenance by `bootstrap-declared-capabilities.ts` — replacing the implicit - derive-from-`systemPermissions` back-door (which stays for back-compat). + derive-from-`systemPermissions` back-door (now retired). The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences` (`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a `requiredPermissions` names a capability registered nowhere — no built-in, no diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index d95f0cbcdb9..cc5483063ab 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -220,7 +220,7 @@ behind that behaviour are set out in 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` +production reader of the table is the declared-capability boot seeder. The row's `active` flag is a catalogue/visibility flag with no authorization effect: clearing it revokes nothing. Withdraw a capability by withdrawing the **grant**. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 20f43a137d6..eef1295c08f 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -40,7 +40,7 @@ export const SalesUser = definePermissionSet({ // Tab/app visibility tabPermissions: { app_crm: 'default_on', app_admin: 'hidden' }, - // System capabilities (flat strings, resolved against sys_capability) + // System capabilities (flat strings, matched by capability name) systemPermissions: ['export_reports'], }); ``` @@ -147,8 +147,12 @@ the anchor decides *which* unit `unit*` means. ## Capabilities & required permissions (ADR-0066) -- **Capabilities** are first-class `sys_capability` records — named privileges - such as `manage_users`, `manage_metadata`, `setup.access`, `studio.access`. +- **Capabilities** are named privileges such as `manage_users`, + `manage_metadata`, `setup.access`, `studio.access`. The platform's curated + ones are declared `capability` metadata served by the registry + (`GET /api/v1/meta/capability`) and have no `sys_capability` row; a + capability a package declares with `defineCapability` is also seeded as a + `sys_capability` row. Grant them via `systemPermissions`; declare them as prerequisites via an object/field/app `requiredPermissions` (an **AND**-gate checked before any CRUD grant). diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index d441f7454cc..893f8fda28f 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 53 of them — just over a fifth +typed `any` have no type to read, and there are 51 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 241 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 239 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **241** | +| 175 write call sites | quoted in the merged changeset | **239** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 241** decidable, **81** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 105 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 239** decidable, **79** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 126 decidably elevated, 0 decidably not, 105 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 53 sites reached +one, and both are counted in the generated tables below: the 51 sites reached through an erased (`any`) receiver, and the 53 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -128 of 241 (53%) as decidably elevated, with 105 more whose elevation is a +126 of 239 (53%) as decidably elevated, with 105 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 241`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **32 further sites** have an options argument this @@ -223,17 +223,17 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **241** | +| write call sites on the application surface | **239** | | …whose object name is statically decidable | 160 | -| …whose object name is chosen at run time | 81 | +| …whose object name is chosen at run time | 79 | | …against an object with tenancy ENABLED | 159 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 180 | +| threading a tenant context | 178 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 53 | | …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 128 | +| threading a decidably ELEVATED (`isSystem`) context | 126 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 105 | @@ -241,12 +241,12 @@ cannot read, and they are neither in nor out. | :--- | ---: | | receiver carried a readable engine type | 188 | | receiver erased, placed by the object NAME | 33 | -| receiver erased, placed by an `object: string` PARAMETER | 15 | +| receiver erased, placed by an `object: string` PARAMETER | 13 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 107 | | object name spelled through a `const` | 53 | -| object name is an `object: string` parameter | 19 | +| object name is an `object: string` parameter | 17 | | object name is some other run-time expression | 62 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-09 at `5551f8d8d`. +Measured on 2026-10-10 at `b88b35678`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 625 | +| tracked non-test sources scanned | 631 | | engine-shaped types recognised | 72 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index afd8e12c5eb..e274581676d 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 241 | +| Write call sites | 239 | | Object name statically decidable | 160 | -| Object name chosen at run time | 81 | +| Object name chosen at run time | 79 | | Against a tenancy-enabled object | 159 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 180 | +| Threading a tenant context | 178 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 128 | +| Threading a decidably elevated context | 126 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 105 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-09 at `5551f8d8d`. +Measured on 2026-10-10 at `b88b35678`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 625 | +| tracked non-test sources scanned | 631 | | engine-shaped types recognised | 72 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 162 | ## Every site @@ -165,8 +165,6 @@ Measured on 2026-10-09 at `5551f8d8d`. | `packages/plugins/plugin-security/src/bootstrap-declared-positions.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts` | `update` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `insert` | `object` | undecidable | elevated | 1 | -| `packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/claim-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/cleanup-package-permissions.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | diff --git a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts index 7bf1b751b22..f7f472036af 100644 --- a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts @@ -379,7 +379,6 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob // The NULL-safe key part is what makes this hold: a bare // `(organization_id, name)` composite would be NULL-distinct under SQL, // so every platform-seeded capability could be duplicated at will. - // `bootstrapSystemCapabilities` upserts by name and depends on it. const d = makeDriver(); await d.initObjects(FIXED_APP as any); diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index 69481e3e56e..e299ac5882e 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -19,8 +19,7 @@ * (`stack.capabilities`) — the explicit, package-provenanced declaration * (ADR-0066 D1), materialized at boot by `bootstrapDeclaredCapabilities`, * 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 + * `systemPermissions` (granting a capability also declares it), and * 4. any `sys_capability` row shipped as seed data. * * WARNING, not error: a single package's lint cannot see capabilities declared diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts index 89f4b88b1e7..bcdd1ecad8c 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts @@ -6,7 +6,6 @@ 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, @@ -40,10 +39,9 @@ function makeQl(declared: any[] = []) { // 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. + // is strict equality against an ABSENT key; strict `===` would make an + // `organization_id: null` lookup unsatisfiable here while it works in + // production. // [#11096] `$in` is supported because the real engine supports it — the // seeder now issues ONE batched `{ name: { $in: [...] } }` existence read // for the whole declaration. ⛔ A double that did not match it would @@ -64,8 +62,7 @@ function makeQl(declared: any[] = []) { ); // [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` + // shipped drivers; this double once ignored `limit` // entirely, which made the whole class of page-cap defect INEXPRESSIBLE // here — including the cap commit e1d773eb7 fixed, whose consequence lands on THIS seeder. if (q?.limit === undefined) return matched; @@ -161,10 +158,10 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () = }); it('CLAIMS a derived-from-systemPermissions platform placeholder', async () => { - // Simulate the back-compat path: bootstrapSystemCapabilities derived a - // placeholder from a permission set's systemPermissions. + // A platform placeholder row, as the retired derive-from-systemPermissions + // seeder left one on an existing database. const ql = makeQl([{ name: 'export_data', label: 'Export Data', description: 'Nice.', scope: 'org', _packageId: 'com.acme.reports' }]); - await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data'] }]); + ql.rows.push({ id: 'cap_derived', name: 'export_data', label: 'Export Data', description: 'Capability export_data.', managed_by: 'platform' }); const derived = ql.rows.find((r) => r.name === 'export_data'); expect(derived.managed_by).toBe('platform'); // Now the explicit declaration claims it. @@ -175,19 +172,6 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () = 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); @@ -196,138 +180,11 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () = }); // ─────────────────────────────────────────────────────────────────────────── -// [#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). +// [#4967 Part 1] `materializedNames` reports the names this pass CONFIRMED have +// a row — the refusal paths land on different sides of that line. The cases +// that paired it with the back-compat derivation went with that seeder. // ─────────────────────────────────────────────────────────────────────────── describe('refused declarations vs. the derived placeholder (#4967 Part 1)', () => { - 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 diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts index 661a06ee977..92957e006b3 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts @@ -201,8 +201,7 @@ describe('#8692 — provenance of a seeder-created default permission set (measu // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored // `'admin'` — the declaration's `defaultValue` — because the insert omitted // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts` and - // `bootstrap-system-capabilities.ts`, which always have. + // line with `bootstrap-builtin-positions.ts`, which always has. const engine = await boot(); await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); 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..8ff408fb832 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 @@ -45,7 +45,6 @@ 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'; /** * The engine registry as the security catalog read uses one — its list, its @@ -775,122 +774,11 @@ describe('#11096 — a read that CANNOT ANSWER is not the answer "none exist"', }); /** - * [commit c33f18592] `bootstrapSystemCapabilities` — the OTHER capability seeder, whose - * definition set is the union of every `systemPermissions[]` string rather than - * the explicit `defineCapability` declarations. - * - * ## What is pinned, and what is deliberately NOT - * - * Two halves, and since commit 1a6855226 BOTH are batched — as two SEPARATE reads asking - * two different questions, which is the whole subtlety: - * - * - the CURATED half (`KNOWN_CAPABILITIES`) costs ONE batched `$in` read - * carrying the #8470 predicate, and zero writes on a steady-state rebuild; - * - the DERIVED half costs ONE batched `$in` read carrying NO predicate. It - * stays wide because its question is cross-organization by construction and - * its counters are computed from the lowest-id row installation-wide (see the - * seeder's header). ⛔ A "simplification" that folds the two into one read — - * or that narrows the derived one to the platform bucket to make folding - * possible — reverses #8552 and #8751 by read shape; the predicate pin below - * and the `platformStampedInOrg` suite in `bootstrap-system-capabilities.test - * .ts` are what stop it. - * - * ⚠️ These counts MOVED in commit 1a6855226, deliberately: this doc previously stated the - * derived residue as `1 + derived` and said "a later card that batches it is - * expected to move these numbers deliberately". Commit e1d773eb7 removed the objection - * that kept it per-item (the page cap became a measurement, so an unnarrowed - * batched read that truncates degrades loudly instead of silently reading - * `absent` and inserting), so the residue is now a second constant read rather - * than a linear one. - * - * ⚠️ NO speedup is claimed. The hosted `bootstrap-curve.mjs` rig lives in - * `objectstack-ai/cloud` and its axes are permission sets / positions / objects, - * not this one. These tests count round trips and pin WHICH ROW each leg - * touched; nothing here measures wall time. + * [commit 1a6855226] `buildExistingByName` gained an optional bucket predicate for + * the curated capability read. The fence below holds the callers that pass + * none to the exact key set they emitted before. */ -describe('#11451/#11520 — BOTH halves are O(1) round trips, as two differently-shaped reads', () => { - const CURATED_NAMES = KNOWN_CAPABILITIES.map((c) => c.name); - const derivedSets = (n: number) => [{ systemPermissions: Array.from({ length: n }, (_, i) => `app.cap.${i}`) }]; - const capQl = (behaviour = {}) => makeCountingQl('sys_capability', 'capability', [], behaviour); - - it('the existence reads are O(1) at every derived size — the residue is GONE', async () => { - const measure = async (d: number) => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, derivedSets(d)); // first boot: seeds - ql.reset(); - const r = await bootstrapSystemCapabilities(ql, derivedSets(d)); // REBUILD - expect(r.seeded).toBe(0); - expect(r.updated).toBe(0); // ⬅ the gate - expect(r.unchanged).toBe(CURATED_NAMES.length + d); - return { finds: ql.calls.find, updates: ql.calls.update, unchanged: r.unchanged, derived: d }; - }; - - const rows = [await measure(0), await measure(5), await measure(20)]; - // [commit 1a6855226] FLAT, not `1 + d`. One read for the curated half, one for the - // derived half — and at d=0 the derived read is not issued at all, because - // `buildExistingByName` returns before reading when no name survives its - // filter. That asymmetry is the reason the expectation is written out per - // size rather than as a single constant. - expect(rows.map((x) => x.finds)).toEqual([1, 2, 2]); - // ⭐ The anti-vacuity half lives in `measure` itself, and it has to: a - // `finds` of 2 reached by SKIPPING the derived half would satisfy the line - // above. `expect(r.unchanged).toBe(CURATED_NAMES.length + d)` there is what - // rules that out — every derived name was looked up, judged ours, and found - // already correct. Restated here so the count and the work are read together: - expect(rows.map((x) => x.unchanged)).toEqual([ - CURATED_NAMES.length, CURATED_NAMES.length + 5, CURATED_NAMES.length + 20, - ]); - // ⬅ The unconditional UPDATE is gone from BOTH halves. - expect(rows.map((x) => x.updates)).toEqual([0, 0, 0]); - }); - - it('the batched read carries the #8470 predicate IN the query, with no other keys', async () => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, []); - ql.reset(); - await bootstrapSystemCapabilities(ql, []); - expect(ql.calls.find).toBe(1); - expect(ql.wheres[0]).toEqual({ - name: { $in: CURATED_NAMES }, - managed_by: 'platform', - organization_id: null, - }); - // `toEqual` ignores `undefined`-valued properties, so the KEY SET is pinned - // separately: a predicate leaking in as `key: undefined` would pass the - // assertion above while changing what every other caller emits. - expect(Object.keys(ql.wheres[0]).sort()).toEqual(['managed_by', 'name', 'organization_id']); - }); - - /** - * ⭐ [commit 1a6855226] The RULED pin on the derived read's SHAPE. `bootstrap-system- - * capabilities.test.ts` pins the consequences (#8751's `platformStampedInOrg`, - * #8552's untouched bucket); this pins the cause, because the cheap fix that - * reverses both is a one-key edit right here. - * - * The derived question is "the lowest-id row for this name, installation-wide" - * (`X`). Adding `organization_id: null` asks for the bucket occupant (`B`) - * instead — a different row whenever an organization's row sorts lower — which - * silently stops #8751's signal and turns #8552's deliberate decline-to-seed - * into an insert. Neither has a maintainer ruling. So the derived read carries - * `name` and NOTHING else. - */ - it('⭐ the DERIVED read is UNNARROWED — `name` only, no bucket predicate (#8552/#8751)', async () => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, derivedSets(3)); - ql.reset(); - await bootstrapSystemCapabilities(ql, derivedSets(3)); - - // Two reads, in loop order: curated (predicated) then derived (wide). - expect(ql.calls.find).toBe(2); - const derivedWhere = ql.wheres[1]; - expect(derivedWhere).toEqual({ name: { $in: ['app.cap.0', 'app.cap.1', 'app.cap.2'] } }); - // The KEY SET separately, for the same reason the curated pin above does it: - // `toEqual` ignores `undefined`-valued properties, so a leaked - // `organization_id: undefined` would pass the assertion above while changing - // the question the driver is asked. - expect(Object.keys(derivedWhere).sort()).toEqual(['name']); - }); - +describe('#11451 — a caller that passes no predicate emits its old key set', () => { it('the existing callers still emit their exact key set — no predicate leaked in', async () => { // The measurement fence: `buildExistingByName` gained an optional predicate, // and a caller that passes none must emit the keys it emitted before, not @@ -901,119 +789,4 @@ describe('#11451/#11520 — BOTH halves are O(1) round trips, as two differently await bootstrapDeclaredPermissions(ql, undefined); expect(Object.keys(ql.wheres[0])).toEqual(['name']); }); - - it('⭐ pins IDENTITY, not just the count: the rebuild re-reads and re-writes the same rows', async () => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, derivedSets(2)); - const idsAfterFirstBoot = ql.rows.map((r) => r.id).sort(); - expect(idsAfterFirstBoot).toHaveLength(CURATED_NAMES.length + 2); - - ql.reset(); - await bootstrapSystemCapabilities(ql, derivedSets(2)); - // Same rows, same ids — no row was re-created, and no offsetting pair of - // "one dropped, one inserted" is hiding behind a constant count. - expect(ql.rows.map((r) => r.id).sort()).toEqual(idsAfterFirstBoot); - expect(ql.calls.insert).toBe(0); - - // …and when a curated row DRIFTS, the UPDATE lands on THAT row's id. - const target = ql.rows.find((r) => r.name === CURATED_NAMES[0])!; - target.label = 'Hand-edited'; - ql.reset(); - const r = await bootstrapSystemCapabilities(ql, derivedSets(2)); - expect(r.updated).toBe(1); - expect(r.unchanged).toBe(CURATED_NAMES.length + 1); - expect(ql.calls.update).toBe(1); - expect(ql.rows.find((x) => x.id === target.id)!.label) - .toBe(KNOWN_CAPABILITIES.find((c) => c.name === CURATED_NAMES[0])!.label); - }); - - /** - * ⚠️ LOAD-BEARING. Without this, an implementation that simply stopped writing - * would satisfy every count above while reconciling nothing at all. - */ - it('a curated row whose stored label/description drifted STILL gets its UPDATE', async () => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, []); - const row = ql.rows.find((r) => r.name === CURATED_NAMES[1])!; - row.label = 'Renamed'; - row.description = 'new text'; - ql.reset(); - const r = await bootstrapSystemCapabilities(ql, []); - expect(r.updated).toBe(1); - expect(ql.calls.update).toBe(1); - expect(row.label).toBe(KNOWN_CAPABILITIES.find((c) => c.name === CURATED_NAMES[1])!.label); - expect(row.description).toBe(KNOWN_CAPABILITIES.find((c) => c.name === CURATED_NAMES[1])!.description); - }); - - it('a genuinely absent curated name is still created — "absent" is not turned into "present"', async () => { - const ql = capQl(); - await bootstrapSystemCapabilities(ql, []); - const dropped = ql.rows.findIndex((r) => r.name === CURATED_NAMES[2]); - ql.rows.splice(dropped, 1); - ql.reset(); - const r = await bootstrapSystemCapabilities(ql, []); - expect(r.seeded).toBe(1); - expect(r.unchanged).toBe(CURATED_NAMES.length - 1); - expect(ql.roundTrips()).toBe(2); // one batched read + one insert - expect(ql.rows.find((x) => x.name === CURATED_NAMES[2])).toBeDefined(); - }); - - it('a read that CANNOT ANSWER is not the answer "none exist" — nothing is re-created', async () => { - const broken = capQl({ findThrows: true }); - broken.rows.push(...KNOWN_CAPABILITIES.map((c, i) => ({ - id: `cap_${i}`, name: c.name, label: c.label, description: c.description, - scope: c.scope, managed_by: 'platform', organization_id: null, active: true, - }))); - const warns: string[] = []; - // No derived names here: this pin is about the CURATED half, and commit 1a6855226 adds - // the derived counterpart as its own test below rather than widening this one. - const r = await bootstrapSystemCapabilities(broken, [], { logger: { warn: (m) => warns.push(m) } }); - expect(r.unreadable).toBe(KNOWN_CAPABILITIES.length); - expect(r.seeded).toBe(0); - expect(broken.calls.insert).toBe(0); // ⛔ no blind insert - expect(broken.rows).toHaveLength(KNOWN_CAPABILITIES.length); - 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); - }); - - /** - * ⭐ [commit 1a6855226] The derived counterpart — and the one place that commit changes - * observable behaviour, pinned so the change is a decision rather than a - * side effect. - * - * BEFORE: the derived half read through `tryFind`, which catches and returns - * `[]`. An unreadable database therefore read as "absent" and routed every - * derived name to its INSERT branch. Where the read failed but the write did - * not — a transient read timeout, a lagging replica — that is a DUPLICATE - * placeholder, refused only where the unique index happens to exist; and where - * the insert failed too it was silent, because the `blockedCurated` diagnostic - * is curated-only. - * - * AFTER: `unknown` is declined, exactly as the shared oracle's module header - * requires of every other caller. Strictly stricter, in the direction #10946 - * chose deliberately for the curated half and commit e1d773eb7 extended to truncation. - */ - it('⭐ [#11520] a DERIVED name whose read cannot answer is DECLINED, never blind-inserted', async () => { - const DERIVED = 2; - const broken = capQl({ findThrows: true }); - broken.rows.push(...KNOWN_CAPABILITIES.map((c, i) => ({ - id: `cap_${i}`, name: c.name, label: c.label, description: c.description, - scope: c.scope, managed_by: 'platform', organization_id: null, active: true, - }))); - const before = broken.rows.length; - const warns: string[] = []; - const r = await bootstrapSystemCapabilities(broken, derivedSets(DERIVED), { - logger: { warn: (m) => warns.push(m) }, - }); - - // BOTH halves decline — every definition is left entirely alone. - expect(r.unreadable).toBe(KNOWN_CAPABILITIES.length + DERIVED); - expect(r.seeded).toBe(0); - // ⛔ LOAD-BEARING: the derived insert that used to happen here does not. - expect(broken.calls.insert).toBe(0); - expect(broken.rows).toHaveLength(before); - // …and the summary warning covers both halves, so its total is the whole - // definition set rather than the curated count it would otherwise exceed. - expect(warns.some((w) => w.includes('capabilities left untouched'))).toBe(true); - }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts deleted file mode 100644 index 581d4d1669a..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts +++ /dev/null @@ -1,1376 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { bootstrapSystemCapabilities, KNOWN_CAPABILITIES } from './bootstrap-system-capabilities.js'; -import { buildExistingByName } from './seed-name-lookup.js'; - -/** - * Minimal in-memory ql for sys_capability seeding. - * - * [#8470] Three behaviours are modelled on purpose, and NOT because a test - * needed them to pass: each was measured against `SqlDriver` on better-sqlite3 - * (better-sqlite3 fixture mirroring `sys-capability.object.ts`, including its - * `{ fields: ['name'], unique: 'organization' }` index) before being written - * here. The point of the exercise was #8470's own warning — that the ORIGINAL - * fake returned rows in INSERTION order, which is a property of the double and - * of no driver in the system, so a pin written against it could be green for a - * reason the product does not have. - * - * 1. **`limit` orders by `id` ascending.** #4363 appends a pagination - * tie-breaker (`ORDER BY id`) to any paged read of a driver-managed table, - * and `limit: 1` counts as paged on `SqlDriver` and `MongoDBDriver` alike — - * only `findOne` opts out (`singleRowLookup`). Measured: with a platform row - * and an org row sharing a name, `find({ where: { name }, limit: 1 })` - * returned the row with the smaller `id` under BOTH insertion orders. So the - * seeder's lookup is not a coin flip; it is a stable choice made on a key - * unrelated to ownership. Insertion order is what the double used to model, - * and it models nothing. - * 2. **A `null` comparand matches a null OR absent value.** `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`); MongoDB matches - * null-or-missing. Strict `===`, which - * this double used, matches NONE of them and would have made - * `organization_id: null` unsatisfiable here while working in production. - * 4. **`$in` membership**, because the real engine has it (`security-plugin.ts` - * already reads `sys_permission_set` with `{ name: { $in: names } }`) and - * the curated half's batched existence read (commit c33f18592) uses it. A double that - * refused it would be pinning the double's limits, not the seeder's - * behaviour. Every OTHER value-level operator is still REFUSED rather than - * read as a column comparison. - * 3. **`insert` enforces the declared unique key**, `(COALESCE(organization_id, - * '__global__'), name)` — measured rejecting a second platform-bucket row - * for a name, and admitting an organization's row for that same name. - * `tryInsert` swallows the engine's refusal, which is why the seeder counts - * it (`blockedCurated`) rather than trusting silence. - */ -function makeQl() { - const rows: any[] = []; - /** The NULL-safe organization key part, ADR-0120 D3. */ - const bucketOf = (r: any): string => - r?.organization_id == null ? '__global__' : String(r.organization_id); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_capability') return []; - const where = q?.where ?? {}; - const matched = rows.filter((r) => - // (2) `null` is IS NULL, not `=== null`. - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - // (4) `$in` is modelled; anything else object-shaped is refused. - if (v !== null && typeof v === 'object' && !Array.isArray(v)) { - const list = (v as { $in?: unknown }).$in; - if (!Array.isArray(list)) throw new Error(`fake driver: unsupported operator ${Object.keys(v as object).join(',')}`); - return list.includes(r[k]); - } - // (2) `null` is IS NULL, not `=== null`. - return v === null ? r[k] == null : r[k] === v; - }), - ); - if (q?.limit === undefined) return matched; - // (1) The #4363 tie-breaker. BINARY collation, as SQLite compares ids. - 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; - // (3) One holder per (organization bucket, name). - if (rows.some((r) => r.name === data.name && bucketOf(r) === bucketOf(data))) 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('bootstrapSystemCapabilities (ADR-0066 D1 back-compat seed)', () => { - it('seeds the curated platform capabilities as records (idempotent)', async () => { - const ql = makeQl(); - const r1 = await bootstrapSystemCapabilities(ql, []); - expect(r1.seeded).toBe(KNOWN_CAPABILITIES.length); - // every known capability is now a row with managed_by=platform + active - for (const cap of KNOWN_CAPABILITIES) { - const row = ql.rows.find((x) => x.name === cap.name); - expect(row).toBeDefined(); - expect(row.managed_by).toBe('platform'); - expect(row.active).toBe(true); - expect(row.scope).toBe(cap.scope); - } - // re-run → no new inserts (idempotent) - const r2 = await bootstrapSystemCapabilities(ql, []); - expect(r2.seeded).toBe(0); - expect(ql.rows.length).toBe(KNOWN_CAPABILITIES.length); - }); - - it('derives extra capabilities referenced by permission sets', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['manage_users', 'export_data', 'approve_invoice'] }]); - expect(ql.rows.find((x) => x.name === 'export_data')).toBeDefined(); - expect(ql.rows.find((x) => x.name === 'approve_invoice')).toBeDefined(); - // org-scoped known capability keeps its scope - expect(ql.rows.find((x) => x.name === 'manage_org_users')?.scope).toBe('org'); - }); - - it('does NOT derive a placeholder for an already-materialized capability', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, [{ systemPermissions: ['export_data', 'approve_invoice'] }], { - materializedCapabilityNames: ['export_data'], - }); - // `export_data` is owned by the declared seeder — no platform placeholder. - expect(ql.rows.find((x: any) => x.name === 'export_data')).toBeUndefined(); - // `approve_invoice` is still derived as before. - expect(ql.rows.find((x: any) => x.name === 'approve_invoice')).toBeDefined(); - }); - - // [#2909 T3] `scope` is an admin-editable classification face — seed-once. - it('does NOT clobber an admin-edited scope on re-seed (label/description still refresh)', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, []); - const cap = KNOWN_CAPABILITIES[0]; - const row = ql.rows.find((x) => x.name === cap.name)!; - // Admin reclassifies the capability and tweaks nothing else. - row.scope = row.scope === 'org' ? 'platform' : 'org'; - const adminScope = row.scope; - row.label = 'stale label'; - await bootstrapSystemCapabilities(ql, []); - expect(row.scope).toBe(adminScope); // admin's edit survives the boot - expect(row.label).toBe(cap.label); // platform display fields refreshed - }); - - it('still writes scope on first insert', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, []); - for (const cap of KNOWN_CAPABILITIES) { - expect(ql.rows.find((x) => x.name === cap.name)?.scope).toBe(cap.scope); - } - }); - - it('marks manage_org_users as org-scoped and the rest platform', () => { - const org = KNOWN_CAPABILITIES.find((c) => c.name === 'manage_org_users'); - expect(org?.scope).toBe('org'); - expect(KNOWN_CAPABILITIES.filter((c) => c.scope === 'platform').length).toBeGreaterThanOrEqual(5); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#5876] The DERIVED half reconciles display fields only on rows it OWNS. -// -// The seed loop used to refresh `label`/`description` on whatever row it found -// for a derived name, whatever its provenance — while the comment above it said -// admin edits were not clobbered. For a derived name `label` is `humanize(name)` -// and `description` is `Capability .`, so an admin-authored row was -// rewritten to a humanized placeholder on EVERY boot (silent data loss; the -// reachable chain is: admin creates the capability in Setup → an app whose -// bootstrap permission set grants it by name is installed → every boot after). -// -// The CURATED half keeps refreshing (the platform ships new copy for its own -// definitions — pinned by 'does NOT clobber an admin-edited scope on re-seed' -// above); only the derived half is guarded, so these pins must DISCRIMINATE -// rather than just prove nothing is written. -// ─────────────────────────────────────────────────────────────────────────── -describe('derived defaults never clobber an authored row (#5876)', () => { - const OPS_SETS = [{ systemPermissions: ['showcase.export_data'] }]; - const AUTHORED = { label: 'Admin Made', description: 'Admin wrote this.' }; - - /** A pre-existing row for a name the derivation would otherwise derive. */ - function seedRow(ql: ReturnType, managed_by: string | undefined) { - ql.rows.push({ - id: 'cap_existing', - name: 'showcase.export_data', - ...AUTHORED, - scope: 'org', - ...(managed_by === undefined ? {} : { managed_by }), - active: true, - }); - } - - it('leaves an ADMIN-authored row untouched', async () => { - const ql = makeQl(); - seedRow(ql, 'admin'); - const out = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - ...AUTHORED, managed_by: 'admin', scope: 'org', - }); - expect(out.skippedAuthored).toBe(1); - // The skip is a SKIP, not a silent failed write: it is not counted as an update. - expect(ql.rows.filter((r) => r.name === 'showcase.export_data')).toHaveLength(1); - }); - - it('leaves a PACKAGE-authored row untouched', async () => { - const ql = makeQl(); - ql.rows.push({ - id: 'cap_pkg', name: 'showcase.export_data', label: 'Export Data', description: 'Bulk export.', - managed_by: 'package', package_id: 'com.acme.reports', active: true, - }); - const out = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - label: 'Export Data', description: 'Bulk export.', managed_by: 'package', package_id: 'com.acme.reports', - }); - expect(out.skippedAuthored).toBe(1); - }); - - it('leaves a row of UNKNOWN provenance untouched (the field defaults to admin)', async () => { - // `sys_capability.managed_by` is required with `defaultValue: 'admin'`, so a - // row that reaches this pass without one is not a platform placeholder — - // "not provably ours" resolves to leave-it-alone, never to overwrite. - const ql = makeQl(); - seedRow(ql, undefined); - const out = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject(AUTHORED); - expect(out.skippedAuthored).toBe(1); - }); - - it('POSITIVE CONTROL: still refreshes its OWN platform placeholder', async () => { - // Same fixture, same grant — only the provenance differs. A guard that also - // switched this case off would be indistinguishable from deleting the - // reconcile, so this is what gives the three pins above their teeth. - const ql = makeQl(); - seedRow(ql, 'platform'); - const out = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toMatchObject({ - label: 'Showcase Export Data', description: 'Capability showcase.export_data.', managed_by: 'platform', - }); - expect(out.skippedAuthored).toBe(0); - expect(out.updated).toBeGreaterThanOrEqual(1); - // [#2909 T3] `scope` stays seed-once even on a row this pass owns. - expect(ql.rows.find((r) => r.name === 'showcase.export_data')?.scope).toBe('org'); - }); - - it('stays stable across boots: derive, then never re-write the row again', async () => { - const ql = makeQl(); - const boot1 = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(boot1.seeded).toBe(KNOWN_CAPABILITIES.length + 1); - // An admin renames the derived placeholder… which the platform/package write - // guard actually refuses today (see #5876's reachability note), so simulate - // the storage effect only, and re-boot. - const row = ql.rows.find((r) => r.name === 'showcase.export_data')!; - row.label = 'Renamed By Admin'; - row.managed_by = 'admin'; - const boot2 = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(row.label).toBe('Renamed By Admin'); - expect(boot2.seeded).toBe(0); - expect(boot2.skippedAuthored).toBe(1); - }); - - // [#8470] POSITIVE CONTROL for the untouched half. The curated fix must not - // make this guard unreachable: a DERIVED name on an org-authored row still - // skips. If the curated scoping ever leaked into the derived branch, the - // derived lookup would stop finding the authored row and would DERIVE a - // placeholder over it instead of skipping — `skippedAuthored` would fall to 0. - it('[#8470] the derived guard still fires for an ORG-authored row (untouched half)', async () => { - const ql = makeQl(); - ql.rows.push({ - id: 'aaa_org_derived', organization_id: 'org_jia', name: 'showcase.export_data', - ...AUTHORED, scope: 'org', managed_by: 'admin', active: true, - }); - const out = await bootstrapSystemCapabilities(ql, OPS_SETS); - expect(out.skippedAuthored).toBe(1); - expect(ql.rows.find((r) => r.id === 'aaa_org_derived')).toMatchObject({ - ...AUTHORED, managed_by: 'admin', - }); - }); - - it('the guard is scoped to the DERIVED half — curated names still refresh', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, []); - const curated = KNOWN_CAPABILITIES[0]; - const row = ql.rows.find((r) => r.name === curated.name)!; - row.label = 'stale label'; - row.description = 'stale description'; - const out = await bootstrapSystemCapabilities(ql, []); - expect(row.label).toBe(curated.label); - expect(row.description).toBe(curated.description); - expect(out.skippedAuthored).toBe(0); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#8470] The CURATED half reconciles THE ROW THE PLATFORM OWNS. -// -// Since #8461 a capability `name` is unique per ORGANIZATION (ADR-0120 D1), so -// an admin may author `manage_users` inside their organization while the -// platform holds its own row in the NULL-organization bucket. A lookup on -// `{ name }` alone then has two candidates and cannot say which it means. -// -// ## Why "just add an ORDER BY" is not the fix, and how these pins are forced -// -// The lookup was ALREADY ordered. #4363 appends `ORDER BY id` to any paged read -// of a driver-managed table and `limit: 1` is paged, so on `SqlDriver` and -// `MongoDBDriver` the seeder's choice is stable — stable on `id`, which says -// nothing about who owns the row, and stable FOREVER on a given installation, -// so a boot that reconciles the wrong row never self-heals. The double models -// that rule (see `makeQl`), which is what makes the adverse case FORCIBLE -// rather than accidental: the org row is given the id `aaa_org_authored`, which -// sorts before every seeder-minted `cap_…` id, so the old code selects it. -// `zzz_org_authored` is the same fixture with the ordering benign. -// -// That pair is the anti-vacuity argument. The old behaviour DIFFERS between the -// two ids; the fixed behaviour is identical, because it never depended on the -// order in the first place. -// ─────────────────────────────────────────────────────────────────────────── -describe('[#8470] the curated half owns its row, not whichever row shares the name', () => { - const CURATED = KNOWN_CAPABILITIES.find((c) => c.name === 'manage_users')!; - const ORG_AUTHORED = { - name: 'manage_users', - label: 'ORG CUSTOM LABEL', - description: 'Authored by the organization admin in Setup.', - scope: 'org' as const, - managed_by: 'admin', - active: true, - }; - - /** An organization's row for a CURATED name — a supported ADR-0066 D1 action. */ - function orgRow(id: string, organization_id = 'org_jia') { - return { id, organization_id, ...ORG_AUTHORED }; - } - - const platformRowFor = (ql: ReturnType, name: string) => - ql.rows.find((r) => r.name === name && r.managed_by === 'platform' && r.organization_id == null); - - // ── The headline harm: `platformRowExists: false` ── - // - // Card row 3. Ordering is irrelevant here — with no platform row there is only - // ONE candidate, so the old lookup selected the organization's row whatever - // the tie-breaker did. Two distinct harms, asserted separately so a partial - // regression cannot hide behind the other. - it('seeds the platform row even when an organization already holds the name', async () => { - const ql = makeQl(); - ql.rows.push(orgRow('aaa_org_authored')); - const out = await bootstrapSystemCapabilities(ql, []); - - // Harm 2 — the worse one: the curated definition exists in NO bucket. - const platform = platformRowFor(ql, 'manage_users'); - expect(platform).toBeDefined(); - expect(platform).toMatchObject({ - label: CURATED.label, description: CURATED.description, scope: CURATED.scope, - managed_by: 'platform', active: true, - }); - // All 8 curated names land, not just the 7 with no collision. - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length); - expect(out.blockedCurated).toBe(0); - }); - - it("leaves the organization's authored copy exactly as its author wrote it", async () => { - const ql = makeQl(); - ql.rows.push(orgRow('aaa_org_authored')); - await bootstrapSystemCapabilities(ql, []); - - // Harm 1: the org row must not be reconciled to the platform's copy. - expect(ql.rows.find((r) => r.id === 'aaa_org_authored')).toEqual({ - id: 'aaa_org_authored', organization_id: 'org_jia', ...ORG_AUTHORED, - }); - }); - - // ── The forced coin flip: card row 2, which the filer flagged as NOT safe ── - it.each([ - ['adverse — the org row sorts FIRST', 'aaa_org_authored'], - ['benign — the org row sorts LAST', 'zzz_org_authored'], - ])('reconciles the platform row and only the platform row (%s)', async (_label, orgId) => { - const ql = makeQl(); - // Boot 1 seeds the platform's row; the admin then authors theirs. - await bootstrapSystemCapabilities(ql, []); - ql.rows.push(orgRow(orgId)); - // A new platform version ships new copy — simulate the stale row it finds. - const platform = platformRowFor(ql, 'manage_users')!; - platform.label = 'stale label'; - platform.description = 'stale description'; - - const out = await bootstrapSystemCapabilities(ql, []); - - // The platform's own row IS refreshed (the curated reconcile is not simply - // switched off — the failure mode that would make every pin here vacuous). - expect(platform).toMatchObject({ - label: CURATED.label, description: CURATED.description, managed_by: 'platform', - }); - // …and the organization's row is untouched under BOTH id orders. Without - // the ownership scoping these two rows disagree: the adverse id makes the - // seeder write the platform copy onto the org row and leave `stale label` - // on its own. - expect(ql.rows.find((r) => r.id === orgId)).toEqual({ - id: orgId, organization_id: 'org_jia', ...ORG_AUTHORED, - }); - expect(out.seeded).toBe(0); - expect(out.blockedCurated).toBe(0); - }); - - // ── The PLATFORM-BUCKET provenance matrix ───────────────────────────────── - // - // Every case above is about a name with too MANY candidates. This block is - // the other axis: one row, in the platform's own bucket, varying only by - // `managed_by`. It exists because the scoped predicate is a CONJUNCTION, and - // a conjunction can also match ZERO rows where it should match one. - // - // `managed_by: 'platform'` is the row the seeder owns and must still find — - // the NEGATIVE case, and the one that would catch the predicate excluding the - // platform's own row on an installation that predates something. - // - // Reachability of the other three, measured rather than assumed: - // - `admin` — REACHABLE and ordinary. `organization_id` auto-stamping lives - // in the `@objectstack/organizations` runtime, which is also - // what ACTIVATES every walled posture. A deployment without it is `single` - // posture with no stamper, so EVERY Setup-authored capability row lands in - // the NULL-organization bucket. This is the default community shape, not - // an edge case. - // - `package` — REACHABLE via name promotion: `bootstrapDeclaredCapabilities` - // refuses a name in `PLATFORM_CAPABILITY_NAMES`, but a package that - // declared a name BEFORE the platform curated it (`setup.write` and - // `manage_sharing` were both added to the curated set after the fact) left - // a `managed_by:'package'` row in that bucket. - // - no value at all — NOT reachable through the engine: `managed_by` is - // `required: true` with `defaultValue: 'admin'`, and `applyFieldDefaults` - // resolves defaults on insert BEFORE the beforeInsert hooks, so an insert - // omitting it stores `'admin'`, never null. Kept as a pin anyway because - // the diagnostic must not assert an ownership verdict it cannot observe — - // which is the whole reason the message names the value it READ. - it.each([ - ['platform — the row the seeder OWNS', 'platform', 'matched'], - ['admin — Setup-authored, single-posture deployment', 'admin', 'blocked'], - ['package — declared before the name was curated', 'package', 'blocked'], - ['(absent) — not engine-reachable; the message must still be truthful', undefined, 'blocked'], - ])('platform-bucket row, managed_by=%s', async (_label, managedBy, expected) => { - const ql = makeQl(); - const PRE = { - id: 'aaa_pre_existing', - name: 'manage_users', - label: 'PRE-EXISTING LABEL', - description: 'written by whoever owns this row', - scope: 'platform' as const, - active: true, - ...(managedBy === undefined ? {} : { managed_by: managedBy }), - }; - ql.rows.push({ ...PRE }); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn } }); - const row = ql.rows.find((r) => r.id === 'aaa_pre_existing')!; - - if (expected === 'matched') { - // The platform's own row is found by the conjunction and reconciled. - expect(out.blockedCurated).toBe(0); - expect(warn).not.toHaveBeenCalled(); - expect(row).toMatchObject({ label: CURATED.label, description: CURATED.description }); - expect(out.updated).toBeGreaterThanOrEqual(1); - } else { - expect(out.blockedCurated).toBe(1); - // Declining is the point — the blocking row is untouched… - expect(row).toEqual(PRE); - // …and reported. The message names the provenance it READ rather than - // asserting who authored the row: on a row carrying some other - // `managed_by`, "a row this pass does not own" would be a false sentence - // printed on every boot. - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain('manage_users'); - expect(warn.mock.calls[0][0]).toContain( - managedBy === undefined ? 'a row carrying no managed_by value' : `managed_by='${managedBy}'`, - ); - expect(warn.mock.calls[0][0]).not.toContain('does not own'); - // [#8552] The ruling leaves an existing colliding row to the OPERATOR, - // so the warning that reports it carries the one line that says how to - // resolve it by hand. - expect(warn.mock.calls[0][0]).toContain('To resolve by hand'); - expect(warn.mock.calls[0][0]).toContain('rename the blocking row to a name outside the curated set'); - expect(warn.mock.calls[0][1]).toEqual({ - name: 'manage_users', - blockingRowId: 'aaa_pre_existing', - blockingManagedBy: managedBy ?? null, - }); - } - // Either way the other 7 curated names are unaffected. - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length - 1); - }); - - // The THIRD branch of the diagnostic. The insert was refused, so something - // stopped it — but if the follow-up read then finds nothing, that is NOT an - // ordinary collision (a racing writer, or a refusal that was never the unique - // key) and must not be described as an unstamped row. Reachable here as the - // general shape of a rejected write: `tryInsert` returns null on ANY engine - // refusal, not only a duplicate key. - it('says so when the insert was refused but no blocking row is visible', async () => { - // Built by OVERRIDING the file's one double rather than declaring a second: - // `check:engine-double-contract` counts unguarded engine doubles per file - // against a shrink-only baseline, and a fresh literal declaring the engine - // verbs would raise it. Overriding states the same fixture — this is that - // double, with the write refused and nothing stored. - const ql = Object.assign(makeQl(), { - async find() { return []; }, - async insert() { return null; }, - }); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn } }); - - expect(out.seeded).toBe(0); - expect(out.blockedCurated).toBe(KNOWN_CAPABILITIES.length); - expect(warn).toHaveBeenCalledTimes(KNOWN_CAPABILITIES.length); - expect(warn.mock.calls[0][0]).toContain('NO blocking row is visible'); - expect(warn.mock.calls[0][0]).toContain('worth investigating'); - // …and it must NOT borrow either of the other two branches' sentences. - expect(warn.mock.calls[0][0]).not.toContain('carrying no managed_by value'); - expect(warn.mock.calls[0][0]).not.toContain('left exactly as it is'); - // [#8552] The remediation line instructs renaming THE BLOCKING ROW; with - // no row visible there is nothing to rename, so it must not print here — - // telling the operator to rename a row the read just failed to find would - // assert what was not seen. - expect(warn.mock.calls[0][0]).not.toContain('To resolve by hand'); - expect(warn.mock.calls[0][1]).toEqual({ - name: 'manage_users', blockingRowId: undefined, blockingManagedBy: null, - }); - }); - - // A clean install must not pay for any of this: no collision, no warn, no - // counter. (A `blockedCurated` that fired on the happy path would make the - // pin above green for the wrong reason.) - it('a clean install seeds every curated name with nothing blocked', async () => { - const ql = makeQl(); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn } }); - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length); - expect(out.blockedCurated).toBe(0); - expect(warn).not.toHaveBeenCalled(); - }); - - // Two organizations may each hold the name (ADR-0066 D1 "admins EXTEND"), and - // neither displaces the platform's row. This is also the pin that would catch - // a fix written as "take the row with no organization_id" alone: it would - // still be a singleton here, but see the sibling case above for why - // `managed_by` is needed as well. - it('is unaffected by the NUMBER of organizations holding the name', async () => { - const ql = makeQl(); - ql.rows.push(orgRow('aaa_org_jia', 'org_jia'), orgRow('aab_org_yi', 'org_yi')); - const out = await bootstrapSystemCapabilities(ql, []); - expect(platformRowFor(ql, 'manage_users')).toBeDefined(); - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length); - expect(ql.rows.filter((r) => r.name === 'manage_users')).toHaveLength(3); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#8536] The DERIVED half's skip is REPORTED, never silent. -// -// The #5876 guard keeps skipping — that is the ruled behaviour and none of the -// pins below change it. What changes is that a skip which leaves the platform -// (NULL-organization) bucket with no platform-owned row is COUNTED and WARNED -// with the same provenance-naming shape the curated half emits, instead of -// being visible only as a number in the boot summary. -// -// Two facts have to be told apart, and #8461 is why they are no longer the same -// statement. The derived lookup is `{ name }` and runs with no `tenantId`, so it -// reads ACROSS organizations: an organization's row can satisfy it while the -// platform bucket is never written at all. So "the guard skipped" does NOT imply -// "the platform's definition is missing", and the last two pins here are the -// opposite-direction controls that force the difference — an implementation that -// warned on every skip, or that warned unconditionally, fails them. -// ─────────────────────────────────────────────────────────────────────────── -describe('[#8536] the derived skip is counted and warned when it leaves the bucket unseeded', () => { - const GRANTS = [{ systemPermissions: ['showcase.export_data'] }]; - const AUTHORED = { label: 'Admin Made', description: 'Admin wrote this.' }; - /** Deliberately distinctive: a message that merely prints a placeholder cannot contain it. */ - const ORG = 'org_jia_9f2'; - const NAME = 'showcase.export_data'; - - const platformRowFor = (ql: ReturnType, name: string) => - ql.rows.find((r) => r.name === name && r.organization_id == null); - - /** - * An organization's authored row for the derived name. `aaa_` sorts before - * every `cap_…`, so the `{ name }` lookup selects it under the #4363 - * tie-breaker the double models. - * - * `managed_by` is REQUIRED, with no default value: a parameter defaulted to - * `'admin'` would silently rebuild the `undefined` row of the provenance - * matrix below as an admin row (JS defaults fire on an explicitly passed - * `undefined`), collapsing that case into its neighbour while staying green. - */ - const orgRow = (managed_by: string | undefined) => ({ - id: 'aaa_org_derived', - organization_id: ORG, - name: NAME, - ...AUTHORED, - scope: 'org', - active: true, - ...(managed_by === undefined ? {} : { managed_by }), - }); - - // ── The headline state: the bucket is FREE and stays empty ── - it('counts and warns when an ORGANIZATION row blocks the derivation', async () => { - const ql = makeQl(); - ql.rows.push(orgRow('admin')); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - // BEHAVIOUR IS UNCHANGED — this card is observability only. The guard still - // skips, the organization's copy is untouched, and nothing is adopted into - // the platform's identity or backfilled into its bucket (#8552). - expect(out.skippedAuthored).toBe(1); - expect(ql.rows.find((r) => r.id === 'aaa_org_derived')).toEqual(orgRow('admin')); - expect(platformRowFor(ql, NAME)).toBeUndefined(); - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length); - - // …and the gap the skip leaves behind is now stated out loud. - expect(out.unseededDerived).toBe(1); - expect(warn).toHaveBeenCalledTimes(1); - const [message, meta] = warn.mock.calls[0]; - expect(message).toContain(NAME); - expect(message).toContain("managed_by='admin'"); - expect(message).toContain(ORG); // the blocking row's REAL organization, not a placeholder - expect(message).toContain('NO row holds the name in that bucket at all'); - // The organization's row is a supported ADR-0066 D1 extension, so the - // hand-resolution line — which tells an operator to rename or delete the - // blocking row — must NOT print here. It belongs to a blocked PLATFORM - // bucket only; printing it here would advise removing a legitimate row. - expect(message).not.toContain('To resolve by hand'); - expect(meta).toEqual({ - name: NAME, - blockingRowId: 'aaa_org_derived', - blockingManagedBy: 'admin', - blockingOrganizationId: ORG, - platformRowId: undefined, - }); - }); - - // The message must name the provenance it READ. Same fixture, same grant — - // only `managed_by` differs, so a diagnostic that printed a fixed string (or - // asserted an ownership verdict) cannot pass all three rows. - it.each([ - ['admin — Setup-authored inside the organization', 'admin'], - ['package — declared by an installed package', 'package'], - ['(absent) — not engine-reachable; the message must still be truthful', undefined], - ])('names the provenance it READ (%s)', async (_label, managedBy) => { - const ql = makeQl(); - ql.rows.push(orgRow(managedBy)); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.unseededDerived).toBe(1); - expect(warn).toHaveBeenCalledTimes(1); - const message = warn.mock.calls[0][0] as string; - if (managedBy === undefined) { - expect(message).toContain('a row carrying no managed_by value'); - // never `managed_by='undefined'` / `managed_by='null'` — the field is absent. - expect(message).not.toContain('managed_by='); - } else { - expect(message).toContain(`managed_by='${managedBy}'`); - expect(message).not.toContain('carrying no managed_by value'); - // …and never the OTHER row's provenance: the value is read, not guessed. - expect(message).not.toContain(`managed_by='${managedBy === 'admin' ? 'package' : 'admin'}'`); - } - expect(warn.mock.calls[0][1]).toMatchObject({ blockingManagedBy: managedBy ?? null }); - }); - - // ── The other unseeded state: the PLATFORM bucket itself is occupied ── - // This is #5876's own fixture (no `organization_id` at all), which post-#8461 - // means the platform bucket. The platform's placeholder is just as absent - // here, so it warns too — and here the blocking row IS one an operator may - // legitimately rename, so the #8552 hand-resolution line applies. - it('counts and warns when the PLATFORM bucket itself is held by an authored row', async () => { - const ql = makeQl(); - ql.rows.push({ - id: 'cap_existing', name: NAME, ...AUTHORED, scope: 'org', managed_by: 'admin', active: true, - }); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.skippedAuthored).toBe(1); - expect(out.unseededDerived).toBe(1); - expect(ql.rows.find((r) => r.id === 'cap_existing')).toMatchObject({ ...AUTHORED, managed_by: 'admin' }); - expect(warn).toHaveBeenCalledTimes(1); - const [message, meta] = warn.mock.calls[0]; - expect(message).toContain("managed_by='admin'"); - expect(message).toContain('that row is itself the one holder the bucket admits'); - expect(message).toContain('To resolve by hand'); - expect(meta).toEqual({ - name: NAME, - blockingRowId: 'cap_existing', - blockingManagedBy: 'admin', - blockingOrganizationId: null, - platformRowId: 'cap_existing', - }); - }); - - // ── Opposite-direction control #1: the ordinary path ── - // Without this an unconditional warn would pass every pin above. - it('OPPOSITE-DIRECTION CONTROL: an ordinary derivation warns nothing and moves no counter', async () => { - const ql = makeQl(); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length + 1); - expect(platformRowFor(ql, NAME)).toMatchObject({ managed_by: 'platform' }); - expect(out.skippedAuthored).toBe(0); - expect(out.unseededDerived).toBe(0); - expect(warn).not.toHaveBeenCalled(); - }); - - // ── Opposite-direction control #2: a skip that is NOT a gap ── - // The sharp one. The guard fires, `skippedAuthored` moves — and nothing is - // missing, because the platform's placeholder is sitting in its bucket; the - // cross-organization lookup simply selected the organization's row first. - // "Warn on every skip" fails here, which is what makes the warning mean one - // specific thing: the platform's definition for this name is absent. - it('OPPOSITE-DIRECTION CONTROL: a skip whose platform placeholder EXISTS is not reported', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, GRANTS); // boot 1 derives the placeholder - expect(platformRowFor(ql, NAME)).toMatchObject({ managed_by: 'platform' }); - // An organization then authors its own row, with an id that sorts FIRST — so - // the `{ name }` lookup selects it and the #5876 guard fires on boot 2. - ql.rows.push(orgRow('admin')); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.skippedAuthored).toBe(1); // the guard really did fire… - expect(out.unseededDerived).toBe(0); // …and there is no missing definition to report - expect(warn).not.toHaveBeenCalled(); - expect(platformRowFor(ql, NAME)).toMatchObject({ managed_by: 'platform' }); - }); -}); - -// ─────────────────────────────────────────────────────────────────────────── -// [#8751] The DERIVED half owns its row by the SAME conjunction as the curated -// half — `managed_by:'platform'` AND `organization_id: null`. -// -// #5876 chose `managed_by:'platform'` as the test for "this is our own -// placeholder, so we may refresh it", and it was sufficient while `name` was -// unique installation-wide. #8461 (ADR-0120 D1) made `name` unique per -// ORGANIZATION, at which point a platform-STAMPED row can sit inside an -// organization's bucket — the shape the file header names ("from seed data or a -// legacy import") and the shape #8470 refused to let `managed_by` alone stand -// for on the curated side, in as many words: it "would not carry that -// guarantee". -// -// ## What each direction is for -// -// The suite has to fail in BOTH directions, because the two easy wrong fixes -// fail in opposite ones: -// -// - Delete the guard (or loosen it) ⇒ the organization's copy is rewritten -// with `humanize(name)`. Pinned by the OVERWRITE assertions. -// - Make the guard always decline (or scope the derived LOOKUP the way the -// curated one is scoped) ⇒ the derived half stops refreshing its OWN -// placeholder and #8536's whole skip branch goes unreachable. Pinned by the -// POSITIVE CONTROLS. -// -// ## The ordering pair, and why it is the anti-vacuity argument -// -// `makeQl` models #4363's `ORDER BY id` tie-breaker, so which of two same-named -// rows the cross-organization lookup returns is decided by `id`. `aaa_org_…` -// sorts before every seeder-minted `cap_…` and `zzz_org_…` after it. Where both -// rows exist the organization's copy must survive under BOTH orderings — under -// `aaa_` because the guard declines it, under `zzz_` because it is never the row -// selected. Before this change the `aaa_` leg overwrote it and the `zzz_` leg did -// not, so the pair separates "the guard works" from "the fixture got lucky". -// -// ## What this does NOT fix, asserted so nobody reads it as a win -// -// The platform bucket is STILL not seeded when an organization's row satisfies -// the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped -// for the admin-authored case in #8536 and unchanged here; what changes is that -// the state stops being invisible. `platformRowFor(...)` is therefore asserted -// ABSENT on purpose — a future "fix" that starts backfilling the bucket must -// fail this suite, not pass it. -// ─────────────────────────────────────────────────────────────────────────── -describe('[#8751] a platform-STAMPED row inside an organization is not the platform\'s own row', () => { - const GRANTS = [{ systemPermissions: ['showcase.export_data'] }]; - const NAME = 'showcase.export_data'; - const ORG = 'org_jia_9f2'; - /** Distinctive on purpose: `humanize(NAME)` is "Showcase Export Data". */ - const AUTHORED = { label: 'Stale Org Label', description: 'Authored inside the organization.' }; - - /** The row the card is about: platform provenance, organization bucket. */ - const stampedOrgRow = (id = 'aaa_org_derived') => ({ - id, - organization_id: ORG, - name: NAME, - ...AUTHORED, - scope: 'org', - managed_by: 'platform', - active: true, - }); - - const platformRowFor = (ql: ReturnType, name: string) => - ql.rows.find((r) => r.name === name && r.organization_id == null); - - // ── Direction 1: the overwrite. #5876's guarantee, restored ── - it("leaves the organization's copy exactly as its author wrote it", async () => { - const ql = makeQl(); - ql.rows.push(stampedOrgRow()); - await bootstrapSystemCapabilities(ql, GRANTS); - - expect(ql.rows.find((r) => r.id === 'aaa_org_derived')).toEqual(stampedOrgRow()); - // Stated positively too: the generated placeholder never reached the row. - expect(ql.rows.find((r) => r.id === 'aaa_org_derived')!.label).not.toBe('Showcase Export Data'); - }); - - // ── Direction 2: the silence. Every counter read 0 and nothing was logged ── - it('counts the misplaced stamp, and reports the bucket it leaves unseeded', async () => { - const ql = makeQl(); - ql.rows.push(stampedOrgRow()); - const warn = vi.fn(); - const info = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn, info } }); - - expect(out.platformStampedInOrg).toBe(1); - // #8536's counters are NOT re-scoped: the newly-declined row is an ordinary - // skip that leaves the bucket empty, which is exactly what they mean. - expect(out.skippedAuthored).toBe(1); - expect(out.unseededDerived).toBe(1); - // …and the SUBSET invariant #8536 documented still holds. - expect(out.unseededDerived).toBeLessThanOrEqual(out.skippedAuthored); - expect(out.platformStampedInOrg).toBeLessThanOrEqual(out.skippedAuthored); - - // The boot summary is the other half of "invisible": the card's measured run - // had every counter reading zero there too. - expect(info).toHaveBeenCalledTimes(1); - expect(info.mock.calls[0][1]).toMatchObject({ platformStampedInOrg: 1, unseededDerived: 1 }); - }); - - // ── NOT fixed, and pinned as not fixed (#8552: no adoption, no backfill) ── - it('does NOT backfill the platform bucket — the empty bucket is the ruled posture', async () => { - const ql = makeQl(); - ql.rows.push(stampedOrgRow()); - const out = await bootstrapSystemCapabilities(ql, GRANTS); - - expect(platformRowFor(ql, NAME)).toBeUndefined(); - // The curated names still land; only the derived one is left underived. - expect(out.seeded).toBe(KNOWN_CAPABILITIES.length); - }); - - it('names the stamp, the organization, and the source — not the org-extension line', async () => { - const ql = makeQl(); - ql.rows.push(stampedOrgRow()); - const warn = vi.fn(); - await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(warn).toHaveBeenCalledTimes(1); - const [message, meta] = warn.mock.calls[0]; - expect(message).toContain(NAME); - expect(message).toContain("managed_by='platform'"); - expect(message).toContain(ORG); // the real organization, not a placeholder - expect(message).toContain('NO row holds the name in that bucket at all'); - expect(message).toContain("wears the platform's OWN provenance stamp"); - expect(message).toContain('Fix it AT ITS SOURCE'); - // The admin-row remediation must NOT print: this row is not "a supported - // extension", and it is the one row Setup cannot touch at all. - expect(message).not.toContain('supported extension'); - expect(message).not.toContain('there is nothing for an operator to remove'); - // Nor the blocked-platform-bucket line — that bucket is free. - expect(message).not.toContain('To resolve by hand'); - expect(meta).toEqual({ - name: NAME, - blockingRowId: 'aaa_org_derived', - blockingManagedBy: 'platform', - blockingOrganizationId: ORG, - platformRowId: undefined, - }); - }); - - // ── POSITIVE CONTROL 1: the guard must not over-decline ── - // The derived half's whole job is to refresh its OWN placeholder. A "fix" that - // declined everything, or that scoped the derived LOOKUP the way the curated - // one is scoped, passes every pin above and fails here. - it('POSITIVE CONTROL: still refreshes its own placeholder in the platform bucket', async () => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, GRANTS); // boot 1 derives it - const own = platformRowFor(ql, NAME)!; - expect(own).toMatchObject({ managed_by: 'platform' }); - own.label = 'stale placeholder'; - own.description = 'stale placeholder'; - - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(own.label).toBe('Showcase Export Data'); - expect(own.description).toBe(`Capability ${NAME}.`); - expect(out.skippedAuthored).toBe(0); - expect(out.platformStampedInOrg).toBe(0); - expect(warn).not.toHaveBeenCalled(); - }); - - // ── POSITIVE CONTROL 2: the counter is not a synonym for "skipped" ── - it('OPPOSITE-DIRECTION CONTROL: an ADMIN-authored org row moves the #8536 counters only', async () => { - const ql = makeQl(); - ql.rows.push({ ...stampedOrgRow(), managed_by: 'admin' }); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.skippedAuthored).toBe(1); - expect(out.unseededDerived).toBe(1); - expect(out.platformStampedInOrg).toBe(0); // the stamp is what this counts - // …and #8536's own remediation still prints for the row it was written for. - expect(warn.mock.calls[0][0]).toContain('supported extension'); - expect(warn.mock.calls[0][0]).not.toContain("wears the platform's OWN provenance stamp"); - }); - - // ── The ordering pair: the organization's copy survives either tie-break ── - // - // Both rows exist here, so `id` really does decide which one the - // cross-organization lookup returns. Before this change the two legs diverged - // (the `aaa_` leg overwrote the organization's copy); now they agree, because - // the outcome never depended on the ordering in the first place. - it.each([ - ['adverse — the org row sorts FIRST, so the lookup returns it', 'aaa_org_derived', 1], - ['benign — the org row sorts LAST, so the lookup never sees it', 'zzz_org_derived', 0], - ])('the organization copy survives (%s)', async (_label, orgId, expectedStamped) => { - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, GRANTS); // the platform's placeholder exists - ql.rows.push(stampedOrgRow(orgId)); - - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - // Identical in both legs: the authored copy is untouched. - expect(ql.rows.find((r) => r.id === orgId)).toEqual(stampedOrgRow(orgId)); - // Nothing is MISSING in either leg — the placeholder is in its bucket — so - // this stays summary-only (#4632) and warns nothing, exactly as #8536 ruled - // for the admin-authored equivalent. - expect(out.unseededDerived).toBe(0); - expect(warn).not.toHaveBeenCalled(); - // What differs is only which row the lookup had to judge. - expect(out.platformStampedInOrg).toBe(expectedStamped); - expect(out.skippedAuthored).toBe(expectedStamped); - expect(platformRowFor(ql, NAME)).toMatchObject({ managed_by: 'platform' }); - }); - - // ── The absent-column case the `== null` in the guard is written for ── - // A projection that does not return `organization_id` leaves ownership - // undecidable; the historical answer ("ours") is kept, so a missing column can - // never manufacture a skip. - it('treats an ABSENT organization_id as the platform bucket, not as an organization', async () => { - const ql = makeQl(); - const { organization_id: _omitted, ...noOrgColumn } = stampedOrgRow('cap_no_org_column'); - ql.rows.push(noOrgColumn); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, GRANTS, { logger: { warn } }); - - expect(out.platformStampedInOrg).toBe(0); - expect(out.skippedAuthored).toBe(0); - // It is treated as the platform's own placeholder — so it is REFRESHED. - expect(ql.rows.find((r) => r.id === 'cap_no_org_column')).toMatchObject({ - label: 'Showcase Export Data', description: `Capability ${NAME}.`, - }); - expect(warn).not.toHaveBeenCalled(); - }); -}); - -/** - * [commit c33f18592] WHY THE #8470 PREDICATE TRAVELS INSIDE THE BATCHED READ. - * - * The filing offered "two batched reads, the curated half post-filtering on - * `managed_by`/`organization_id` in memory" as the cheap option. It is not - * cheap; it is wrong, and this is the measurement rather than the argument. - * - * Both harms are visible on the SAME fixture, and both are properties of the - * shipped drivers that this double models deliberately (see `makeQl`'s header): - * `limit` orders by `id` ascending (#4363's pagination tie-breaker), and the - * unscoped batched page is capped at one row per requested name. - */ -describe('#11451 — the batched curated read must carry its predicate, not filter afterwards', () => { - const CURATED_NAMES = KNOWN_CAPABILITIES.map((c) => c.name); - const CURATED_LOOKUP = { managed_by: 'platform', organization_id: null }; - /** The name two organizations also hold — ADR-0066 D1 "admins EXTEND". */ - const SHARED = CURATED_NAMES[0]; - - /** Every curated name in the platform bucket, plus two organizations' rows. */ - function fixture() { - const ql = makeQl(); - // Organization ids sort BEFORE the platform's, so they win the tie-breaker. - ql.rows.push( - { id: 'aaa_org_jia', organization_id: 'org_jia', name: SHARED, label: 'Jia copy', description: 'jia', scope: 'org', managed_by: 'admin', active: true }, - { id: 'aab_org_yi', organization_id: 'org_yi', name: SHARED, label: 'Yi copy', description: 'yi', scope: 'org', managed_by: 'admin', active: true }, - ); - KNOWN_CAPABILITIES.forEach((c, i) => ql.rows.push({ - id: `cap_${i}`, name: c.name, label: c.label, description: c.description, - scope: c.scope, managed_by: 'platform', organization_id: null, active: true, - })); - return ql; - } - - it('POSITIVE CONTROL: with the predicate, every curated name resolves to the PLATFORM row', async () => { - const ql = fixture(); - const index = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES, undefined, undefined, CURATED_LOOKUP); - for (const name of CURATED_NAMES) { - const found = await index.get(name); - expect(found.status).toBe('present'); - expect((found as { row: any }).row.managed_by).toBe('platform'); - expect((found as { row: any }).row.organization_id).toBeNull(); - } - // The predicate makes the result a provable singleton per name, so the page - // cap is exactly right and nothing falls off the end. - const shared = await index.get(SHARED); - expect((shared as { row: any }).row.id).toBe('cap_0'); - }); - - it('WITHOUT the predicate the same read returns an ORGANIZATION\'s row for the shared name', async () => { - const ql = fixture(); - const index = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES); - const found = await index.get(SHARED); - expect(found.status).toBe('present'); - // …and it is NOT the platform's row. Reconciling this one is #8470 exactly: - // an organization's authored copy overwritten with the platform's, every - // boot, while the platform's own row is never the one addressed. - expect((found as { row: any }).row.id).toBe('aaa_org_jia'); - expect((found as { row: any }).row.managed_by).toBe('admin'); - }); - - it('WITHOUT the predicate the page hauls every organization\'s copy — and the wrong row answers', async () => { - const ql = fixture(); - // ⚠️ [commit e1d773eb7] THIS TEST USED TO PIN THE OPPOSITE OUTCOME, and the change is - // the repair rather than a weakened assertion. The unscoped page was capped - // at one row per requested name, so on this same 10-row fixture the two - // highest-id platform rows fell off the 8-row page and their names read as - // `absent` — which routes the caller to its INSERT branch. That was commit e1d773eb7's - // measurement, taken here; the cap is now measured rather than trusted - // (`seed-name-lookup.ts`), so the page carries all ten rows and loses nobody. - const index = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES); - for (const name of CURATED_NAMES) { - expect((await index.get(name)).status, name).toBe('present'); - } - - // What commit e1d773eb7 does NOT repair — and cannot — is WHICH row answers. The - // unpredicated read still hauls both organizations' copies into the page, - // and unscoped the first row is the row, so an organization's authored copy - // answers for the platform's own definition. That is the defect commit c33f18592 fixed, and no - // page budget reaches it: only the predicate does. - const shared = await index.get(SHARED); - expect((shared as { row: any }).row.id).toBe('aaa_org_jia'); - expect((shared as { row: any }).row.managed_by).toBe('admin'); - - // …while the predicated read over the same fixture answers with the - // platform's row for every name, and never sees the copies at all. - const scoped = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES, undefined, undefined, CURATED_LOOKUP); - for (const name of CURATED_NAMES) expect((await scoped.get(name)).status).toBe('present'); - expect(((await scoped.get(SHARED)) as { row: any }).row.id).toBe('cap_0'); - }); - - it('the seeder itself is unharmed by the fixture that breaks the unpredicated read', async () => { - const ql = fixture(); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn } }); - expect(out.seeded).toBe(0); - expect(out.blockedCurated).toBe(0); - expect(out.unchanged).toBe(KNOWN_CAPABILITIES.length); // nothing differed… - expect(out.updated).toBe(0); // …so nothing was written - expect(warn).not.toHaveBeenCalled(); - // The organizations' rows are untouched. - expect(ql.rows.find((r) => r.id === 'aaa_org_jia')).toMatchObject({ label: 'Jia copy' }); - }); - - it('an unreadable database leaves every curated definition ALONE, and says so once', async () => { - const ql = Object.assign(fixture(), { - async find() { throw new Error('fake driver: read unavailable'); }, - }); - const warn = vi.fn(); - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn } }); - expect(out.unreadable).toBe(KNOWN_CAPABILITIES.length); - expect(out.seeded).toBe(0); - expect(out.blockedCurated).toBe(0); // ⛔ never a collision that nobody saw - expect(warn.mock.calls.map((c) => c[0]).some((m) => String(m).includes('could not be read'))).toBe(true); - }); -}); - -/** - * [commit e1d773eb7] THE UNSCOPED PAGE CAP IS A MEASUREMENT, NOT A PROMISE. - * - * `readNamePage` used to cap an unscoped page at `names.length`, which is exact - * only while one row can exist per name. Since #8461 / ADR-0120 D1 the identity - * tables are unique PER ORGANIZATION and ADR-0066 D1 encourages admins to EXTEND - * the registry inside their own organization, so one name legitimately carries a - * row per organization plus the platform's — and the rows that fall off a full - * page are the highest `id`s under #4363's `ORDER BY id ASC`, so WHOLE NAMES - * vanish. A vanished name reads as `absent`, and `absent` INSERTS. - * - * ⛔ `names.length * 2` would have been the same defect with a larger constant: - * rows-per-name is bounded only by the number of organizations. So the cap is - * not widened to a "safe" number — the read asks for ONE ROW MORE than it is - * willing to hold, and a page that comes back with that extra row is a PREFIX of - * the answer. It joins the module's existing "could not answer" causes and - * degrades to the per-item read, which is the fallback that was already there - * for a driver without `$in`. - * - * ⚠️ These tests use the `makeQl` double at the top of this file precisely - * because it HONOURS `limit` and orders by `id` ascending — the two shipped - * driver behaviours the defect is made of. A double that ignored `limit` could - * not express any of this. - */ -describe('#11518 — a page that could not fit the answer must not report "absent"', () => { - const CURATED_NAMES = KNOWN_CAPABILITIES.map((c) => c.name); - /** The name organizations also hold — ADR-0066 D1 "admins EXTEND". */ - const SHARED = CURATED_NAMES[0]; - - /** Record every query the double is asked, in order. */ - function recordFinds(ql: any): any[] { - const seen: any[] = []; - const inner = ql.find; - ql.find = async (object: string, q: any, ctx?: any) => { - seen.push({ object, ...q }); - return inner(object, q, ctx); - }; - return seen; - } - - /** Every curated name in the platform bucket, plus `copies` organizations' rows for one of them. */ - function fixture(copies = 2) { - const ql = makeQl(); - // Organization ids sort BEFORE the platform's, so they take the head of any - // page and it is the PLATFORM rows that fall off the end. - for (let i = 0; i < copies; i++) { - ql.rows.push({ - id: `aaa_org_${String(i).padStart(3, '0')}`, organization_id: `org_${i}`, name: SHARED, - label: `Copy ${i}`, description: `copy ${i}`, scope: 'org', managed_by: 'admin', active: true, - }); - } - KNOWN_CAPABILITIES.forEach((c, i) => ql.rows.push({ - id: `cap_${i}`, name: c.name, label: c.label, description: c.description, - scope: c.scope, managed_by: 'platform', organization_id: null, active: true, - })); - return ql; - } - - it('POSITIVE CONTROL: the cap that was live before this fix truncates on this fixture', async () => { - // Harness-level and stable — it pins the DOUBLE, so it holds before and - // after the repair, and it is what makes the next test meaningful. Without - // it, "no name reads absent" could be green because the trap was never set. - const ql = fixture(); - const page: any[] = await ql.find('sys_capability', { - where: { name: { $in: CURATED_NAMES } }, - limit: CURATED_NAMES.length, // ← the UNSCOPED cap this card repairs - }); - expect(ql.rows).toHaveLength(CURATED_NAMES.length + 2); - expect(page).toHaveLength(CURATED_NAMES.length); - const namesOnThePage = new Set(page.map((r) => r.name)); - expect(namesOnThePage.size, 'two names are missing from a page that is full').toBe(CURATED_NAMES.length - 2); - // [#12702] The LAST TWO curated names, derived — the two org rows' ids - // sort ahead of every `cap_*` id, so under `ORDER BY id ASC` it is always - // the tail of the curated list that falls off a `length`-capped page. This - // read hard-coded `[6]`/`[7]` while the list had eight entries, and went - // red the day the list grew — the count belongs to the list, never to - // prose (the rule of the write-door census commit b5378550e created, applied to indices). - for (const lost of CURATED_NAMES.slice(-2)) { - expect(namesOnThePage.has(lost)).toBe(false); - expect(ql.rows.some((r: any) => r.name === lost && r.managed_by === 'platform')).toBe(true); - } - }); - - it('reads every name whose row exists, in ONE round trip', async () => { - const ql = fixture(); - const finds = recordFinds(ql); - const warn = vi.fn(); - const index = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES, { warn }); - - const absent: string[] = []; - for (const name of CURATED_NAMES) { - if ((await index.get(name)).status === 'absent') absent.push(name); - } - // ⛔ The two names the page used to lose. Their rows are right there. - expect(absent).toEqual([]); - // The whole point of #10946 survives: an install that does not overflow the - // budget still pays exactly ONE read, and says nothing. - expect(finds).toHaveLength(1); - expect(finds[0].limit).toBeGreaterThan(CURATED_NAMES.length); - expect(warn).not.toHaveBeenCalled(); - }); - - it('an overflowing page degrades to the per-item read — LOUDLY, and never to "absent"', async () => { - // 40 organizations have extended one curated name (ADR-0066 D1). No page - // budget covers this, which is the point: the bound is the organization - // count, so the read has to notice rather than guess. - const ql = fixture(40); - const finds = recordFinds(ql); - const warn = vi.fn(); - const index = await buildExistingByName(ql, 'sys_capability', CURATED_NAMES, { warn }); - - for (const name of CURATED_NAMES) { - expect((await index.get(name)).status, name).toBe('present'); - } - - // The degradation is structural, not incidental: one batched read that could - // not answer, then one read per name — `limit: 1`, exactly the shape the - // loops used before the hoist. - expect(finds).toHaveLength(1 + CURATED_NAMES.length); - expect(finds[0].where).toEqual({ name: { $in: CURATED_NAMES } }); - expect(finds[0].limit).toBeGreaterThan(CURATED_NAMES.length); - expect(finds.slice(1).every((q) => q.limit === 1)).toBe(true); - - // …and the answer is the one the pre-#10946 per-item read gave: unscoped, - // the first row by id is the row. - expect(((await index.get(CURATED_NAMES[7])) as { row: any }).row.id).toBe('cap_7'); - expect(((await index.get(SHARED)) as { row: any }).row.id).toBe('aaa_org_000'); - - // Loud: ONE line, naming the object and the budget it could not fit inside. - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain('TRUNCATED'); - expect(String(warn.mock.calls[0][0])).toContain('falling back to one read per item'); - expect(warn.mock.calls[0][1]).toMatchObject({ object: 'sys_capability', names: CURATED_NAMES.length }); - expect(typeof warn.mock.calls[0][1].rowBudget).toBe('number'); - }); - - it('an unreadable page is still reported as a FAILED read, not as a truncated one', async () => { - // The two causes share a consequence and must not share a diagnostic: an - // outage and a wide catalog take opposite remedies. - const ql = Object.assign(fixture(), { async find() { throw new Error('fake driver: read unavailable'); } }); - const warn = vi.fn(); - await buildExistingByName(ql, 'sys_capability', CURATED_NAMES, { warn }); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain('batched seed existence read failed'); - expect(String(warn.mock.calls[0][0])).not.toContain('TRUNCATED'); - }); - - it('the SCOPED arm keeps #10103\'s exact bound — its probe never fires on a healthy catalog', async () => { - // Scoped, the bound is PROVEN rather than budgeted: `applyTenantScope` - // returns `organization_id = :tenant OR organization_id IS NULL` and the - // declared name index is unique per organization, so two rows per name is - // the ceiling. #10103's cap stays exactly that. - const ql = makeQl(); - ql.rows.push( - { id: 'a_residue', organization_id: null, name: 'manage_users', label: 'Residue', description: 'pre-fix', scope: 'org', managed_by: 'platform', active: true }, - { id: 'b_own', organization_id: 'org_jia', name: 'manage_users', label: 'Own', description: 'ours', scope: 'org', managed_by: 'platform', active: true }, - ); - const finds = recordFinds(ql); - const warn = vi.fn(); - const index = await buildExistingByName(ql, 'sys_capability', ['manage_users'], { warn }, 'org_jia'); - - const found = await index.get('manage_users'); - expect(found.status).toBe('present'); - expect((found as { row: any }).row.id).toBe('b_own'); - expect(finds).toHaveLength(1); - expect(warn).not.toHaveBeenCalled(); - }); - - it('a SCOPED page that overflows that bound degrades too — the uniqueness it rests on is not holding', async () => { - // Three rows for one name under one organization's scope is only reachable - // where the declared unique index is absent or not yet created — the same - // deployment the module header calls out as the one the pre-#10946 shape - // duplicated rows on. It is now a loud degradation instead of a silent - // truncation. - const ql = makeQl(); - ql.rows.push( - { id: 'a_residue', organization_id: null, name: 'manage_users', label: 'Residue', description: 'pre-fix', scope: 'org', managed_by: 'platform', active: true }, - { id: 'b_dup', organization_id: 'org_jia', name: 'manage_users', label: 'Dup', description: 'dup', scope: 'org', managed_by: 'platform', active: true }, - { id: 'c_own', organization_id: 'org_jia', name: 'manage_users', label: 'Own', description: 'ours', scope: 'org', managed_by: 'platform', active: true }, - ); - const warn = vi.fn(); - const index = await buildExistingByName(ql, 'sys_capability', ['manage_users'], { warn }, 'org_jia'); - - const found = await index.get('manage_users'); - expect(found.status).toBe('present'); - expect((found as { row: any }).row.organization_id).toBe('org_jia'); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).toContain('TRUNCATED'); - expect(warn.mock.calls[0][1]).toMatchObject({ organization: 'org_jia' }); - }); -}); - -/** - * [#12981] The `catch { return null; }` / `catch { return false; }` swallow - * family — this file is where the card quotes its shape from. - * - * ⚠️ The engines below THROW where `makeQl` returns `null`. That is not a - * harsher double for its own sake: a SQL driver signals a refused write by - * throwing, and `makeQl`'s unique-key model answers `null` instead, so nothing - * in this file previously exercised the `catch` arms at all. Every assertion - * here is about what the seeder does with a thrown refusal. - */ -describe('[#12981] a wholly refused seeding pass is distinguishable from a quiet one', () => { - const CURATED = KNOWN_CAPABILITIES[0]; - - /** A store that accepts reads and refuses every write. */ - function refuseWrites(ql: any) { - ql.insert = async () => { throw new Error('store is refusing writes'); }; - ql.update = async () => { throw new Error('store is refusing writes'); }; - return ql; - } - - it('counts refused INSERTS on the derived half, which reported nothing at all before', async () => { - // The derived half falls through `else if (!isDerived)`, so a refused - // derived insert hit no counter and no log — total silence. - const refused = await bootstrapSystemCapabilities( - refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }], { logger: { warn: () => {} } }, - ); - expect(refused.seeded).toBe(0); - expect(refused.refused).toBeGreaterThan(0); - - // The steady-state comparison: a pass with nothing to do also seeds 0. - const settled = makeQl(); - await bootstrapSystemCapabilities(settled, [{ systemPermissions: ['export_data'] }]); - const quiet = await bootstrapSystemCapabilities(settled, [{ systemPermissions: ['export_data'] }]); - expect(quiet.seeded).toBe(0); - expect(quiet.refused).toBe(0); - - // ⛔ Before the repair these two agreed on every field they reported. - expect(refused.refused).not.toBe(quiet.refused); - }); - - it('counts a refused UPDATE, which was silent on BOTH halves', async () => { - // A platform row that exists but has drifted from the curated definition: - // the seeder reaches `tryUpdate`, and `tryUpdate` answered `false` for - // "refused" and for "nothing to do" alike. - const ql = makeQl(); - ql.rows.push({ - id: 'cap_drifted', name: CURATED.name, label: 'STALE', description: 'STALE', - scope: CURATED.scope, managed_by: 'platform', organization_id: null, active: true, - }); - ql.update = async () => { throw new Error('store is refusing writes'); }; - - const out = await bootstrapSystemCapabilities(ql, [], { logger: { warn: () => {} } }); - expect(out.updated).toBe(0); - expect(out.unchanged).toBe(0); - expect(out.refused).toBeGreaterThan(0); - // The row is still stale — the point of counting. - expect(ql.rows.find((r: any) => r.id === 'cap_drifted').label).toBe('STALE'); - }); - - it('reports ONCE on the durability channel, with the consequence and the remedy', async () => { - const errors: Array<{ msg: string; meta?: any }> = []; - const warns: string[] = []; - const out = await bootstrapSystemCapabilities( - refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }], - { - logger: { - warn: (m: string) => { warns.push(m); }, - error: (msg: string, _e?: Error, meta?: any) => { errors.push({ msg, meta }); }, - }, - }, - ); - - // ONE line for the whole pass, on `error` — a refused registry write is a - // durability degradation (AGENTS.md "Degradation log levels"). - expect(errors).toHaveLength(1); - expect(errors[0].msg).toContain('REFUSED'); - expect(errors[0].msg).toContain('WILL GO ON LOOKING'); - // The CONSEQUENCE is stated without overclaiming: registry state, not authz. - expect(errors[0].msg).toContain('resolve capabilities BY NAME'); - // ...and the REMEDY, both classes named. - expect(errors[0].msg).toContain('os migrate'); - expect(errors[0].meta).toMatchObject({ refused: out.refused }); - }); - - it('falls back to `warn` when the host injected a sink with no `error`', async () => { - // `logSeedDurabilityFailure`'s contract: an absent `error` has a declared - // destination. ⛔ Never `logger?.error?.(…)`, which prints nothing here. - const warns: string[] = []; - await bootstrapSystemCapabilities( - refuseWrites(makeQl()), [{ systemPermissions: ['export_data'] }], - { logger: { warn: (m: string) => { warns.push(m); } } }, - ); - expect(warns.filter((m) => m.includes('REFUSED'))).toHaveLength(1); - }); - - it('adds no steady-state noise — a settled pass reports nothing new', async () => { - const errors: string[] = []; - const warns: string[] = []; - const ql = makeQl(); - await bootstrapSystemCapabilities(ql, []); - const out = await bootstrapSystemCapabilities(ql, [], { - logger: { - warn: (m: string) => { warns.push(m); }, - error: (m: string) => { errors.push(m); }, - }, - }); - expect(out.refused).toBe(0); - expect(errors).toEqual([]); - expect(warns.filter((m) => m.includes('REFUSED'))).toEqual([]); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts deleted file mode 100644 index 93e57082f8a..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts +++ /dev/null @@ -1,995 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapSystemCapabilities — back-compat seed the capability registry - * (ADR-0066 D1). - * - * Promotes the platform's authorization capabilities from bare strings to - * first-class `sys_capability` records. Idempotently upserts (by `name`): - * 1. a CURATED set of well-known platform capabilities (label/description/ - * scope), and - * 2. any capability referenced by the seeded permission sets' - * `systemPermissions[]` that isn't in the curated set (derived defaults), - * so every string a default grant references resolves to a definition record - * while existing string references keep working unchanged (no migration). - * - * Pre-launch posture: upsert only — never prune (admins may add their own - * capabilities in Setup; package-declared capabilities arrive via their own - * seeding). Platform-seeded rows are `managed_by: 'platform'` so they are not - * presented as admin-deletable. Runs on `kernel:ready` alongside the other - * security bootstraps. - * - * [#5876] The two halves have DIFFERENT authority over an existing row's - * display fields, because they have different claims to authorship: - * - CURATED — the platform authored `label`/`description`, and a new version - * may ship new copy, so the row it finds is refreshed; - * - DERIVED — there is no authored copy at all, only `humanize(name)` and - * `Capability .` generated from a granted string, so it refreshes - * only its OWN placeholder (`managed_by:'platform'` on a non-curated name) - * and never a row an admin or a package authored. - * The seed loop used to refresh both alike while the comment in front of it - * claimed admin edits were preserved — what #2909 T3 actually made seed-once is - * `scope`, and only `scope`. - * - * [#8470] The CURATED half looks up THE ROW THE PLATFORM OWNS, not the first row - * that happens to share the name. - * - * `tryFind` runs under `SYSTEM_CTX`, which carries no `tenantId`: the security - * middleware short-circuits on `isSystem` before Layer 0 is composed, and the - * engine's `buildDriverOptions` sets a driver tenant scope only when - * `execCtx.tenantId !== undefined`. The lookup therefore reads ACROSS - * organizations by construction — which is correct for a seeder, and is exactly - * why the predicate has to say which row it means. - * - * [commit 1a6855226] The two batched reads inherit that property rather than re-deriving - * it: `seed-name-lookup.ts` reads under `seedCtx(organizationId)`, and with no - * organization threaded that is `{ isSystem: true }` — the same object literal as - * `SYSTEM_CTX`. Both batched reads and the surviving `tryFind` bucket reads - * therefore ask under one context. - * - * Since #8461 made `sys_capability.name` unique per ORGANIZATION rather than per - * installation (ADR-0120 D1, the cross-tenant existence oracle #8323 reports), an - * admin may author `manage_users` inside their organization while the platform - * holds its own row in the NULL-organization bucket. A lookup on `{ name }` alone - * then has two candidates and picks between them on grounds unrelated to - * ownership, with two harms: an organization's authored copy is overwritten with - * the platform's at every boot, and — when the org row is the one selected before - * the platform's row exists — the curated row is NEVER INSERTED, in any bucket, - * installation-wide. - * - * Note what the remedy is NOT. "Give the lookup an ORDER BY" was already true and - * did not help: #4363's pagination tie-breaker appends `ORDER BY id ASC` to any - * paged read of a driver-managed table, and `limit: 1` counts as paged on both - * `SqlDriver` and `MongoDBDriver` (only `findOne` opts out via - * `singleRowLookup`). So this lookup was already DETERMINISTIC on the shipped - * drivers — deterministic on `id`, a key with no relationship whatsoever to who - * owns the row, and stable per installation, so a boot that reconciles the wrong - * row keeps reconciling it forever rather than self-healing. Determinism was - * never the missing property; OWNERSHIP was. - * - * The predicate is therefore `managed_by: 'platform'` AND `organization_id: null` - * — the two facts that jointly define "the platform's own row". They also make - * the result set provably a singleton, which is what retires the ordering - * question rather than answering it: the post-#8461 unique key is - * `(COALESCE(organization_id, …), name)`, so the NULL-organization bucket admits - * at most ONE row per name, and `limit: 1` over a set of size ≤ 1 cannot be - * arbitrary. `managed_by` alone would not carry that guarantee (a platform-marked - * row sitting inside an organization — from seed data or a legacy import — would - * restore the two-candidate state), and `organization_id` alone would not - * distinguish the platform's row from an admin's in a single-organization - * deployment, where both live in the same bucket. - * - * The DERIVED half's lookup is deliberately UNCHANGED (its own guard, #5876, - * already refuses to touch a row it does not own). - * - * [#8751] …and that guard now spells "a row it does not own" with the SAME - * conjunction. The lookup stays cross-organization; the OWNERSHIP TEST is what - * changes, from `managed_by === 'platform'` to - * `managed_by === 'platform' AND organization_id == null`. - * - * The paragraph four blocks up already stated why, for the curated half: - * `managed_by` alone "would not carry that guarantee (a platform-marked row - * sitting inside an organization — from seed data or a legacy import — would - * restore the two-candidate state)". The derived half kept the single-condition - * test, so post-#8461 it ADMITTED exactly that row: the guard passed, and the - * update below rewrote an organization's `label`/`description` with - * `humanize(name)` — the precise harm #5876 exists to prevent — while the - * platform bucket was never written. No counter moved and nothing was logged, - * because both #5876's counter and #8536's live on the branch where the guard - * DECLINES. - * - * This restores a declared invariant; it does not widen an accept set. What the - * derived half may refresh is narrowed to the rows it provably owns, and the - * newly-declined row flows through the #8536 skip branch unchanged — counted in - * `skippedAuthored`, its bucket read once, warned only where the platform's own - * placeholder is genuinely absent. The misplaced stamp gets its OWN signal - * ({@link CapabilitySeedResult.platformStampedInOrg}) rather than being folded - * into `unseededDerived`: "the platform's definition is missing" and "a row is - * wearing the platform's stamp where the platform never writes" are different - * facts, and #8536's counter is left meaning exactly what it was defined to - * mean. - * - * REACHABILITY, measured (the filing declined to guess, and this is the answer). - * The platform's own artifacts do NOT produce such a row: both capability - * seeders run under `SYSTEM_CTX` with no `tenantId` and never write - * `organization_id`; `normalizeManagedByVocab` does not touch this object; and - * the admin door refuses the stamp outright — `assertSystemRowWriteGate` (a) - * rejects any payload CLAIMING platform/package provenance on `sys_capability`. - * No `sys_capability` seed dataset exists anywhere in this repository. - * - * The ROUTE, however, is live and needs no unsupported step. `SeedLoaderService` - * writes as `isSystem` precisely "so seeds can target system tables like - * `sys_*`", which short-circuits the write gate above; `defineSeed(SysCapability, - * …)` type-checks `managed_by: 'platform'` because the column is a plain - * authorable select; and on the per-organization replay the loader's tenant stamp - * is `config.organizationId ?? (/^(sys_|cloud_|ai_)/.test(objectName) ? undefined - * : fallbackOrgId)` — the pinned organization SHORT-CIRCUITS the `sys_` exemption, - * so a replayed `sys_capability` seed lands stamped with that organization's id. - * `applyPublishedSeeds` pins that `organizationId` from the caller's ACTIVE - * organization, so an app seeding a capability with platform provenance produces - * this row in every organization its seeds are applied into. - * - * That last link is MEASURED, not traced — against the real `SeedLoaderService` - * (harness shaped like objectql's `seed-loader-org-stamp.test.ts`). A - * `sys_capability` seed carrying `managed_by: 'platform'` inserted - * `organization_id: 'org_msbubm8g3j35rgx0'` with an organization pinned, and - * inserted the same row UNSTAMPED with none pinned — the control that shows the - * `sys_` exemption is otherwise intact, so the stamp is the pin's doing and not - * the harness's. What is deliberately NOT claimed: how many organizations a given - * deployment replays seeds into is a provisioning question this repository cannot - * answer. - * - * VERDICT — a DORMANT asymmetry with a LIVE route, not a live defect. Dormant - * because nothing shipped here walks the route; live-routed because walking it - * takes ordinary authoring and no unsupported step. The fix lands on that basis: - * it removes a trap and restores a declared invariant, and the severity claim - * stays exactly that size. The trap is worth removing because the mistake would be - * invisible — the resulting row is one ADR-0066 asset ownership forbids the - * organization's own admin from editing or deleting through Setup. - * - * [#8536] The DERIVED half's skip is REPORTED. The guard stays exactly as #5876 - * wrote it — it keeps declining, and #8552 settled the posture on an occupied - * platform bucket: keep declining, LOUDLY, with no adoption and no backfill. - * Only the observability changes here. - * - * What made the silence wrong is again #8461. "A row resolves this name" and - * "the platform holds a row for this name" used to be ONE statement, which is - * what #5876's reasoning rests on ("the capability resolves and the authored - * copy is the better one"). Per-organization uniqueness split them: the derived - * lookup reads across organizations, so an organization's row satisfies it while - * the NULL-organization bucket is never written at all — and `continue` runs - * before any insert is attempted, so nothing anywhere reports the empty bucket. - * - * So the skip branch reads the platform bucket once — on that branch only, the - * same cost the curated half accepted — and warns with the same provenance- - * naming shape. It warns only where the platform's OWN placeholder is genuinely - * absent: a skip that declines a mere REFRESH (the placeholder is present, it - * simply was not the row the cross-organization lookup selected) stays - * summary-only. That keeps the warning meaning exactly one thing — the - * platform's definition for this name is missing from sys_capability — instead - * of firing on every authored row, which is the state #4632 declined to alarm - * about and which remains counted in `skippedAuthored`. - * - * [commit c33f18592] ROUND TRIPS. The curated half's existence read is now ONE batched - * `$in`, and the reconcile is equality-gated for both halves. The design choice - * the filing asked to be made deliberately is recorded here so it is not - * re-litigated from the diff: - * - * - The predicate is carried INTO the batched read ({@link CURATED_LOOKUP}), - * not applied to its answer. Batching the wide question `{ name }` and - * filtering afterwards would read every organization's row for every curated - * name — a set bounded only by the number of organizations — against a page - * capped at one row per name, so it would truncate, and a truncated page - * reads as "absent", which inserts. It would also not avoid widening - * `seed-name-lookup.ts`: that module's index answers with ONE row chosen by - * arrival order, so filtering needs an all-rows accessor, which hands every - * caller its own spelling of "which row is mine" — the shape #10103 repaired. - * - The DERIVED half kept its per-item read, filed rather than taken. Not - * because it is smaller (it is the half that grows) but because its question - * is cross-organization by construction and its counters — `skippedAuthored`, - * and #8751's `platformStampedInOrg` — are computed from the lowest-id row - * installation-wide. Narrowing it to the platform bucket answers a different - * question and silently reverses part of the #8552 ruling; batching it - * unnarrowed needed an unbounded read. - * - * [commit 1a6855226] The derived half is now batched too — UNNARROWED, on the second of - * those two objections being removed rather than accepted. Commit e1d773eb7 turned the - * page cap from a promise into a measurement (`readNamePage` asks for one row - * more than its budget and calls the overflow `truncated` = "could not answer", - * degrading to the per-item read), so "batching it unnarrowed needs an unbounded - * read" stopped being true. The FIRST objection is untouched and still binding: - * ⛔ the derived read is not narrowed to the platform bucket, because that - * answers a different question and reverses #8552 and #8751 by read shape. The - * seeder therefore issues two batched reads that ask different questions — the - * curated one predicated (#8470), the derived one deliberately wide. - * - * ⚠️ NOTHING here claims a measured speedup. The hosted `bootstrap-curve.mjs` - * rig lives in `objectstack-ai/cloud` and its axes are permission sets / - * positions / objects, not this one. What is pinned in-repo is the round-trip - * COUNT and the identity of the row each leg reads and writes - * (`bootstrap-seed-round-trips.test.ts`). - */ - -import { PLATFORM_CAPABILITIES, type PlatformCapability } from '@objectstack/spec/security'; -import { buildExistingByName } from './seed-name-lookup.js'; -import { - createSeedWriteRefusals, - logSeedDurabilityFailure, - type SeedLogger, - type SeedWriteRefusals, -} from './write-refusals.js'; - -const SYSTEM_CTX = { isSystem: true }; - -/** - * [#8470] The two facts that jointly define "the platform's OWN row", as ONE - * named constant so the batched read below and any future reader see the same - * predicate rather than two copies of it. - * - * ⛔ Do not relax this to `managed_by` alone or `organization_id` alone. The - * module header says why at length; the short version is that each half fails a - * different deployment (a platform-stamped row inside an organization; an - * admin's row in a single-organization deployment), and only the CONJUNCTION - * makes the result a provable singleton — the NULL-organization bucket admits - * one row per name under the declared key `(COALESCE(organization_id, …), name)`. - * That singleton property is also what makes the batched read below safe to cap - * at one row per name. - */ -const CURATED_LOOKUP = { managed_by: 'platform', organization_id: null } as const; - -function genId(prefix: string): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `${prefix}_${ts}${rand}`; -} - -async function tryFind(ql: any, object: string, where: any, limit = 1): Promise { - try { - const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } -} -/** - * [#12981] The refusal channel on the two write helpers. - * - * ⛔ The `catch` still answers `null`/`false` and still never throws: a seeder - * that turns a refused row into a broken boot is a behaviour change this - * repair does not make. What changes is that the refusal is now COUNTED before - * it is absorbed, because `null`/`false` alone cannot tell "the store refused - * this write" from "there was nothing to write" — and every counter in - * {@link CapabilitySeedResult} is a count of writes that LANDED, so a pass in - * which every write was refused produced numbers identical to a steady-state - * boot and a `[security] system capabilities seeded` line at `info` over zero - * rows. That is #12981's shape verbatim, and this file is where the card - * quotes it from. - * - * `refusals` is OPTIONAL so the helpers stay callable from the read-only - * probes that do not run inside a pass; a pass always supplies one. - */ -async function tryInsert( - ql: any, - object: string, - data: any, - refusals?: SeedWriteRefusals, -): Promise { - try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } - catch (e) { refusals?.record(object, e); return null; } -} -async function tryUpdate( - ql: any, - object: string, - data: any, - refusals?: SeedWriteRefusals, -): Promise { - try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } - catch (e) { refusals?.record(object, e); return false; } -} - -type CapabilityDef = PlatformCapability; - -/** - * Well-known platform capabilities. Re-exported from the canonical spec registry - * (`@objectstack/spec/security` `PLATFORM_CAPABILITIES`) so the seeder and the - * authoring lint (ADR-0066 ⑨) share ONE source of truth. `managed_by` is always - * `'platform'` for these. Kept as a named export for back-compat consumers/tests. - */ -export const KNOWN_CAPABILITIES: readonly CapabilityDef[] = PLATFORM_CAPABILITIES; - -function humanize(name: string): string { - return name - .replace(/[._]/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - -interface SeedOptions { - /** - * [#12981] The in-package {@link SeedLogger}, not a fourth inline sink - * literal. - * - * Reused rather than re-declared for two measured reasons. It already - * carries the OPTIONAL `error` this file now needs (a refused catalog write - * is a durability degradation, AGENTS.md "Degradation log levels") beside a - * NON-optional `warn` — which is what - * `scripts/check-optional-error-sink-contract.mjs` requires of any sink type - * declaring `error`: an optional `error` with no guaranteed alternative is a - * contract that permits silence. Declaring `{ info?, warn?, error? }` here - * instead would enrol a NEW type into that gate's population, red on - * arrival. - * - * `warn` becoming non-optional is an AUTHORING-time tightening with no - * published cost: `bootstrapSystemCapabilities` is deliberately absent from - * this package's `index.ts` (its only callers are `security-plugin.ts`, which - * passes the kernel logger, and this package's own tests). ⛔ The sibling - * repairs in `cleanup-package-permissions.ts` and - * `suggested-audience-bindings.ts` could NOT do this — their sinks ride on - * types that ARE exported — which is why those two stay on `warn`. - */ - logger?: SeedLogger; - /** - * [ADR-0066 D1] Capability names that `bootstrapDeclaredCapabilities` has - * confirmed ALREADY HAVE a `sys_capability` row - * ({@link CapabilitySeedOutcome.materializedNames}). The implicit - * derived-defaults path SKIPS these so it never overwrites an authored - * capability's label/description (or its package provenance) with a humanized - * placeholder. Curated platform capabilities are unaffected. - * - * [#4967 Part 1] "Materialized", NOT "declared": a declaration the seeder - * REFUSED (no owning package) writes no row, so suppressing the derivation - * for it left the capability existing in no row at all and every - * `systemPermissions` grant naming it inert. Such a name is deliberately - * absent from this list and derives its placeholder as it always did. - */ - materializedCapabilityNames?: Iterable; -} - -/** Aggregated outcome of a back-compat capability seeding pass. */ -export interface CapabilitySeedResult { - /** Rows inserted (curated definitions + derived placeholders). */ - seeded: number; - /** Rows whose platform display fields were reconciled. */ - updated: number; - /** - * [#5876] Derived names whose existing row is NOT the platform's own, so its - * `label`/`description` were left as their author wrote them. Not a - * degradation where the platform's own placeholder exists — the capability - * resolves and the authored copy is the better one — so THAT case is reported - * in the boot summary rather than warned about (#4632). - * - * [#8751] "The platform's own" is the #8470 conjunction — `managed_by: - * 'platform'` AND `organization_id: null`. This doc used to read "`managed_by` - * anything but `'platform'`", which is what the guard actually tested; the - * text is corrected in step with the guard, not repurposed. The concept is - * untouched (a row this pass does not own was left alone); what changed is - * that a platform-STAMPED row inside an organization now falls inside it, - * instead of being silently reconciled as though the platform had written it. - * - * [#8536] Counts EVERY such skip, unchanged. The subset where the skip also - * leaves the platform bucket without a platform-owned row is the degradation - * #4632 never had to consider, and is counted again — and warned — as - * {@link CapabilitySeedResult.unseededDerived}. - */ - skippedAuthored: number; - /** - * [#8536] The subset of {@link CapabilitySeedResult.skippedAuthored} whose - * skip leaves the platform (NULL-organization) bucket with NO platform-owned - * row: the derived placeholder exists nowhere the platform owns, installation- - * wide. The derived counterpart of {@link CapabilitySeedResult.blockedCurated} - * — same harm, reached by a different route (the #5876 guard `continue`s - * before an insert is ever attempted, so there is no refusal to observe). - * - * A SUBSET rather than a split, deliberately: `skippedAuthored` keeps its - * meaning and its value, because "an authored row was left alone" and "the - * platform's definition is missing" are different facts and #8461 is what made - * them separable. Both are reported; neither is inferred from the other. - */ - unseededDerived: number; - /** - * [#8751] The subset of {@link CapabilitySeedResult.skippedAuthored} whose - * skipped row carries the platform's OWN provenance stamp - * (`managed_by: 'platform'`) while sitting INSIDE an organization — the shape - * the header contemplates ("from seed data or a legacy import"), and the one - * the derived guard used to ADMIT and overwrite. - * - * Its own signal, deliberately, and NOT a re-scoping of - * {@link CapabilitySeedResult.unseededDerived}. The two answer different - * questions and neither implies the other: `unseededDerived` says the - * platform's definition is missing installation-wide; this says a row is - * wearing the platform's stamp somewhere no platform writer writes. They - * co-occur on the headline fixture, but this one is counted even when the - * platform bucket IS properly occupied and nothing is missing — a state that - * stays summary-only (#4632) and warns nothing, yet is still an anomaly worth - * seeing in the boot summary, because a platform-stamped row is one ADR-0066 - * asset ownership refuses to let the organization's own admin edit or delete. - * - * Reported, never acted on: adoption and backfill were both rejected in - * #8552, and re-stamping somebody else's row is a data migration either way. - */ - platformStampedInOrg: number; - /** - * [#8470] Curated definitions whose platform row is ABSENT and could not be - * written, because a row this pass does not own already holds the name in the - * NULL-organization bucket (a Setup-authored row on a single-organization - * deployment, or a package row that claimed a curated name). - * - * This counter exists because the alternative is silence in both directions. - * Before the ownership-scoped lookup the seeder resolved that collision by - * OVERWRITING the other author's row; now it correctly declines to — but - * `tryInsert` swallows the engine's unique-constraint refusal, so declining - * would otherwise look exactly like a clean boot while a curated capability is - * missing from the registry installation-wide. Counted and warned, never - * silent. - */ - blockedCurated: number; - /** - * [commit c33f18592] Rows found ALREADY MATCHING the platform's definition, so no - * `UPDATE` was issued. Reported rather than folded into `updated`, for the - * same reason #10946 reports it on the sibling seeders: without it, "wrote - * nothing because nothing differed" and "wrote nothing because the writes - * stopped working" are the same observation. - */ - unchanged: number; - /** - * [commit c33f18592] Definitions left ENTIRELY alone because the existence read could - * not answer — not read as absent, and therefore never inserted. - * - * [commit 1a6855226] Counts BOTH halves since the derived read was batched. It was - * curated-only while the derived half swallowed a failed read into `[]` and - * went on to attempt an insert; that half now declines on `unknown` like every - * other caller of the shared oracle, so its unanswerable names land here. One - * counter rather than two, deliberately: the fact reported is "this pass could - * not find out", and which half asked does not change the remedy (the next - * boot with a readable database does the work). - * - * ⛔ This is the counter that exists because hoisting a read out of a loop - * changes what a failure means. Per item, a failed read fell through to an - * insert the unique index refused, for that one name; batched, one failure - * speaks for the whole set, so `unknown` is declined instead of guessed - * (#10946). Nothing is missing that was not already missing, and the next boot - * with a readable database seeds it. - */ - unreadable: number; - /** - * [#12981] Writes the store REFUSED this pass — inserts and updates - * together, across both halves. - * - * ⚠️ Not a spelling of any counter above. Every one of those counts a write - * that LANDED (or a definition deliberately left alone); this counts the - * ones that were attempted and rejected. Without it the two states that - * matter most are the same observation: a steady-state boot with nothing to - * do, and a boot whose every write was refused, both report - * `seeded: 0, updated: 0`. The refusal is also REPORTED once per pass on the - * durability channel — the counter is for callers and tests, the log line is - * for the operator. - */ - refused: number; - /** Definitions considered this pass (curated + derived). */ - total: number; -} - -export async function bootstrapSystemCapabilities( - ql: any, - permissionSets: Array<{ systemPermissions?: string[] }> = [], - options: SeedOptions = {}, -): Promise { - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { - seeded: 0, updated: 0, skippedAuthored: 0, unseededDerived: 0, - platformStampedInOrg: 0, blockedCurated: 0, unchanged: 0, unreadable: 0, - refused: 0, total: 0, - }; - } - - const materialized = new Set(options.materializedCapabilityNames ?? []); - - // Build the full definition set: curated first, then any extra capability - // string referenced by the seeded permission sets (derived defaults) — EXCEPT - // ones that already have a row, which the declared seeder owns. - const byName = new Map(); - for (const c of KNOWN_CAPABILITIES) byName.set(c.name, c); - // [#5876] Which names came from the DERIVED half. The two halves carry - // different authority over an existing row's display fields (see the - // reconcile guard below), and after this loop `byName` cannot tell them - // apart on its own. - const derivedNames = new Set(); - for (const ps of permissionSets) { - for (const cap of ps?.systemPermissions ?? []) { - if (typeof cap === 'string' && cap && !byName.has(cap) && !materialized.has(cap)) { - byName.set(cap, { name: cap, label: humanize(cap), description: `Capability ${cap}.`, scope: 'platform' }); - derivedNames.add(cap); - } - } - } - - // [#12981] ONE refusal log for the whole pass — reported once at the end, - // never per row: the seeder walks every declared capability, and the failure - // this exists to surface refuses ALL of them, so a line per refused row - // would bury its own meaning (the aggregate-then-report discipline - // `createSeedWriteRefusals` was built for). - const refusals = createSeedWriteRefusals(); - let seeded = 0; - let updated = 0; - let skippedAuthored = 0; - let unseededDerived = 0; - let platformStampedInOrg = 0; - let blockedCurated = 0; - let unchanged = 0; - let unreadable = 0; - - // [commit c33f18592] ONE batched existence read for the CURATED half, hoisted out of the - // loop below — the shape #10946 established and #11096 carried to the seeder - // next door. Each curated definition used to cost its own sequential - // `SELECT … LIMIT 1`: invisible on a local file database, one separate awaited - // HTTP request on the remote libsql/Turso database every hosted environment - // runs, competing for the same request budget as the rest of boot. - // - // ⚠️ The predicate travels WITH the `$in`; it is NOT applied afterwards in - // memory. The curated question is the platform's own organization-less row - // ({@link CURATED_LOOKUP}, #8470), and post-#8461 that is strictly narrower - // than "a row with this name". Asking the wide question and filtering the - // answer would read every organization's row for every curated name — a set - // bounded only by the number of organizations — against a page capped at one - // row per name, so the page would TRUNCATE, and a truncated page reads as - // `absent`, which inserts. Keeping the predicate in the query keeps the result - // a provable singleton per name, which is the property `sys-capability.object - // .ts`'s own index comment names ("exactly the bucket this key part keeps a - // singleton"). - // - // [commit c33f18592] What this half does NOT do is narrow the derived read — see the - // derived index below, which commit 1a6855226 batched on terms commit c33f18592 could not. - const curatedExisting = await buildExistingByName( - ql, - 'sys_capability', - KNOWN_CAPABILITIES.map((c) => c.name), - options.logger, - undefined, - CURATED_LOOKUP, - ); - - // [commit 1a6855226] The DERIVED half, batched — UNNARROWED, which is the only shape - // that preserves what it computes. Commit c33f18592 filed this rather than taking it, - // and the two objections it recorded resolved in opposite ways: one was - // removed by commit e1d773eb7, the other still stands and still forbids the cheap fix. - // - // ## Why the batched read is the SAME question, not a cheaper one - // - // Everything this half does depends on WHICH row it finds: `derivedRowIsOurs`, - // `skippedAuthored` and #8751's `platformStampedInOrg` are all read off `X` — - // the lowest-id row for the name, installation-wide. Three properties, each - // checked against the shared module rather than assumed, make the index answer - // with that same `X`: - // - // - NO predicate is passed, so `readNamePage` emits `{ name: { $in: … } }` — - // the set-widening of the per-item `{ name }`, with no key added (the - // `...(equals ?? {})` spread contributes nothing when omitted); - // - the read runs under `seedCtx(undefined)` — `{ isSystem: true }`, byte- - // identical to this file's `SYSTEM_CTX`, so it stays unscoped and - // cross-organization; - // - unscoped, `resolveOwnOrganizationRow` returns the FIRST row and applies - // NO bucket filter, so the index resolves to the row `tryFind(…, 1)[0]` - // returned under #4363's `ORDER BY id ASC`. The sibling seeder on this very - // table records the same equivalence for its own unscoped adoption - // (`bootstrap-declared-capabilities.ts`: "an unscoped lookup is EXACTLY the - // question the per-item read asked"). - // - // ## What commit e1d773eb7 removed - // - // The blocking objection was the PAGE CAP, not the question: this set is - // bounded only by the number of organizations, against a page that was capped - // at one row per name — so it truncated, and a truncated page reads as - // `absent`, which INSERTS. `readNamePage` now asks for one row MORE than its - // budget and reports the overflow as `truncated` — "could not answer" — - // degrading, loudly, to exactly the per-item read this half used to do - // unconditionally. So the unbounded-read trade commit c33f18592 declined no longer - // exists: the worst case is the old cost plus a warning naming the budget. - // - // ## What is still forbidden - // - // ⛔ NARROWING this to the platform bucket (`organization_id: null`). It - // answers a DIFFERENT question — `B`, the bucket occupant, not `X` — and the - // two diverge on ruled ground in both directions: it would stop counting an - // organization's platform-stamped row whenever our own bucket row also exists - // (#8751's signal, in precisely the case its doc says it is counted for), and - // it would seed the bucket in the very case #8552 ruled must be left alone. - // Both are maintainer decisions and neither has been made, so the read stays - // wide and pays a page budget instead. - const derivedExisting = await buildExistingByName( - ql, - 'sys_capability', - [...derivedNames], - options.logger, - ); - - for (const def of byName.values()) { - const isDerived = derivedNames.has(def.name); - // [#8470] CURATED: address the platform's OWN row. DERIVED: still the WIDE - // question — its own `managed_by` guard below is what keeps it off rows it - // does not own (#5876), and narrowing its lookup is the thing #8552/#8751 - // forbid rather than an optimisation left undone. - // [commit 1a6855226] ONE consumption idiom for both halves. They consult DIFFERENT - // indexes — the curated one carries the #8470 predicate, the derived one is - // deliberately unpredicated — but "what does a lookup answer mean" is one - // question with one answer, and a second spelling of it is how the two halves - // would drift apart again. - const existing = isDerived ? derivedExisting : curatedExisting; - const found = await existing.get(def.name); - if (found.status === 'unknown') { - // ⛔ [#10946] "I could not find out" is not the answer "it is not there". - // The per-item shape was accidentally immune to the conflation — a failed - // read fell through to an insert the unique index refused, for that one - // name — and a batched read is not, because one failure now speaks for - // the entire set. Declining is also STRICTER than the code it replaces, - // deliberately: on an unreadable database this half used to attempt an - // insert per curated name and then report a `blockedCurated` collision - // for each, describing a row nobody ever saw. - // - // [commit 1a6855226] The DERIVED half reaches this branch now too, and there the - // strictness replaces something worse than a phantom diagnostic: its - // `tryFind` swallowed a failed read into `[]`, which reads as absent, so - // the half went on to attempt an insert. Where the read failed but the - // write did not — a transient read timeout, a replica lagging — that - // insert is a DUPLICATE placeholder, refused only if the unique index - // happens to exist. Declining is the answer the module header already - // requires of every other caller of this oracle. - unreadable += 1; - continue; - } - const row: any = found.status === 'present' ? found.row : undefined; - if (row?.id) { - // [#5876] Reconcile display fields only where THIS pass owns the copy. - // - // A DERIVED name has no authored copy to ship: `label` is `humanize(name)` - // and `description` is `Capability .`, both generated from the - // string a permission set happened to grant. Refreshing those onto a row - // somebody else authored is not reconciliation, it is overwriting an - // author with a placeholder — every boot, silently. For a non-curated - // name a `managed_by:'platform'` row can only be this same derivation's - // placeholder from an earlier boot, so that is exactly the set of rows - // the derived half may refresh; `admin` (Setup-authored), `package` - // (declared by its owning package) and anything else are left alone. - // - // The CURATED half is unchanged: those definitions are authored by the - // platform and a new version legitimately ships new copy, so a curated - // name still refreshes the row it finds. - // - // NOTE this is the WRITE-side enforcement of the same rule - // `materializedCapabilityNames` states at the CALL site (#4967 Part 1): - // the caller says which names another pass already materialized, and - // this guard holds even when nothing said so — an admin row for a name - // no package ever declared is invisible to that list. - // [#8751] "Ours" is the #8470 conjunction, applied to the row the - // cross-organization lookup returned. `managed_by` alone was the #5876 - // test, and it was sufficient only while `name` was unique - // installation-wide; since #8461 it admits a platform-STAMPED row an - // organization holds, which this half would then rewrite with - // `humanize(name)` — the one thing #5876 exists to prevent. - // - // `== null` covers null AND absent, on purpose. A driver or projection - // that does not return `organization_id` at all leaves ownership - // undecidable, and the historical answer there is "this is our row" — - // which is also the only answer that cannot invent a skip out of a - // missing column. - const derivedRowIsOurs = row.managed_by === 'platform' && row.organization_id == null; - if (isDerived && !derivedRowIsOurs) { - skippedAuthored += 1; - // Counted for the CLASS, before the bucket read below decides whether - // anything is missing: a misplaced stamp is an anomaly whether or not - // the platform's placeholder happens to exist elsewhere. - if (row.managed_by === 'platform') platformStampedInOrg += 1; - // [#8536] Declining is the ruled behaviour (#5876, reaffirmed by #8552); - // being SILENT about what the decline leaves behind is not. The lookup - // above reads across organizations, so the row just skipped says nothing - // about the platform's own bucket: it may be an organization's row while - // the NULL-organization bucket sits empty, or the bucket's own occupant, - // or neither — the platform's placeholder may be present and simply not - // have been the row selected. Those are different facts, so read the - // bucket rather than infer it, once, on this branch only. - // - // The unique key `(COALESCE(organization_id, …), name)` admits one row - // per name there, so this read returns THE occupant or nothing. - const platformRow = ( - await tryFind(ql, 'sys_capability', { name: def.name, organization_id: null }, 1) - )[0]; - if (platformRow?.managed_by === 'platform') { - // The placeholder exists; only a refresh of somebody else's row was - // declined. Nothing is missing, so this stays summary-only (#4632) — - // and warning here would make the warning mean nothing in particular. - continue; - } - unseededDerived += 1; - // Name the provenance READ, never an ownership verdict: on a row - // carrying some other `managed_by` a sentence like "a row this pass does - // not own" would be false, printed every boot (the curated half's rule, - // applied to the row THIS half actually skipped). - const provenance = row.managed_by == null - ? 'a row carrying no managed_by value' - : `a row with managed_by='${String(row.managed_by)}'`; - const locality = row.organization_id == null - ? 'in the platform (NULL-organization) bucket itself' - : `inside organization '${String(row.organization_id)}'`; - // THREE outcomes for the bucket, as on the curated side — and here a - // fourth is impossible by construction: a platform-owned occupant - // returned above. - const bucket = platformRow === undefined - ? 'NO row holds the name in that bucket at all — it is free, and nothing was written to it ' + - 'because the guard declines before any insert is attempted' - : platformRow.id === row.id - ? 'that row is itself the one holder the bucket admits for this name' - : platformRow.managed_by == null - ? 'a row carrying no managed_by value already holds the name there, and was left exactly as it is' - : `a row with managed_by='${String(platformRow.managed_by)}' already holds the name there, ` + - 'and was left exactly as it is'; - // [#8552] The ruling leaves a colliding row to the OPERATOR, so where one - // blocks the platform bucket the warning owes them the line that says how - // to clear it by hand. Where the bucket is FREE there is nothing of theirs - // to clear: what stands in the way is an organization's row, which - // ADR-0066 D1 explicitly supports ("admins EXTEND the registry"), so - // saying "rename it" would be advising the removal of a legitimate row. - // - // [#8751] …and a THIRD shape reaches this branch now that the guard - // spells ownership as the conjunction: a row wearing the platform's own - // stamp inside an organization. The organization-row sentence below must - // NOT print for it — "a supported extension (admins EXTEND the registry)" - // is true of `managed_by:'admin'` and false of this one, and "there is - // nothing for an operator to remove" would be the wrong advice about the - // one row here that nobody can remove through Setup at all. - const remediation = platformRow !== undefined - ? ' To resolve by hand: rename the row that holds the name in the platform bucket (or delete ' + - 'it) — through Setup for an admin-authored row, or by editing and re-publishing the owning ' + - "package for a package-declared one — then restart; the seeder will then derive the platform's " + - 'placeholder.' - : row.organization_id == null - ? '' - : row.managed_by === 'platform' - ? " That row wears the platform's OWN provenance stamp while sitting inside an organization — " + - 'a shape no platform writer produces (both capability seeders write the NULL-organization ' + - 'bucket and never set organization_id, and the admin door refuses to stamp a platform value ' + - 'at all), so it most likely arrived as app seed data replayed per organization, or a legacy ' + - 'import. Fix it AT ITS SOURCE: Setup cannot, because ADR-0066 asset ownership refuses every ' + - 'admin-door edit and delete on a platform-stamped row. Note the platform bucket stays empty ' + - 'either way, and not because of the stamp: when a row the seeder cannot prove is its own ' + - 'already holds the name, the seeder declines rather than adopting that row or backfilling a stamp.' - : " The organization's row is a supported extension (ADR-0066 D1 — admins EXTEND the " + - 'registry), so there is nothing for an operator to remove; the platform bucket is left empty ' + - 'deliberately: the seeder does not adopt a row it cannot prove is its own, and does not ' + - "backfill provenance on the operator's behalf."; - options.logger?.warn?.( - `[security] derived capability "${def.name}" has no platform placeholder and none was seeded. ` + - `The row this pass found for the name is ${provenance} ${locality}, and its label and ` + - 'description were left as their author wrote them (the derivation refreshes them only on a row ' + - "it can prove is the platform's own). In the platform " + - `(NULL-organization) bucket, where the declared unique key admits one row per name: ${bucket}. ` + - "The platform's own derived placeholder is therefore missing from sys_capability " + - 'installation-wide. Grants and requiredPermissions referencing the name are unaffected — ' + - `they resolve by name, not by row.${remediation}`, - { - name: def.name, - blockingRowId: row.id, - blockingManagedBy: row.managed_by ?? null, - blockingOrganizationId: row.organization_id ?? null, - platformRowId: platformRow?.id, - }, - ); - continue; - } - // Keep label/description fresh from the platform's own definition. - // `scope` is an admin-editable classification face (plain select on - // sys_capability), so it is seed-once: written on insert, never - // refreshed (#2909 T3). A curated scope change in a new platform version - // needs a data migration — recorded in the ADR-0094 addendum. - // - // [commit c33f18592] …and only where they actually DIFFER. This write used to fire - // on every boot for every row the pass owns, storing bytes already there — - // one more sequential request per definition on a remote database. The - // gate sits AFTER the derived-ownership guard above, so it removes write - // round trips from BOTH halves, including the one whose read stays - // per-item. - // - // Compared on exactly the two columns this pass owns and nothing else: - // `scope` is seed-once (#2909 T3) and the provenance columns are never - // rewritten here, so a row already matching on `label` and `description` - // is a row this pass has nothing to say about. - if (row.label === def.label && row.description === def.description) { - unchanged += 1; - } else if (await tryUpdate(ql, 'sys_capability', { id: row.id, label: def.label, description: def.description }, refusals)) { - updated += 1; - } - } else { - const payload = { - id: genId('cap'), - name: def.name, - label: def.label, - description: def.description, - scope: def.scope, - managed_by: 'platform', - active: true, - }; - const created = await tryInsert(ql, 'sys_capability', payload, refusals); - if (created) { - seeded += 1; - // [#10946] Recording the row is what keeps hoisting the read out of the - // loop behaviour-preserving: the per-item read saw rows this same loop - // had just inserted, and a snapshot taken before it cannot. `byName` is - // keyed by name, so a curated name cannot repeat within one pass — this - // records the row anyway rather than making the batched read depend on - // an invariant that lives somewhere else. - // - // [commit 1a6855226] …and now the derived half records on ITS index for the same - // reason. `byName` is keyed by name so no name repeats within one pass - // in either half; both record anyway, because "the read cannot see rows - // this loop just inserted" is a property of the hoist, not of the half. - existing.remember(def.name, payload); - } else if (!isDerived) { - // [#8470] The curated row is absent AND could not be written — the - // NULL-organization bucket already holds the name under a row the - // scoped lookup did not match. Report it: a curated capability missing - // from the registry is the harm this card is about, and an unreported - // one is indistinguishable from a clean boot (`tryInsert` swallows the - // engine's unique-constraint refusal). - // - // The diagnostic states what was OBSERVED, and it costs one extra read - // — on this branch only — to be able to. The seeder knows two things: - // no row matched `managed_by:'platform' AND organization_id IS NULL`, - // and the insert was refused. It does NOT know who authored the row - // that blocked it, so it must not say "a row this pass does not own": - // that is an ownership verdict, and on a hypothetical platform row - // carrying some other `managed_by` the sentence would be false, printed - // every boot. Read the blocking row's provenance and name it instead. - // - // Note this REPORTS the distinction; it deliberately does not ACT on - // it. Adopting a differently-stamped row into the platform's identity - // would reverse the #5876 ruling that "not provably ours" resolves to - // leave-it-alone, and backfilling a stamp is a data migration. Both are - // maintainer calls, and neither is made here. - blockedCurated += 1; - // THREE outcomes, not two. "No row came back" and "a row came back - // carrying no managed_by" are different observations, and collapsing - // them would repeat this branch's own defect one level down: the insert - // WAS refused, so something blocked it, and a follow-up read that finds - // nothing is a genuinely interesting state (a racing writer, or a - // refusal that was never the unique key) — not an ordinary unstamped - // row. Say which one was seen. - const blocking = (await tryFind(ql, 'sys_capability', { name: def.name, organization_id: null }, 1))[0]; - const observation = blocking === undefined - ? 'NO blocking row is visible there at all — so the refusal was not the unique key, or the ' + - 'row has gone since. That is not an ordinary collision and is worth investigating' - : blocking.managed_by == null - ? 'a row carrying no managed_by value already holds the name, and was left exactly as it is' - : `a row with managed_by='${String(blocking.managed_by)}' already holds the name, and was ` + - 'left exactly as it is'; - // [#8552] The maintainer's ruling deliberately leaves an existing - // colliding row to the OPERATOR (option 1: no adoption, no backfill), - // so the warning that reports the collision owes them the one line - // that says how to resolve it by hand. Only when a blocking row was - // actually observed — telling an operator to rename a row the read - // just failed to find would be asserting what was not seen. - const remediation = blocking === undefined - ? '' - : ' To resolve by hand: rename the blocking row to a name outside the curated set (or delete ' + - 'it) — through Setup for an admin-authored row, or by editing and re-publishing the owning ' + - "package for a package-declared one — then restart; the seeder will then seed the platform's " + - 'definition. (New Setup rows can no longer take a curated name — refused at the write door.)'; - options.logger?.warn?.( - `[security] curated capability "${def.name}" has no platform row and could not be seeded. ` + - 'In the platform (NULL-organization) bucket, where the declared unique key admits one row ' + - `per name: ${observation}. The platform definition is therefore missing from sys_capability ` + - 'installation-wide. Grants and requiredPermissions referencing the name are unaffected — ' + - `they resolve by name, not by row.${remediation}`, - { name: def.name, blockingRowId: blocking?.id, blockingManagedBy: blocking?.managed_by ?? null }, - ); - } - } - } - if (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 definitions were left ENTIRELY alone, so one that is - // genuinely absent has not been seeded and a drifted one has not been - // reconciled; the next boot with a readable database does both. - // - // [commit 1a6855226] "capabilities", not "curated capabilities": the derived half can - // land here too now. `total` is the whole definition set for the same - // reason — measured against `KNOWN_CAPABILITIES.length` a count that - // included derived names could exceed its own total. - options.logger?.warn?.( - '[security] capabilities left untouched — their sys_capability rows could not be read', - { unreadable, total: byName.size }, - ); - } - reportCapabilityWriteRefusals(options.logger, refusals); - options.logger?.info?.('[security] system capabilities seeded into sys_capability (ADR-0066 D1)', { - seeded, updated, skippedAuthored, unseededDerived, platformStampedInOrg, blockedCurated, - unchanged, unreadable, refused: refusals.total, total: byName.size, - }); - return { - seeded, updated, skippedAuthored, unseededDerived, platformStampedInOrg, blockedCurated, - unchanged, unreadable, refused: refusals.total, total: byName.size, - }; -} - -/** - * [#12981] Report, ONCE per pass, the `sys_capability` writes the store - * refused. - * - * ## Why the durability channel - * - * AGENTS.md "Degradation log levels" asks one question: after the degradation, - * does the system go on looking normal while something it CLAIMS to have - * persisted did not land? Here, exactly. The line below this one — - * `[security] system capabilities seeded into sys_capability` — is an `info` - * claim of persistence, and every count it carries is a count of rows that - * LANDED, so a pass whose every write was refused prints that same reassuring - * line over zero rows. #12923 measured the cost of precisely this on the - * sibling catalog seeders: a boot logged "RBAC catalog seeded" at `info` over - * zero landed rows, on a deployed plane, for weeks. - * - * ## What is and is NOT lost — stated because overstating it is the other failure - * - * `sys_capability` is the capability REGISTRY: it is what Setup lists and where - * a capability's label and description are read from. Grants and - * `requiredPermissions` referencing a capability resolve BY NAME, not by row - * (the same fact the two per-name warnings in this file are careful to state), - * so a refused write does not silently grant or revoke access. What it leaves - * is a capability missing from the registry installation-wide, or one whose - * displayed label and description stay drifted from the platform's own - * definition — under a clean boot log. That is a durability degradation and - * not a functional one, which is why it is reported here and why the report - * does not claim an authorization consequence it does not have. - * - * ## Why ONE line, with the class in `meta` - * - * `reportSeedWriteRefusals` splits its two classes because their REMEDIES - * differ, and both remedies are reachable here — an insert can collide with - * the platform bucket's unique key, an update-by-id cannot — so both are named - * in the one sentence and the per-(object, class) split travels in `meta`. Its - * PROSE is not reused: every sentence of it is about seeding the RBAC catalog - * and names a remedy for the legacy platform-wide index on the catalog name - * column, which is not this pass (the same deviation - * `permission-set-drift.ts` records, and for the same reason — a confident - * wrong answer is worse than no answer). - * - * ## Its relation to the two per-name warnings above - * - * `blockedCurated` already names each CURATED capability whose insert was - * refused, with the blocking row's provenance and a by-hand remedy. This line - * does not replace it and does not double-count: it is the pass-level total, - * and it is the ONLY report for the two halves that had none — a refused - * insert on the DERIVED half (which falls through `else if (!isDerived)` in - * silence) and a refused UPDATE on either half (which was counted nowhere and - * logged nowhere at all). - * - * ⚠️ `check:durability-log-level` does not vouch for this level. `ql.insert` / - * `ql.update` are not in its `DURABILITY_CRITICAL_CALLEES` vocabulary, so its - * green over this file means the site is outside the gate's reach — NOT - * MEASURED — never that the level was approved (#12981's standing premise). - */ -function reportCapabilityWriteRefusals( - logger: SeedLogger | undefined, - refusals: SeedWriteRefusals, -): void { - const entries = refusals.report(); - if (entries.length === 0) return; - logSeedDurabilityFailure( - logger, - `[security] ${refusals.total} sys_capability write(s) were REFUSED while seeding the platform ` + - `capability registry — those rows did NOT land, the "system capabilities seeded" line logged ` + - `alongside this one counts only the writes that DID, and THE DEPLOYMENT WILL GO ON LOOKING ` + - `HEALTHY: nothing else fails, so this line is the only notice. What is missing is registry ` + - `state, not authorization — grants and requiredPermissions resolve capabilities BY NAME rather ` + - `than by row, so no principal gained or lost access; what a refused INSERT leaves is a ` + - `capability absent from sys_capability installation-wide (missing from Setup's list, with no ` + - `label or description anywhere), and what a refused UPDATE leaves is a row whose label and ` + - `description stay drifted from the platform's own definition. Remedy depends on the class, ` + - `which is in this line's meta: a "unique-violation" is a deployment SCHEMA defect — the ` + - `platform (NULL-organization) bucket admits one row per name, so a collision means the ` + - `database still enforces an index from before per-organization materialization; run ` + - `"os migrate plan", where it is reported as a replace_unique_index operation, then ` + - `"os migrate apply". Anything in the "other" class is not that and the migrate remedy does ` + - `NOT apply to it: it is typically a store outage, which the next boot re-attempts. What the ` + - `store actually said is in the query engine's "Insert operation failed" / "Update operation ` + - `failed" entries logged just before this one, which keep the driver's own identifier with the ` + - `bound statement and its values cut. Until a write lands nothing is lost, and nothing arrives ` + - `either.`, - { refused: refusals.total, refusals: entries }, - ); -} 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..58b27f58eae 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.boot.test.ts @@ -4,10 +4,10 @@ * [ADR-0131 D3, ADR-0066 D1] The curated platform capabilities as declared * `capability` metadata, read off a REAL boot of this plugin: what the security * catalog read lists, what the boot writes into `sys_capability`, and what the - * capability seeders report. + * capability seeder reports. * * The boot is the plugin's own — `init`, `start`, then its `kernel:ready` - * handlers in registration order (both capability seeding passes included) — + * handlers in registration order (the declared-capability seeding included) — * over a real `ObjectQL` engine on a SQLite file, so a second boot can open the * database the first one seeded. * @@ -26,9 +26,9 @@ * * 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. + * stack's package row and nothing else: the curated capabilities live in the + * registry, and the boot writes no `sys_capability` row for them (the curated + * seeder is retired), on a fresh database or on one it opened again. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -192,9 +192,8 @@ async function census(engine: ObjectQL): Promise { .sort(); } -/** The census the boot writes, read off the producers (module doc). */ +/** The census the boot writes, read off the producers (module doc): no curated row. */ 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(); @@ -248,10 +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 no curated row — only the stack’s package row — on a fresh and on a seeded database', async () => { expect(await census(fresh.engine)).toEqual(EXPECTED_CENSUS); expect(await census(reseeded.engine)).toEqual(EXPECTED_CENSUS); - // The seeded database needs no write at all: every row already reads as declared. + // The seeded database needs no write at all: the package row already reads as declared. expect(reseeded.writes).toEqual([]); }); diff --git a/packages/plugins/plugin-security/src/builtin-capabilities.ts b/packages/plugins/plugin-security/src/builtin-capabilities.ts index 9eae036ed08..ed40a6d4315 100644 --- a/packages/plugins/plugin-security/src/builtin-capabilities.ts +++ b/packages/plugins/plugin-security/src/builtin-capabilities.ts @@ -19,8 +19,8 @@ * (`GET /api/v1/meta/capability`) list them beside every package-declared * capability, and `GET /api/v1/meta/capability/:name` answers each one's * definition. - * 2. `bootstrapSystemCapabilities` keeps seeding their `sys_capability` rows - * from the same spec list, unchanged. Nothing here writes a row. + * 2. No `sys_capability` row is written for them: the curated capability + * seeder that did so is retired, and nothing here writes a row. * * ## Why the engine registry's item seam, and not the manifest * diff --git a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts b/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts index 91397d8be4e..34ec75602f4 100644 --- a/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-capability.organization-unique.test.ts @@ -26,9 +26,7 @@ import { SysCapability } from './sys-capability.object'; * * Platform-seeded rows carry no organization and the organization key part is * NULL-safe (`COALESCE(organization_id,'__global__')`, ADR-0120 D3), so they - * remain unique among themselves — which is what - * `bootstrapSystemCapabilities`' upsert-by-name relies on. That behaviour is - * pinned driver-side in + * remain unique among themselves. That behaviour is pinned driver-side in * `driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts`; * this test pins the declaration its fixture copies. */ diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6a52dcdbcac..e4fc0df87de 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -85,7 +85,6 @@ import { resolveOwnOrganizationRow, seedCtx, } from './organization-scope.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'; @@ -5023,42 +5022,27 @@ 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[] = []; + // [ADR-0066 D1] Seed the EXPLICIT package capability declarations + // (`defineCapability` / `stack.capabilities`) into `sys_capability` with + // `managed_by:'package'` + package_id provenance. The permission sets are + // passed as well, so a refusal can name the grantor(s) it affects (#4967 + // Part 3). + // [ADR-0131 D3] The platform's curated capabilities get no row here: they + // are served by the registry (`registerBuiltinCapabilities`, + // `GET /api/v1/meta/capability`), and the curated + derived-default + // capability seeder is retired. try { // [ADR-0131 D3] Handed the engine WITHOUT this plugin's own curated // declarations (`builtin-capabilities.ts`): they are not a package's, - // 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, { + // and read here each would be refused as a package claiming a curated + // name. + await bootstrapDeclaredCapabilities(withoutPlatformCapabilityDeclarations(ql), this.metadata, { logger: ctx.logger, permissionSets: this.bootstrapPermissionSets, }); - materializedCapabilityNames = capOutcome.materializedNames; } catch (e) { ctx.logger.warn('[security] declared-capability seeding failed', { error: (e as Error).message }); } - try { - await bootstrapSystemCapabilities(ql, this.bootstrapPermissionSets, { - logger: ctx.logger, - materializedCapabilityNames, - }); - } catch (e) { - ctx.logger.warn('[security] capability seeding failed', { error: (e as Error).message }); - } // [A4 #2920] Heal residual legacy `managed_by` values (env sets stamped // 'user'; older positions stamped system/config/user) onto the unified // platform/package/admin vocab. Runs after the seeders so their canonical @@ -7652,13 +7636,11 @@ export class SecurityPlugin implements Plugin { * * A name in `PLATFORM_CAPABILITY_NAMES` is a capability the PLATFORM * defines: grants (`systemPermissions`) and requirements - * (`requiredPermissions`) resolve it by name, and the curated seeder owns - * the row for it in the NULL-organization bucket - * (`bootstrap-system-capabilities.ts`). A Setup-authored row claiming such - * a name is a declaration the platform cannot honour — it can never BE the - * curated capability (provenance forging is refused by the gate above), and - * once it holds the name's bucket slot the seeder must decline the - * collision on every boot (`blockedCurated`, #8470). Maintainer ruling on + * (`requiredPermissions`) resolve it by name, and its definition is served + * by the registry (`builtin-capabilities.ts`, ADR-0131 D3), not by a row. A + * Setup-authored row claiming such a name is a declaration the platform + * cannot honour — it can never BE the curated capability (provenance + * forging is refused by the gate above). Maintainer ruling on * #8552 (2026-08-13): refuse the collision AT THE WRITE DOOR — create and * rename both — naming the colliding curated name; never answer 200. * diff --git a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts index c66cb02cfd3..d912893a03e 100644 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ b/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts @@ -27,7 +27,7 @@ * - **Case 2** — an injected sink still gets the line, with its receiver and * its structured meta, and the console stays out of it. That is the half the * pre-existing suites (`bootstrap-seed-round-trips`, - * `bootstrap-system-capabilities`, `permission-set-projection`) already pin + * `permission-set-projection`) already pin * from the seeder side; it is restated here because this file is what a * later author edits. * - **Case 3** — a read that ANSWERED is silent on every channel with no sink. diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index 0fb596a18e5..076c4518e3e 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -11,14 +11,14 @@ import { strictObject } from '../shared/strict-object'; * The built-in authorization capabilities the framework ships, as first-class * definitions (name / label / description / scope). This module is the SINGLE * SOURCE OF TRUTH consumed by: - * - `@objectstack/plugin-security` `bootstrapSystemCapabilities`, which seeds - * these into `sys_capability` records at boot (`managed_by: 'platform'`), and + * - `@objectstack/plugin-security` `registerBuiltinCapabilities`, which + * registers these as `capability` metadata items at boot (ADR-0131 D3), and * - the authoring lint `validateCapabilityReferences` (ADR-0066 ⑨), which * resolves capability references (`requiredPermissions`) against these names * plus any a stack declares via a permission set's `systemPermissions`. * * Keeping the list here (in the contract package, which everything depends on) - * avoids a spec→plugin dependency inversion and keeps the seeder and the lint + * avoids a spec→plugin dependency inversion and keeps the registry and the lint * from drifting apart. */ diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 42685321c78..1bf69c0399c 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -424,14 +424,6 @@ "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 25. 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-system-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 9. 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/permission-set-projection.test.ts", "verb": "delete",