diff --git a/.changeset/plump-crabs-sneeze.md b/.changeset/plump-crabs-sneeze.md new file mode 100644 index 0000000000..7adb0b1016 --- /dev/null +++ b/.changeset/plump-crabs-sneeze.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(security): the derived capability seeder owns its row by the same conjunction as the curated half + +`bootstrapSystemCapabilities`' DERIVED half tested ownership with `managed_by === 'platform'` alone. That was sufficient while `sys_capability.name` was unique installation-wide; since #8461 made it unique per ORGANIZATION (ADR-0120 D1) it also admits a platform-STAMPED row sitting inside an organization — 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 half, because it "would not carry that guarantee". The guard admitted such a row and rewrote its `label`/`description` with `humanize(name)`, which is the precise harm #5876 exists to prevent, while the platform (NULL-organization) bucket was never written. Every counter read zero and nothing was logged, because both #5876's counter and #8536's live on the branch where the guard DECLINES. + +The ownership test is now the same conjunction the curated half uses — `managed_by: 'platform'` AND `organization_id: null`. The lookup is unchanged (still cross-organization, by design). This restores a declared invariant rather than widening an accept set: what the derived half may refresh narrows to the rows it provably owns. + +**Reachability: a DORMANT asymmetry with a LIVE route — not a live defect.** No shipped artifact in this repository produces such a row: both capability seeders run under a system context with no tenant and never write `organization_id`, `normalizeManagedByVocab` does not touch this object, the admin door refuses the stamp outright (`assertSystemRowWriteGate`), and no `sys_capability` seed dataset exists anywhere in the repo. The ROUTE is nevertheless live and needs no unsupported step, and its load-bearing link is measured rather than argued: the seed loader writes as `isSystem` specifically so seeds can target `sys_*` tables, `defineSeed` type-checks `managed_by: 'platform'`, and on a per-organization replay the loader's tenant stamp short-circuits its own `sys_` exemption when an organization is pinned. Measured against the real seed loader, a `sys_capability` seed carrying `managed_by: 'platform'` was inserted with `organization_id` set when an organization was pinned, and inserted unstamped when none was — so the stamp is the pinning's doing, not a fixture artifact. Not claimed: how many organizations a given deployment replays seeds into is a provisioning question this repo cannot answer. So the fix lands as trap-removal and invariant-restoration, at exactly that severity — worth landing because the mistake would be invisible, ADR-0066 asset ownership forbidding the organization's own admin from editing or deleting the row through Setup. + +**Observability.** The newly-declined row flows through #8536's skip branch unchanged, so `skippedAuthored` and `unseededDerived` keep their exact documented meanings and their subset relationship; they simply become reachable on a state the broken guard used to swallow. The misplaced stamp gets its OWN signal, a new `platformStampedInOrg` counter on `CapabilitySeedResult`, rather than being folded into `unseededDerived` — "the platform's definition is missing" and "a row wears the platform's stamp where the platform never writes" are different facts, and the second is worth counting even when the first is false. The warning gains a matching remediation arm; the admin-authored row's "supported extension" sentence would be false here, and its "nothing for an operator to remove" advice would be wrong about the one row Setup cannot touch at all. + +**Not changed:** the platform bucket is still not backfilled when another row satisfies the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped for the admin-authored case in #8536; the fix makes the state observable, not repaired, and the suite pins the bucket ABSENT so a future backfill has to fail rather than pass. + +`patch`, not `minor`: the behaviour change is a guard declining a row it should never have rewritten, plus diagnostics. `platformStampedInOrg` is a new field on a returned result object, but `bootstrapSystemCapabilities` is a boot-time internal whose only caller ignores the result shape — no consumer reads the type, so nothing gains a capability it can build on. diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts index 5504cd2224..23750ad006 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts @@ -708,3 +708,230 @@ describe('[#8536] the derived skip is counted and warned when it leaves the buck 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(); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts index 261a24c1eb..dcc596c87b 100644 --- a/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts +++ b/packages/plugins/plugin-security/src/bootstrap-system-capabilities.ts @@ -76,6 +76,71 @@ * 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. @@ -165,12 +230,19 @@ export interface CapabilitySeedResult { /** Rows whose platform display fields were reconciled. */ updated: number; /** - * [#5876] Derived names whose existing row is authored elsewhere - * (`managed_by` anything but `'platform'`), 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). + * [#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 @@ -192,6 +264,28 @@ export interface CapabilitySeedResult { * 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 @@ -217,7 +311,10 @@ export async function bootstrapSystemCapabilities( options: SeedOptions = {}, ): Promise { if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return { seeded: 0, updated: 0, skippedAuthored: 0, unseededDerived: 0, blockedCurated: 0, total: 0 }; + return { + seeded: 0, updated: 0, skippedAuthored: 0, unseededDerived: 0, + platformStampedInOrg: 0, blockedCurated: 0, total: 0, + }; } const materialized = new Set(options.materializedCapabilityNames ?? []); @@ -245,6 +342,7 @@ export async function bootstrapSystemCapabilities( let updated = 0; let skippedAuthored = 0; let unseededDerived = 0; + let platformStampedInOrg = 0; let blockedCurated = 0; for (const def of byName.values()) { const isDerived = derivedNames.has(def.name); @@ -279,8 +377,25 @@ export async function bootstrapSystemCapabilities( // 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. - if (isDerived && row.managed_by !== 'platform') { + // [#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 @@ -330,6 +445,14 @@ export async function bootstrapSystemCapabilities( // 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 ' + @@ -337,9 +460,17 @@ export async function bootstrapSystemCapabilities( 'placeholder.' : row.organization_id == null ? '' - : " 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, and adopting or backfilling it was rejected in #8552.'; + : 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 — that is the #8552 posture for an occupied name, not a consequence of the 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, and adopting or backfilling it was rejected in #8552.'; 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 ` + @@ -439,7 +570,11 @@ export async function bootstrapSystemCapabilities( } } options.logger?.info?.('[security] system capabilities seeded into sys_capability (ADR-0066 D1)', { - seeded, updated, skippedAuthored, unseededDerived, blockedCurated, total: byName.size, + seeded, updated, skippedAuthored, unseededDerived, platformStampedInOrg, blockedCurated, + total: byName.size, }); - return { seeded, updated, skippedAuthored, unseededDerived, blockedCurated, total: byName.size }; + return { + seeded, updated, skippedAuthored, unseededDerived, platformStampedInOrg, blockedCurated, + total: byName.size, + }; }