From 6a081884cdf6338a4590c250825d386c44282b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:20:10 +0000 Subject: [PATCH 1/3] refactor(plugin-security): delete the permission-set projection, the seed-name lookup and the refusal sink with the suites that pin only them (ADR-0131 D3, C3 stage 7a; held) Carried over from the C3 build-ahead onto main after stage 8. Stage 8 stopped registering the sys_permission_set row object, the permission mutation projector and the data-door write-through, so these modules have no live caller left: - permission-set-projection.ts, seed-name-lookup.ts and seed-refusal-sink.ts are deleted, with their index exports and the projection's ADR anchor; - tryFind becomes a private helper of the overlay reading, its one live caller, and the reading's logger option asks only for warn; - the security middleware loses the writesExecutedByDataDoor stand-down, which only the retired data door could arm; - the suites that pin only the deleted modules go, and the packaged-set lock's pins move onto the metadata door's lock gate. Held, not ready for review: permission-set-echo-package-owner.test.ts still imports the projection. It is the only pin of the owner-carrying echo, which lets a runtime package's default permission set keep its owner and record its everyone suggestion on an environment kernel; stage 8 unwired that behaviour with the projector. How that behaviour is carried is a decision owed before this deletion lands. The census, ledgers, liveness registry and the epic's zero-row acceptance are not done yet. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...04-7a-permission-set-projection-retired.md | 29 + content/docs/permissions/system-context.mdx | 1 - .../src/engine-find-bare-array.pin.test.ts | 83 +- packages/plugins/plugin-security/src/index.ts | 16 - .../src/packaged-permission-set-lock.test.ts | 772 +------ ...d-permission-set-overlay-detection.test.ts | 34 +- ...ckaged-permission-set-overlay-detection.ts | 20 +- ...ackaged-permission-set-restore-leg.test.ts | 470 ---- ...mission-set-duplicate-name-refusal.test.ts | 279 --- ...rmission-set-projection-heal-by-id.test.ts | 272 --- .../src/permission-set-projection.test.ts | 1886 ----------------- .../src/permission-set-projection.ts | 1821 ---------------- .../plugin-security/src/security-plugin.ts | 30 +- ...ed-name-lookup-degradation-audible.test.ts | 246 --- .../plugin-security/src/seed-name-lookup.ts | 506 ----- .../plugin-security/src/seed-refusal-sink.ts | 83 - packages/spec/liveness/permission.json | 2 +- ...ty__src__permission-set-projection.ts.json | 9 - scripts/measure-durability-swallow-family.mjs | 9 +- scripts/tenant-audit-census.mjs | 7 +- 20 files changed, 141 insertions(+), 6434 deletions(-) create mode 100644 .changeset/15204-7a-permission-set-projection-retired.md delete mode 100644 packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection.test.ts delete mode 100644 packages/plugins/plugin-security/src/permission-set-projection.ts delete mode 100644 packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts delete mode 100644 packages/plugins/plugin-security/src/seed-name-lookup.ts delete mode 100644 packages/plugins/plugin-security/src/seed-refusal-sink.ts delete mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json diff --git a/.changeset/15204-7a-permission-set-projection-retired.md b/.changeset/15204-7a-permission-set-projection-retired.md new file mode 100644 index 00000000000..778210e2e31 --- /dev/null +++ b/.changeset/15204-7a-permission-set-projection-retired.md @@ -0,0 +1,29 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/spec': patch +--- + +feat(plugin-security)!: the permission-set projection, its seed-name lookup and refusal sink are deleted (ADR-0131 D3, C3 stage 7a) + +Clause-②: no (narrowing) + + + +**BREAKING** (retired exports of `@objectstack/plugin-security`), graded `minor` on the v18 prerelease line. + +Stage 8 stopped registering the `sys_permission_set` row object, the `permission` mutation projector and the data-door write-through. This change deletes the modules behind them: `permission-set-projection`, `seed-name-lookup` and `seed-refusal-sink`. A permission set is written through the metadata door alone (`PUT /api/v1/meta/permission/:name`), and the security catalog serves it by name. + +What a consumer sees: + +- FROM `createPermissionSetWriteThrough(deps)` TO nothing: no `sys_permission_set` data-door write exists to redirect. Write the set through the metadata door. Delete the call. +- FROM `registerPermissionSetProjection(protocol, deps)` TO nothing: no row mirrors a definition. Delete the call. +- FROM `reconcilePermissionSetProjection(...)` TO nothing: there is no projection to reconcile at boot. Delete the call. +- FROM `projectPermissionMutation(...)` TO nothing. Delete the call. +- FROM `upsertEnvPermissionSet(...)` TO `PUT /api/v1/meta/permission/:name`, or the metadata protocol's `saveMetaItem({ type: 'permission', name, item })`. +- FROM `permissionSetBodyFromRow(row)` TO `permissionSetDefinitionFromRow(row)` from `@objectstack/core`: the same mapping, for converting a stored `sys_permission_set` row into its definition. +- FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. +- FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. + +Unchanged: the packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. + +**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c1084bb229f..9591226b30f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -108,7 +108,6 @@ that silently does not happen. | 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | -| 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` | | 11b | Position write-through skipped — a `sys_position` write is not mirrored into the environment ledger | plugin-security | Get: the package door writes rows for definitions that already have their home, untouched (no boot seeder writes a `sys_position` row, ADR-0131 D2). Lose: a system-written row has no environment definition, so the security catalog read does not resolve it until a data-door edit, or the one-time row-only position backfill, gives it one | `packages/plugins/plugin-security/src/position-write-through.ts#createPositionWriteThrough` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | | 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | diff --git a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts index 32b750ed26a..d8683531edc 100644 --- a/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts +++ b/packages/plugins/plugin-security/src/engine-find-bare-array.pin.test.ts @@ -17,7 +17,7 @@ * Every seam answered `[object Array]`, with no own `records` key, on a * populated page and an empty one alike. * - * ⚠️ These are four pins, not one pin repeated. A single reading of "the engine + * ⚠️ These are two pins, not one pin repeated. A single reading of "the engine * returns an array" would say nothing about whether a given seam is even * reachable — some of them sit behind a trigger (a truncated page, a refused * bulk write), and a seam nothing reaches is @@ -25,9 +25,10 @@ * through the real function and asserts the answer that block produced, and * {@link assertBareArrayPage} then reads back the value the engine handed it. * - * Blocks 3 and 6 (the declared-permission seeder's per-name read and the - * `managed_by` normalizer's scan) retired with their modules (ADR-0131 D3: - * the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s + * Blocks 1 and 2 (`seed-name-lookup`'s batched page and its per-item + * degradation read), 3 and 6 (the declared-permission seeder's per-name read + * and the `managed_by` normalizer's scan) retired with their modules (ADR-0131 + * D3: the boot writes no catalog row). The seventh seam, `security-plugin.ts`'s * `sys_permission_set` loader, is gone too: the plugin takes a set's body from the security catalog * (ADR-0131 D3/D4) and reads no `sys_permission_set` row, so it has no * engine page to pin. Its fault direction (a read that did not answer @@ -52,7 +53,6 @@ import { assertEngineDeleteDispatch, } from '@objectstack/metadata-core'; -import { buildExistingByName } from './seed-name-lookup.js'; import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; import { claimSeedOwnership } from './claim-seed-ownership.js'; @@ -170,79 +170,6 @@ function assertBareArrayPage(seen: Seen, label: string): void { } } -/** A logger that records what a seeder said, so a fallback can be asserted absent. */ -function recordingLogger() { - const warns: string[] = []; - const infos: string[] = []; - return { - warns, - infos, - logger: { - info: (m: string) => { infos.push(m); }, - warn: (m: string) => { warns.push(m); }, - debug: () => { /* noop */ }, - error: (m: string) => { warns.push(m); }, - }, - }; -} - -describe('[#15598] block 1 — seed-name-lookup readNamePage (the BATCHED page)', () => { - it('answers from one bare array, so the batched read is live and does not degrade', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_permission_set', - { name: 'ps_batched', label: 'Batched', managed_by: 'admin', organization_id: ORG }, - SYS, - ); - const seen: Seen = []; - const { warns, logger } = recordingLogger(); - - const index = await buildExistingByName( - observed(engine, seen), 'sys_permission_set', ['ps_batched'], logger as any, ORG, - ); - const found = await index.get('ps_batched'); - - assertBareArrayPage(seen, 'readNamePage'); - // The batched page ANSWERED. This is the discriminating half: a normalizer - // that could not read the page degrades to the per-item oracle, which would - // still resolve `present` — so the result alone proves nothing here. - expect(warns.filter((w) => w.includes('falling back to one read per item'))).toEqual([]); - expect(found.status).toBe('present'); - }, 120_000); -}); - -describe('[#15598] block 2 — seed-name-lookup perItemIndex (the DEGRADATION read)', () => { - it('answers from one bare array on the per-item path', async () => { - const engine = await boot(); - await (engine as any).insert( - 'sys_permission_set', - { name: 'ps_peritem', label: 'Per item', managed_by: 'admin', organization_id: ORG }, - SYS, - ); - const seen: Seen = []; - // TRIGGER ONLY: the batched read is refused so the per-item path is taken. - // The per-item read itself reaches the real engine untouched — it is the - // subject, and nothing stands in for it. - const batchedRefused = observed(engine, seen, { - find: async (o: string, q?: any, opt?: any) => { - const name = q?.where?.name; - if (name && typeof name === 'object' && '$in' in name) throw new Error('batched read refused (trigger)'); - const r = await engine.find(o, q, opt); - seen.push(r); - return r; - }, - }); - - const index = await buildExistingByName(batchedRefused, 'sys_permission_set', ['ps_peritem'], undefined, ORG); - const found = await index.get('ps_peritem'); - - assertBareArrayPage(seen, 'perItemIndex'); - // `present`, never `unknown`: a seam that cannot read its page reports the - // read as un-happened, which is the outcome this pin discriminates against. - expect(found.status).toBe('present'); - }, 120_000); -}); - describe('[#15598] block 4 — auto-org-admin-grant tryFind', () => { it('reads the set and the membership out of bare arrays, and grants on them', async () => { const engine = await boot(); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 1d87a5bf8e4..9d7ca1fc7fa 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -70,22 +70,6 @@ export type { CollisionReportSink, PermissionSetNameCollisionDiagnostic, } from './permission-set-name-collision.js'; -// [ADR-0094] sys_permission_set pure-projection machinery. -export { - permissionSetRowFields, - permissionSetBodyFromRow, - upsertEnvPermissionSet, - projectPermissionMutation, - registerPermissionSetProjection, - createPermissionSetWriteThrough, - reconcilePermissionSetProjection, -} from './permission-set-projection.js'; -export type { - PermissionSeedOutcome, - ProjectionDeps, - ProjectionReconcileOutcome, - WriteThroughDeps, -} from './permission-set-projection.js'; export { cleanupPackagePermissions } from './cleanup-package-permissions.js'; export type { PackagePermissionCleanupOutcome } from './cleanup-package-permissions.js'; export { objectPostureGate, registerObjectPostureGate } from './object-posture-gate.js'; diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index 5c91a12dd0e..41a649751e6 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -8,268 +8,60 @@ * the server with a message that names the sanctioned path (clone it), so no * silent overlay row is ever minted again. * - * ## The harness models the hole, not the happy path - * - * The protocol double here has the `OS_METADATA_WRITABLE=permission` operator - * hatch OPEN — its `saveMetaItem` does NOT refuse an artifact-backed name. That - * is deliberate and it is the whole point: with the hatch closed the producer's - * own ADR-0005 tier gate already answers 403 `NOT_OVERRIDABLE`, so a suite - * written against a refusing double would score green on a door that is still - * wide open in exactly the deployment the field report came from. The hatch is - * documented, reachable, and is the one path that mints a fresh overlay of a - * packaged set today. - * - * ## The pins, and why each one is not optional - * - * 1. the refusal fires AND its MESSAGE names the clone path — the ruling's - * point is that the admin learns what to do instead, so asserting only the - * rejection would pass on a bare "no"; - * 2. ⭐ an ordinary org-owned set is STILL accepted. The control: an - * implementation that refuses everything satisfies pin 1 perfectly; - * 3. the clone path works end to end — org-owned row, no upgrade linkage — - * and the package-declared base is untouched by it; - * 5. fail-closed on ambiguity: a provenance read that cannot ANSWER refuses, - * never accepts; - * 6. ⭐ what the clone ACTION SENDS (commit 5cb62d88b) — pin 3 drives the door with a - * hand-written payload, so it could not see that the ACTION ITSELF listed - * only two of the row's six definition facets. Pin 6 reads the payload out - * of the action definition, so editing that params list is what moves it. - * - * (Pin 4 — the detection reading for overlays that already exist — lives in its - * own suite at the bottom of this file, because it reads rather than writes.) - * - * ## ⭐ The fail-open this must not inherit (repaired by commit e1d773eb7) - * - * `buildExistingByName`'s UNSCOPED page cap (`limit: names.length`, - * `seed-name-lookup.ts`) truncated as soon as one name could carry more than one - * row, and a truncated page read as "absent". Asking THAT oracle "is this set - * package-declared?" would turn a truncation into "not package-declared" — and - * the save this ruling exists to refuse would be accepted. A silent fork - * produced by the code written to stop silent forks. - * - * Commit e1d773eb7 has since repaired the cap itself — an overflowing page is now detected - * and degrades to the per-item read rather than answering — so the controls - * below no longer guard against THAT truncation reaching this verdict. They are - * kept, and they still pass, because what they actually pin is structural and - * outlives the repair: this verdict is not decided by a name-keyed page read at - * all. Unscoped, `buildExistingByName` still answers with the FIRST row by id - * for a name, so on a name several organizations hold it answers with somebody - * else's row — the same question, a different wrong answer, and one no page - * budget reaches. - * - * So the provenance question is decided from the engine's SchemaRegistry — the - * same source `readDeclared` already uses, - * an in-memory array with no page, no cap and no `$in`. Two controls prove the - * immunity structurally rather than asserting it: - * - * - CONTROL A builds the exact multi-row shape that truncated before commit e1d773eb7, in a - * double whose `find` HONOURS `limit` (the projection suite's double ignores - * it, so the trap cannot even be expressed there), shows the truncation is - * live, and pins that the refusal still fires; - * - CONTROL B makes every NAME-KEYED page read over `sys_permission_set` fail - * outright — the most extreme form of "this read did not answer" — while - * by-id reads keep working so the middleware still reaches the question. - * The verdict is unchanged, which is only possible if the question was never - * asked of that table. That is a structural proof, not an assertion. + * The lock's one door is the metadata door's authoring gate + * (`packaged-permission-set-lock-gate.ts`, pinned end to end in + * `packaged-permission-set-lock-gate.test.ts`). The `sys_permission_set` data + * door that also consulted it retired with the catalog row objects (ADR-0131 + * D3), and its pins with it. This file pins the two halves that do not depend + * on a door: + * + * - the CLASSIFIER — "shipped by a code package" is judged from the + * provenance the SchemaRegistry carries, never from a name-keyed page read + * over a table (#21789); + * - the refusal's END-USER copy — the guidance reaches the wire as + * `userMessage`, through the producer's own error resolution. */ import { describe, it, expect } from 'vitest'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { mapDataError, resolveThrownHttpError } from '@objectstack/types'; +import { resolveThrownHttpError } from '@objectstack/types'; import { classifyPackagedPermissionSet, PackagedPermissionSetLockedError, PackagedPermissionSetProvenanceUnknownError, } from './packaged-permission-set-lock.js'; import { registerPackagedPermissionSetLockGate } from './packaged-permission-set-lock-gate.js'; -import { - createPermissionSetWriteThrough, - permissionSetRowFields, - registerPermissionSetProjection, -} from './permission-set-projection.js'; // ───────────────────────────────────────────────────────────────────────────── // Doubles // ───────────────────────────────────────────────────────────────────────────── /** - * In-memory ql over `sys_permission_set` + `sys_metadata`. - * - * ⚠️ `find` HONOURS `limit`. The sibling double in - * `permission-set-projection.test.ts` does not, which is why the page-cap - * truncation commit e1d773eb7 repaired cannot be reproduced there — a page cap that the double - * ignores is a page cap that no test in that file can ever measure. + * The engine surface the classifier reads: its SchemaRegistry, and nothing + * else. `null` models a kernel with no readable registry. */ function makeQl(declared: any[] | null = null) { - const permRows: any[] = []; - const metaRows: any[] = []; - const tableFor = (object: string): any[] | null => - object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; - const matches = (r: any, where: any) => - Object.entries(where ?? {}).every(([k, v]) => { - // Refuse combinators rather than reading `$and`/`$or` as a column name - // (check:where-matcher / #8494): no path under test issues one, and a - // matcher that treated a combinator as a field would answer the wrong - // rows while this suite stayed green. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return v === null ? (r[k] ?? null) === null : r[k] === v; - }); - /** Set to fail exactly the NAME-KEYED page reads — CONTROL B. */ - let nameKeyedFindThrows = false; - const ql: any = { - permRows, - metaRows, - /** - * Break precisely the read commit e1d773eb7 is about: a name-keyed page over - * `sys_permission_set`. Reads by `id` (target resolution) keep working, so - * the middleware still reaches the provenance question — which is the only - * way to observe what that question answers when the paged table is - * unavailable. - */ - breakNameKeyedFind() { nameKeyedFindThrows = true; }, - async find(object: string, q: any) { - if ( - nameKeyedFindThrows - && object === 'sys_permission_set' - && q?.where && Object.prototype.hasOwnProperty.call(q.where, 'name') - ) { - throw new Error('fake driver: name-keyed page read unavailable'); - } - const rows = tableFor(object); - if (!rows) return []; - const hit = rows.filter((r) => matches(r, q?.where)); - // The cap a real driver applies — and the one commit e1d773eb7 stopped turning into a false - // "absent". Modelled, not ignored. - return typeof q?.limit === 'number' ? hit.slice(0, q.limit) : hit; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - const rows = tableFor(object); - return rows?.find((r) => matches(r, q?.where)) ?? null; - }, - async insert(object: string, data: any) { - const rows = tableFor(object); - if (!rows) return null; - rows.push({ ...data }); - return { id: data.id }; - }, - // Routed through the PRODUCER's own dispatch predicates - // (check:engine-double-contract / #4434, #5480): a double looser than - // `ObjectQL.update` / `ObjectQL.delete` converts a green suite into no - // suite at all on precisely the paths a double was introduced for. - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const rows = tableFor(object) ?? []; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r: any) => r.id === dispatch.id) - : rows.filter((r: any) => matches(r, options?.where)); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - async delete(object: string, options?: any) { - const dispatch = assertEngineDeleteDispatch(options); - const rows = tableFor(object) ?? []; - const targets = dispatch.kind === 'by-id' - ? rows.filter((r: any) => r.id === dispatch.id) - : rows.filter((r: any) => matches(r, options?.where)); - for (const r of targets) rows.splice(rows.indexOf(r), 1); - return targets.length > 0; - }, - }; + const ql: any = {}; if (declared !== null) { ql.registry = { listItems: (type: string) => (type === 'permission' ? declared : []) }; } return ql; } -/** - * Metadata protocol double with the operator hatch OPEN. - * - * ⛔ `saveMetaItem` deliberately models NO ADR-0005 tier gate. That gate is - * what `OS_METADATA_WRITABLE=permission` switches off, and this suite exists to - * pin the door that stays open when it is off. `declared` still feeds the - * layered read's `code` layer, because that is a READ and the hatch does not - * change it. - */ -function makeHatchOpenProtocol(ql: any, declared: Record = {}) { - let projector: ((evt: any) => Promise) | null = null; - const overlayFor = (name: string) => - ql.metaRows.find( - (r: any) => r.type === 'permission' && r.name === name - && r.state === 'active' && (r.organization_id ?? null) === null, - ); - const protocol: any = { - saves: [] as any[], - deletes: [] as any[], - registerMutationProjector(_type: string, fn: (evt: any) => Promise) { projector = fn; }, - async saveMetaItem(req: { type: string; name: string; item: any; actor?: string }) { - // The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs it - // (`resolveOverlaySchema`), same `[invalid_metadata]` 422 envelope. Only - // the ADR-0005 TIER gate is absent here — that is the hatch. Without the - // schema the double would accept any object and this suite could stay - // green while every real write failed (#4001 / #4669). - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const summary = parsed.error.issues - .map((i: any) => `${i.path.join('.') || ''}: ${i.message}`) - .join('; '); - const err: any = new Error(`[invalid_metadata] permission/${req.name} failed spec validation: ${summary}`); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - const existing = overlayFor(req.name); - if (existing) existing.metadata = JSON.stringify(req.item); - else { - ql.metaRows.push({ - id: `meta_${req.name}`, type: 'permission', name: req.name, state: 'active', - organization_id: null, metadata: JSON.stringify(req.item), - }); - } - protocol.saves.push({ ...req }); - if (projector) await projector({ type: 'permission', name: req.name, state: 'active', organizationId: null }); - return { success: true }; - }, - async deleteMetaItem(req: { type: string; name: string }) { - const i = ql.metaRows.findIndex( - (r: any) => r.type === 'permission' && r.name === req.name && (r.organization_id ?? null) === null, - ); - if (i >= 0) ql.metaRows.splice(i, 1); - protocol.deletes.push({ ...req }); - if (projector) await projector({ type: 'permission', name: req.name, state: 'deleted', organizationId: null }); - return { success: true }; - }, - async getMetaItemLayered(req: { type: string; name: string }) { - const code = declared[req.name] ?? null; - const o = overlayFor(req.name); - const overlay = o ? JSON.parse(o.metadata) : null; - return { - type: 'permission', name: req.name, code, overlay, - overlayScope: overlay ? 'env' : null, effective: overlay ?? code, - }; - }, +/** Register the lock on a protocol double and hand back the gate it registered. */ +function lockGate( + ql: any, + getMetaItemLayered: (req: { name: string }) => Promise = async ({ name }) => + ({ type: 'permission', name, code: null, overlay: null, effective: null }), +) { + let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; + const protocol = { + registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, + getMetaItemLayered, }; - return protocol; + expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); + return gate!; } -const makeMiddleware = (ql: any, protocol: any) => - createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - -/** Drive one middleware call; reports whether the driver leg would have run. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const userCtx = { userId: 'usr_admin' }; - /** A shipped artifact: a body as the SchemaRegistry stamps it (ADR-0010). */ const packagedSet = (over: Record = {}) => ({ name: 'ehr_quality_inspector', @@ -289,365 +81,6 @@ const orgSet = (over: Record = {}) => ({ ...over, }); -/** The row the package door materializes for a shipped artifact. */ -const packagedRow = (over: Record = {}) => ({ - id: 'ps_pkg', - name: 'ehr_quality_inspector', - managed_by: 'package', - package_id: 'com.example.ehr', - active: true, - ...permissionSetRowFields(packagedSet()), - ...over, -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 1 — the silent fork path closes at the server, and the refusal TEACHES -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 1 — a save targeting a package-declared set is refused, and the message names the clone path', () => { - it('UPDATE through the data door is refused with the clone remedy, and mints NO overlay row', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - - // Rejection-class assertion is the ENVELOPE (`code` + `status`), never a - // bare toThrow: an unfixed door throws nothing at all here, and a stub that - // threw for some other reason would read identical to a real refusal. - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'the save must be refused, not accepted').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - // ⭐ The ruling's whole point: the admin has to learn what to do instead. - expect(String(rejection.message)).toMatch(/clone/i); - expect(String(rejection.message)).toContain('ehr_quality_inspector'); - expect(String(rejection.message)).toContain('com.example.ehr'); - - // Refused, not "refused after writing". - expect(ql.metaRows.length, 'no silent overlay row was minted').toBe(0); - expect(protocol.saves.length, 'the metadata write never even ran').toBe(0); - expect( - JSON.parse(ql.permRows[0].system_permissions), - 'the record still projects the shipped declaration', - ).toEqual(['pkg.baseline']); - }); - - it('INSERT of a name a package already declares is refused the same way (the other overlay-minting door)', async () => { - // Reachable whenever a declaration ships but its record was never - // materialized: the duplicate-name probe finds no row, so the insert would - // otherwise walk straight into `saveMetaItem` on a packaged name and mint - // a fresh overlay of it. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'ehr_quality_inspector', label: 'Mine', object_permissions: '{}' }, - }).then(() => null, (e: any) => e); - - expect(rejection).not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(String(rejection.message)).toMatch(/clone/i); - expect(ql.metaRows.length).toBe(0); - expect(ql.permRows.length).toBe(0); - }); - - it('a pure row-state patch (activate/deactivate) is NOT a save of the definition and still passes through', async () => { - // Over-denial guard inside pin 1's own subject: the lifecycle actions send - // `{ active }` and nothing else. Switching a packaged set off is not a - // customization of it (#4669), so the lock must not swallow the column - // write — a lock that refuses the on/off switch has broken the surface it - // was supposed to protect. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', active: false }, - }); - expect(nextCalled, 'the driver performs the column write with its ordinary semantics').toBe(true); - expect(ql.metaRows.length).toBe(0); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// ⭐ PIN 2 — THE CONTROL. Not optional: pin 1 alone scores green on a door -// that refuses everything. -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 2 (control) — an ordinary org-owned permission set is STILL accepted', () => { - it('UPDATE of an env-authored set lands in the metadata store and projects onto the record', async () => { - const ql = makeQl([packagedSet()]); // a package IS installed — just not this name - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'org_support_agent', item: orgSet() }); - const row = ql.permRows.find((r: any) => r.name === 'org_support_agent'); - expect(row, 'precondition: the env-authored row exists').toBeTruthy(); - const savesBefore = protocol.saves.length; - const mw = makeMiddleware(ql, protocol); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: row.id, system_permissions: '["support.use","support.escalate"]' }, - }; - const nextCalled = await run(mw, opCtx); - - expect(nextCalled, 'the driver write is skipped — the record is projector-owned').toBe(false); - expect(protocol.saves.length, 'the definition write LANDED').toBe(savesBefore + 1); - expect(JSON.parse(ql.permRows.find((r: any) => r.name === 'org_support_agent').system_permissions)) - .toEqual(['support.use', 'support.escalate']); - expect(opCtx.result?.id).toBe(row.id); - }); - - it('INSERT of a brand-new env-authored set is accepted and lands org-owned', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'org_support_agent', label: 'Support Agent', - object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - }, - }; - await run(mw, opCtx); - - expect(protocol.saves.length).toBe(1); - const created = ql.permRows.find((r: any) => r.name === 'org_support_agent'); - expect(created?.managed_by).toBe('admin'); - expect(created?.package_id ?? null).toBeNull(); - }); - - it('a `managed_by:package` row with NO artifact behind it keeps editing in place (the ADR-0094 D5-R surviving tier)', async () => { - // The boundary the lock must NOT cross. This row was materialized by the - // ADR-0086 P2 publish path from a definition that lives only in - // `sys_metadata` (authored + published through the METADATA door, ADR-0070) - // — no code artifact backs the name, so an edit of it is a direct edit of - // the one stored definition and forks nothing. The ruling's subject is the - // package-DECLARED set; a `managed_by` column is measurably not that fact - // (`permission-set-projection.ts` header), and locking on it would take - // the surviving `allowRuntimeCreate` tier down with it. - const ql = makeQl([]); // registry present, nothing declared - const protocol = makeHatchOpenProtocol(ql, {}); // no artifact for the name - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_mat', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["materialized.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_mat', system_permissions: '["customized"]' }, - }); - expect(nextCalled).toBe(false); - expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); - expect(ql.permRows[0].managed_by, 'the package still owns the row').toBe('package'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PIN 3 — clone-to-customize is the sanctioned path, and it works -// ───────────────────────────────────────────────────────────────────────────── - -describe('pin 3 — the clone path yields an org-owned set with no upgrade linkage, base untouched', () => { - it('cloning a package-declared set produces an admin-owned row and leaves the base byte-identical', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const baseBefore = JSON.stringify(ql.permRows[0]); - const mw = makeMiddleware(ql, protocol); - - // Exactly what the `clone_permission_set` action POSTs: a NEW machine name - // plus the base's facets, at /api/v1/data/sys_permission_set. - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'ehr_quality_inspector_local', - label: 'Quality Inspector (local)', - active: true, - object_permissions: ql.permRows[0].object_permissions, - field_permissions: ql.permRows[0].field_permissions, - }, - }; - await run(mw, opCtx); - - const clone = ql.permRows.find((r: any) => r.name === 'ehr_quality_inspector_local'); - expect(clone, 'the clone exists').toBeTruthy(); - expect(clone.managed_by, "this org's row").toBe('admin'); - expect(clone.package_id ?? null, 'NO upgrade linkage — upgrades keep flowing to the base').toBeNull(); - expect(JSON.parse(clone.object_permissions), 'the grants came across').toEqual(packagedSet().objects); - - const base = ql.permRows.find((r: any) => r.id === 'ps_pkg'); - expect(JSON.stringify(base), 'the package-declared base is unchanged by the clone').toBe(baseBefore); - expect( - ql.metaRows.some((r: any) => r.name === 'ehr_quality_inspector'), - 'cloning mints no overlay of the base', - ).toBe(false); - }); - - it('the clone is an ordinary set: editing IT is accepted', async () => { - // Without this the "sanctioned path" could be a dead end — a clone that - // exists and then refuses every edit satisfies nothing the ruling asked for. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - const mw = makeMiddleware(ql, protocol); - await run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'ehr_quality_inspector_local', label: 'Local', object_permissions: '{}' }, - }); - const clone = ql.permRows.find((r: any) => r.name === 'ehr_quality_inspector_local'); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: clone.id, system_permissions: '["local.only"]' }, - }); - expect(nextCalled).toBe(false); - expect(JSON.parse(ql.permRows.find((r: any) => r.id === clone.id).system_permissions)).toEqual(['local.only']); - }); -}); - -// [C3 stage 8] Pin 6 pinned the `clone_permission_set` action declared on the -// retired `sys_permission_set` object; the clone is the security catalog's -// `POST /security/permission-sets/:name/clone` door now, pinned beside it -// (`runtime/src/domains/permission-set-clone.test.ts`). - -describe('pin 5 — provenance that cannot be DETERMINED refuses the save', () => { - it('a SchemaRegistry that throws is not the answer "not package-declared"', async () => { - const ql = makeQl([]); - ql.registry = { listItems: () => { throw new Error('registry unavailable'); } }; - // The layered read cannot answer either — so nothing on this kernel can - // say whether the name is packaged, and accepting would be a guess. - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'an unanswerable provenance read must refuse, never accept').not.toBeNull(); - expect(rejection).toMatchObject({ status: 403 }); - expect(ql.metaRows.length, 'nothing was written on the way to the refusal').toBe(0); - }); - - it('a SchemaRegistry that answers with a non-list is the same ambiguity', async () => { - const ql = makeQl([]); - ql.registry = { listItems: () => undefined as any }; - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection).not.toBeNull(); - expect(rejection).toMatchObject({ status: 403 }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// ⭐ CONTROLS — the provenance read cannot inherit the fail-open commit e1d773eb7 repaired -// ───────────────────────────────────────────────────────────────────────────── - -describe('control A — #11518 shape: a name carrying MORE THAN ONE row still refuses', () => { - it('positive control: this double truncates exactly the way #11518 describes', async () => { - // Harness-level and stable: it pins the DOUBLE, so it holds before and - // after the fix, and it is what makes the next test meaningful. Without - // it, "the refusal still fires" could be green simply because the trap was - // never armed. (The sibling double in `permission-set-projection.test.ts` - // ignores `limit` entirely — there, this control cannot even be written.) - const ql = makeQl([packagedSet()]); - ql.permRows.push(packagedRow()); - ql.permRows.push(packagedRow({ id: 'ps_pkg_residue', organization_id: null })); - - const names = ['ehr_quality_inspector']; - const capped = await ql.find('sys_permission_set', { - where: { name: { $in: names } }, - limit: names.length, // ← the UNSCOPED cap seed-name-lookup.ts carried before commit e1d773eb7 - }); - expect(ql.permRows.filter((r: any) => r.name === 'ehr_quality_inspector')).toHaveLength(2); - expect(capped, 'the page is truncated — half the rows for this name are invisible').toHaveLength(1); - }); - - it('the refusal fires on a name that carries two rows', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - ql.permRows.push(packagedRow({ id: 'ps_pkg_residue' })); - const mw = makeMiddleware(ql, protocol); - - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'a truncating name-keyed oracle would have read "absent" here and ACCEPTED').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.metaRows.length).toBe(0); - }); -}); - -describe('control B — the provenance read is not a name-keyed table read at all', () => { - it('every NAME-KEYED page read fails, and the verdict is STILL "package-declared"', async () => { - // The structural proof of immunity. Commit e1d773eb7 repaired a defect of a name-keyed - // page read over `sys_permission_set`; here every such read is made to - // fail outright — the most extreme form of "this read did not answer" — - // while the by-id target resolution keeps working so the middleware still - // reaches the provenance question. The verdict is unchanged, which is - // only possible if the question was never asked of that table. - // - // If the provenance question is ever re-routed through - // `buildExistingByName` (or any other name-keyed page), this test goes RED - // instead of silently inheriting the fail-open. - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { ehr_quality_inspector: packagedSet() }); - ql.permRows.push(packagedRow()); - // Positive control for the break itself: without this, "the read failed" - // could quietly be "the read was never armed". - ql.breakNameKeyedFind(); - await expect( - ql.find('sys_permission_set', { where: { name: 'ehr_quality_inspector' }, limit: 1 }), - ).rejects.toThrow(/name-keyed page read unavailable/); - expect( - await ql.find('sys_permission_set', { where: { id: 'ps_pkg' }, limit: 1 }), - 'by-id reads still work, so the middleware reaches the provenance question', - ).toHaveLength(1); - - const mw = makeMiddleware(ql, protocol); - const rejection = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'a failed name-keyed read must never soften the verdict to "not packaged"').not.toBeNull(); - expect(rejection).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.metaRows.length).toBe(0); - }); -}); - // ───────────────────────────────────────────────────────────────────────────── // [#21789] The lock reads the ROW's provenance, not the presence of a package id // ───────────────────────────────────────────────────────────────────────────── @@ -660,9 +93,7 @@ describe('control B — the provenance read is not a name-keyed table read at al * written over it, and, only where a code artifact ships the name, the * artifact's envelope written over that. These builders produce exactly those * three bodies, so the verdicts below are about the shapes the boot really - * registers, measured on a booted showcase (the dogfood pin - * `permission-set-lock-row-provenance.dogfood.test.ts` asserts the hydrated - * shape before it asserts the edit). + * registers, measured on a booted showcase. */ const RUNTIME_PACKAGE = 'app.crm_workspace'; /** Shape 1: a set saved into a writable runtime package, as hydrated. */ @@ -710,38 +141,12 @@ describe('[#21789] the lock judges "shipped by a code package" from the row\'s p .toEqual({ status: 'packaged', packageId: 'com.example.ehr' }); }); - it('the data door accepts an edit of a runtime-package set, and the edit lands', async () => { - const ql = makeQl([hydratedRuntimePackageRow()]); - const protocol = makeHatchOpenProtocol(ql, {}); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'workspace_rep', item: orgSet({ name: 'workspace_rep' }) }); - const row = ql.permRows.find((r: any) => r.name === 'workspace_rep'); - expect(row, 'precondition: the projected row exists').toBeTruthy(); - const savesBefore = protocol.saves.length; - - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: row.id, system_permissions: '["support.use","workspace.only"]' }, - }).then(() => null, (e: any) => e); - - expect(rejection, 'the lock must not refuse a set no code package ships').toBeNull(); - expect(protocol.saves.length, 'the definition write LANDED').toBe(savesBefore + 1); - expect(JSON.parse(ql.permRows.find((r: any) => r.name === 'workspace_rep').system_permissions)) - .toEqual(['support.use', 'workspace.only']); - }); - it('the metadata door accepts a runtime-package set and still refuses a code-shipped one with 403 NOT_OVERRIDABLE', async () => { - const ql = makeQl([hydratedRuntimePackageRow(), shippedArtifact()]); - let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; - const protocol = { - registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, - getMetaItemLayered: async ({ name }: { name: string }) => ({ type: 'permission', name, code: null, overlay: null, effective: null }), - }; - expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); + const gate = lockGate(makeQl([hydratedRuntimePackageRow(), shippedArtifact()])); - await expect(gate!({ type: 'permission', name: 'workspace_rep', body: orgSet({ name: 'workspace_rep' }) })) + await expect(gate({ type: 'permission', name: 'workspace_rep', body: orgSet({ name: 'workspace_rep' }) })) .resolves.toBeUndefined(); - await expect(gate!({ type: 'permission', name: 'ehr_quality_inspector', body: packagedSet() })) + await expect(gate({ type: 'permission', name: 'ehr_quality_inspector', body: packagedSet() })) .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); }); }); @@ -759,13 +164,8 @@ describe('[#21789] the lock judges "shipped by a code package" from the row\'s p * the whole point of the ruling the lock implements. * * The wire envelope is computed by the producer's own mapping, not restated: - * - * - `mapDataError` is the call the REST `/data` door makes in its catch - * (`PATCH` and `POST /data/:object[/:id]`, `rest-server.ts`) — the door that - * serves Setup's save, measured on a booted showcase as the flat - * `{ error, code, object }` body; - * - `resolveThrownHttpError` is the dispatcher door's resolution of the same - * throw. + * `resolveThrownHttpError` is the dispatcher door's resolution of the throw the + * metadata door's gate raises. * * Every case asserts the envelope (`status` + `code`) AND the `userMessage`: * the code and status are the refusal's identity and must not move, and the @@ -788,106 +188,40 @@ describe('the lock refusal carries its guidance as `userMessage` on the wire env expect(text, 'userMessage must not carry an API path').not.toMatch(/\/api\//); }; - const refusedUpdate = async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { [SET_NAME]: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push(packagedRow()); - return run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', description: 'edit' }, - }).then(() => null, (e: any) => e); - }; - - it('UPDATE at the data door: 403 NOT_OVERRIDABLE with the clone guidance in `userMessage`', async () => { - const rejection = await refusedUpdate(); - expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); - - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(wire.body.userMessage, SET_NAME, PACKAGE_ID, 'sys_permission_set'); - expect(String(wire.body.userMessage)).toMatch(/clone it instead/i); - // The diagnostic channel is untouched: it still names the set and the - // package for logs and developers. The mark never replaces `message`. - expect(String(wire.body.error)).toContain(SET_NAME); - expect(String(wire.body.error)).toContain(PACKAGE_ID); - }); - - it('INSERT of a packaged name at the data door: 403 NOT_OVERRIDABLE with its own guidance (choose another name, or clone)', async () => { - const ql = makeQl([packagedSet()]); - const protocol = makeHatchOpenProtocol(ql, { [SET_NAME]: packagedSet() }); - registerPermissionSetProjection(protocol, { ql }); - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: SET_NAME, label: 'Mine', object_permissions: '{}' }, - }).then(() => null, (e: any) => e); - expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); - - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(wire.body.userMessage, SET_NAME, PACKAGE_ID, 'sys_permission_set'); - expect(String(wire.body.userMessage)).toMatch(/different name/i); - expect(String(wire.body.userMessage)).toMatch(/clone/i); - - // Each operation carries the guidance that fits it. - const update = mapDataError(await refusedUpdate(), 'sys_permission_set'); - expect(wire.body.userMessage).not.toBe(update.body.userMessage); - }); - - it('the dispatcher door resolves the same throw to the same status, code and `userMessage`', async () => { - const rejection = await refusedUpdate(); - const rest = mapDataError(rejection, 'sys_permission_set'); - const resolved = resolveThrownHttpError(rejection); - expect(resolved.status).toBe(403); - expect(resolved.code).toBe('NOT_OVERRIDABLE'); - expectEndUserCopy(resolved.userMessage, SET_NAME, PACKAGE_ID); - expect(resolved.userMessage).toBe(rest.body.userMessage); - }); - - it('the metadata door\'s registered lock throws the same class with the same `userMessage`', async () => { - const ql = makeQl([shippedArtifact()]); - let gate: ((ctx: { type: string; name: string; body: unknown }) => Promise) | undefined; - const protocol = { - registerAuthoringGate: (_type: string, g: typeof gate) => { gate = g; }, - getMetaItemLayered: async ({ name }: { name: string }) => ({ type: 'permission', name, code: null, overlay: null, effective: null }), - }; - expect(registerPackagedPermissionSetLockGate(protocol, ql)).toBe(true); - - const rejection = await gate!({ type: 'permission', name: SET_NAME, body: packagedSet() }) + it('a save of a package-declared set: 403 NOT_OVERRIDABLE with the clone guidance in `userMessage`', async () => { + const gate = lockGate(makeQl([shippedArtifact()])); + const rejection = await gate({ type: 'permission', name: SET_NAME, body: packagedSet() }) .then(() => null, (e: any) => e); expect(rejection).toBeInstanceOf(PackagedPermissionSetLockedError); + const resolved = resolveThrownHttpError(rejection); expect(resolved.status).toBe(403); expect(resolved.code).toBe('NOT_OVERRIDABLE'); expectEndUserCopy(resolved.userMessage, SET_NAME, PACKAGE_ID); - expect(resolved.userMessage).toBe(mapDataError(await refusedUpdate(), 'sys_permission_set').body.userMessage); + expect(String(resolved.userMessage)).toMatch(/clone it instead/i); + // The diagnostic channel is untouched: it still names the set and the + // package for logs and developers. The mark never replaces `message`. + expect(String(rejection.message)).toContain(SET_NAME); + expect(String(rejection.message)).toContain(PACKAGE_ID); }); it('the fail-closed sibling (provenance unknown): 403 NOT_OVERRIDABLE with retry-or-clone guidance, and no diagnostic reason', async () => { const ql = makeQl([]); ql.registry = { listItems: () => { throw new Error('registry unavailable'); } }; - const protocol = makeHatchOpenProtocol(ql, {}); - protocol.getMetaItemLayered = async () => { throw new Error('metadata store unavailable'); }; - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_x', name: 'unknown_provenance', managed_by: 'admin', system_permissions: '[]' }); - const rejection = await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_x', system_permissions: '["whatever"]' }, - }).then(() => null, (e: any) => e); + const gate = lockGate(ql, async () => { throw new Error('metadata store unavailable'); }); + const rejection = await gate({ type: 'permission', name: 'unknown_provenance', body: {} }) + .then(() => null, (e: any) => e); expect(rejection).toBeInstanceOf(PackagedPermissionSetProvenanceUnknownError); - const wire = mapDataError(rejection, 'sys_permission_set'); - expect(wire.status).toBe(403); - expect(wire.body.code).toBe('NOT_OVERRIDABLE'); + const resolved = resolveThrownHttpError(rejection); + expect(resolved.status).toBe(403); + expect(resolved.code).toBe('NOT_OVERRIDABLE'); expectEndUserCopy( - wire.body.userMessage, 'unknown_provenance', 'sys_permission_set', - 'registry unavailable', 'metadata store unavailable', + resolved.userMessage, 'unknown_provenance', 'registry unavailable', 'metadata store unavailable', ); - expect(String(wire.body.userMessage)).toMatch(/try again/i); - expect(String(wire.body.userMessage)).toMatch(/clone/i); + expect(String(resolved.userMessage)).toMatch(/try again/i); + expect(String(resolved.userMessage)).toMatch(/clone/i); // The reason stays where it was: the diagnostic channel. - expect(String(wire.body.error)).toContain('registry unavailable'); + expect(String(rejection.message)).toContain('registry unavailable'); }); }); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts index aecb8940a46..deca1cefeeb 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts @@ -41,7 +41,6 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { permissionSetRowFields } from './permission-set-projection.js'; import { detectPackagedPermissionSetOverlays, reportPackagedPermissionSetOverlays, @@ -89,7 +88,8 @@ const rowFor = (ps: any, over: Record = {}) => ({ name: ps.name, managed_by: 'package', package_id: ps._packageId, - ...permissionSetRowFields(ps), + label: ps.label, + object_permissions: JSON.stringify(ps.objects ?? {}), ...over, }); @@ -269,3 +269,33 @@ describe('pin 4 — the reading is LOUD, and quiet when there is nothing to say' expect(log.warn).not.toHaveBeenCalled(); }); }); + +describe('the overlay sweep reads `sys_metadata` through `tryFind`', () => { + it('a refused overlay read is a reading of nothing — the boot is not failed by a diagnostic', async () => { + // `tryFind`'s contract, pinned at the one door that still calls it: a read + // that throws answers no rows, so the reading reports no fork rather than + // throwing into the boot that runs it. A diagnostic that could fail a boot + // would be worse than the fork it reports. + const a = declaredSet(); + const ql = makeQl([a]); + ql.metaRows.push(overlayFor('ehr_quality_inspector')); + const control = await detectPackagedPermissionSetOverlays(ql); + expect(control.names, 'control: the same overlay is found when the read answers').toEqual(['ehr_quality_inspector']); + + const seen: unknown[] = []; + const refused: any = { + ...ql, + async find(object: string, q: any) { + seen.push({ object, where: q?.where, limit: q?.limit }); + throw new Error('fake driver: sys_metadata unavailable'); + }, + }; + const reading = await detectPackagedPermissionSetOverlays(refused); + expect(reading).toEqual({ count: 0, names: [], findings: [] }); + // Both type spellings were asked, each under the shared page cap. + expect(seen).toEqual([ + { object: 'sys_metadata', where: { type: 'permission', state: 'active' }, limit: 1000 }, + { object: 'sys_metadata', where: { type: 'permissions', state: 'active' }, limit: 1000 }, + ]); + }); +}); diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts index 5197d6e66b2..f5229fbd0de 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.ts @@ -65,8 +65,23 @@ * silent fork, which is why the LOCK's provenance read is not a page at all. */ -import { tryFind, type ProjectionLogger } from './permission-set-projection.js'; import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; +import { seedCtx } from './organization-scope.js'; + +/** + * One environment-wide page of `object`, read as the system. A read that + * throws answers `[]`: this module is a diagnostic READING run at boot, and a + * reading that could fail the boot would be worse than the fork it reports, so + * a refused read is a reading of nothing. ⛔ Never use this where `[]` is read + * as "gone" — a write door that must tell an outage from an absence asks the + * error's type instead. + */ +async function tryFind(ql: any, object: string, where: any, limit: number): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: seedCtx() }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} /** * Page cap for the `sys_metadata` overlay sweep — the same value @@ -96,7 +111,8 @@ export interface PackagedPermissionSetOverlayReading { } export interface OverlayDetectionOptions { - logger?: ProjectionLogger; + /** Where {@link reportPackagedPermissionSetOverlays} reports a non-empty reading. */ + logger?: { warn: (m: string, meta?: Record) => void }; } /** diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts deleted file mode 100644 index 84c9aef4175..00000000000 --- a/packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * THE RESTORE LEG — the fourth write point of `createPermissionSetWriteThrough` - * under the 2026-08-24 "lock the base, clone to customize" ruling: guarded - * since commit 9cfc1f7e9, with the refusal on the DURABILITY channel, plus the - * reachability fence. - * - * The lock (`packaged-permission-set-lock.ts`) refuses a save that targets a - * package-declared permission set. The `insert` and `update` legs of - * `permission-set-projection.ts` THROW it; this leg cannot — it runs after - * the engine has already un-trashed the row, so a throw would misreport a - * completed engine operation and half-apply a multi-row restore. It consults - * the SAME lock before its one guarded write (the `saveMetaItem` mint), - * refuses the mint, and reports on the durability channel (#4632) through the - * #9754 error→warn chain. The un-trash stands: with no overlay minted, boot - * reconciliation re-projects the DECLARED body onto the record, so the - * environment converges to the package truth rather than to a fork. The full - * argument — why not refuse BEFORE the un-trash (no trashed-row read exists; - * a pre-pass would go green in doubles and resolve nothing on a real engine), - * why not compensate (this middleware's own delete leg guarantees the record - * survives, so a re-trash through the data door cannot happen, and a failed - * compensation makes the refusal a lie) — lives at the leg itself. - * - * ## What this file pins, and what it deliberately does not - * - * It pins six facts, in the order they answer the question: - * - * 1. ⭐ CONTROL — the harness reaches the LOCK, and the lock is what answers. - * An `update` on an artifact-backed name is refused with `NOT_OVERRIDABLE` - * / 403 AND the lock's own message (it names the clone path). The code - * alone would not identify the gate: ADR-0005's tier gate inside - * `saveMetaItem` answers with the same code for the same row, and its - * message instead says the item "is provided by a managed package and is - * sealed" (the double below throws the producer's sentence, ADR-0131 D6's - * managed-content seal). Without this leg, case 2 below could pass for the wrong reason - * — a probe answered by a gate that is not the one under test. - * - * 2. THE LOCK AT THE RESTORE LEG — the same fixture, the same set, through - * `restore`: the engine un-trash runs (the leg stays a post-pass), the - * mint is refused BEFORE the metadata write, no overlay lands, and the - * refusal is REPORTED on the durability channel with the lock's own error - * — never thrown. - * - * This case is the INVERSION commit 1e79aa4f8's MEASURED RESIDUAL demanded of the - * follow-up (commit 9cfc1f7e9): it used to pin the re-authoring of a packaged body - * through this leg with no lock consulted. The deliberate red for the - * inversion was measured for commit 9cfc1f7e9: with the leg's lock consultation - * removed, this case fails on `saves.length` — so it does not pass with - * the lock absent. - * - * 2b. NON-PACKAGED CONTROL — an org-owned set through the same leg is - * re-authored exactly as before the lock arrived. The ruling's scope is - * "packaged sets cannot be re-authored via restore" and nothing else; - * this case is the evidence the extension did not grow past it. - * - * 2c. FAIL-CLOSED — provenance that cannot be answered refuses the mint - * too, reported with the lock's `unknown` error. Accepting on a failed - * read would reopen the hatch-open gap on every transient — the lock's - * three-verdict contract (see its header) applies at every door it - * guards, this one included. - * - * 3. THE FENCE — why the trashed-packaged-row scenario stays remote: the write-through's - * `delete` leg cannot put a package-declared set into a restorable state. - * An artifact-backed definition tombstones its overlay (an ADR-0005 RESET) - * and the record re-projects to the shipped body; the driver delete never - * runs, so there is no trashed row for a `restore` to act on. - * - * 4. THE BLIND SPOT in the gate that WOULD pre-empt it. `security-plugin.ts`'s - * `assertPackageManagedWriteGate` runs OUTSIDE this middleware and admits - * `restore` into its package-row refusal — but it keys on the `managed_by` - * COLUMN, and the lock's own header records that column as measurably the - * wrong fact. On the `provenance_skip` shape (`permission-set-drift.ts`) a - * genuinely package-declared set carries `managed_by` other than - * `'package'`, so the outer gate does not fire and the unlocked leg is - * what the row reaches. Pinned through the exported classifier, which is - * the fact the lock reads. - * - * ⛔ NOT re-pinned here, on purpose (Prime Directive #8 — one spelling): - * - that `restore` is not an engine middleware dispatch verb. That is - * `packages/objectql/src/engine-middleware-operation-vocabulary.test.ts` - * (#7809), which welds the 7-member union to `engine.ts` source AND drives - * a real engine through every public method. A second copy here would be a - * claim about this file rather than about the engine. - * - that `restore` never derives as an API operation. That is - * `packages/spec/src/data/api-derivation.test.ts` against - * `API_METHOD_DERIVATION.restore.flag`, permanently `false` since - * `enable.trash` was retired (#2377 / ADR-0049); a real recycle bin is - * parked at #3146. - * - * Those two are what make this leg unreachable TODAY. The day either goes - * red — #3146 landing a real recycle bin is the expected way — the lock - * pinned by case 2 is what stands between a trashed packaged row and a - * silent fork, and this file is the proof it was already in place. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { createPermissionSetWriteThrough } from './permission-set-projection.js'; -import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js'; - -/** The package-declared body every case in this file turns on. */ -const declaredBody = () => ({ - name: 'crm_rep', - label: 'CRM Representative', - objects: { crm_lead: { allowRead: true } }, - fields: {}, - systemPermissions: ['pkg.baseline'], - rowLevelSecurity: [], - tabPermissions: {}, - _packageId: 'com.example.crm', -}); - -/** - * A `sys_permission_set` row projected from that declaration. - * - * `managed_by` is a parameter because the two gates in play read DIFFERENT - * facts about the same row: the outer two-doors gate reads this column, the - * lock reads the artifact registry. Case 4 is about exactly that difference. - */ -const packagedRow = (managedBy: string) => ({ - id: 'ps_pkg', - name: 'crm_rep', - label: 'CRM Representative', - managed_by: managedBy, - package_id: 'com.example.crm', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify(['pkg.baseline']), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), -}); - -/** - * Minimal engine double over `sys_permission_set` + the env overlay store. - * - * `registry.listItems('permission')` is the source the lock reads — an - * in-memory array, which is the whole reason the lock uses it (no page, no - * cap). Seeding it IS seeding "this name is package-declared". - */ -function makeQl(rows: any[], declared: any[]) { - const permRows = [...rows]; - const overlays: any[] = []; - return { - permRows, - overlays, - /** True once the DRIVER delete actually ran (i.e. the row was removed). */ - driverDeleted: false, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - // REFUSES what it does not implement, rather than quietly matching - // nothing: a double that answered `[]` for an operator it never learned - // would report "no such row", and every case here would still pass while - // measuring the double instead of the middleware. - return permRows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); - if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operand for ${k}`); - return r[k] === v; - }), - ); - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - return (await this.find(object, q))[0] ?? null; - }, - // Opens with the PRODUCER's own dispatch predicate, never a hand-mirrored - // guard: a fixture that drifts to a call shape `ObjectQL` would refuse must - // fail loudly here rather than collect a green the real engine would not - // have given. `tryUpdate` in the subject SWALLOWS a throw, so a double - // looser than the engine would be invisible twice over. - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? permRows.filter((r) => r.id === dispatch.id) - : await this.find(object, options); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - async insert(_object: string, data: any) { - permRows.push({ ...data }); - return { id: data.id }; - }, - }; -} - -/** - * Metadata protocol double. - * - * `tierGate: 'open'` models the documented `OS_METADATA_WRITABLE=permission` - * operator hatch — the deployment shape this whole family of cards came from, - * where the producer's own ADR-0005 refusal is switched off and the write-door - * lock is the ONLY thing left. `tierGate: 'closed'` is the default posture. - * - * The real `PermissionSetSchema` runs on every accepted save, exactly as - * `saveMetaItem` runs it, so a body this double accepts is a body production - * would accept too. - */ -function makeProtocol(ql: any, declaredNames: string[], tierGate: 'open' | 'closed') { - const codeFor = (name: string) => - declaredNames.includes(name) ? declaredBody() : null; - return { - saves: [] as any[], - deletes: [] as any[], - async saveMetaItem(req: { type: string; name: string; item: any }) { - if (tierGate === 'closed' && declaredNames.includes(req.name)) { - const err: any = new Error( - `Metadata item 'permission/${req.name}' is provided by a managed package and is sealed ` - + 'against in-place edits. Clone it under a new name to customize it (the "Clone" action on ' - + 'the permission set, or POST /api/v1/data/sys_permission_set with a new name). ' - + 'See docs/adr/0126-packaged-metadata-customization-model.md.', - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - throw err; - } - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const err: any = new Error( - `[invalid_metadata] permission/${req.name} failed spec validation: ` - + parsed.error.issues.map((i: any) => `${i.path.join('.') || ''}: ${i.message}`).join('; '), - ); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - ql.overlays.push({ name: req.name, item: req.item }); - this.saves.push({ ...req }); - return { success: true }; - }, - async deleteMetaItem(req: { type: string; name: string }) { - const i = ql.overlays.findIndex((o: any) => o.name === req.name); - if (i >= 0) ql.overlays.splice(i, 1); - this.deletes.push({ ...req }); - // Artifact-backed: the overlay is tombstoned and the definition RESETS - // to the shipped body — the record is never removed. `reset: true` is - // the real protocol's own answer for this case. - return { success: true, reset: declaredNames.includes(req.name) }; - }, - async getMetaItemLayered(req: { type: string; name: string }) { - const code = codeFor(req.name); - const overlay = ql.overlays.find((o: any) => o.name === req.name)?.item ?? null; - return { - type: 'permission', - name: req.name, - code, - overlay, - overlayScope: overlay ? 'env' : null, - effective: overlay ?? code, - }; - }, - }; -} - -/** Run the middleware, reporting whether the engine's own write (`next`) ran. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const userCtx = { userId: 'usr_admin' }; - -describe('[#11725] the restore leg of the permission-set write-through', () => { - it('CONTROL: an UPDATE on the same set is refused by the #11702 LOCK — named by its message, not just its code', async () => { - // The gate identification this whole file rests on. `NOT_OVERRIDABLE`/403 - // alone does NOT name a gate: ADR-0005's tier gate answers with the same - // envelope for the same row. The lock is distinguished by its MESSAGE, - // which teaches the sanctioned path — and by `saves.length === 0`, which - // says the refusal happened BEFORE the metadata write rather than inside - // it. Both halves are asserted, because either alone is ambiguous. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - // Hatch OPEN: the producer's tier gate is switched off, so anything that - // refuses here can only be the write-door lock. - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - let caught: any; - try { - await run(mw, { - object: 'sys_permission_set', - operation: 'update', - data: { id: 'ps_pkg', label: 'hijacked' }, - context: userCtx, - }); - } catch (e) { caught = e; } - - expect(caught, 'the update was refused').toBeDefined(); - expect(caught).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(caught.name).toBe('PackagedPermissionSetLockedError'); - // The wording IS the contract here (it is what separates this refusal from - // the tier gate's), so it is asserted on top of the envelope, never instead. - expect(caught.message).toContain("is declared by package 'com.example.crm'"); - expect(caught.message).toContain('clone'); - expect(protocol.saves.length, 'refused BEFORE the metadata write').toBe(0); - expect(ql.overlays.length, 'no overlay of a packaged set was minted').toBe(0); - }); - - it('LOCK AT THE RESTORE LEG: the same set through RESTORE keeps the engine un-trash but the re-author is REFUSED — reported on the durability channel, never thrown', async () => { - // ⭐ The INVERSION of commit 1e79aa4f8's MEASURED RESIDUAL, demanded by that case's - // own comment and delivered by commit 9cfc1f7e9. It used to assert: no refusal, one - // save, the packaged body in the overlay store. Now the lock is consulted - // before the leg's one guarded write, and every half inverts. - // - // Identical fixture to the CONTROL above — same ql, same registry, same - // protocol posture, same row. The ONLY difference is the operation, so the - // difference in outcome is attributable to the leg and nothing else. The - // hatch is OPEN, so anything that refuses can only be the write-door lock. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - // The engine operation is NOT misreported: the leg stays a post-pass and - // the caller of a completed restore hears success, not a throw. - expect(nextCalled, 'the engine un-trash runs first — the leg is a post-pass').toBe(true); - - // The refusal is LOUD, and it is the LOCK's — same gate identification as - // the CONTROL: the error names the package and teaches the clone path, - // which ADR-0005's tier gate (the only other 403 on this row) does not. - expect(errors.length, 'the refusal reached the durability channel').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].e.name).toBe('PackagedPermissionSetLockedError'); - expect(errors[0].e.message).toContain("is declared by package 'com.example.crm'"); - expect(errors[0].e.message).toContain('clone'); - // The leg's own report is the REFUSAL wording, not the failed-write one - // (#5240 — one condition, one wording): it says the skip was deliberate. - expect(errors[0].m).toContain('deliberately NOT re-authored'); - expect(errors[0].m).toContain('clone'); - - // And the mint never happened: refused BEFORE the metadata write, so no - // overlay of a package-declared set exists to win over upgrades. - expect(protocol.saves.length, 'refused BEFORE the metadata write').toBe(0); - expect(ql.overlays, 'no overlay of a packaged set was minted').toEqual([]); - }); - - it('NON-PACKAGED CONTROL: an org-owned set through RESTORE is re-authored exactly as before — the lock changes nothing for it', async () => { - // The ruled scope is "packaged sets cannot be re-authored via restore" - // and NOTHING else — this case is the evidence the extension did not grow - // past it. Same registry as above (crm_rep IS declared), so the classifier - // is answering `org` for THIS name, not `org` for everything. - const orgRow = { - id: 'ps_org', - name: 'my_own_set', - label: 'My Own Set', - managed_by: 'user', - object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), - }; - const ql = makeQl([orgRow], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_org' } }, - context: userCtx, - }); - - expect(nextCalled, 'the engine un-trash runs').toBe(true); - expect(errors, 'nothing was refused, nothing was reported').toEqual([]); - expect(protocol.saves.length, 'the org-owned body IS re-authored, as always').toBe(1); - expect(protocol.saves[0].name).toBe('my_own_set'); - expect(ql.overlays.map((o: any) => o.name)).toEqual(['my_own_set']); - }); - - it('FAIL-CLOSED: provenance that cannot be answered refuses the re-author too — reported with the lock\'s unknown error, never guessed', async () => { - // Both artifact sources are broken: the registry read throws and the - // layered probe fails. The lock's three-verdict contract (its header says - // why accepting on `unknown` is the one guess a write door must not make) - // applies at this door exactly as at insert/update — except the refusal - // is reported, not thrown, like every refusal on this leg. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - (ql as any).registry = { listItems: () => { throw new Error('registry offline'); } }; - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - (protocol as any).getMetaItemLayered = async () => { throw new Error('metadata layer unreadable'); }; - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, - getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'restore', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - expect(nextCalled, 'the engine un-trash runs').toBe(true); - expect(errors.length, 'the fail-closed refusal reached the durability channel').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].e.name).toBe('PackagedPermissionSetProvenanceUnknownError'); - expect(protocol.saves.length, 'no mint on a guess').toBe(0); - expect(ql.overlays).toEqual([]); - }); - - it('FENCE: DELETE cannot produce a restorable row — the packaged definition RESETS and the driver delete never runs', async () => { - // Why a trashed packaged row cannot arise through this door: there is - // nothing to restore. The write-through owns the delete, tombstones the - // overlay, and returns WITHOUT calling `next()` — so the engine never - // removes (or trashes) the record. - const ql = makeQl([packagedRow('package')], [declaredBody()]); - const protocol = makeProtocol(ql, ['crm_rep'], 'open'); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const nextCalled = await run(mw, { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }); - - expect(nextCalled, 'the driver delete is never reached').toBe(false); - expect(protocol.deletes.length, 'the overlay tombstone IS the delete').toBe(1); - expect(protocol.deletes[0]).toMatchObject({ type: 'permission', name: 'crm_rep' }); - expect(ql.permRows.length, 'the record survives — a packaged set cannot be removed').toBe(1); - expect(ql.permRows[0].name).toBe('crm_rep'); - }); - - it('BLIND SPOT: the outer two-doors gate keys on `managed_by`, which the lock records as the WRONG fact', async () => { - // `assertPackageManagedWriteGate` (security-plugin.ts) admits `restore` - // into its package-row refusal and would therefore pre-empt this leg — but - // only for rows whose `managed_by` column reads `'package'`. The - // `provenance_skip` shape is a genuinely package-declared set whose column - // does not, and on that row the outer gate returns without refusing while - // the lock's own fact says `packaged`. That disagreement is the reason - // "the outer gate already covers it" is not an answer. - const declared = [declaredBody()]; - const provenanceSkipRow = packagedRow('user'); // the drift-report shape - const ql = makeQl([provenanceSkipRow], declared); - - // The lock's fact, read from the artifact registry the lock actually uses. - expect(classifyPackagedPermissionSet('crm_rep', ql)).toEqual({ - status: 'packaged', - packageId: 'com.example.crm', - }); - // The outer gate's fact, on the very same row. - expect(provenanceSkipRow.managed_by, 'the column the outer gate reads').not.toBe('package'); - - // Control: the classifier is not simply answering `packaged` for - // everything — a name the registry does not declare comes back `org`, - // which is what makes the assertion above evidence of anything. - expect(classifyPackagedPermissionSet('my_own_set', ql)).toEqual({ status: 'org' }); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts b/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts deleted file mode 100644 index 22a456a8021..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 8f6d83147] THE DUPLICATE-NAME REFUSAL on `sys_permission_set` — its ADR-0112 - * envelope, and the ORDER it stands in relative to the packaged-set lock. - * - * Two halves of one defect, both measured live on `examples/app-showcase` - * before the fix (seeded admin, cookie session): - * - * 1. the refusal was a bare `Error` carrying `.status = 409` and NO `.code`, - * so the flat `{ error, code }` responder — which invents nothing for a - * producer that declared nothing — put prose on the wire: - * `409 {"error":"[Security] permission set 'showcase_manager' already - * exists","object":"sys_permission_set"}`. ADR-0112's 2026-08-17 amendment - * (#9232) closed `error.code` at the flat door too, so a 409 with no code - * is that contract unhonoured, and a dialog that must branch on the - * refusal is pushed to string-matching; - * - * 2. ⭐ it ran BEFORE `assertPermissionSetNotPackageDeclared`. A - * package-declared set HAS a projected row, so its name is duplicate and - * locked at once — and the admin who opens the Clone dialog on a packaged - * set and types the base set's own name (the single most likely thing to - * type) got the duplicate refusal and never reached `NOT_OVERRIDABLE`, - * the refusal that explains the actual situation and names the remedy. - * - * ## What each case is for - * - * 1. CONTROL — the ordinary (non-packaged) duplicate still refuses, and the - * refusal now carries the closed member. Asserting the ENVELOPE (`code` + - * `status`), never a bare "it threw": the unfixed producer threw too. - * 2. ⭐ THE ORDERING — same middleware, same duplicate row, but the name is - * package-declared: the answer is the lock's `NOT_OVERRIDABLE` / 403 - * carrying the lock's OWN message. The code alone would not identify the - * gate — ADR-0005's tier gate inside `saveMetaItem` answers 403 - * `NOT_OVERRIDABLE` for the same row — so the message and `saves.length` - * are what prove the LOCK answered, ahead of any metadata write. - * 3. NEGATIVE CONTROL — the reorder did not make every duplicate answer - * `NOT_OVERRIDABLE`: case 1's refusal is asserted to be NOT that code. - * (Stated as its own case because that is the regression the ordering - * change could plausibly introduce.) - * 4. HAPPY PATH — a free name on an org-verdict kernel still lands. Running - * the lock first must not cost a create. - * 5. FAIL-CLOSED, DECLARED — an ordinary duplicate attempted while NO - * artifact source can answer takes the lock's `unknown` refusal (403) - * instead of the 409. That case MOVED with the reorder; it is pinned so - * the behaviour is declared rather than incidental. Both answers are - * refusals and neither writes. - */ - -import { describe, it, expect } from 'vitest'; -import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { createPermissionSetWriteThrough } from './permission-set-projection.js'; -import { - PERMISSION_SET_NAME_CONFLICT_CODE, - PERMISSION_SET_NAME_CONFLICT_STATUS, -} from './errors.js'; - -/** The package-declared body case 2 turns on. */ -const declaredBody = () => ({ - name: 'showcase_manager', - label: 'Showcase Manager', - objects: { showcase_task: { allowRead: true } }, - fields: {}, - systemPermissions: ['showcase.manage'], - rowLevelSecurity: [], - tabPermissions: {}, - _packageId: 'com.example.showcase', -}); - -/** A `sys_permission_set` row that already holds `name`. */ -const rowFor = (name: string, over: Record = {}) => ({ - id: `ps_${name}`, - name, - label: name, - managed_by: 'admin', - object_permissions: JSON.stringify({}), - field_permissions: JSON.stringify({}), - system_permissions: JSON.stringify([]), - row_level_security: JSON.stringify([]), - tab_permissions: JSON.stringify({}), - ...over, -}); - -/** - * Minimal engine double. - * - * `registry.listItems('permission')` is the artifact source the lock reads, so - * seeding it IS seeding "this name is package-declared". It REFUSES query - * shapes it does not implement rather than answering `[]`, which would report - * "no such row" and let every case pass while measuring the double — and it - * holds the caller's `limit` and opens `update` with the producer's own - * dispatch predicate, so a fake looser than `ObjectQL` cannot collect a green - * the real engine would not have given. - */ -function makeQl(rows: any[], declared: any[]) { - const permRows = [...rows]; - return { - permRows, - registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - const matched = permRows.filter((r) => - Object.entries(where).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); - if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operand for ${k}`); - return r[k] === v; - }), - ); - // The caller's bound, applied AFTER the filter and by PRESENCE — the - // duplicate pre-check asks for `limit: 1`, and a limit-blind double - // would be answering a different question than the engine does. - return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - return (await this.find(object, q))[0] ?? null; - }, - async insert(_object: string, data: any) { - permRows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any, options?: any) { - const dispatch = assertEngineUpdateDispatch(data, options); - const targets = dispatch.kind === 'by-id' - ? permRows.filter((r) => r.id === dispatch.id) - : await this.find(object, options); - for (const r of targets) Object.assign(r, data); - return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; - }, - }; -} - -/** - * Metadata protocol double. The real `PermissionSetSchema` runs on every - * accepted save, exactly as `saveMetaItem` does. - * - * `layeredThrows` models a metadata layer that cannot answer — case 5's - * `unknown` verdict, which is a READ failure and not an empty answer. - */ -function makeProtocol(ql: any, declaredNames: string[], opts: { layeredThrows?: boolean } = {}) { - return { - saves: [] as any[], - projected: [] as string[], - registerMutationProjector(_type: string, _fn: any) { /* not exercised here */ }, - async saveMetaItem(req: { type: string; name: string; item: any }) { - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const err: any = new Error( - `[invalid_metadata] permission/${req.name} failed spec validation: ` - + parsed.error.issues.map((i: any) => `${i.path.join('.') || ''}: ${i.message}`).join('; '), - ); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - this.saves.push({ ...req }); - ql.permRows.push(rowFor(req.name)); - return { success: true }; - }, - async deleteMetaItem() { return { success: true }; }, - async getMetaItemLayered(req: { type: string; name: string }) { - if (opts.layeredThrows) throw new Error('metadata store unreachable'); - const code = declaredNames.includes(req.name) ? declaredBody() : null; - return { type: 'permission', name: req.name, code, overlay: null, overlayScope: null, effective: code }; - }, - }; -} - -/** Run the middleware, reporting whether the engine's own write (`next`) ran. */ -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -const insertCtx = (name: string) => ({ - object: 'sys_permission_set', - operation: 'insert', - context: { userId: 'usr_admin' }, - data: { name, label: 'Clone of the base' }, -}); - -/** The thrown value, or a loud failure — `rejects` alone reads a bare throw as a pass. */ -async function refusalOf(fn: () => Promise): Promise { - try { - await fn(); - } catch (e) { - return e; - } - throw new Error('expected the insert to be REFUSED; it was accepted'); -} - -describe('[#19307] duplicate-name refusal on sys_permission_set', () => { - it('1. CONTROL — an ORDINARY duplicate is refused with the closed member on the envelope', async () => { - const ql = makeQl([rowFor('org_owned_set')], []); - const protocol = makeProtocol(ql, []); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - // The envelope, not the throw: `status` AND the machine-readable `code`, - // which is what the flat door puts on the wire (#9232). - expect(err.code).toBe(PERMISSION_SET_NAME_CONFLICT_CODE); - expect(err.code).toBe('UNIQUE_VIOLATION'); - expect(err.status).toBe(PERMISSION_SET_NAME_CONFLICT_STATUS); - expect(err.status).toBe(409); - // Both spellings: the two transports read different property names. - expect(err.statusCode).toBe(409); - expect(err.name).toBe('PermissionSetNameConflictError'); - expect(err.message).toContain("permission set 'org_owned_set' already exists"); - // Refused BEFORE the write, on both stores. - expect(protocol.saves.length).toBe(0); - expect(ql.permRows.length).toBe(1); - }); - - it('2. ⭐ ORDERING — a duplicate that is ALSO package-declared answers the LOCK, not the conflict', async () => { - // The Clone-dialog case: the base set is declared by a package AND has its - // projected row, so both refusals are true at once. - const ql = makeQl([rowFor('showcase_manager', { managed_by: 'package', package_id: 'com.example.showcase' })], - [declaredBody()]); - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('showcase_manager'))); - - expect(err.code).toBe('NOT_OVERRIDABLE'); - expect(err.status).toBe(403); - expect(err.statusCode).toBe(403); - // ⭐ The LOCK answered, not ADR-0005's tier gate inside `saveMetaItem` - // (same code, different message) and not the duplicate check: the lock's - // message is the only one that names the clone path. - expect(err.name).toBe('PackagedPermissionSetLockedError'); - expect(err.message).toContain("declared by package 'com.example.showcase'"); - expect(err.message).toContain('clone'); - expect(err.message).not.toContain('already exists'); - // Nothing was written, and the metadata door was never reached. - expect(protocol.saves.length).toBe(0); - }); - - it('3. NEGATIVE CONTROL — the reorder did NOT turn ordinary duplicates into NOT_OVERRIDABLE', async () => { - const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); // a packaged set exists, under ANOTHER name - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - expect(err.code).not.toBe('NOT_OVERRIDABLE'); - expect(err.code).toBe('UNIQUE_VIOLATION'); - expect(err.status).toBe(409); - }); - - it('4. HAPPY PATH — a free name still lands with the lock consulted first', async () => { - const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); - const protocol = makeProtocol(ql, ['showcase_manager']); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const nextCalled = await run(mw, insertCtx('my_new_set')); - - expect(nextCalled).toBe(false); // projector-owned record; no driver write - expect(protocol.saves.map((s) => s.name)).toEqual(['my_new_set']); - }); - - it('5. FAIL-CLOSED, DECLARED — a duplicate whose provenance cannot be resolved takes the lock refusal', async () => { - // No registry AND a layered read that throws ⇒ no source answered. - const ql: any = makeQl([rowFor('org_owned_set')], []); - delete ql.registry; - const protocol = makeProtocol(ql, [], { layeredThrows: true }); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); - - const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); - - expect(err.code).toBe('NOT_OVERRIDABLE'); - expect(err.status).toBe(403); - expect(err.name).toBe('PackagedPermissionSetProvenanceUnknownError'); - expect(protocol.saves.length).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts b/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts deleted file mode 100644 index 79503892e83..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#22169] The boot heal converges on a table holding DUPLICATE rows of a name. - * - * `reconcilePermissionSetProjection` pages `sys_permission_set` and heals every - * overlay-less, env-authored row that drifted from its declared body. It used - * to hand each drifted row to a by-NAME upsert, which re-read "the" row as the - * first one of that name (lowest id) — so with two or more rows per name only - * that one was ever written, and every later row was warned about as - * "re-projected" and left drifted, on every boot. Measured on a real engine - * (8 names × 3 rows): 16 of 24 rows still drifted and 16 drift warns on boot 2, - * 3, … . The same by-name read had an INSERT branch that a refused read reached - * (`tryFind` answers `[]` for a throw), minting a duplicate of a row the loop - * was holding. And a layered metadata read (four serial statements on the real - * protocol) was awaited for every candidate row although a readable - * SchemaRegistry makes the trust rule ignore it. - * - * ## The harness, and why each piece is real - * - * A REAL `ObjectQL` over a REAL `SqlDriver` (better-sqlite3 `:memory:`), both - * aliased to source by this package's `vitest.config.ts`. The by-name read the - * defect lives in is the engine's own `ORDER BY id LIMIT 1`, and the heal's - * write is the engine's own update by id — a fake table would answer for its - * author instead. The per-organization unique index is DROPPED after schema - * sync: that is the shape of a remote table built before the index retrofit, - * the only shape that can hold duplicates, and a table that already holds them - * can never build the index again. - * - * The engine is SPIED, never replaced: each spied verb counts and then runs - * the real method, so no double stands between the heal and the engine's own - * dispatch contract. The protocol is a counting stub — what is pinned about it - * is whether it is CALLED, which a stub answers exactly. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; - -import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; -import { - permissionSetRowFields, - reconcilePermissionSetProjection, - recordDiffersFromBody, -} from './permission-set-projection.js'; - -const SYS = { context: { isSystem: true } } as any; - -/** Just enough `sys_metadata` for the overlay pass to read an empty table. */ -const SYS_METADATA_OBJECT: any = { - name: 'sys_metadata', - label: 'System Metadata', - fields: { - id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, - type: { name: 'type', label: 'Type', type: 'text', required: true }, - name: { name: 'name', label: 'Name', type: 'text', required: true }, - organization_id: { name: 'organization_id', label: 'Org', type: 'text' }, - package_id: { name: 'package_id', label: 'Package', type: 'text' }, - metadata: { name: 'metadata', label: 'Body', type: 'textarea' }, - state: { name: 'state', label: 'State', type: 'text' }, - }, -}; - -const declaredBody = (name: string) => ({ - name, - label: `Declared ${name}`, - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['declared.baseline'], -}); - -/** A row of `name` whose facets drifted from the declaration. */ -const driftedRow = (id: string, name: string) => ({ - id, - name, - managed_by: 'admin', - active: true, - ...permissionSetRowFields(declaredBody(name)), - system_permissions: '["drifted.edit"]', -}); - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -async function boot(declaredNames: string[], rows: Array>): Promise { - const engine = new ObjectQL(); - const driver = new SqlDriver({ - client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, - }); - engine.registerDriver(driver, true); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.heal-by-id-22169', - name: 'Heal by id', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysPermissionSet, SYS_METADATA_OBJECT], - } as any); - await engine.syncSchemas(); - engines.push(engine); - await (driver as any).getKnex().raw('DROP INDEX IF EXISTS uniq_sys_permission_set_organization_id_name'); - for (const name of declaredNames) { - engine.registry.registerItem('permission', declaredBody(name), 'name', 'com.example.pkg'); - } - for (const row of rows) await (engine as any).insert('sys_permission_set', row, SYS); - return engine; -} - -/** - * The real engine with its verbs SPIED — every call still runs the real method, - * so the engine's own dispatch contract answers it; the spies only count and, - * when `refuseNameReads` is set, make a by-name read of the table throw. - * - * `withRegistry: false` hands the heal the same engine with `registry` hidden, - * the shape of a kernel whose SchemaRegistry is not readable — the one shape - * whose trust rule still reads the layered item. - */ -function observe(engine: any, opts: { withRegistry?: boolean } = {}) { - const log = { updatedIds: [] as string[], inserts: 0 }; - const state = { refuseNameReads: false }; - const realFind = engine.find.bind(engine); - const realInsert = engine.insert.bind(engine); - const realUpdate = engine.update.bind(engine); - vi.spyOn(engine, 'find').mockImplementation(async (o: any, q?: any, opt?: any) => { - const byName = o === 'sys_permission_set' && q?.where && typeof q.where === 'object' && 'name' in q.where; - if (byName && state.refuseNameReads) throw new Error('fake outage: the by-name read was refused'); - return realFind(o, q, opt); - }); - vi.spyOn(engine, 'insert').mockImplementation(async (o: any, d: any, opt?: any) => { - if (o === 'sys_permission_set') log.inserts += 1; - return realInsert(o, d, opt); - }); - vi.spyOn(engine, 'update').mockImplementation(async (o: any, d: any, opt?: any) => { - if (o === 'sys_permission_set') log.updatedIds.push(String(d?.id)); - return realUpdate(o, d, opt); - }); - const ql = opts.withRegistry === false - ? new Proxy(engine, { - get(target, prop) { - if (prop === 'registry') return undefined; - const value = Reflect.get(target, prop, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) - : engine; - return { ql, log, state }; -} - -/** A protocol that answers the layered read from the declarations and counts every call. */ -function countingProtocol(declaredNames: string[]) { - const layeredReads: string[] = []; - return { - layeredReads, - async getMetaItemLayered(req: { type: string; name: string }) { - layeredReads.push(req.name); - const code = declaredNames.includes(req.name) ? declaredBody(req.name) : null; - return { type: 'permission', name: req.name, code, overlay: null, effective: code }; - }, - }; -} - -function captureWarns() { - const warns: Array<{ msg: string; meta?: any }> = []; - return { - warns, - driftWarns: () => warns.filter((w) => w.msg.includes('drifted from its metadata definition')), - logger: { info: () => {}, warn: (msg: string, meta?: any) => warns.push({ msg, meta }) }, - }; -} - -async function allRows(engine: ObjectQL): Promise { - return (await (engine as any).find('sys_permission_set', { where: {}, limit: 100 }, SYS)) as any[]; -} - -describe('#22169 — the boot heal writes the row it read, by id', () => { - it('a duplicated name: each drifted row is written once, by its own id, and the next boot logs no drift warn', async () => { - const engine = await boot(['member_default'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log } = observe(engine); - const protocol = countingProtocol(['member_default']); - - const first = captureWarns(); - const out1 = await reconcilePermissionSetProjection(protocol, { ql, logger: first.logger }); - - expect([...log.updatedIds].sort(), 'each drifted row written once, by ITS id').toEqual(['ps_a', 'ps_b']); - expect(log.inserts).toBe(0); - expect(out1.driftHealed).toBe(2); - // The warn is said per row healed, AFTER its write, and names the row. - expect(first.driftWarns().map((w) => w.meta?.id).sort()).toEqual(['ps_a', 'ps_b']); - for (const row of await allRows(engine)) { - expect(recordDiffersFromBody(row, declaredBody('member_default')), `${row.id} converged`).toBe(false); - } - - // Boot 2: nothing drifted, so nothing is written and nothing is warned. - log.updatedIds.length = 0; - const second = captureWarns(); - const out2 = await reconcilePermissionSetProjection(protocol, { ql, logger: second.logger }); - expect(second.driftWarns(), 'the next boot logs no drift warn').toEqual([]); - expect(log.updatedIds).toEqual([]); - expect(out2.driftHealed).toBe(0); - expect(await allRows(engine)).toHaveLength(2); // ⛔ duplicates are healed, never deleted here - }); - - it('control: a single drifted row heals as it always did — one write, one warn, then silence', async () => { - const engine = await boot(['viewer_readonly'], [driftedRow('ps_v', 'viewer_readonly')]); - const { ql, log } = observe(engine); - const protocol = countingProtocol(['viewer_readonly']); - - const first = captureWarns(); - const out1 = await reconcilePermissionSetProjection(protocol, { ql, logger: first.logger }); - expect(log.updatedIds).toEqual(['ps_v']); - expect(out1.driftHealed).toBe(1); - expect(first.driftWarns()).toHaveLength(1); - const [row] = await allRows(engine); - expect(JSON.parse(row.system_permissions)).toEqual(['declared.baseline']); - expect(row.managed_by, 'facets only — provenance untouched').toBe('admin'); - - const second = captureWarns(); - await reconcilePermissionSetProjection(protocol, { ql, logger: second.logger }); - expect(second.driftWarns()).toEqual([]); - }); - - it('issues NO layered metadata read while a SchemaRegistry answers — on the healing boot or after it', async () => { - const engine = await boot(['member_default', 'viewer_readonly'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - driftedRow('ps_v', 'viewer_readonly'), - ]); - const { ql } = observe(engine); - const protocol = countingProtocol(['member_default', 'viewer_readonly']); - - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(protocol.layeredReads).toEqual([]); - }); - - it('without a SchemaRegistry the layered read is still the source — asked once per NAME, not per row', async () => { - const engine = await boot([], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log } = observe(engine, { withRegistry: false }); - const protocol = countingProtocol(['member_default']); - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(protocol.layeredReads).toEqual(['member_default']); - expect([...log.updatedIds].sort()).toEqual(['ps_a', 'ps_b']); - expect(out.driftHealed).toBe(2); - }); - - it('a refused by-name existence read inserts nothing — the heal holds the row and writes it by id', async () => { - const engine = await boot(['member_default'], [ - driftedRow('ps_a', 'member_default'), - driftedRow('ps_b', 'member_default'), - ]); - const { ql, log, state } = observe(engine); - state.refuseNameReads = true; - const protocol = countingProtocol(['member_default']); - - await reconcilePermissionSetProjection(protocol, { ql, logger: captureWarns().logger }); - expect(log.inserts, '⛔ no row minted on a read that did not answer').toBe(0); - expect(await allRows(engine)).toHaveLength(2); - expect([...log.updatedIds].sort()).toEqual(['ps_a', 'ps_b']); - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts deleted file mode 100644 index ddab6b9aa30..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ /dev/null @@ -1,1886 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ADR-0094 — sys_permission_set as a pure projection of the metadata layer. - * Covers the env-door projector (create/update/reset/retire + evaluator - * registry sync), the data-door write-through middleware, and the boot - * reconciliation/backfill pass. The package door stays covered in - * bootstrap-declared-permissions.test.ts. - * - * [#6858 / ADR-0094 D5-R] `makeProtocol` models ADR-0005's TIER GATE, which - * this suite used to be blind to: `permission` is `allowEnvironmentOverlay: false` - * since commit ee58392e1, so a metadata write whose (type, name) is backed by - * a code ARTIFACT is refused 403 `NOT_OVERRIDABLE`, while an artifact-free - * name rides `allowRuntimeCreate: true` and still lands. Commit ee58392e1 records the - * blind spot in its own message: this suite stubs `saveMetaItem`, so the real - * gate is pinned elsewhere (dogfood cases and a dedicated 403 suite) — - * which is exactly why four cases here kept asserting the RETIRED overlay - * direction and stayed green: the stub could not refuse. They are triaged - * below, each one individually. - */ - -import { describe, it, expect } from 'vitest'; -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { - permissionSetRowFields, - permissionSetBodyFromRow, - permissionSpecBodyKeys, - pickRowStateColumns, - mergeRowPatchIntoBody, - recordDiffersFromBody, - upsertEnvPermissionSet, - projectPermissionMutation, - registerPermissionSetProjection, - createPermissionSetWriteThrough, - reconcilePermissionSetProjection, - type ProjectionLogger, -} from './permission-set-projection.js'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; - -/** - * In-memory ql over sys_permission_set + sys_metadata. - * - * [#11097] Supports the `$in` membership operator, because the real engine does - * — `bootstrapDeclaredPermissions` has read `sys_permission_set` with - * `{ name: { $in: names } }` since #10946, on this very table. A double that - * refused it (or, worse, quietly matched nothing and returned `[]`) would pin - * the double's limits rather than the projector's behaviour: `[]` is the answer - * "none of these names exist", so a non-matching double would make the - * reconciler re-create every overlay record on every boot while this suite - * stayed green. - * - * `calls` counts every round trip — the defect #11096/#11097 fix is a COUNT. - */ -function makeQl() { - const permRows: any[] = []; - const metaRows: any[] = []; - const tableFor = (object: string): any[] | null => - object === 'sys_permission_set' ? permRows : object === 'sys_metadata' ? metaRows : null; - const matches = (r: any, where: any) => - Object.entries(where ?? {}).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - if (v && typeof v === 'object' && !Array.isArray(v)) { - const inList = (v as any).$in; - if (Array.isArray(inList)) return inList.includes(r[k]); - throw new Error(`fake driver: unsupported operator ${Object.keys(v).join(',')}`); - } - return v === null ? (r[k] ?? null) === null : r[k] === v; - }); - return { - permRows, - metaRows, - /** Round trips by table, since issue order matters to the count tests. */ - calls: { find: 0, insert: 0, update: 0 }, - resetCalls() { (this as any).calls = { find: 0, insert: 0, update: 0 }; }, - async find(object: string, q: any) { - const rows = tableFor(object); - if (object === 'sys_permission_set') (this as any).calls.find += 1; - return rows ? rows.filter((r) => matches(r, q?.where)) : []; - }, - async findOne(object: string, q: any) { - assertEngineFindOnePredicate(object, q); - const rows = tableFor(object); - return rows?.find((r) => matches(r, q?.where)) ?? null; - }, - async insert(object: string, data: any) { - const rows = tableFor(object); - if (!rows) return null; - if (object === 'sys_permission_set') (this as any).calls.insert += 1; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - const rows = tableFor(object); - if (object === 'sys_permission_set') (this as any).calls.update += 1; - const r = rows?.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - async delete(object: string, opts: any) { - const rows = tableFor(object); - if (!rows) return false; - const id = opts?.where?.id; - const i = rows.findIndex((x) => x.id === id); - if (i >= 0) rows.splice(i, 1); - return i >= 0; - }, - }; -} - -/** - * Mock metadata protocol over the ql's sys_metadata table: env-scope active - * overlays, layered read (overlay-wins over `declared`), and the ADR-0094 - * awaited mutation-projector seam. - * - * [#21861] The package dimension of a row is modelled, because the defect it - * hid lives there. A save targets the ONE row keyed by its `packageId` (absent - * = the package-less row), as `SysMetadataRepository.put` does, so a save that - * names no package beside a package-bound row mints a second row instead of - * quietly overwriting the only one. The reads serve the first env-wide row of - * the name in any package (`findServedOverlayRow` with no package), and the - * single-item read states that row's `package_id` on the item as `_packageId`, - * as `getMetaItem` does. - */ -function makeProtocol(ql: any, declared: Record = {}) { - let projector: ((evt: any) => Promise) | null = null; - const isActiveEnvRow = (r: any, name: string) => - r.type === 'permission' && r.name === name && r.state === 'active' && (r.organization_id ?? null) === null; - const overlayFor = (name: string) => ql.metaRows.find((r: any) => isActiveEnvRow(r, name)); - const rowAt = (name: string, packageId: string | null) => - ql.metaRows.find((r: any) => isActiveEnvRow(r, name) && (r.package_id ?? null) === packageId); - const protocol = { - saves: [] as any[], - deletes: [] as any[], - registerMutationProjector(_type: string, fn: (evt: any) => Promise) { - projector = fn; - }, - async saveMetaItem(req: { type: string; name: string; item: any; actor?: string; packageId?: string | null }) { - // [#6858 / ADR-0094 D5-R] ADR-0005's tier gate, first — ahead of schema - // validation, exactly as `protocol.ts` orders it. `declared` IS this - // stub's artifact registry, so `declared[name] !== undefined` is the - // `isArtifactBacked` fact the production gate reads (never the record's - // `managed_by` column). The refusal is topology-independent: the - // protocol gate fires when `environmentId` is set, and - // `SysMetadataRepository.assertAllowed` refuses the `override-artifact` - // intent on every other kernel — pinned in - // metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts. - // - // `deleteMetaItem` below deliberately does NOT model a gate: its - // artifact-backed refusal sits inside `environmentId !== undefined` - // with no repository-level twin, so the answer is topology-dependent - // and a single modelled verdict here would be a fabrication. It stays - // pinned where the real gate can be reached (the protocol suite above, - // and packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts). - if (declared[req.name] !== undefined) { - const err: any = new Error( - `Metadata item 'permission/${req.name}' is provided by a managed package and is sealed ` - + 'against in-place edits. Clone it under a new name to customize it (the "Clone" action on ' - + 'the permission set, or POST /api/v1/data/sys_permission_set with a new name). ' - + 'See docs/adr/0126-packaged-metadata-customization-model.md.', - ); - err.code = 'NOT_OVERRIDABLE'; - err.status = 403; - throw err; - } - // [#4669] The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs - // it (metadata-protocol/src/protocol.ts → `resolveOverlaySchema`), same - // `[invalid_metadata]` 422 envelope. Without this the mock accepts any - // object and the suite stays green while every real backfill fails — - // which is how a 100%-failing projection shipped. - const parsed = PermissionSetSchema.safeParse(req.item); - if (!parsed.success) { - const summary = parsed.error.issues - .map((i: any) => `${i.path.join('.') || ''}: ${i.message}`) - .join('; '); - const err: any = new Error(`[invalid_metadata] permission/${req.name} failed spec validation: ${summary}`); - err.code = 'INVALID_METADATA'; - err.status = 422; - throw err; - } - const packageId = req.packageId ?? null; - const existing = rowAt(req.name, packageId); - if (existing) existing.metadata = JSON.stringify(req.item); - else { - ql.metaRows.push({ - id: packageId === null ? `meta_${req.name}` : `meta_${req.name}@${packageId}`, - type: 'permission', name: req.name, state: 'active', - organization_id: null, ...(packageId === null ? {} : { package_id: packageId }), - metadata: JSON.stringify(req.item), - }); - } - protocol.saves.push({ ...req }); - if (projector) await projector({ type: 'permission', name: req.name, state: 'active', organizationId: null, body: req.item }); - return { success: true }; - }, - async deleteMetaItem(req: { type: string; name: string; actor?: string }) { - const i = ql.metaRows.findIndex( - (r: any) => r.type === 'permission' && r.name === req.name && (r.organization_id ?? null) === null, - ); - if (i >= 0) ql.metaRows.splice(i, 1); - protocol.deletes.push({ ...req }); - if (projector) await projector({ type: 'permission', name: req.name, state: 'deleted', organizationId: null }); - return { success: true, reset: true }; - }, - async getMetaItemLayered(req: { type: string; name: string }) { - const code = declared[req.name] ?? null; - const o = overlayFor(req.name); - const overlay = o ? JSON.parse(o.metadata) : null; - return { - type: 'permission', name: req.name, code, overlay, - overlayScope: overlay ? 'env' : null, effective: overlay ?? code, - }; - }, - getMetaItemCalls: 0, - async getMetaItem(req: { type: string; name: string }) { - protocol.getMetaItemCalls += 1; - const o = overlayFor(req.name); - const item = o - ? { ...JSON.parse(o.metadata), ...(o.package_id ? { _packageId: o.package_id } : {}) } - : declared[req.name]; - if (item === undefined) { - const err: any = new Error(`permission/${req.name} not found`); - err.status = 404; - throw err; - } - return { type: 'permission', name: req.name, item }; - }, - }; - return protocol; -} - -/** Metadata-manager facade stub for the evaluator-registry sync. */ -function makeMetadataFacade() { - const registry = new Map(); - return { - registry, - registerInMemory(type: string, name: string, body: any) { - registry.set(`${type}/${name}`, body); - }, - async get(type: string, name: string) { - return registry.get(`${type}/${name}`); - }, - unregister(type: string, name: string) { - registry.delete(`${type}/${name}`); - }, - }; -} - -const envBody = (over: Record = {}) => ({ - name: 'organization_admin', - label: 'Organization Administrator', - objects: { crm_lead: { allowRead: true, allowEdit: true } }, - fields: { 'crm_lead.amount': { readable: true, editable: false } }, - systemPermissions: ['setup.access', 'manage_org_users'], - rowLevelSecurity: [{ name: 'tenant', object: '*', operation: 'all', using: 'org == current_user.org', enabled: true }], - tabPermissions: { crm_leads: 'visible' }, - adminScope: { businessUnit: 'Sales', includeSubtree: true, assignablePermissionSets: ['member_default'] }, - ...over, -}); - -describe('permissionSetBodyFromRow / permissionSetRowFields (round-trip)', () => { - it('rebuilds the body a row was projected from', () => { - const fields = permissionSetRowFields(envBody()); - const row = { id: 'ps_1', name: 'organization_admin', active: true, ...fields }; - const body = permissionSetBodyFromRow(row); - expect(body.name).toBe('organization_admin'); - expect(body.label).toBe('Organization Administrator'); - expect(body.objects).toEqual(envBody().objects); - expect(body.fields).toEqual(envBody().fields); - expect(body.systemPermissions).toEqual(envBody().systemPermissions); - expect(body.rowLevelSecurity[0].using).toBe('org == current_user.org'); - expect(body.tabPermissions).toEqual({ crm_leads: 'visible' }); - expect(body.adminScope.businessUnit).toBe('Sales'); - // and projecting the rebuilt body changes nothing - expect(recordDiffersFromBody(row, body)).toBe(false); - }); -}); - -// ── The definition ⊆ spec contract (#4669) ───────────────────────────────── -// -// `sys_permission_set` carries columns the DEFINITION does not (`active`, the -// timestamps, the provenance trio). Feeding them to `saveMetaItem` is what -// #4001's `.strict()` schema rejects, and what took the ADR-0094 D4 backfill -// to a 100% failure rate. - -describe('row→body projection keeps ONLY spec-declared keys (#4669)', () => { - const legacyRow = () => ({ - id: 'ps_1', - name: 'organization_admin', - ...permissionSetRowFields(envBody()), - // every storage column a real row carries… - active: true, - managed_by: 'admin', - package_id: null, - customized: false, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-02-02T00:00:00Z', - // …plus a column this code has never heard of - some_future_column: 'whatever', - }); - - it('the whitelist is DERIVED from the spec schema, not transcribed', () => { - const keys = permissionSpecBodyKeys(); - // identical to PermissionSetSchema's own shape — the single source - expect([...keys].sort()).toEqual(Object.keys((PermissionSetSchema as any).shape).sort()); - expect(keys.has('objects')).toBe(true); - expect(keys.has('systemPermissions')).toBe(true); - expect(keys.has('adminScope')).toBe(true); - // `active` is a TABLE column, never a spec key — that is the whole bug - expect(keys.has('active')).toBe(false); - }); - - it('drops `active` and every other storage column from the projected body', () => { - const body = permissionSetBodyFromRow(legacyRow()); - for (const col of ['active', 'managed_by', 'package_id', 'customized', 'created_at', 'updated_at', 'id', 'some_future_column']) { - expect(body, `storage column '${col}' must not enter the metadata body`).not.toHaveProperty(col); - } - // the definition itself survives intact - expect(body.objects).toEqual(envBody().objects); - expect(body.systemPermissions).toEqual(envBody().systemPermissions); - }); - - it('every key the projection emits is one the spec ACCEPTS (parsed by the real schema)', () => { - const parsed = PermissionSetSchema.safeParse(permissionSetBodyFromRow(legacyRow())); - expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); - // …and the reverse guard: a spec RENAME must fail here rather than silently - // dropping the value at runtime. - const keys = permissionSpecBodyKeys(); - for (const key of Object.keys(permissionSetBodyFromRow(legacyRow()))) { - expect(keys.has(key), `body key '${key}' is not declared by PermissionSetSchema`).toBe(true); - } - }); - - it('filters a body STORED before #4001 (data at rest can still carry `active`)', () => { - // a legacy sys_metadata overlay written while the schema still stripped it - const legacyStored = { ...envBody(), active: false, _packageId: 'com.x' }; - const merged = mergeRowPatchIntoBody(legacyStored, { label: 'Renamed' }); - expect(merged).not.toHaveProperty('active'); - expect(merged).not.toHaveProperty('_packageId'); - expect(PermissionSetSchema.safeParse(merged).success).toBe(true); - }); - - it('pickRowStateColumns isolates the record-state columns (normalized)', () => { - expect(pickRowStateColumns({ active: 'false', label: 'x' })).toEqual({ active: false }); - expect(pickRowStateColumns({ active: true })).toEqual({ active: true }); - expect(pickRowStateColumns({ label: 'x' })).toBeNull(); - expect(pickRowStateColumns(null)).toBeNull(); - }); -}); - -describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () => { - it('CREATES a missing record (managed_by admin) — Studio-authored sets appear in Setup', async () => { - const ql = makeQl(); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.seeded).toBe(1); - const row = ql.permRows[0]; - expect(row.name).toBe('organization_admin'); - // [A4 #2920] env/Studio-authored sets are ADMIN-owned (formerly 'user'). - expect(row.managed_by).toBe('admin'); - expect(row.active).toBe(true); - expect(JSON.parse(row.object_permissions)).toEqual(envBody().objects); - }); - - it('projects all facets onto an existing env-authored row', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: true }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - const row = ql.permRows[0]; - expect(row.id).toBe('ps_env'); // id stable — junction FKs stay valid - expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); - expect(JSON.parse(row.admin_scope).businessUnit).toBe('Sales'); - }); - - it('[#4669] NEVER re-flips `active` from a body — it is row state, not definition', async () => { - // A body carrying `active` can only be legacy data at rest (pre-#4001) or a - // caller mistake. Projecting it would silently undo an admin's - // deactivate — the record's switch is the record's own. - const ql = makeQl(); - ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '[]', active: false }); - await upsertEnvPermissionSet(ql, { ...envBody(), active: true } as any); - expect(ql.permRows[0].active, 'a stale body must not re-activate a deactivated set').toBe(false); - // …and a record the projector CREATES starts active (column default). - await upsertEnvPermissionSet(ql, envBody({ name: 'fresh_set' })); - expect(ql.permRows.find((r: any) => r.name === 'fresh_set')?.active).toBe(true); - }); - - it('projects onto a legacy row with ABSENT provenance (platform default)', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_legacy', name: 'organization_admin', system_permissions: '[]' }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - }); - - it('projects onto a PACKAGE-OWNED row (overlay customization) while preserving its provenance', async () => { - // Unit-level shape, unchanged by ADR-0094 D5-R: handed a body for a - // package-owned row, this function projects the facets and preserves the - // provenance. What D5-R retired is the CLAIM about where that body comes - // from — an env overlay of a packaged set is no longer a supported - // customization channel (commit ee58392e1); see the D5-R lifecycle block - // below for the refusal this projector now sits behind. - const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); - const r = await upsertEnvPermissionSet(ql, envBody()); - expect(r.updated).toBe(1); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['setup.access', 'manage_org_users']); - expect(row.managed_by).toBe('package'); // provenance preserved - expect(row.package_id).toBe('com.example.crm'); - expect(row.id).toBe('ps_pkg'); // id stable - }); -}); - -describe('projectPermissionMutation (the awaited projector)', () => { - it('re-reads the fresh layered body and projects it (record + evaluator registry)', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) }); - const metadata = makeMetadataFacade(); - const r = await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - expect(r?.seeded).toBe(1); - expect(ql.permRows[0].managed_by).toBe('admin'); - // evaluator's registry-first list('permission') now resolves the same body, - // marked as a projection echo so it can never masquerade as an artifact - const entry = metadata.registry.get('permission/organization_admin'); - expect(entry?.systemPermissions).toEqual(['setup.access', 'manage_org_users']); - expect(entry?._envProjection).toBe(true); - }); - - it('overlay delete on a DECLARED set heals the registry echo back to the declared body', async () => { - const ql = makeQl(); - const declaredBody = envBody({ systemPermissions: ['declared.only'] }); - const declared = { organization_admin: declaredBody }; - // engine SchemaRegistry — the artifact source the projection never writes - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, declared); - const metadata = makeMetadataFacade(); - const deps = { ql, metadata }; - // env overlay shadows the declaration → registry synced with marked overlay body - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody({ systemPermissions: ['overlaid'] })) }); - await projectPermissionMutation(protocol, deps, { type: 'permission', name: 'organization_admin', state: 'active' }); - expect(metadata.registry.get('permission/organization_admin')?.systemPermissions).toEqual(['overlaid']); - // overlay deleted → record resets to declared AND the echo heals to declared - ql.metaRows.length = 0; - await projectPermissionMutation(protocol, deps, { type: 'permission', name: 'organization_admin', state: 'deleted' }); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); - const healed = metadata.registry.get('permission/organization_admin'); - expect(healed?.systemPermissions).toEqual(['declared.only']); - expect(healed?._envProjection).toBeUndefined(); - }); - - // [#21789] The echo is served by the protocol's layered read as the item's - // `code` layer, and the envelope's `provenance` is read off it — so the echo - // must say what the lock says, or the read reports a lock the doors do not - // enforce (an org's own set rendered "locked by a code package"). - it('[#21789] the echo of a set no code package ships says so: `_provenance: \'org\'`', async () => { - const ql = makeQl(); - // The registry holds the stored row the way the hydrator registers it — - // a runtime package id stamped on, tenant provenance written over it. - (ql as any).registry = { - listItems: (t: string) => (t === 'permission' - ? [{ ...envBody(), _packageId: 'app.crm_workspace', _provenance: 'org' }] - : []), - }; - const protocol = makeProtocol(ql); - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) }); - const metadata = makeMetadataFacade(); - await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - const entry = metadata.registry.get('permission/organization_admin'); - expect({ marker: entry?._envProjection, provenance: entry?._provenance }).toEqual({ marker: true, provenance: 'org' }); - }); - - it('[#21789] control: the echo of a legacy overlay of a code-shipped set does NOT claim tenant provenance', async () => { - const ql = makeQl(); - const shipped = { ...envBody({ systemPermissions: ['declared.only'] }), _packageId: 'com.example.crm', _provenance: 'package' }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [shipped] : []) }; - const protocol = makeProtocol(ql, { organization_admin: shipped }); - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody({ systemPermissions: ['overlaid'] })) }); - const metadata = makeMetadataFacade(); - await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'active' }); - const entry = metadata.registry.get('permission/organization_admin'); - expect(entry?.systemPermissions, 'precondition: the overlay was projected').toEqual(['overlaid']); - expect({ marker: entry?._envProjection, provenance: entry?._provenance ?? null }).toEqual({ marker: true, provenance: null }); - }); - - it('skips draft saves and non-permission events', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - expect(await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'x', state: 'draft' })).toBeNull(); - expect(await projectPermissionMutation(protocol, { ql }, { type: 'object', name: 'x', state: 'active' })).toBeNull(); - }); - - it('a delete that leaves NO body retires the record and drops the registry entry', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); // no declared artifact, no overlay - const metadata = makeMetadataFacade(); - metadata.registry.set('permission/organization_admin', envBody()); - ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user' }); - const r = await projectPermissionMutation(protocol, { ql, metadata }, { type: 'permission', name: 'organization_admin', state: 'deleted' }); - expect(r?.deleted).toBe(1); - expect(ql.permRows.length).toBe(0); - expect(metadata.registry.has('permission/organization_admin')).toBe(false); - }); - - it('a delete that reveals the artifact baseline RESETS the record instead (ADR-0005 reset)', async () => { - const ql = makeQl(); - const declared = { organization_admin: envBody({ systemPermissions: ['declared.only'] }) }; - const protocol = makeProtocol(ql, declared); - ql.permRows.push({ id: 'ps_env', name: 'organization_admin', managed_by: 'user', system_permissions: '["overlaid"]' }); - const r = await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'deleted' }); - expect(r?.updated).toBe(1); - expect(ql.permRows.length).toBe(1); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); - }); -}); - -describe('registerPermissionSetProjection', () => { - it('prefers the awaited registerMutationProjector seam — a save projects before it returns', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - expect(registerPermissionSetProjection(protocol, { ql })).toBe(true); - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - // no timers, no event loop yield — the record is already there - expect(ql.permRows.length).toBe(1); - expect(ql.permRows[0].name).toBe('organization_admin'); - }); - - it('falls back to onMetadataMutation on older protocols, and returns false with neither', async () => { - const ql = makeQl(); - let listener: any = null; - const older = { - onMetadataMutation: (fn: any) => { listener = fn; return () => {}; }, - getMetaItemLayered: async () => ({ effective: envBody(), code: null }), - }; - expect(registerPermissionSetProjection(older, { ql })).toBe(true); - listener({ type: 'permission', name: 'organization_admin', state: 'active' }); - await new Promise((r) => setTimeout(r, 0)); - expect(ql.permRows.length).toBe(1); - expect(registerPermissionSetProjection({}, { ql })).toBe(false); - expect(registerPermissionSetProjection(null, { ql })).toBe(false); - }); -}); - -// ── Package-set customization (ADR-0094 D5-R — the 2026-07-14 direction is -// RETIRED; what survives is the `allowRuntimeCreate` tier) ─────────────── - -/** Seed an env-scope overlay row directly — a LEGACY overlay, authored before - * commit ee58392e1's rollback closed the write door. `saveMetaItem` can no longer mint - * one for an artifact-backed name, but `supportsOverlay: true` is unchanged, - * so rows that already exist still merge overlay-wins at read time. */ -const seedLegacyOverlay = (ql: any, name: string, body: any) => { - ql.metaRows.push({ - id: `meta_${name}`, type: 'permission', name, state: 'active', - organization_id: null, metadata: JSON.stringify(body), - }); -}; - -describe('package-owned set customization lifecycle (ADR-0094 D5-R)', () => { - it('an env-scope save on an ARTIFACT-BACKED package name is REFUSED (403 NOT_OVERRIDABLE) — no overlay, record untouched', async () => { - // Was: "a Studio env-scope save on a PACKAGE name customizes the record - // and keeps provenance" — the 2026-07-14 direction. Commit ee58392e1 rolled - // `permission` back to `allowEnvironmentOverlay: false` and ADR-0094 D5-R's ruling A - // accepted the tightening, so the write this case used to assert is the - // write production now refuses. Rejection-class: the ENVELOPE is the - // claim (`code` AND `status`), because a bare "it threw" would stay green - // on a stub that threw for any other reason. - const ql = makeQl(); - const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { organization_admin: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - - await expect( - protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(ql.metaRows.length, 'refused, not "refused after writing" — no phantom overlay row').toBe(0); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions), 'the record still projects the shipped declaration').toEqual(['pkg.baseline']); - expect(row.customized, 'nothing was customized').toBeFalsy(); - }); - - it('a package row MATERIALIZED through the metadata door is still customizable — the surviving allowRuntimeCreate tier', async () => { - // The boundary commit ee58392e1 measured UNAFFECTED, and the reason D5-R names a - // surviving NEIGHBOUR rather than a re-route: this row is - // `managed_by:'package'` like the one above, but its DEFINITION lives in - // `sys_metadata` (authored + published through the metadata door, - // ADR-0070), so no artifact backs it and the write rides - // `allowRuntimeCreate`. It is a direct edit of the one stored - // definition — there is no code layer for it to be an overlay OF. - const ql = makeQl(); - const protocol = makeProtocol(ql, {}); // no artifact registry entry - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_mat', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["materialized.baseline"]' }); - - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); - - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by, 'the package still owns the row').toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - }); - - it('a LEGACY overlay (authored before the rollback) still projects, and deleting it still RESETS to the declaration', async () => { - // `supportsOverlay: true` was not touched by commit ee58392e1 — only the WRITE flag - // was. A row that already exists keeps merging overlay-wins, so the - // reset invariant still has to hold for it. Seeded directly because the - // write door that used to mint it is closed. - const ql = makeQl(); - const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { organization_admin: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['legacy.overlay'] })); - - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); - expect(JSON.parse(ql.permRows[0].system_permissions), 'the legacy overlay still wins at read time').toEqual(['legacy.overlay']); - - await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); - const row = ql.permRows[0]; - expect(row, 'a packaged definition is never removed by an overlay reset').toBeTruthy(); - expect(JSON.parse(row.system_permissions)).toEqual(['pkg.baseline']); - expect(row.managed_by).toBe('package'); - expect(row.customized, 'reset clears the customized flag').toBe(false); - }); -}); - -// ── Data-door write-through (ADR-0094 D3) ─────────────────────────────────── - -function makeMiddleware(ql: any, protocol: any, metadata?: any) { - return createPermissionSetWriteThrough({ ql, metadata, getProtocol: () => protocol }); -} - -async function run(mw: any, opCtx: any): Promise { - let nextCalled = false; - await mw(opCtx, async () => { nextCalled = true; }); - return nextCalled; -} - -describe('createPermissionSetWriteThrough (data door → metadata store)', () => { - const userCtx = { userId: 'usr_admin' }; - - it('passes system-context writes through (the projector/seeder channel)', async () => { - const ql = makeQl(); - const mw = makeMiddleware(ql, makeProtocol(ql)); - const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'x' }, context: { isSystem: true } }); - expect(nextCalled).toBe(true); - }); - - it('passes through when the protocol is missing/incapable (single store — no split brain)', async () => { - const ql = makeQl(); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => null }); - const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'x' }, context: userCtx }); - expect(nextCalled).toBe(true); - }); - - it('INSERT authors the definition into metadata; the record is projector-created (no driver write)', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'support_agent', label: 'Support Agent', - object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - system_permissions: '["support.use"]', - }, - }; - const nextCalled = await run(mw, opCtx); - expect(nextCalled).toBe(false); // driver write skipped - expect(protocol.saves.length).toBe(1); - expect(protocol.saves[0].actor).toBe('usr_admin'); - expect(protocol.saves[0].item.objects).toEqual({ ticket: { allowRead: true } }); - expect(ql.permRows.length).toBe(1); - expect(ql.permRows[0].managed_by).toBe('admin'); - expect(opCtx.result?.name).toBe('support_agent'); - }); - - it('INSERT of a duplicate name is rejected with 409', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ id: 'ps_1', name: 'support_agent', managed_by: 'user' }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'insert', data: { name: 'support_agent' }, context: userCtx }), - ).rejects.toMatchObject({ status: 409 }); - }); - - it('UPDATE merges the column patch into the layered effective body and saves metadata', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - // existing env set: record + overlay - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - const rowId = ql.permRows[0].id; - const mw = makeMiddleware(ql, protocol); - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, system_permissions: '["setup.access"]', active: false }, - }; - const nextCalled = await run(mw, opCtx); - expect(nextCalled).toBe(false); - // metadata is the store that changed… - const overlay = JSON.parse(ql.metaRows[0].metadata); - expect(overlay.systemPermissions).toEqual(['setup.access']); - expect(overlay.objects).toEqual(envBody().objects); // unmentioned facets preserved - // [#4669] …but `active` rode along as a COLUMN, never as a body key: the - // definition stays spec-clean while the record's switch still flips. - expect(overlay).not.toHaveProperty('active'); - // …and the record followed via projection - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['setup.access']); - expect(ql.permRows[0].active).toBe(false); - expect(ql.permRows[0].id).toBe(rowId); - expect(opCtx.result?.id).toBe(rowId); - }); - - it('[#4669] the activate/deactivate ACTIONS write the column and nothing else', async () => { - // `sys-permission-set.object.ts` ships two `type:'api'` actions that PATCH - // /data/sys_permission_set/{id} with `bodyExtra: { active: true|false }`. - // A row-state-only patch is not a definition write: it passes through to - // the driver, mints no overlay, and touches the metadata store not at all. - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - const rowId = ql.permRows[0].id; - const savesBefore = protocol.saves.length; - const metaRowsBefore = JSON.stringify(ql.metaRows); - const mw = makeMiddleware(ql, protocol); - - for (const active of [false, true]) { - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, active }, - }); - expect(nextCalled, 'the driver performs the column write, with its ordinary semantics').toBe(true); - } - expect(protocol.saves.length, 'no metadata write for a pure row-state patch').toBe(savesBefore); - expect(JSON.stringify(ql.metaRows)).toBe(metaRowsBefore); - }); - - it('[#4669] deactivating a PACKAGE-owned set mints no customization overlay', async () => { - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', active: true }); - const mw = makeMiddleware(ql, protocol); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, data: { id: 'ps_pkg', active: false }, - }); - expect(nextCalled).toBe(true); - expect(ql.metaRows.length, 'switching a packaged set off is not a customization of it').toBe(0); - expect(ql.permRows[0].customized).toBeUndefined(); - }); - - it('[#4669] INSERT honours an explicit `active` on the record (Clone action sends one)', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - const opCtx: any = { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { - name: 'support_agent', label: 'Support Agent', active: false, - object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - }, - }; - await run(mw, opCtx); - expect(protocol.saves[0].item, 'the definition never carries row state').not.toHaveProperty('active'); - expect(ql.permRows[0].active).toBe(false); - expect(opCtx.result?.active).toBe(false); - }); - - it('UPDATE that renames is rejected (the name is the metadata identity)', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ id: 'ps_1', name: 'organization_admin', managed_by: 'user' }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_1', name: 'renamed' }, context: userCtx }), - ).rejects.toMatchObject({ status: 400 }); - }); - - it('DELETE of a runtime-only set hard-deletes the definition and retires the record', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - const rowId = ql.permRows[0].id; - const mw = makeMiddleware(ql, protocol); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'delete', options: { where: { id: rowId } }, context: userCtx, - }); - expect(nextCalled).toBe(false); - expect(protocol.deletes.length).toBe(1); - expect(ql.metaRows.length).toBe(0); - expect(ql.permRows.length).toBe(0); - }); - - it('DELETE of an artifact-backed set resets the record to the declared body instead of removing it', async () => { - const ql = makeQl(); - const declared = { organization_admin: envBody({ systemPermissions: ['declared.only'] }) }; - const protocol = makeProtocol(ql, declared); - registerPermissionSetProjection(protocol, { ql }); - // [#6858] A LEGACY env overlay shadows the declaration — seeded directly - // because `saveMetaItem` can no longer mint one for an artifact-backed - // name (ADR-0094 D5-R). The invariant under test is unchanged: the record - // is projected from the overlay, and the data-door delete resets it. - seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['overlaid'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active' }); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['overlaid']); - const mw = makeMiddleware(ql, protocol); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'delete', options: { where: { id: ql.permRows[0].id } }, context: userCtx, - }); - expect(nextCalled).toBe(false); - expect(ql.permRows.length).toBe(1); // record survives… - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); // …reset to the declaration - }); - - it('UPDATE of an ARTIFACT-BACKED set surfaces the producer\'s 403 to the caller (write point :794 — left to 403 loudly)', async () => { - // Was: "UPDATE of a PACKAGE-OWNED set becomes an env overlay" — the - // retired D5 direction. This is the ONE of the four production write - // points that commit ee58392e1's rollback actually closes, and the card's design - // question was what to do with it. Decision (ADR-0094 D5-R): leave it to - // the producer. The middleware still TRANSLATES the write; the protocol's - // ADR-0005 tier gate refuses it; the middleware neither pre-empts the - // refusal (it would need a second copy of `isArtifactBacked` — Prime - // Directive #8) nor swallows it. The caller hears the envelope. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["pkg.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(ql.metaRows.length, 'no overlay was minted by the refused edit').toBe(0); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions), 'the record still shows the shipped declaration').toEqual(['pkg.baseline']); - expect(row.managed_by).toBe('package'); - }); - - it('UPDATE of a package row MATERIALIZED through the metadata door still lands (the surviving tier)', async () => { - // The other half of the boundary — same `managed_by:'package'` row shape, - // no artifact behind the name, so the write rides `allowRuntimeCreate` - // and the data door keeps working. Without this the case above would also - // pass on a harness that could not write ANYTHING through the middleware. - const ql = makeQl(); - const protocol = makeProtocol(ql, {}); // no artifact registry entry - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_mat', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', - system_permissions: '["materialized.baseline"]', - }); - const mw = makeMiddleware(ql, protocol); - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_mat', system_permissions: '["customized"]' }, - }; - const nextCalled = await run(mw, opCtx); - expect(nextCalled, 'the driver write is still skipped — the record is projector-owned').toBe(false); - expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); - const row = ql.permRows[0]; - expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by, 'the package still owns the row').toBe('package'); - expect(row.package_id).toBe('com.example.crm'); - }); - - it('INSERT of a name that shadows a code-declared set surfaces the 403 too (write point :752)', async () => { - // Reachable when a declaration ships but its record was never - // materialized: the duplicate-name probe finds no row, so the insert - // proceeds to `saveMetaItem` on an artifact-backed name. Fail-closed and - // unswallowed, same disposition as the UPDATE above. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep' }); - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - const mw = makeMiddleware(ql, protocol); - await expect( - run(mw, { - object: 'sys_permission_set', operation: 'insert', context: userCtx, - data: { name: 'crm_rep', label: 'Shadow', object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }) }, - }), - ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(ql.permRows.length, 'no record was created by the refused insert').toBe(0); - expect(ql.metaRows.length).toBe(0); - }); - - it('RESTORE reports a refused re-author on the durability channel instead of throwing (write point :713)', async () => { - // The deliberate asymmetry. `restore` runs AFTER the engine has already - // un-trashed the row, so throwing would leave the caller with a restored - // record and a failed request; the write point catches and reports on the - // durability channel (#4632) instead. Pinned with an artifact-backed name - // so the refusal is the ADR-0005 one — the scenario is narrow (a packaged - // definition cannot be trashed through the data door at all), which is - // why the DISPOSITION, not the frequency, is what this case fixes. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep' }); - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - const errors: any[] = []; - const mw = createPermissionSetWriteThrough({ - ql, getProtocol: () => protocol, - logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, - }); - ql.permRows.push({ id: 'ps_r', name: 'crm_rep', managed_by: 'user' }); - const nextCalled = await run(mw, { - object: 'sys_permission_set', operation: 'restore', options: { where: { id: 'ps_r' } }, context: userCtx, - }); - expect(nextCalled, 'the engine un-trash still runs').toBe(true); - expect(errors.length, 'the failure is reported, not swallowed').toBe(1); - expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(errors[0].m).toContain('NOT re-authored into metadata'); - }); - - it('DELETE of a customized PACKAGE set removes the overlay and resets to the declared baseline', async () => { - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); - const mw = makeMiddleware(ql, protocol); - // [#6858] The overlay is LEGACY (pre-rollback) and seeded directly — the - // data-door edit that used to mint it now answers 403 (ADR-0094 D5-R). - // The invariant under test is untouched: "delete" lifts the overlay and - // the record resets to the shipped declaration. - seedLegacyOverlay(ql, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active' }); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); - // "delete" = reset - const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); - expect(nextCalled).toBe(false); - expect(ql.metaRows.length).toBe(0); // overlay gone - expect(ql.permRows.length).toBe(1); // record survives - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['pkg.baseline']); - expect(ql.permRows[0].managed_by).toBe('package'); - }); - - it('the delete OUTCOME tells a real deletion apart from a packaged RESET', async () => { - // Both endings above are correct — a runtime-only set goes, a packaged one - // re-projects to its declaration. What the CALLER could not do was tell - // them apart: the outcome this middleware wrote was `true` on every path - // (it started there and the loop could only ever assign `true` again), so - // the data door answered `200 {"success":true}` for both. A UI fired a - // success toast on a permission set it had not deleted and showed the row - // again on refresh — the two legs' own assertions above stayed green - // throughout, because they read the STORE and never the answer. - // - // ⛔ The reset's outcome must not be `false` either: that value is the - // driver contract's "no row matched", which `deleteData` turns into a - // `404 RECORD_NOT_FOUND` — about a record this same caller can still GET. - // Zero rows removed is the other declared arm and the only one that says - // "it is still there"; `metadata-protocol`'s own - // `protocol.record-not-found.test.ts` pins the mapping onto `success`. - - // Leg 1 — runtime-only definition: the record really goes. - const envQl = makeQl(); - const envProtocol = makeProtocol(envQl); - registerPermissionSetProjection(envProtocol, { ql: envQl }); - await envProtocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody() }); - const envCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: envQl.permRows[0].id } }, - context: userCtx, - }; - await run(makeMiddleware(envQl, envProtocol), envCtx); - expect(envQl.permRows.length, 'control: the env-authored record really went').toBe(0); - expect(envCtx.result, 'a completed delete keeps answering the boolean arm').toBe(true); - - // Leg 2 — artifact-backed definition: the record survives the RESET. - const pkgQl = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (pkgQl as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const pkgProtocol = makeProtocol(pkgQl, { crm_rep: declaredBody }); - registerPermissionSetProjection(pkgProtocol, { ql: pkgQl }); - pkgQl.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', - package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', - }); - seedLegacyOverlay(pkgQl, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); - await projectPermissionMutation(pkgProtocol, { ql: pkgQl }, { type: 'permission', name: 'crm_rep', state: 'active' }); - const pkgCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }; - await run(makeMiddleware(pkgQl, pkgProtocol), pkgCtx); - expect(pkgQl.permRows.length, 'the record survives — the designed ADR-0094 reset').toBe(1); - expect( - typeof pkgCtx.result, - 'the COUNT arm — `false` would be read as not-found and 404 a record that is still there', - ).toBe('number'); - expect(pkgCtx.result, 'zero records removed').toBe(0); - - // The card's own claim, stated as an assertion: the two endings no longer - // hand the caller the same answer. - expect(pkgCtx.result).not.toEqual(envCtx.result); - }); - - it('a read-back that cannot ANSWER reports the record as NOT deleted (fail-closed)', async () => { - // The outcome above is read off the record, which puts the whole answer at - // the mercy of ONE read — and the obvious helper for it, `tryFind`, ends in - // `catch { return []; }`. Probing through that helper makes a FAILED read - // byte-identical to "the row is gone", so the door answers - // `200 {"success":true}` with the record sitting exactly where it was: - // this card's own lie, re-triggered by an outage instead of by a packaged - // set. Nothing else in this file would have said a word about it. - // - // `unknown` is therefore counted as a survivor. A caller told "not - // deleted" re-reads the record and learns the truth; a caller told - // "deleted" has no reason to look again. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - registerPermissionSetProjection(protocol, { ql }); - ql.permRows.push({ - id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', - package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]', - }); - - // Fail ONLY the read-back. Target resolution and the probe both read - // `sys_permission_set` by id, so they are told apart by ORDER: the first - // is the middleware resolving what the caller addressed, the second is the - // read-back under test. `byIdReads` is asserted below so that a change in - // that shape turns this test RED rather than leaving it vacuously green - // with an injection that never fired. - const realFind = ql.find.bind(ql); - let byIdReads = 0; - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set' && q?.where?.id !== undefined) { - byIdReads += 1; - if (byIdReads >= 2) throw new Error('db unavailable'); - } - return realFind(object, q); - }; - - const warnings: { m: string; meta?: any }[] = []; - const mw = createPermissionSetWriteThrough({ - ql, getProtocol: () => protocol, - logger: { info: () => {}, warn: (m: string, meta?: any) => warnings.push({ m, meta }) }, - }); - const opCtx: any = { - object: 'sys_permission_set', - operation: 'delete', - options: { where: { id: 'ps_pkg' } }, - context: userCtx, - }; - await run(mw, opCtx); - - expect(byIdReads, 'the read-back really ran — otherwise this test proves nothing').toBeGreaterThanOrEqual(2); - expect(ql.permRows.length, 'the record is still right there').toBe(1); - expect( - opCtx.result, - '⛔ never `true`: that is the deletion claim this card exists to remove, and a failed read must not manufacture it', - ).not.toBe(true); - expect(opCtx.result, 'counted as a survivor').toBe(0); - expect( - warnings.some((w) => String(w.m).includes('could not read the sys_permission_set record back')), - 'the degradation is reported, not swallowed', - ).toBe(true); - }); - - it('SINGLE-STORE kernel (no protocol): package rows keep the legacy two-doors refusal', async () => { - const ql = makeQl(); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => null }); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_pkg', label: 'hijack' }, context: userCtx }), - ).rejects.toMatchObject({ status: 403 }); - await expect( - run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }), - ).rejects.toMatchObject({ status: 403 }); - // env rows still pass through to the driver in single-store kernels - ql.permRows.push({ id: 'ps_env', name: 'my_custom', managed_by: 'user' }); - expect(await run(mw, { object: 'sys_permission_set', operation: 'update', data: { id: 'ps_env', label: 'ok' }, context: userCtx })).toBe(true); - }); - - it('leaves non-sys_permission_set objects and unrelated operations alone', async () => { - const ql = makeQl(); - const mw = makeMiddleware(ql, makeProtocol(ql)); - expect(await run(mw, { object: 'sys_user', operation: 'insert', data: {}, context: { userId: 'u' } })).toBe(true); - expect(await run(mw, { object: 'sys_permission_set', operation: 'find', context: { userId: 'u' } })).toBe(true); - }); -}); - -// ── [#21861] The update leg writes back into the row it edits ────────────── - -describe('[#21861] a data-door UPDATE writes back into the stored row it edits — no package-less fork', () => { - const userCtx = { userId: 'usr_admin' }; - const PKG = 'com.acme.runtime'; - - /** Every active stored row of `name`, as its scope, its binding and the description its body carries. */ - const activeRows = (ql: any, name: string) => - ql.metaRows - .filter((r: any) => r.name === name && r.state === 'active') - .map((r: any) => ({ - organization_id: r.organization_id ?? null, - package_id: r.package_id ?? null, - description: JSON.parse(r.metadata).description, - })); - - /** A stored set (one row, optionally package-bound) with its projected record. */ - async function storedSet(packageId?: string) { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ - type: 'permission', name: 'support_agent', item: envBody({ name: 'support_agent' }), - ...(packageId ? { packageId } : {}), - }); - protocol.saves.length = 0; - return { ql, protocol, rowId: ql.permRows[0].id as string }; - } - - it('a set stored in a writable runtime package: the edit lands on that row, which stays bound', async () => { - const { ql, protocol, rowId } = await storedSet(PKG); - expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: PKG, description: undefined }]); - - const opCtx: any = { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, description: 'edited at the data door' }, - }; - expect(await run(makeMiddleware(ql, protocol), opCtx)).toBe(false); - - expect(activeRows(ql, 'support_agent'), 'after: still one row, same binding, carrying the edit') - .toEqual([{ organization_id: null, package_id: PKG, description: 'edited at the data door' }]); - expect(protocol.saves.map((s: any) => s.packageId), 'the save names the row\'s own package').toEqual([PKG]); - expect(opCtx.result?.description, 'the projected record follows').toBe('edited at the data door'); - }); - - it('a package-less set: the save still names no package, and its one row carries the edit', async () => { - const { ql, protocol, rowId } = await storedSet(); - expect(activeRows(ql, 'support_agent'), 'before').toEqual([{ organization_id: null, package_id: null, description: undefined }]); - - await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: rowId, description: 'edited' }, - }); - - expect(protocol.saves).toHaveLength(1); - expect(protocol.saves[0], 'no binding is invented for a set that has none').not.toHaveProperty('packageId'); - expect(activeRows(ql, 'support_agent'), 'after') - .toEqual([{ organization_id: null, package_id: null, description: 'edited' }]); - }); - - it('a filtered edit spanning a package-bound and a package-less set writes each back into its own row', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - registerPermissionSetProjection(protocol, { ql }); - await protocol.saveMetaItem({ type: 'permission', name: 'pkg_set', item: envBody({ name: 'pkg_set' }), packageId: PKG }); - await protocol.saveMetaItem({ type: 'permission', name: 'org_set', item: envBody({ name: 'org_set' }) }); - protocol.saves.length = 0; - const before = { pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') }; - expect(before).toEqual({ - pkg: [{ organization_id: null, package_id: PKG, description: undefined }], - org: [{ organization_id: null, package_id: null, description: undefined }], - }); - - await run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { description: 'bulk' }, options: { where: { name: { $in: ['pkg_set', 'org_set'] } } }, - }); - - expect({ pkg: activeRows(ql, 'pkg_set'), org: activeRows(ql, 'org_set') }, 'after').toEqual({ - pkg: [{ organization_id: null, package_id: PKG, description: 'bulk' }], - org: [{ organization_id: null, package_id: null, description: 'bulk' }], - }); - expect(protocol.saves.map((s: any) => [s.name, s.packageId ?? null])) - .toEqual([['pkg_set', PKG], ['org_set', null]]); - }); - - it('a set a code package ships is still refused by the lock (403 NOT_OVERRIDABLE) before any binding read or save', async () => { - const ql = makeQl(); - const declaredBody = { ...envBody({ name: 'crm_rep' }), _packageId: 'com.example.crm' }; - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm' }); - const before = activeRows(ql, 'crm_rep'); - - await expect(run(makeMiddleware(ql, protocol), { - object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', description: 'customized' }, - })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); - - expect(activeRows(ql, 'crm_rep'), 'the refused edit stored nothing').toEqual(before); - expect(protocol.saves).toHaveLength(0); - expect(protocol.getMetaItemCalls, 'the binding is never read for a refused edit').toBe(0); - }); -}); - -// ── Boot reconciliation + one-time backfill (ADR-0094 D4) ─────────────────── - -describe('reconcilePermissionSetProjection', () => { - it('projects env overlays onto records, creating missing ones', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.metaRows.push({ id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', organization_id: null, metadata: JSON.stringify(envBody()) }); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.permRows[0]?.name).toBe('organization_admin'); - expect(ql.permRows[0]?.managed_by).toBe('admin'); - }); - - it('backfills a legacy data-door-only record into the metadata store ONCE', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_legacy', name: 'support_agent', managed_by: 'user', active: true, - label: 'Support Agent', object_permissions: JSON.stringify({ ticket: { allowRead: true } }), - system_permissions: '["support.use"]', - }); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.backfilledIntoMetadata).toBe(1); - expect(out.backfillFailed).toBe(0); - expect(ql.metaRows.length).toBe(1); - const body = JSON.parse(ql.metaRows[0].metadata); - expect(body.objects).toEqual({ ticket: { allowRead: true } }); - // second run: the overlay now exists — nothing to backfill again - const out2 = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out2.backfilledIntoMetadata).toBe(0); - }); - - it('[#6858] the backfill never targets an ARTIFACT-BACKED name — write point :928 cannot reach the tier gate', async () => { - // The measured half of ADR-0094 D5-R's "3 of the 4 write points were - // already on the surviving tier". The backfill runs only for records - // whose name has NO metadata presence at all; a code-declared name has a - // declared body, so the branch is not entered and no `saveMetaItem` is - // issued. Asserted on the write LEDGER (`protocol.saves`) rather than on - // the absence of a throw: "it did not fail" would also be true if the - // gate had simply accepted the write. - const ql = makeQl(); - const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; - const protocol = makeProtocol(ql, { crm_rep: declaredBody }); - ql.permRows.push({ - id: 'ps_pkg_env', name: 'crm_rep', managed_by: 'admin', active: true, - label: 'CRM Rep', system_permissions: '["pkg.baseline"]', - }); - - const out = await reconcilePermissionSetProjection(protocol, { ql }); - - expect(protocol.saves.length, 'no metadata write was attempted for a declared name').toBe(0); - expect(out.backfilledIntoMetadata).toBe(0); - expect(out.backfillFailed, 'and therefore nothing could be refused').toBe(0); - }); - - it('[#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation', async () => { - // The reported symptom: every `sys_permission_set` row has an `active` - // column, `permissionSetBodyFromRow` handed it to `saveMetaItem`, and - // #4001's `.strict()` schema rejected all of them — a 100%-failing - // backfill behind one `warn`, with `backfilledIntoMetadata` stuck at 0. - const ql = makeQl(); - const protocol = makeProtocol(ql); // validates with the real PermissionSetSchema - ql.permRows.push({ - id: 'ps_d8', name: 'd8_qc_user', managed_by: 'admin', - active: true, customized: false, package_id: null, - created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', - label: 'D8 QC User', ...permissionSetRowFields(envBody({ name: 'd8_qc_user' })), - }); - const logs: Array<{ level: string; msg: string }> = []; - const logger = { - info: (m: string) => logs.push({ level: 'info', msg: m }), - warn: (m: string) => logs.push({ level: 'warn', msg: m }), - error: (m: string) => logs.push({ level: 'error', msg: m }), - }; - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - expect(out.backfilledIntoMetadata).toBe(1); - expect(out.backfillFailed).toBe(0); - expect(logs.some((l) => /backfill into metadata failed|FAILED/i.test(l.msg))).toBe(false); - const stored = JSON.parse(ql.metaRows[0].metadata); - expect(stored).not.toHaveProperty('active'); - expect(stored.name).toBe('d8_qc_user'); - }); - - it('[#4669/#4632] a REAL backfill failure is loud: error level, counted, consequence + fix', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // Not a key problem — the stored facet JSON itself is off-contract, so no - // amount of key-filtering saves it. This is the case that MUST shout. - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - ql.permRows.push({ - id: 'ps_bad2', name: 'broken_set_2', managed_by: 'admin', active: true, - label: 'Broken Set 2', object_permissions: JSON.stringify({ ticket: { nonsense: true } }), - }); - // `error` follows the platform Logger contract: (message, error?, meta?). - const logs: Array<{ level: string; msg: string; meta?: any; cause?: Error }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - error: (m: string, cause?: Error, meta?: any) => logs.push({ level: 'error', msg: m, cause, meta }), - }; - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - // counted in the RESULT — not only in a log line nobody reads - expect(out.backfillFailed).toBe(2); - expect(out.backfilledIntoMetadata).toBe(0); - expect(ql.metaRows.length).toBe(0); - - // level: error, never warn/info for a durability degradation - const errors = logs.filter((l) => l.level === 'error'); - expect(errors.length).toBeGreaterThan(0); - expect(logs.some((l) => l.level === 'warn' && /backfill/i.test(l.msg))).toBe(false); - // said ONCE, at the first failure — not once per failed write - const firstFailure = errors[0]!; - expect(errors.filter((l) => /backfill into metadata FAILED/.test(l.msg)).length).toBe(1); - // the consequence… - expect(firstFailure.msg).toMatch(/Nothing will look broken/); - expect(firstFailure.msg).toMatch(/re-provision/); - // …and the fix - expect(firstFailure.msg).toMatch(/Fix:/); - expect(firstFailure.meta?.name).toBe('broken_set'); - - // the summary carries the failure too — an `info` "reconciled" line over a - // failed backfill is the reassuring half-truth the rule exists to remove - const summary = errors.at(-1)!; - expect(summary.msg).toMatch(/2 FAILED backfill/); - expect(summary.meta?.failedNames).toEqual(['broken_set', 'broken_set_2']); - expect(logs.some((l) => l.level === 'info' && /reconciled/.test(l.msg))).toBe(false); - }); - - it('[#9657] a sink with NO `error` still hears the backfill failure — at warn, not in silence', async () => { - // `ProjectionLogger.error` is declared OPTIONAL, and the report used to be - // spelled `logger?.error?.(…)` — an optional call that emits NOTHING when - // the method is absent. A host injecting `{ info, warn }` therefore lost - // the whole durability report, on the one path that must never be quiet. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - }; - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - expect(out.backfillFailed).toBe(1); - const firstFailure = logs.find((l) => /backfill into metadata FAILED/.test(l.msg)); - expect(firstFailure).toBeDefined(); - expect(firstFailure!.level).toBe('warn'); - // The consequence and the fix survive the fallback — a downgraded level is - // a degradation of the CHANNEL, never of the message. - expect(firstFailure!.msg).toMatch(/Nothing will look broken/); - expect(firstFailure!.msg).toMatch(/Fix:/); - expect(firstFailure!.meta?.name).toBe('broken_set'); - }); - - it('[#9748] the reconcile SUMMARY also reaches a sink with NO `error` — at warn, not in silence', async () => { - // #9657 repaired the FIRST-FAILURE line above; this summary sits outside - // any `catch`, so the durability gate could not see it and it kept the - // `logger?.error?.(…)` spelling. Against a `{ info, warn }` sink the repair - // therefore made the split WORSE, not better: the first failure survived at - // `warn` while the TOTAL — how many definitions will not survive a - // re-provision — vanished, and the `info` "reconciled" line is skipped too, - // so the sink heard neither. That silence is the reassuring half-truth this - // rule exists to remove, arrived at from the other side. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - ql.permRows.push({ - id: 'ps_bad2', name: 'broken_set_2', managed_by: 'admin', active: true, - label: 'Broken Set 2', object_permissions: JSON.stringify({ ticket: { nonsense: true } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - }; - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger }); - - expect(out.backfillFailed).toBe(2); - const summary = logs.filter((l) => /FAILED backfill/.test(l.msg)); - expect(summary).toHaveLength(1); - expect(summary[0]!.level).toBe('warn'); - expect(summary[0]!.msg).toMatch(/2 FAILED backfill/); // the COUNT is the whole point - expect(summary[0]!.msg).toMatch(/will not survive a re-provision/); - expect(summary[0]!.meta?.failedNames).toEqual(['broken_set', 'broken_set_2']); - // and never the reassuring half-truth instead - expect(logs.some((l) => /reconciled \(ADR-0094 D4\)/.test(l.msg))).toBe(false); - }); - - it('[#9748] a sink that HAS `error` still gets the summary at error, not downgraded', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const logs: Array<{ level: string; msg: string; meta?: any; cause?: Error }> = []; - const logger = { - info: (m: string, meta?: any) => logs.push({ level: 'info', msg: m, meta }), - warn: (m: string, meta?: any) => logs.push({ level: 'warn', msg: m, meta }), - error: (m: string, cause?: Error, meta?: any) => logs.push({ level: 'error', msg: m, cause, meta }), - }; - - await reconcilePermissionSetProjection(protocol, { ql, logger }); - - const summary = logs.filter((l) => /FAILED backfill/.test(l.msg)); - expect(summary).toHaveLength(1); - expect(summary[0]!.level).toBe('error'); - expect(summary[0]!.meta?.failedNames).toEqual(['broken_set']); - }); - - it('[#9754] a sink with NO `warn` cannot be spelled at all — the TYPE forbids the silence', async () => { - // THE HARM, reproduced before the fix is believed. Until #9754 every member - // of `ProjectionLogger` was optional, so `{ info }` was a legal sink — and - // against it the reconcile pass reported NOTHING: the first-failure line - // and the summary both reach for `error`, fall back to `warn`, and find - // neither, while the `else` branch carrying the reassuring "reconciled" - // line is skipped because the pass did fail. A permission set that will not - // survive a re-provision, and a boot log that says nothing whatsoever. - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ - id: 'ps_bad', name: 'broken_set', managed_by: 'admin', active: true, - label: 'Broken Set', object_permissions: JSON.stringify({ ticket: { allowRead: 'yes-please' } }), - }); - const heard: string[] = []; - const silent = { info: (m: string) => heard.push(m) }; - - const out = await reconcilePermissionSetProjection(protocol, { - ql, - logger: silent as unknown as ProjectionLogger, - }); - - expect(out.backfillFailed).toBe(1); - expect(heard).toEqual([]); // neither the failure, nor the count, nor the "reconciled" line - - // THE CONTRACT is what removes that cast's subject: `warn` is non-optional - // on `ProjectionLogger` (#9754), so no TS caller can build the sink above - // without saying `as unknown as` out loud. - // - // ⚠️ Not pinned here with `@ts-expect-error`, and [commit a68c61267] moved the reason - // out from under that sentence. It used to be that this package's tsconfig - // excluded `**/*.test.ts` (it carried a TEST_DEBT ledger entry in - // scripts/check-type-check-coverage.mjs), so no tsc program compiled this - // file and a directive here would have evaluated NEVER — a phantom check - // that reads like proof, the failure AGENTS.md → "Build & Test" names. The - // sibling `tsconfig.test.json` compiles this file now and that ledger entry - // is gone, so a directive here would be LIVE. The compile-time half of this - // contract is pinned in plugin-email's `outbox-sweep.test.ts` (observed: - // reverting `warn` there turns that directive into `error TS2578: Unused - // '@ts-expect-error' directive`), and the type half of BOTH sinks is held by - // `pnpm check:optional-error-sink` — so the pin is redundant here rather - // than impossible, which is a different sentence and the true one. - }); - - it('heals a record that drifted from an EXISTING metadata definition (metadata wins)', async () => { - const ql = makeQl(); - const declared = { member_default: envBody({ name: 'member_default', systemPermissions: ['declared.baseline'] }) }; - const protocol = makeProtocol(ql, declared); - // record drifted via a historic data-door edit that was never enforced - ql.permRows.push({ - id: 'ps_md', name: 'member_default', label: 'Organization Administrator', - object_permissions: JSON.stringify(envBody().objects), - field_permissions: JSON.stringify(envBody().fields), - system_permissions: '["drifted.edit"]', - row_level_security: JSON.stringify(envBody().rowLevelSecurity), - tab_permissions: JSON.stringify(envBody().tabPermissions), - admin_scope: JSON.stringify(envBody().adminScope), - active: true, - }); - const warns: string[] = []; - const out = await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: (m) => warns.push(m), info: () => {} } }); - expect(out.driftHealed).toBe(1); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.baseline']); - expect(warns.some((w) => w.includes('drifted'))).toBe(true); - expect(out.backfilledIntoMetadata).toBe(0); // drift is never promoted into metadata - }); - - it('never touches package-owned records', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.backfilledIntoMetadata).toBe(0); - expect(out.driftHealed).toBe(0); - expect(ql.metaRows.length).toBe(0); - expect(ql.permRows[0].system_permissions).toBe('["pkg"]'); - }); -}); - -describe('mergeRowPatchIntoBody', () => { - it('maps snake_case column patches onto body keys, preserving unmentioned facets', () => { - const merged = mergeRowPatchIntoBody(envBody(), { label: 'Renamed Label', tab_permissions: '{"crm_leads":"hidden"}' }); - expect(merged.label).toBe('Renamed Label'); - expect(merged.tabPermissions).toEqual({ crm_leads: 'hidden' }); - expect(merged.objects).toEqual(envBody().objects); - expect(merged.adminScope).toEqual(envBody().adminScope); - }); - - it('accepts object-typed facet values and clears adminScope on null', () => { - const merged = mergeRowPatchIntoBody(envBody(), { object_permissions: { ticket: { allowRead: true } }, admin_scope: null }); - expect(merged.objects).toEqual({ ticket: { allowRead: true } }); - expect('adminScope' in merged).toBe(false); - }); - - it('strips layered-read decorations from the base body', () => { - const merged = mergeRowPatchIntoBody({ ...envBody(), _packageId: 'com.x', _provenance: { a: 1 } }, {}); - expect('_packageId' in merged).toBe(false); - expect('_provenance' in merged).toBe(false); - }); -}); - -// ── #11097 — boot reconciliation is O(1) round trips, and still reconciles ── - -/** - * [#11097] The env door's boot reconciler projected every env-scope `permission` - * overlay in a per-name loop, and each iteration issued its OWN existence - * `SELECT` inside `upsertEnvPermissionSet` plus an `UPDATE` that fired whether - * or not the record already matched. Invisible on a local file database; one - * sequential HTTP request per leg on the remote libsql/Turso database every - * hosted environment runs. - * - * ## What is measured here, and what is NOT - * - * A COUNT — every `find`/`insert`/`update` the reconciler issues against - * `sys_permission_set` is one round trip, counted by `makeQl().calls`. ⚠️ The - * slope of THIS axis has never been measured: the hosted `bootstrap-curve.mjs` - * rig lives in `objectstack-ai/cloud`, and its axes are permission sets / - * positions / objects, not env overlays. What is established is that the code - * shape is the one #10946 measured at 4.0000 round trips per item on the two - * sibling loops. Nothing here measures wall time. - * - * ## Why every counting test below is paired with a reconciliation test - * - * ⚠️ LOAD-BEARING. A reconciler that simply stopped writing would produce a - * perfect count while silently reconciling nothing — the loop keeping its shape - * and losing its purpose. So each count is paired with a drift fixture over the - * same data, and `customized` gets its own pair because it is written by this - * projector and deliberately NOT compared by `recordDiffersFromBody`. - */ -describe('#11097 — env overlay reconciliation: round trips', () => { - const overlay = (n: number) => `env_set_${n}`; - - /** Seed `n` env-authored overlays and settle them into records. */ - const seedOverlays = async (n: number) => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < n; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: overlay(i), state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: overlay(i) })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); // first boot: creates - return { ql, protocol }; - }; - - it('does not grow the steady-state round-trip count with the number of overlays', async () => { - const measure = async (n: number) => { - const { ql, protocol } = await seedOverlays(n); - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); // REBUILD - // Nothing changed, so nothing is written — and that is the whole point. - expect(out.projectedFromMetadata).toBe(0); - expect(ql.calls.update).toBe(0); - expect(ql.calls.insert).toBe(0); - return ql.calls.find; - }; - - const [n1, n5, n20, n40] = [await measure(1), await measure(5), await measure(20), await measure(40)]; - // ONE batched `$in` existence read + the pre-existing full-record page read. - // The COUNT is asserted, never the wall time. - expect([n1, n5, n20, n40]).toEqual([2, 2, 2, 2]); - }); - - it('issues ONE batched `$in` existence read for the whole overlay set', async () => { - const { ql, protocol } = await seedOverlays(12); - const seen: any[] = []; - const origFind = ql.find.bind(ql); - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set') seen.push(q?.where); - return origFind(object, q); - }; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(seen).toHaveLength(2); - expect(seen[0]).toEqual({ name: { $in: Array.from({ length: 12 }, (_, i) => overlay(i)) } }); - }); - - it('first boot costs one batched read plus one INSERT per genuinely new overlay', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 10; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: overlay(i), state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: overlay(i) })), - }); - } - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(10); - expect(ql.calls.insert).toBe(10); - expect(ql.calls.update).toBe(0); - expect(ql.permRows).toHaveLength(10); - }); -}); - -/** - * ⚠️ LOAD-BEARING. Without these, an implementation that skipped every write - * would pass every count above while reconciling nothing at all. - */ -describe('#11097 — drift STILL reconciles', () => { - it('an overlay whose stored facets differ still gets its UPDATE', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows).toHaveLength(1); - - // Someone wrote straight at the record. - ql.permRows[0].object_permissions = JSON.stringify({ crm_lead: { allowDelete: true } }); - - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.calls.update).toBe(1); - expect(JSON.parse(ql.permRows[0].object_permissions)).toEqual({ crm_lead: { allowRead: true, allowEdit: true } }); - }); - - it('an overlay whose label drifted still gets its UPDATE', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - await reconcilePermissionSetProjection(protocol, { ql }); - ql.permRows[0].label = 'Stale Label'; - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.calls.update).toBe(1); - expect(ql.permRows[0].label).toBe('Organization Administrator'); - }); - - it('only the DRIFTED overlay is written — the other 19 cost nothing', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 20; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: `env_set_${i}`, state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: `env_set_${i}` })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); - const target = ql.permRows.find((r: any) => r.name === 'env_set_7'); - target.label = 'drifted'; - - ql.resetCalls(); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.calls.update).toBe(1); - expect(target.label).toBe('Organization Administrator'); - }); -}); - -/** - * ⛔ [#11097] The `customized` stamp is written by this projector and is - * deliberately NOT part of `recordDiffersFromBody` — it is provenance, not - * definition, so no metadata body can declare it. That makes it the one column - * a naive "skip when the body matches" would stop maintaining, while the Setup - * list badges on it and the reset action reads it. These pin the flag's own - * comparison term. - */ -describe('#11097 — the `customized` stamp is still maintained', () => { - const pkgRow = (over: Record = {}) => ({ - id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', - package_id: 'com.example.crm', active: true, - ...permissionSetRowFields(envBody()), - ...over, - }); - - it('an overlay appearing over a package row still STAMPS `customized` on a matching row', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // The record's facets already equal the overlay body — only the flag differs. - ql.permRows.push(pkgRow({ customized: false })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - expect(ql.calls.update).toBe(1); - }); - - it('a row that predates the flag (NULL) is still stamped, not read as already-customized', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push(pkgRow({ customized: null })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - }); - - it('a package row whose flag is ALREADY true costs no write', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.permRows.push(pkgRow({ customized: true })); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(true); - expect(ql.calls.update).toBe(0); - }); - - it('an env-authored row carrying a stale flag is still CLEARED', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - // `managed_by:'admin'` — the flag does not apply; a stale `true` must go. - ql.permRows.push({ - id: 'ps_env', name: 'organization_admin', managed_by: 'admin', active: true, - customized: true, ...permissionSetRowFields(envBody()), - }); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows[0].customized).toBe(false); - expect(ql.calls.update).toBe(1); - }); -}); - -/** - * ⛔ [#11097] The in-memory evaluator registry sync is NOT a database round trip, - * and gating it on "a write happened" is what compare-before-write would - * otherwise have done to it. The evaluator resolves permission sets - * registry-first, so a steady-state boot that skipped the sync would leave it - * enforcing the stale DECLARED body while the record and Setup showed the - * overlay. - */ -describe('#11097 — the evaluator registry is synced even when nothing was written', () => { - it('an overlay whose record already matches STILL syncs the evaluator registry', async () => { - const ql = makeQl(); - const declared = { organization_admin: envBody({ label: 'Shipped Baseline' }) }; - const protocol = makeProtocol(ql, declared); - const registered: any[] = []; - const metadata = { - registerInMemory: (type: string, name: string, body: any) => { registered.push({ type, name, body }); }, - }; - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ label: 'Overlay Wins' })), - }); - - // First boot creates the record AND syncs. - await reconcilePermissionSetProjection(protocol, { ql, metadata }); - expect(registered).toHaveLength(1); - - // Steady state: the record already matches, so NO update is issued — the - // sync must happen anyway. - registered.length = 0; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql, metadata }); - expect(ql.calls.update).toBe(0); - expect(registered).toHaveLength(1); - expect(registered[0].body.label).toBe('Overlay Wins'); - }); -}); - -/** - * ⛔ #3807's conflation class at the seam the batched read newly exposes: a read - * that CANNOT ANSWER is not the answer "no such record". The per-item shape was - * accidentally immune (a failed read fell through to an insert that failed too, - * for that ONE name); a batched read speaks for the whole set at once. - */ -describe('#11097 — a read that CANNOT ANSWER is not the answer "none exist"', () => { - it('a throwing existence read does NOT re-create records that are already projected', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - for (let i = 0; i < 4; i += 1) { - ql.metaRows.push({ - id: `m${i}`, type: 'permission', name: `env_set_${i}`, state: 'active', - organization_id: null, metadata: JSON.stringify(envBody({ name: `env_set_${i}` })), - }); - } - await reconcilePermissionSetProjection(protocol, { ql }); - expect(ql.permRows).toHaveLength(4); - - // Every sys_permission_set read now fails — batched and per-item alike. - const origFind = ql.find.bind(ql); - (ql as any).find = async (object: string, q: any) => { - if (object === 'sys_permission_set') throw new Error('fake driver: read unavailable'); - return origFind(object, q); - }; - const warns: string[] = []; - ql.resetCalls(); - await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: (m: string) => warns.push(m) } }); - - expect(ql.calls.insert).toBe(0); // ⛔ no blind insert - expect(ql.permRows).toHaveLength(4); // ⛔ nothing re-created - expect(warns.some((w) => w.includes('batched seed existence read failed'))).toBe(true); - }); - - it('an EMPTY result set is still trusted as "none exist" — the first boot depends on it', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - ql.metaRows.push({ - id: 'm1', type: 'permission', name: 'organization_admin', state: 'active', - organization_id: null, metadata: JSON.stringify(envBody()), - }); - const out = await reconcilePermissionSetProjection(protocol, { ql }); - expect(out.projectedFromMetadata).toBe(1); - expect(ql.permRows).toHaveLength(1); - }); -}); - -/** - * [#22169] The heal's layered read is cached per NAME for the pass — and a - * backfill is the one write in this pass that changes that name's answer, so it - * drops the cached one. Without the drop, a second row of a name this pass had - * just backfilled would see the pre-write "no definition" answer and backfill - * AGAIN, replacing the definition with whichever duplicate the page served last. - * This suite's double has no SchemaRegistry, which is the only shape that reads - * the layered item at all. - */ -describe('#22169 — the per-name layered read is re-asked after this pass backfills the name', () => { - it('a second row of a just-backfilled name heals to the backfilled body instead of backfilling again', async () => { - const ql = makeQl(); - const protocol = makeProtocol(ql); - const layeredReads: string[] = []; - const layered = protocol.getMetaItemLayered.bind(protocol); - (protocol as any).getMetaItemLayered = async (req: { type: string; name: string }) => { - layeredReads.push(req.name); - return layered(req); - }; - const legacy = (id: string, systemPermissions: string[]) => ({ - id, name: 'support_agent', managed_by: 'admin', active: true, - ...permissionSetRowFields(envBody({ name: 'support_agent', systemPermissions })), - }); - ql.permRows.push(legacy('ps_first', ['support.use']), legacy('ps_second', ['support.other'])); - - const out = await reconcilePermissionSetProjection(protocol, { ql, logger: { warn: () => {} } }); - - expect(protocol.saves, 'ONE definition is backfilled for the name').toHaveLength(1); - expect(out.backfilledIntoMetadata).toBe(1); - expect(layeredReads, 'asked for the first row, and again after the backfill wrote').toEqual(['support_agent', 'support_agent']); - expect(out.driftHealed).toBe(1); - for (const row of ql.permRows) { - expect(JSON.parse(row.system_permissions), `${row.id} carries the backfilled body`).toEqual(['support.use']); - } - }); -}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts deleted file mode 100644 index 251b44ecf50..00000000000 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ /dev/null @@ -1,1821 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `sys_permission_set` as a PURE PROJECTION of the metadata layer (ADR-0094; - * framework#2875 — retires the two-store split-brain behind #2857/#2867). - * - * The metadata layer (packaged declarations + the `sys_metadata` overlay, - * merged overlay-wins by the protocol's layered read) is the ONLY - * authoritative store for a permission-set DEFINITION. The queryable - * `sys_permission_set` record is a derived read-model: - * - * - every non-system data-door write (the Setup UI's generic CRUD, bulk - * imports, any future API routed through ObjectQL) is REDIRECTED into a - * metadata write at the engine-middleware choke point - * ({@link createPermissionSetWriteThrough}) — the driver write never - * executes, so no data-plane path can produce a record the metadata - * doesn't back; - * - the record (and the metadata manager's in-memory `permission` entry, - * which the evaluator's registry-first `list('permission')` resolution - * reads) is written ONLY by the projector - * ({@link projectPermissionMutation}), which the protocol AWAITS on every - * save / publish / delete (`registerMutationProjector`) — no projection - * race; - * - boot reconciliation ({@link reconcilePermissionSetProjection}) heals - * drift left by historic writes and migrates legacy data-door-created - * records into the metadata store (one-time backfill). - * - * Package-owned records (`managed_by:'package'`) keep their shipped - * declaration as the BASELINE (boot seeding / publish materialization). The - * 2026-07-14 direction confirmation that used to sit here — "the environment - * customizes them through the platform's standard ADR-0005 metadata overlay" - * — is **RETIRED** (ADR-0094 D5-R, 2026-08-09, recording ruling A, executed by - * #6858). Commit ee58392e1 rolled `permission` back to `allowEnvironmentOverlay: false` - * (the nine-type rollback), so ADR-0005's security row is enforced again: an overlay of the - * authorization surface IS the "silent privilege drift" it excludes. - * - * What that means for THIS file, per write point: - * - * - the middleware still TRANSLATES every data-door write into a metadata - * write — that half is unchanged. Whether the translated write is accepted - * is ADR-0005's tier gate, decided by the target's ARTIFACT provenance: - * a CODE-DECLARED set (`*.permission.ts`, a stack's `permissionSets`) is - * refused with 403 `NOT_OVERRIDABLE`; a set whose definition lives only in - * `sys_metadata` — created through the data door, or authored and - * published through the METADATA door (ADR-0070) — rides - * `allowRuntimeCreate`, still `true`, and keeps working; - * - that refusal used to be deliberately LEFT TO THE PRODUCER. **RETIRED** - * by the maintainer ruling of 2026-08-24 (verbatim, untranslated: 「同意 - * 第一步(创业阶段,Salesforce 式)」) — LOCK THE BASE, CLONE TO CUSTOMIZE. - * The data door now refuses a save that targets a package-declared set - * ITSELF, before translating it, with a message that names the clone path - * ({@link createPermissionSetWriteThrough}'s insert and update legs throw - * it; the `restore` leg consults the same lock before its re-author and - * reports the refusal on the durability channel instead — commit 9cfc1f7e9, argued - * at the leg; the rule and its reasoning live in - * `packaged-permission-set-lock.ts`). Two - * measured reasons the producer could not carry it alone: the producer's - * ADR-0005 tier gate is exactly what the documented - * `OS_METADATA_WRITABLE=permission` operator hatch switches off — on a - * deployment running with the hatch there was no refusal at all, which is - * the silent fork this ruling closes — and its message names the type's - * overlay flag rather than anything an admin can act on. - * - * That is NOT the parallel-allowlist failure Prime Directive #8 exists to - * prevent: the lock does not re-derive `isArtifactBacked`, it reads the - * engine SchemaRegistry — the one source this plugin already calls - * "package-declared" ({@link readDeclaredBody} here, and - * `permission-set-drift.ts`'s declared sweep). The `managed_by` column - * stays measurably NOT that fact and the lock still never reads it — - * `member_default`'s row is `managed_by:'admin'`, `twodoors_pkgset`'s is - * `managed_by:'package'`, and a `managed_by:'package'` row with no artifact - * behind it (published through the METADATA door, ADR-0070) keeps editing - * in place as ADR-0094 D5-R's surviving `allowRuntimeCreate` neighbour; - * - the two write points that CATCH a failed metadata write - * ({@link createPermissionSetWriteThrough}'s `restore` leg and - * {@link reconcilePermissionSetProjection}'s backfill) keep catching: both - * run after the record already exists, so a throw would strand the caller - * with a healthy-looking row and no way to hear about it. They log on the - * durability channel (#4632) and the backfill counts the failure — that is - * the degradation report, not a swallow. Neither targets an artifact-backed - * name in the first place (a packaged definition cannot be trashed, and the - * backfill only runs for names with NO metadata presence at all) — and the - * `restore` leg no longer relies on that fence alone: it consults the - * packaged-permission-set lock before re-authoring, refusing the mint on - * the same durability channel (commit 9cfc1f7e9). - * - * Cross-package composition stays a POSITION concern (bind several packages' - * sets to one position); package-first authoring (ADR-0070) gives - * runtime-created sets a home package. - */ - -import { PermissionSetSchema } from '@objectstack/spec/security'; -import { seedCtx } from './organization-scope.js'; -import { type SeedWriteRefusals } from './write-refusals.js'; -import { buildExistingByName, type ExistingByNameIndex } from './seed-name-lookup.js'; -import type { PermissionSetNameCollisionDiagnostic } from './permission-set-name-collision.js'; -import { - ENV_PROJECTION_MARKER, - assertPermissionSetNotPackageDeclared, - classifyPackagedPermissionSet, - type LayeredProbe, -} from './packaged-permission-set-lock.js'; -import { PermissionSetNameConflictError } from './errors.js'; - -export const SYSTEM_CTX = { isSystem: true }; - -export function genId(prefix: string): string { - const rand = Math.random().toString(36).slice(2, 10); - const ts = Date.now().toString(36); - return `${prefix}_${ts}${rand}`; -} - -/** - * The three write helpers take an optional `organizationId`, threaded into the - * execution context so reads route through `SqlDriver.applyTenantScope` and - * writes are stamped with that organization. Omitting it reproduces the - * pre-existing organization-less behaviour EXACTLY, which is what the ADR-0094 - * environment door still wants (its residue is deliberately outside the - * per-organization catalog work) and what a `single`-posture deployment wants. - * The PACKAGE door passes an organization under a walled posture — see - * `per-organization-catalog.ts`. - */ -export async function tryFind(ql: any, object: string, where: any, limit = 100, organizationId?: string): Promise { - try { - const rows = await ql.find(object, { where, limit }, { context: seedCtx(organizationId) }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } -} - -/** - * Is this record still on the table? THREE outcomes, not two. - * - * {@link tryFind} deliberately collapses a failed read into `[]` — right for - * every caller that wants "the rows I could see", wrong for anyone who has to - * decide whether a row is GONE. `[]` from that helper means "no row matched OR - * the read blew up", and a caller reading it as "gone" reports a deletion that - * an outage invented. The retired seeder's organization enumeration - * stated the same rule for the same shape: zero organizations is "nothing to - * seed", an unreadable table is "we do not know". - * - * So `unknown` is its own answer and the caller must decide what it means — - * ⛔ never folded into `gone` here. The context and the read are otherwise - * IDENTICAL to `tryFind`'s (no `transaction` key, so an ambient handle still - * carries it), because the difference under test is the catch and nothing else. - */ -async function probeRecordSurvival( - ql: any, - object: string, - id: unknown, -): Promise<'gone' | 'present' | 'unknown'> { - try { - const rows = await ql.find(object, { where: { id }, limit: 1 }, { context: seedCtx() }); - return (Array.isArray(rows) ? rows : [])[0] ? 'present' : 'gone'; - } catch { return 'unknown'; } -} -/** - * ⛔ The `catch` RECORDS the refusal before it answers, when the caller passed - * a log to record into. - * - * Answering `null`/`false` alone is what made a refused write - * indistinguishable from "nothing to do": the caller's `seeded` counter never - * increments, the pass returns normally, and the boot reports a successful - * seed of zero rows. `refusals` is optional so the callers that already report - * their own outcome honestly — the package/environment door, whose publish - * answers `success: false` with a reason when it materialized nothing — are - * unchanged. It is passed by the boot CATALOG seeders, which had no such - * channel. Never a rethrow either way: these helpers report, they do not - * decide whether the deployment boots. - */ -export async function tryInsert( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - return await ql.insert(object, data, { context: seedCtx(organizationId) }); - } catch (e) { refusals?.record(object, e); return null; } -} -export async function tryUpdate( - ql: any, object: string, data: any, organizationId?: string, refusals?: SeedWriteRefusals, -): Promise { - try { - await ql.update(object, data, { context: seedCtx(organizationId) }); return true; - } catch (e) { refusals?.record(object, e); return false; } -} - -export interface ProjectionLogger { - info?: (m: string, meta?: Record) => void; - /** - * The GUARANTEED channel (#9754). `error` below stays optional — hosts do - * inject reduced sinks — so `warn` is where a durability report lands when - * `error` is absent, and a fallback that may itself be missing is not a - * fallback: `{ info }` alone used to be a legal sink here, and against it the - * reconcile summary's "N FAILED backfill(s)" printed nothing at all, while - * the `info` "reconciled" line was skipped too — the sink heard neither. - * Non-optional is what makes that silence unrepresentable instead of merely - * discouraged. - * - * ⛔ Do NOT "simplify" this by making `error` required instead — that - * forecloses the reduced sinks hosts legitimately pass (#9754 option C, - * measured and rejected). - * - * ⚠️ Call sites still spell the fallback `logger?.warn?.(…)`. That `?.` is not - * doubt about this declaration — it is the backstop for hosts the TYPE cannot - * reach (a plain-JS embedder, or a cast). Dropping it was measured: a sink - * that lies about its shape then throws `logger?.warn is not a function` - * INSIDE the per-row durability catch, aborting the very batch this function - * promises never to stop. Silence for a lying host is the lesser failure; the - * guarantee this member adds is at AUTHORING time, where it belongs. - */ - warn: (m: string, meta?: Record) => void; - /** - * Durability-degradation channel (AGENTS.md "Degradation log levels", #4632): - * a metadata write that was supposed to land and did not is an `error`, not a - * `warn` — nothing looks broken afterwards, which is exactly why the failure - * has to be loud. - * - * Signature matches `Logger.error` in `@objectstack/spec/contracts` - * (`message, error?, meta?` — the cause is its own argument), so the kernel - * logger satisfies this interface as-is. - */ - error?: (m: string, error?: Error, meta?: Record) => void; -} - -/** Aggregated outcome of a projection pass (shared with the boot seeders). */ -export interface PermissionSeedOutcome { - seeded: number; - updated: number; - /** - * Rows this seeder OWNS whose stored columns already equalled what it would - * have written, so no `UPDATE` was issued (#10946). - * - * ⚠️ Read this together with {@link updated}, never instead of it. "The row - * now matches the declaration" is `seeded + updated + unchanged`; `updated` - * alone means "a write was needed AND landed". A consumer that asks the - * first question while reading only the second reports a converged row as a - * failure — which is exactly what the ADR-0086 P2 publish materializer did - * before this counter existed, because an unconditional re-write made the - * two questions accidentally identical. They are not, and the boot loops - * that stopped re-writing unchanged rows are why. - */ - unchanged: number; - /** - * Names this pass DECLINED to touch because their record could not be read - * (#10946). Distinct from `unchanged` in every way that matters: nothing was - * compared, nothing was reconciled, and the declaration may or may not be - * materialized. Never fold it into a success count. - */ - unreadable: number; - skippedEnvAuthored: number; - skippedForeign: number; - /** - * [#18571] Declarations refused for want of an owning package - * (`_packageId`/`packageId` both absent), so no `managed_by:'package'` row - * was written — the ADR-0086 D3 ambiguity the refusal exists to prevent. - * - * ⚠️ Required, not optional: it is a REFUSAL count, and an absent key on a - * pass that refused reads exactly like a pass that had nothing to refuse. - * Every door that returns this outcome initializes it, so a caller may add - * it into the accounting unconditionally — `seeded + updated + unchanged + - * skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is the - * number of named declarations this pass read. (Pre-existing caveat, - * unchanged: a write the engine REJECTS increments no counter — `tryInsert` - * answers `null` and `tryUpdate` `false`, and the refusal travels on - * `SeedWriteRefusals` instead.) Before this counter existed that sum was - * short by every unowned declaration, and the outcome said nothing at all - * about them. - * - * Named for its `CapabilitySeedOutcome.skippedUnowned` sibling on the - * capability axis, which counts the same refusal at the same ADR-0086 D3 - * boundary — ⛔ not a new vocabulary for one seeder. - */ - skippedUnowned: number; - /** Records retired because their definition was deleted from metadata. */ - deleted?: number; - /** - * [#17516] The diagnostic for each set counted in {@link skippedForeign} — - * the declaration was dropped WHOLE because another package owns that set - * name (ADR-0086 D4). - * - * ⚠️ This is the read-back half of that refusal, not a duplicate of the log - * line. The counter alone is what made the drop invisible: a caller holding - * it knows a number and nothing about which sets, which packages, or what to - * do about it. Absent — never `[]` — when the pass hit no collision, so a - * consumer can tell "none" apart from "this pass does not report them". - */ - collisions?: readonly PermissionSetNameCollisionDiagnostic[]; -} - -/** - * Serialize a PermissionSet body into the `sys_permission_set` facet/identity - * columns. THE one row shape both doors project through — package boot-seed / - * publish materialization and the env projector — so the two can never - * hydrate differently. - */ -export function permissionSetRowFields(ps: any): Record { - return { - label: ps.label ?? ps.name, - description: ps.description ?? null, - object_permissions: JSON.stringify(ps.objects ?? {}), - field_permissions: JSON.stringify(ps.fields ?? {}), - system_permissions: JSON.stringify(ps.systemPermissions ?? []), - row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), - tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), - // [ADR-0090 D12] Delegated-admin scope travels with the set row so the - // delegated-admin gate can resolve a DB-loaded delegate's authority. - admin_scope: ps.adminScope ? JSON.stringify(ps.adminScope) : null, - }; -} - -const parseMaybeJson = (v: any, fallback: any): any => { - if (typeof v !== 'string') return v ?? fallback; - try { - const parsed = JSON.parse(v === '' ? 'null' : v); - return parsed ?? fallback; - } catch { return fallback; } -}; - -const asBool = (v: any): boolean => !(v === false || v === 0 || v === '0' || v === 'false'); - -/** - * [#11097] Read the `customized` COLUMN as a definite boolean. - * - * ⛔ Deliberately NOT {@link asBool}, which answers `true` for `undefined` and - * `null` because its subject (`active`) defaults to on. `customized` defaults - * to **off** (`Field.boolean({ defaultValue: false })` on - * `sys_permission_set`), and a row that predates the flag stores NULL — so - * `asBool` would read every such row as "already customized" and the - * compare-before-write below would then decline to stamp the flag on exactly - * the legacy rows that need it. - * - * Absent, null, 0, '0', false and 'false' are all "not customized"; everything - * else is customized. - */ -const customizedFlag = (v: any): boolean => - !(v === undefined || v === null || v === false || v === 0 || v === '0' || v === 'false' || v === ''); - -/** - * `sys_permission_set` columns that are ROW STATE, not part of the metadata - * DEFINITION — the spec declares no such key, so they must never travel into a - * metadata body (#4669). Each entry maps a column to the normalizer the record - * expects, so the data door can write it straight onto the record. - * - * `active` is the on/off switch the Setup list views filter on and the two - * lifecycle actions toggle (`bodyExtra: { active: true|false }` in - * `objects/sys-permission-set.object.ts`). It is runtime state OF THE RECORD, - * never a capability boundary its author declared — which is precisely why it - * is not, and should not be, a key on `PermissionSetSchema`. - */ -const ROW_STATE_COLUMNS: Readonly any>> = { - active: asBool, -}; - -/** - * The body keys the permission SPEC declares, read from the Zod schema's own - * shape — derived, never transcribed (#4669). - * - * `sys_permission_set` is a projection of a metadata definition, but the TABLE - * carries columns the definition does not: {@link ROW_STATE_COLUMNS}, the - * timestamps, and the `managed_by` / `package_id` / `customized` provenance. - * Until #4001 a row-derived body that dragged those along still parsed — - * `PermissionSetSchema` stripped the extras silently — so handing a whole row - * to `saveMetaItem` appeared to work. #4001 sealed the schema `.strict()`, and - * every such body began failing validation with `[invalid_metadata] … - * Unrecognized key(s) on this permission set: 'active'`, which took the - * ADR-0094 D4 boot backfill to a 100% failure rate. - * - * Derived from `.shape` rather than hand-listed because a literal list here - * would go stale the moment the spec grows a key — silently dropping it from - * every projected body, i.e. reproducing the very defect this fixes one layer - * over. `permission-set-projection.test.ts` additionally pins that every key - * the row→body seams can emit is one the spec declares, so a spec RENAME fails - * a test instead of quietly losing the value at runtime. - * - * Resolved on first use, not at module load: `PermissionSetSchema` is a - * {@link lazySchema} proxy and touching `.shape` at import time would - * materialize it for every process that merely loads this module. - */ -let cachedSpecBodyKeys: ReadonlySet | null = null; -export function permissionSpecBodyKeys(): ReadonlySet { - return (cachedSpecBodyKeys ??= new Set( - Object.keys((PermissionSetSchema as unknown as { shape?: Record }).shape ?? {}), - )); -} - -/** - * Keep only what the permission spec declares. THE choke point every row→body - * seam passes through, so no storage column can reach `saveMetaItem` — neither - * from a live row nor from a body STORED before #4001 (data at rest written - * while the schema still stripped the extras can still carry `active`). - */ -function pickSpecDeclaredKeys(candidate: Record): Record { - const keys = permissionSpecBodyKeys(); - const body: Record = {}; - for (const [key, value] of Object.entries(candidate)) { - if (keys.has(key)) body[key] = value; - } - return body; -} - -/** - * Inverse of {@link permissionSetRowFields}: rebuild a PermissionSet body from - * a `sys_permission_set` row (snake_case JSON-string columns → camelCase - * body). Used by the one-time boot backfill (a legacy data-door-created - * record becomes a metadata item) and by the data-door update merge when a - * name has no metadata presence yet. - * - * The result is filtered through {@link pickSpecDeclaredKeys}: a DEFINITION is - * what the spec declares, and nothing else off the row goes with it (#4669). - */ -export function permissionSetBodyFromRow(row: any): any { - const adminScope = row?.admin_scope ? parseMaybeJson(row.admin_scope, undefined) : undefined; - return pickSpecDeclaredKeys({ - name: row?.name, - label: row?.label ?? row?.name, - ...(row?.description != null ? { description: row.description } : {}), - objects: parseMaybeJson(row?.object_permissions, {}), - fields: parseMaybeJson(row?.field_permissions, {}), - systemPermissions: parseMaybeJson(row?.system_permissions, []), - rowLevelSecurity: parseMaybeJson(row?.row_level_security, []), - tabPermissions: parseMaybeJson(row?.tab_permissions, {}), - ...(adminScope ? { adminScope } : {}), - }); -} - -/** - * The row-state columns a data-door payload carries, normalized for the - * record — `null` when it carries none. These bypass the metadata store - * entirely: they are the record's own state (#4669). - */ -export function pickRowStateColumns(payload: any): Record | null { - if (!payload || typeof payload !== 'object') return null; - const out: Record = {}; - for (const [col, normalize] of Object.entries(ROW_STATE_COLUMNS)) { - if (col in payload) out[col] = normalize(payload[col]); - } - return Object.keys(out).length > 0 ? out : null; -} - -/** - * Does this data-door payload touch the DEFINITION at all? Identity (`id` / - * `name`) and {@link ROW_STATE_COLUMNS} do not; anything else is treated as a - * definition edit and routes through the metadata store (an unrecognized - * column therefore keeps the pre-#4669 behavior rather than silently skipping - * the write-through). - */ -function touchesDefinition(payload: Record): boolean { - return Object.keys(payload).some( - (k) => k !== 'id' && k !== 'name' && !(k in ROW_STATE_COLUMNS), - ); -} - -/** - * Marker stamped on bodies this module writes into the metadata manager's - * in-memory registry ({@link syncEvaluatorRegistry}). The manager's `get`/ - * `list` are registry-first, so without a marker our own synced copy would be - * indistinguishable from a real packaged artifact — and after the overlay is - * deleted, the layered read's `code` layer would keep echoing it, turning a - * retire into a bogus "reset" (the definition would be undeletable). - * - * [2026-08-24 ruling] The constant now LIVES in - * `packaged-permission-set-lock.ts` and is imported above, because the lock - * decides "is this a shipped artifact?" partly by the absence of this marker. - * Two spellings of it would let the two modules disagree about what an echo - * is, and only one of them guards a write door. - */ - -/** Strip layered-read / registry decorations so a re-authored body is clean. */ -function stripDecorations(body: any): any { - if (!body || typeof body !== 'object') return body; - const { _packageId, _provenance, _diagnostics, _lock, _lockReason, _lockSource, [ENV_PROJECTION_MARKER]: _mark, ...clean } = body; - return clean; -} - -/** True when a layered-read layer is just our own registry echo, not a real artifact. */ -const isProjectionEcho = (v: any): boolean => - !!(v && typeof v === 'object' && (v as any)[ENV_PROJECTION_MARKER]); - -/** - * Read the DECLARED (artifact) body for a permission set from the engine's - * SchemaRegistry — the same source the retired `bootstrapDeclaredPermissions` seeded from, - * and the one store the env projection never writes, so it can't be poisoned - * by our own registry sync. It is the body for a declared set with no overlay - * at all, and the reset target when an overlay IS lifted off one — which since - * ADR-0094 D5-R means a LEGACY row (older than commit ee58392e1) removed through the operator - * hatch, not a data-door delete: #6960 measures that delete refusing with 403 - * `NOT_OVERRIDABLE`. - * - * Items tagged `_packageId: 'sys_metadata'` are RUNTIME SHADOWS — hydrated - * into the registry from overlay rows (loadMetaFromDb / getMetaItems), not - * shipped artifacts — and are skipped: after a runtime-only definition is - * deleted, its shadow may linger (`removeRuntimeShadow` only drops shadows - * that cover a packaged artifact), and treating it as declared would make the - * definition undeletable. - */ -function readDeclaredBody(ql: any, name: string): any { - try { - const items = ql?.registry?.listItems?.('permission') ?? []; - for (const body of items) { - // [#8378] The registered item IS the body — no `{ name, content }` - // envelope to unwrap (`PermissionSetSchema` rejects `content` as an - // unrecognized key, and nothing in the tree produces the envelope). - // Skip projection echoes too: deleteMetaItem's registry heal - // (restoreArtifactRegistryView) can re-register the metadata manager's - // view — which may be OUR marked copy — into the engine registry as a - // plain item; without this skip a deleted runtime-only definition - // would zombie back as "declared" and become undeletable. - if (body?.name === name && body?._packageId !== 'sys_metadata' && !isProjectionEcho(body)) { - return body; - } - } - } catch { /* fall through */ } - return null; -} - -/** Whether the engine exposes a SchemaRegistry we can read declared bodies from. */ -function hasSchemaRegistry(ql: any): boolean { - return typeof ql?.registry?.listItems === 'function'; -} - -/** - * Project a PermissionSet body onto its `sys_permission_set` row from the - * ENVIRONMENT side. - * - * [ADR-0094] The record is a pure projection, so a missing row is CREATED - * (`managed_by:'admin'` — A4 #2920 unified vocab, formerly 'user'; a - * Studio-authored set appears in Setup, where the - * #2867 band-aid declined to create). A PACKAGE-OWNED row is also projected — - * its facets follow the body this pass is handed, while the - * `managed_by:'package'` + `package_id` provenance is PRESERVED: the row - * still belongs to the package. - * - * WHICH body that is, is the part this comment used to get wrong. The - * 2026-07-14 direction confirmation that used to sit here — "an env-scope - * overlay is the platform's standard ADR-0005 customization of a packaged - * definition, and deleting the overlay resets the row to the shipped - * declaration" — is **RETIRED**, in BOTH halves (ADR-0094 D5-R, 2026-08-09; - * the ruling A it records, executed by #6858; the file header above records the same - * retirement). Since commit ee58392e1 rolled `permission` back to - * `allowEnvironmentOverlay: false`: - * - * - **no new overlay of a packaged set can be minted.** A metadata write - * against a CODE-DECLARED (artifact-backed) set — `*.permission.ts`, a - * stack's `permissionSets` — is refused by the producer with 403 - * `NOT_OVERRIDABLE`, so for those names the body reaching here is the - * DECLARED one and the projected facets ARE the shipped declaration. The - * supported way to change them is the one ADR-0086 always named: edit the - * package and re-publish. What survives is the neighbouring - * `allowRuntimeCreate` tier (still `true`) — a set whose definition lives - * only in `sys_metadata`, created through the data door or authored and - * published through the METADATA door (ADR-0070). That tier edits the - * single stored definition IN PLACE: no code-vs-overlay layering, and - * nothing to reset to (ADR-0094 D5-R calls it the surviving neighbour, not - * D5's successor); - * - **`customized` therefore badges LEGACY state, not a supported channel.** - * `supportsOverlay` is unchanged, so an overlay row authored BEFORE the - * rollback still merges overlay-wins at read time - * ({@link projectPermissionMutation} hands us `overlay ?? declared`) and - * this pass still stamps the flag for it. The in-repo corpus had zero such - * rows when commit ee58392e1 measured it; - * - **"delete = reset" must NOT be read back into that.** #6960 measures the - * ordinary delete path refusing to lift exactly such a legacy overlay: on - * an environment-scoped kernel `deleteMetaItem` throws `NOT_OVERRIDABLE` / - * 403 for an artifact-backed target of a non-overridable type BEFORE it - * probes for the row, and a kernel with no `environmentId` refuses the - * same write as `override-artifact` intent — leaving the operator hatch - * (`OS_METADATA_WRITABLE=permission`) as the only documented removal. With - * no overlay to lift — the normal case — the delete is a no-op success and - * the row keeps projecting the declaration. {@link readDeclaredBody} stays - * the correct reset target IF an overlay is ever lifted; it is no longer a - * path the data door can walk. - */ -export async function upsertEnvPermissionSet( - ql: any, - ps: any, - _logger?: ProjectionLogger, - opts?: { - customized?: boolean; - /** - * [#11097] A batched existence oracle built by the CALLER over every name - * it is about to project, so a boot reconciliation pays one read for the - * whole overlay set instead of one per name. - * - * Omitted — the live single-mutation path, which has exactly one name and - * nothing to batch — keeps the per-item read verbatim, including its - * pre-existing treatment of a failed read (fall through to an insert the - * `name` unique index refuses). Tightening THAT path to the oracle's - * decline-on-unknown rule is a real behaviour change on a hot write seam - * and belongs to its own card, not to a round-trip fix. - */ - existing?: ExistingByNameIndex; - }, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 }; - if (!ql || typeof ql.find !== 'function' || !ps?.name) return out; - - // [ADR-0094] `customized` marks a PACKAGE-owned row that an env overlay is - // currently shadowing, so the Setup list can badge it "customized" and the - // reset action reads honestly. An env-authored row is not a customization of - // anything (it IS the definition), so the flag only rides on package rows. - const customized = opts?.customized; - - let existing: any; - if (opts?.existing) { - // ⛔ THREE outcomes, not two (`seed-name-lookup.ts`): a read that could not - // ANSWER is not the answer "no such record". Collapsing them here would - // make a boot during a brief database outage conclude that every overlay - // needs creating — and a batched read fails for the WHOLE set at once, - // where the per-item read below could only ever mislead about one name. - const lookup = await opts.existing.get(String(ps.name)); - if (lookup.status === 'unknown') { - out.unreadable += 1; - return out; - } - existing = lookup.status === 'present' ? lookup.row : undefined; - } else { - existing = (await tryFind(ql, 'sys_permission_set', { name: ps.name }, 1))[0]; - } - if (!existing?.id) { - const row = { - id: genId('ps'), - name: ps.name, - ...permissionSetRowFields(ps), - // [#4669] `active` is ROW STATE, never read from the definition body: a - // new record starts active (same as the package seeder and the field's - // own `defaultValue`), and the data door writes the column directly - // afterwards. Taking it from the body would also let a body STORED - // before #4001 — which may still carry a stale `active` — silently - // re-flip a record an admin had just deactivated. - active: true, - // [A4 #2920] Unified provenance vocab: an env/Studio-authored set is - // ADMIN-owned (formerly stamped 'user'). No runtime path branches on the - // value except the 'package' guard, so this is a pure vocab rename. - managed_by: 'admin', - // [#11097] `false`, not `!!customized`, and this is NOT optional once the - // write-skip below exists. This row is being CREATED, so it is - // `managed_by:'admin'` one line up — an env-authored definition, not a - // customization of a packaged one — and the update branch's rule a few - // lines down already encodes exactly that (`existing.managed_by === - // 'package' ? !!customized : false`). Stamping the caller's raw opinion - // here instead (main's behaviour) means a fresh overlay-backed admin row - // is born `customized: true`; the NEXT `projectPermissionMutation` call - // for the same name recomputes `patch.customized` via that SAME rule, - // gets `false`, and — now that writes are equality-gated — that mismatch - // is a real, measured drift, not a no-op. Verified by ablation: with - // `!!customized` here, `#11097 — env overlay reconciliation: round - // trips > does not grow the steady-state round-trip count` and - // `#11097 — drift STILL reconciles > only the DRIFTED overlay is - // written` both fail — the "steady state" boot immediately following a - // creation issues a real corrective UPDATE for every overlay-backed - // admin row, forever, not once historically. So this insert-time fix is - // required for the round-trip claim itself to hold, not an unrelated - // rider — the equality check needed on the write side implies the - // matching write on the create side. - ...(customized !== undefined ? { customized: false } : {}), - }; - const created = await tryInsert(ql, 'sys_permission_set', row); - if (created) { - out.seeded += 1; - // [#11097] The oracle is a SNAPSHOT taken before the caller's loop, so it - // cannot see this insert. Two projections of the same name in one pass - // would otherwise each attempt an insert, and the second would be refused - // by the `name` unique index instead of taking the update branch below. - opts?.existing?.remember(String(ps.name), row); - } - return out; - } - - // Facets follow the effective body; provenance columns are never touched - // here — a package-owned row keeps its owner while carrying the overlay's - // customization, and an env row keeps its user/platform/legacy provenance. - // [#4669] Facets only — `active` is row state and is NOT projected from the - // body (a projection pass must never re-flip a record's on/off switch). - const patch: Record = { id: existing.id, ...permissionSetRowFields(ps) }; - // Only stamp `customized` on package-owned rows (an overlay of a packaged - // set). For env rows the concept doesn't apply — clear any stale flag. - if (customized !== undefined) { - patch.customized = existing.managed_by === 'package' ? !!customized : false; - } - - // [#11097] Write only when the stored row actually differs from the patch. On - // every boot the reconciler re-projected each env overlay unconditionally, - // paying a remote `UPDATE` per overlay to store what was already there. - // - // ⛔ THE COMPARISON MUST COVER EVERY COLUMN THE PATCH WRITES, and - // `recordDiffersFromBody` alone does NOT: it compares the facet/display - // columns, and `customized` is deliberately not among them (it is provenance, - // not definition — no metadata body can declare it). Skipping on the facets - // alone would therefore stop maintaining a flag the Setup list badges on and - // the reset action reads — the record would keep saying "customized" after - // its overlay was lifted, or never say it after one appeared. So the flag - // gets its own term, evaluated against the SAME `existing.managed_by` - // condition the patch uses, and only against the patch's own value: when the - // caller passes no `customized` opinion the patch carries no such key and - // there is nothing to compare. - // - // ⚠️ EQUALITY decides, never presence. Replacing this with an unconditional - // `unchanged` would give the reconciler a perfect round-trip count while it - // silently stopped healing anything. - const facetsDiffer = recordDiffersFromBody(existing, ps); - const customizedDiffers = customized !== undefined - && customizedFlag(existing.customized) !== customizedFlag(patch.customized); - if (!facetsDiffer && !customizedDiffers) { - out.unchanged += 1; - return out; - } - - if (await tryUpdate(ql, 'sys_permission_set', patch)) { - out.updated += 1; - } - return out; -} - -/** - * Sync the metadata manager's in-memory `permission` entry with the effective - * body just projected. The evaluator's `resolvePermissionSets` resolves from - * `metadata.list('permission')`, which is REGISTRY-FIRST — without this, an - * env overlay of a declared set would display (layered read + record) while - * the evaluator kept enforcing the stale declared body. - * - * Only runs while an OVERLAY actually exists (`overlayBacked`) — an - * overlay-less name needs no shadow (the registry already holds the declared - * body, or the DatabaseLoader serves the runtime row) and writing one would - * clobber the pristine declared entry. The synced copy is stamped with - * {@link ENV_PROJECTION_MARKER} so it can never masquerade as a packaged - * artifact after the overlay is gone. When the overlay disappears, a stale - * echo is healed back to `restoreTo` (the declared body) or dropped. - * - * [#21789] ⭐ The echo also states its PROVENANCE, and it states the lock's - * verdict. The metadata manager's get is registry-first, so the protocol's - * layered read serves this echo as the item's `code` layer, and the envelope's - * `provenance` is read off that layer. Unstamped, every set with an overlay row - * (an org's own set, a clone, a set in a writable runtime package) reported a - * code layer with no provenance, which is how a client reads "a code package - * ships this" (objectui's permission-matrix editor rendered such a set locked - * while every write door accepted the save). So the echo carries - * `_provenance: 'org'` exactly when {@link classifyPackagedPermissionSet}, the - * classifier both write doors ask, answers `org` for the name: the reported - * state and the enforced state are one judgment. A `packaged` verdict (a - * legacy overlay of a code-shipped set) or an `unknown` one leaves the echo - * unstamped, as before, so the read keeps reporting the lock the doors keep - * enforcing. - * - * [#23233] ⭐ A tenant-authored echo also names its OWNER: `ownerPackageId`, - * the package its stored row is bound to, as the protocol's by-name read - * serves it ({@link readServedPackageBinding}). The security catalog reads a - * set the engine registry does not hold from this echo — on an environment - * kernel the protocol hydrates no stored row into the engine registry — and - * takes its owner from `_packageId` alone. Unstamped, a set published into a - * writable runtime package was served as UNOWNED while `getMetaItem` named the - * package, so the suggestion collector skipped its `isDefault: true` and no - * `everyone` suggestion was ever recorded (ADR-0090 D5: packages suggest, - * admins confirm). The owner is carried, never derived: `stripDecorations` - * still removes whatever `_packageId` the body arrived with (the layered read's - * `overlay` layer is the stored document and carries none), and only the - * protocol's answer is stamped back. A `packaged` or `unknown` verdict carries - * none, as before — a code-shipped set's owner is its artifact's, which the - * catalog reads from the engine registry ahead of this echo. - * - * Best-effort: when the facade lacks `registerInMemory`, overlay-only names - * still resolve via the DatabaseLoader / record dbLoader. - */ -async function syncEvaluatorRegistry( - metadata: any, - name: string, - body: any, - overlayBacked: boolean, - tenantAuthored = false, - ownerPackageId?: string, -): Promise { - try { - if (!metadata || typeof metadata.registerInMemory !== 'function' || !name) return; - if (overlayBacked && body?.name) { - metadata.registerInMemory('permission', name, { - ...stripDecorations(body), - ...(tenantAuthored ? { _provenance: 'org' } : {}), - ...(tenantAuthored && ownerPackageId !== undefined ? { _packageId: ownerPackageId } : {}), - [ENV_PROJECTION_MARKER]: true, - }); - return; - } - // Overlay gone: heal a stale echo of ours back to the real body, or drop it. - const current = typeof metadata.get === 'function' ? await metadata.get('permission', name) : undefined; - if (!isProjectionEcho(current)) return; - if (body?.name) { - metadata.registerInMemory('permission', name, stripDecorations(body)); - } else { - dropEvaluatorRegistryEntry(metadata, name); - } - } catch { /* best-effort */ } -} - -/** - * [#23233] The package the stored row a by-name read SERVES for `name` is - * bound to: the `_packageId` the protocol's single-item read states on the - * item from that row's `package_id` column. `undefined` when the row is bound - * to no package, or the protocol has no single-item read (minimal embeddings, - * unit-test stubs), which keys no row by package either. - * - * ⛔ The one read of that binding in this module — the data door's update leg - * ({@link createPermissionSetWriteThrough}, #21861) and the registry echo - * ({@link syncEvaluatorRegistry}) both ask it. Never from a patch, the - * projected record or a registry item: those are copies, and the row is the - * fact. A read that fails is not caught here; each caller decides what a - * binding it could not read means for its own write. - */ -async function readServedPackageBinding(protocol: any, name: string): Promise { - if (typeof protocol?.getMetaItem !== 'function') return undefined; - const served = await protocol.getMetaItem({ type: 'permission', name }); - const packageId = served?.item?._packageId; - return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; -} - -/** Drop the in-memory `permission` entry for a retired definition. */ -function dropEvaluatorRegistryEntry(metadata: any, name: string): void { - try { - if (metadata && typeof metadata.unregister === 'function' && name) { - // unregister() also asks writable DB loaders to delete — the overlay - // row is already gone (deleteMetaItem ran first), so this is a no-op - // there and an in-memory removal here. - void metadata.unregister('permission', name); - } - } catch { /* best-effort */ } -} - -/** Retire the record of a definition deleted from metadata (trash applies). */ -async function retirePermissionSetRecord( - ql: any, - metadata: any, - name: string, - logger?: ProjectionLogger, -): Promise { - const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0, deleted: 0 }; - const existing = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; - if (!existing?.id) return out; - if (existing.managed_by === 'package') { - out.skippedForeign += 1; - logger?.warn?.('[security] metadata delete targets a package-owned set record — left to the package door', { name }); - return out; - } - try { - await ql.delete('sys_permission_set', { where: { id: existing.id }, context: SYSTEM_CTX }); - out.deleted = 1; - dropEvaluatorRegistryEntry(metadata, name); - // Drop any engine-registry ghost of the retired definition (a runtime - // shadow, or a projection echo re-registered by the delete-time registry - // heal) so metadata lists don't keep showing a deleted set. - try { ql?.registry?.unregisterItem?.('permission', name); } catch { /* best-effort */ } - } catch (e) { - logger?.warn?.('[security] failed to retire sys_permission_set record after metadata delete', { - name, error: (e as Error)?.message, - }); - } - return out; -} - -export interface ProjectionDeps { - ql: any; - /** Metadata manager facade (`getService('metadata')`) for the evaluator-registry sync. */ - metadata?: any; - logger?: ProjectionLogger; -} - -/** - * THE `permission` mutation projector (ADR-0094): re-read the FRESH effective - * body via the protocol's layered read (overlay-wins; the boot-time registry - * would hand back a stale declared body) and project it onto the record + - * evaluator registry. A mutation whose layered read yields NO body (a - * runtime-only definition was deleted) retires the record; a delete that - * reveals the artifact baseline (overlay tombstone) re-projects the declared - * body — the "reset" semantic. - * - * Returns the projection outcome, or `null` when the event is skipped - * (draft, non-permission, or unnamed). - */ -export async function projectPermissionMutation( - protocol: any, - deps: ProjectionDeps, - evt: { type?: string; name?: string; state?: string } | null | undefined, - /** - * [#11097] Forwarded to {@link upsertEnvPermissionSet} so a boot pass over - * many overlay names pays ONE existence read for the whole set. The live - * mutation projector passes nothing and keeps its per-item read. - */ - opts?: { existing?: ExistingByNameIndex }, -): Promise { - if (evt?.type !== 'permission' || evt.state === 'draft' || !evt.name) return null; - const { ql, metadata, logger } = deps; - let body: any = null; - let overlayBacked = false; - // [#21789] The layered read, handed to the lock's classifier below as its - // second source, exactly as the write doors hand it theirs. - let layeredProbe: LayeredProbe | undefined; - if (protocol && typeof protocol.getMetaItemLayered === 'function') { - // [ADR-0131 D6] The environment → code read: no organization is part - // of a metadata read, so the projection reads what every reader reads. - const layered = await protocol.getMetaItemLayered({ type: 'permission', name: evt.name }); - // `getMetaItemLayered` may return a layered envelope (`{ effective | code }`) - // OR the effective body directly (top-level `name`) — accept both. The - // envelope carries `name` too, so detect it by its layer keys: an envelope - // whose layers are all null means the definition is GONE (retire), and - // must not be mistaken for a body. Layers that are just our own registry - // echo ({@link ENV_PROJECTION_MARKER}) don't count as a definition either — - // the declared (artifact) baseline is read from the engine SchemaRegistry, - // which this module never writes. - const isEnvelope = layered && typeof layered === 'object' - && ('effective' in layered || 'overlay' in layered || 'code' in layered); - if (isEnvelope) { - layeredProbe = { status: 'read', envelope: layered }; - const overlay = layered.overlay ?? null; - overlayBacked = !!overlay; - const declared = readDeclaredBody(ql, evt.name); - // The envelope's `code`/`effective` layers are only a fallback for - // kernels without a readable SchemaRegistry: they can echo a deleted - // definition (tombstoned overlay row via the DatabaseLoader, a lingering - // runtime shadow, or our own registry sync), so where the registry is - // available, overlay ?? declared is the whole truth — an empty result - // means the definition is GONE (retire). - if (hasSchemaRegistry(ql)) { - body = overlay ?? declared; - } else { - const code = isProjectionEcho(layered.code) ? null : (layered.code ?? null); - const effective = isProjectionEcho(layered.effective) ? null : (layered.effective ?? null); - body = overlay ?? code ?? effective; - } - } else { - body = layered ?? null; - } - } - if (!body?.name) { - await syncEvaluatorRegistry(metadata, evt.name, null, false); - return retirePermissionSetRecord(ql, metadata, evt.name, logger); - } - const out = await upsertEnvPermissionSet(ql, body, logger, { - customized: overlayBacked, - ...(opts?.existing ? { existing: opts.existing } : {}), - }); - // [#11097] ⚠️ `unchanged` COUNTS HERE, and leaving it out was a real defect - // waiting on the write-skip above. This sync is an IN-MEMORY registry write, - // not a database round trip, and the evaluator resolves permission sets from - // that registry REGISTRY-FIRST — so on every steady-state boot (records - // already matching, hence no `UPDATE`) an overlay-backed name would have gone - // unsynced, and the evaluator would have kept enforcing the stale DECLARED - // body while the record and the Setup list showed the overlay. The gate is - // "the record is now in the desired state", which is `seeded + updated + - // unchanged` — never "a write happened". A FAILED write still leaves all - // three at zero and still skips the sync, exactly as before. - if (out.seeded + out.updated + out.unchanged > 0) { - // [#21789] The echo's provenance is the lock's verdict for the name — see - // {@link syncEvaluatorRegistry}. - const tenantAuthored = overlayBacked - && classifyPackagedPermissionSet(evt.name, ql, layeredProbe).status === 'org'; - // [#23233] …and a tenant-authored echo names the package its stored row is - // bound to, read from the protocol — see {@link syncEvaluatorRegistry}. - let ownerPackageId: string | undefined; - if (tenantAuthored) { - try { - ownerPackageId = await readServedPackageBinding(protocol, evt.name); - } catch (e) { - // Functional, not durability: nothing persisted is lost and nothing - // grants differently — the echo is registered unowned, exactly as it - // was before it carried an owner, so it still enforces the new body. - // What is missing is said once, with what restores it. - logger?.warn?.( - '[security] the package binding of this permission set could not be read, so its registry echo ' + - 'names no owning package: the security catalog serves the set as unowned, and an `isDefault: true` ' + - 'set records no audience-binding suggestion (ADR-0090 D5), until the name is projected again — ' + - 'the next save, publish or boot re-reads the binding. Enforcement is unaffected.', - { name: evt.name, error: String((e as Error)?.message ?? e) }, - ); - } - } - await syncEvaluatorRegistry(metadata, evt.name, body, overlayBacked, tenantAuthored, ownerPackageId); - } - return out; -} - -/** - * Register the permission projector on the protocol. Prefers the AWAITED - * `registerMutationProjector` seam (ADR-0094 — no projection race); falls - * back to the fire-and-forget `onMetadataMutation` subscription (#2867) for - * protocol implementations that predate the projector. Returns `true` when - * wired. - */ -export function registerPermissionSetProjection( - protocol: any, - deps: ProjectionDeps, -): boolean { - if (!protocol) return false; - const handler = (evt: any) => projectPermissionMutation(protocol, deps, evt); - if (typeof protocol.registerMutationProjector === 'function') { - protocol.registerMutationProjector('permission', async (evt: any) => { await handler(evt); }); - return true; - } - if (typeof protocol.onMetadataMutation === 'function') { - protocol.onMetadataMutation((evt: any) => { - void handler(evt).catch((err: any) => { - deps.logger?.warn?.('[security] env permission projection after save failed', { - name: evt?.name, error: err?.message, - }); - }); - }); - return true; - } - return false; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Data-door write-through (ADR-0094 D3) -// ───────────────────────────────────────────────────────────────────────────── - -const scalarId = (v: unknown): v is string | number | bigint => - v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); - -/** Resolve the rows a data-door update/delete targets (single-id or filtered). */ -async function resolveTargetRows(ql: any, opCtx: any): Promise { - const data = opCtx?.data; - if (data && typeof data === 'object' && !Array.isArray(data) && scalarId(data.id)) { - return tryFind(ql, 'sys_permission_set', { id: data.id }, 1); - } - const where = opCtx?.options?.where; - if (where && typeof where === 'object' && scalarId((where as any).id)) { - return tryFind(ql, 'sys_permission_set', { id: (where as any).id }, 1); - } - if (where && typeof where === 'object') { - try { - const rows = await ql.find('sys_permission_set', { where, limit: 500 }, { context: SYSTEM_CTX }); - return Array.isArray(rows) ? rows : []; - } catch { return []; } - } - return []; -} - -/** - * Column-patch → body-key merge for the data-door update redirect. - * - * Row-state columns ({@link ROW_STATE_COLUMNS}) are deliberately NOT merged — - * they are the record's state, applied to the record itself by the - * write-through (#4669). The merged result is filtered through - * {@link pickSpecDeclaredKeys} because `base` may be a body STORED before - * #4001 sealed the schema and can still carry a stripped-at-the-time `active`; - * without the filter that legacy row would make every subsequent data-door - * edit of the set fail spec validation. - */ -export function mergeRowPatchIntoBody(base: any, patch: Record): any { - const body: any = { ...stripDecorations(base) }; - if ('label' in patch) body.label = patch.label; - if ('description' in patch) { - if (patch.description == null) delete body.description; - else body.description = patch.description; - } - if ('object_permissions' in patch) body.objects = parseMaybeJson(patch.object_permissions, {}); - if ('field_permissions' in patch) body.fields = parseMaybeJson(patch.field_permissions, {}); - if ('system_permissions' in patch) body.systemPermissions = parseMaybeJson(patch.system_permissions, []); - if ('row_level_security' in patch) body.rowLevelSecurity = parseMaybeJson(patch.row_level_security, []); - if ('tab_permissions' in patch) body.tabPermissions = parseMaybeJson(patch.tab_permissions, {}); - if ('admin_scope' in patch) { - const scope = patch.admin_scope == null ? undefined : parseMaybeJson(patch.admin_scope, undefined); - if (scope === undefined) delete body.adminScope; - else body.adminScope = scope; - } - if (!body.objects || typeof body.objects !== 'object') body.objects = {}; - return pickSpecDeclaredKeys(body); -} - -/** - * Effective (layered, overlay-wins) body for a record's name, else the row - * itself. - * - * `preReadLayered` lets a caller that ALREADY holds the layered envelope for - * this name hand it over instead of paying a second round trip. The write - * door's package-provenance pre-pass reads exactly this envelope one step - * earlier, so threading it through keeps the update path at the same number of - * metadata reads it had before the lock existed. `undefined` means "I have - * none" and restores the original read verbatim, including its fallback. - */ -async function effectiveBodyForRow( - protocol: any, - ql: any, - row: any, - preReadLayered?: unknown, -): Promise { - try { - const layered = preReadLayered !== undefined - ? preReadLayered as any - : await protocol.getMetaItemLayered({ type: 'permission', name: row.name }); - let body: any; - if (hasSchemaRegistry(ql)) { - body = layered?.overlay ?? readDeclaredBody(ql, row.name); - } else { - body = layered?.overlay - ?? (isProjectionEcho(layered?.code) ? null : layered?.code) - ?? (isProjectionEcho(layered?.effective) ? null : layered?.effective); - } - if (body?.name) return body; - } catch { /* fall through */ } - return permissionSetBodyFromRow(row); -} - -export interface WriteThroughDeps extends ProjectionDeps { - /** Lazy protocol handle — the protocol service may register after start(). */ - getProtocol: () => any; -} - -/** - * Engine middleware: redirect every non-system data-door write on - * `sys_permission_set` into the metadata store (ADR-0094 D3). Registered - * INSIDE the security middleware (later in the onion), so the provenance- - * forging gate, the delegated-admin gate, and the ordinary CRUD/FLS checks - * have all passed before a write is translated. The driver write never - * executes — `opCtx.result` is the projected record — so no data-plane path - * can desync record from metadata. - * - * A PACKAGE-OWNED row is writable through here too: its update/delete - * translate into env-scope overlay operations (customize / reset) — the - * ADR-0005 layering carries the two doors now, instead of a flat refusal. - * System-context writes pass through untouched: they ARE the projector / - * seeder channel. Kernels without a capable metadata protocol (minimal - * embeddings, unit-test stubs) pass through for env rows and keep the legacy - * two-doors refusal for package rows — with no overlay layer there is - * nothing to carry a customization. - */ -export function createPermissionSetWriteThrough( - deps: WriteThroughDeps, -): (opCtx: any, next: () => Promise) => Promise { - const { ql, logger } = deps; - - /** - * Read the layered envelope for ONE name, reporting whether the read - * ANSWERED — never collapsing a failed read into "no artifact". - * - * The envelope is the lock's second artifact source (kernels with no readable - * SchemaRegistry, the same fallback `projectPermissionMutation` uses), and it - * is the same envelope the update leg then merges its column patch into — so - * this read replaces the one `effectiveBodyForRow` used to make rather than - * adding to it. - */ - const probeLayered = async ( - protocol: any, - name: string, - ): Promise<{ probe: LayeredProbe; envelope: unknown }> => { - try { - const envelope = await protocol.getMetaItemLayered({ type: 'permission', name }); - return { probe: { status: 'read', envelope }, envelope }; - } catch (e) { - return { - probe: { status: 'failed', reason: String((e as Error)?.message ?? e) }, - envelope: undefined, - }; - } - }; - - /** - * [#21861] The `saveMetaItem` argument that writes an update back into the - * stored row it edits: `{ packageId }` when that row is bound to a package, - * nothing when it is not. - * - * A `sys_metadata` row is keyed `(org, type, name, package_id)`, and a save - * that names no package targets the package-less row. So an update leg that - * named none, for a set whose only row is bound to a writable runtime - * package (`PUT /meta/permission/:name?package=`), minted a second, - * package-less row carrying the edit beside the untouched package-bound one: - * two active rows for one name. - * - * The row is the one the update merges its patch into — the `overlay` layer - * of `envelope`, which {@link effectiveBodyForRow} takes as the base — and its - * binding is read from the metadata door's own single-item read, which serves - * that row by the same served-row resolution the layered read uses and states - * the row's `package_id` on it as `_packageId`. ⛔ Never from the patch, the - * projected record (the projector writes no binding onto an admin row), or a - * registry item: those are copies, and the row is the fact. - * - * No `overlay` layer means no stored row, so there is nothing to fork and the - * save stays package-less, exactly as before. Every target reaching this read - * has already passed the lock (verdict `org`), so a code-shipped set never - * gets here. A read that fails is not caught: guessing the binding would - * choose which row the save lands in. - */ - const storedRowPackageArg = async ( - protocol: any, - name: string, - envelope: unknown, - ): Promise<{ packageId?: string }> => { - const overlay = (envelope as { overlay?: unknown } | null | undefined)?.overlay; - if (overlay === null || overlay === undefined) return {}; - // [#23233] The module's one read of the binding — see - // `readServedPackageBinding`, which also answers nothing for a protocol - // with no single-item read. - const packageId = await readServedPackageBinding(protocol, name); - return packageId !== undefined ? { packageId } : {}; - }; - - const projectAndFetch = async (protocol: any, name: string): Promise => { - // The awaited projector inside saveMetaItem/deleteMetaItem normally did - // this already — re-running is an idempotent upsert, and covers the - // window where the projector isn't registered yet (pre-kernel:ready). - await projectPermissionMutation(protocol, deps, { type: 'permission', name, state: 'active' }); - return (await tryFind(ql, 'sys_permission_set', { name }, 1))[0] ?? null; - }; - - return async (opCtx: any, next: () => Promise): Promise => { - if (opCtx?.object !== 'sys_permission_set') return next(); - if (opCtx?.context?.isSystem) return next(); - const op = opCtx?.operation; - if (!['insert', 'update', 'delete', 'restore'].includes(op)) return next(); - - const protocol = deps.getProtocol?.(); - const capable = !!protocol - && typeof protocol.saveMetaItem === 'function' - && typeof protocol.deleteMetaItem === 'function' - && typeof protocol.getMetaItemLayered === 'function'; - if (!capable) { - // Single-store kernel: there is no overlay layer to translate a - // package-set customization into, so the legacy ADR-0086 two-doors - // protection applies HERE (the outer security gate delegates the - // update/delete package-row check to this middleware): a - // package-managed row stays read-only through the data door. - // - // [ADR-0094 D5-R] This is no longer the ONLY reason a packaged set is - // read-only through the data door — since commit ee58392e1 a CAPABLE kernel refuses - // an artifact-backed set too, at the protocol's ADR-0005 tier gate. The - // remedy this message names ("edit the package and re-publish") is - // therefore right on every kernel; only the stated cause is specific to - // this branch. The two conditions keep separate wordings deliberately - // (#5240 — one condition, one wording): they are distinguishable and an - // operator needs to know which one they hit. Note also that this branch - // keys on `managed_by:'package'` — a record-provenance heuristic that is - // the best this file can do with no protocol to ask, and measurably NOT - // the artifact-provenance fact the capable path's gate reads. - if (op === 'update' || op === 'delete') { - const targets = await resolveTargetRows(ql, opCtx); - const pkg = targets.find((t: any) => t?.managed_by === 'package'); - if (pkg) { - const err: any = new Error( - `[Security] Access denied: '${String(pkg.name ?? pkg.id)}' is a package-managed permission set ` + - `(managed_by:'package') and this kernel has no metadata overlay layer to carry an environment ` + - `customization — change it by editing its package and re-publishing (ADR-0086 two-doors).`, - ); - err.name = 'PermissionDeniedError'; - err.status = 403; - throw err; - } - } - return next(); - } - - const actor = opCtx?.context?.userId ? String(opCtx.context.userId) : undefined; - const actorArg = actor ? { actor } : {}; - - if (op === 'restore') { - // Let the engine un-trash the record, then re-author its definition - // into metadata (the delete removed it) so the stores converge live. - // - // [2026-08-24 ruling — lock the base, clone to customize; commit 9cfc1f7e9] This - // leg consults the SAME lock as the insert and update legs — but the - // refusal lands on the durability channel instead of being thrown, and - // the engine un-trash above it stands. The placement is argued, not - // inherited: - // - // - the write the ruling locks is the metadata MINT (`saveMetaItem` - // below): hatch open (`OS_METADATA_WRITABLE=permission`), it would - // author an environment overlay of a packaged set, which - // `reconcilePermissionSetProjection` re-projects onto the record on - // every boot, forever. The check runs BEFORE that write — the same - // "refuse before the write" property the insert and update legs - // have, at this leg's own write. - // - refusing BEFORE the un-trash is not honestly implementable: the - // rows are still trashed there, `resolveTargetRows` reads through - // ordinary `find`, and no trash state exists since #2377 retired - // `enable.trash` (#3146, parked, will define whether trashed rows - // are visible to reads — the natural answer is that they are not). - // A pre-pass would resolve nothing on a real engine while unit - // doubles (no trash concept, rows always visible) showed it green — - // coverage certified by a test the real path would not have. - // - refusing AFTER and compensating (re-trash) is structurally - // unavailable: this middleware's own delete leg guarantees a - // packaged row's record survives the data door (the overlay - // tombstones; the driver delete never runs), it holds no raw driver - // handle, and the trash primitive belongs to unlanded #3146. A - // compensation that can fail turns the refusal into a lie — the - // caller is told "refused" while the state says "restored". - // - a THROW here would misreport a completed engine operation and - // half-apply a multi-row restore — the stranding this leg's - // catch-not-throw disposition exists to avoid (see the file header, - // and the RESTORE case in `permission-set-projection.test.ts`). - // - // With the mint refused, the restored record carries no overlay, so - // boot reconciliation re-projects the DECLARED body onto it — the - // environment converges to the package truth rather than to a fork. - // `unknown` provenance refuses the mint too (fail-closed, the lock's - // own three-verdict contract): accepting on a failed read would reopen - // exactly the hatch-open gap this closes, on every transient. - await next(); - const restored = await resolveTargetRows(ql, opCtx); - for (const row of restored) { - if (!row?.name) continue; - const name = String(row.name); - try { - // `'update'` for the same reason the metadata-door gate passes it - // (`packaged-permission-set-lock-gate.ts`): re-authoring a - // package-declared name is an override of the shipped base, so the - // remedy the refusal teaches is the clone path. - assertPermissionSetNotPackageDeclared(name, ql, 'update', (await probeLayered(protocol, name)).probe); - } catch (lockErr) { - // A REFUSAL, not a failed write — distinct wording from the catch - // below (#5240: one condition, one wording), same channel and the - // same #9754 error→warn fallback chain. - const message = - '[security] restored permission set was deliberately NOT re-authored into metadata — the ' + - 'packaged-permission-set lock refused the write (2026-08-24 ruling: lock the base, clone to ' + - 'customize; an environment overlay of a package-declared set would silently fork it from its ' + - 'package, and provenance that cannot be resolved refuses fail-closed for the same reason). The ' + - 'record is back and lists normally; for a package-declared set, boot reconciliation re-projects ' + - 'the declared body onto it. To customize a packaged set, clone it and edit the clone.'; - if (logger?.error) logger.error(message, lockErr as Error, { name }); - else logger?.warn?.(message, { name, error: String((lockErr as Error)?.message ?? lockErr) }); - continue; - } - try { - await protocol.saveMetaItem({ type: 'permission', name: row.name, item: permissionSetBodyFromRow(row), ...actorArg }); - } catch (e) { - // [#4632 — AGENTS.md "Degradation log levels"] Durability, not - // functionality: the record is back and lists normally, but its - // definition never returned to the metadata store — the stores - // disagree silently until someone notices the set behaves like a - // legacy data-door row. - const message = - '[security] restored permission set was NOT re-authored into metadata (ADR-0094 D3) — the record is ' + - 'back and looks healthy, but the metadata store has no definition for it, so a metadata-driven ' + - 're-provision will not recreate it. Fix: make the record body spec-valid (the error names the ' + - 'offending key) and re-save the set through Setup, or re-run boot reconciliation.'; - // `ProjectionLogger.error` is OPTIONAL, so `logger?.error?.(…)` printed - // NOTHING against a sink that has only `warn` — the durability - // degradation described above would then be reported by nobody at all - // (#9657). Reach for `error`, fall back to `warn`, never to silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754), so no TS caller can - // hand over a sink this line evaporates against. The `?.` below is - // the backstop for untyped hosts only — see the interface. - if (logger?.error) logger.error(message, e as Error, { name: row.name }); - else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) }); - } - } - return; - } - - if (op === 'insert') { - const rows = Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]; - // A payload without a usable machine name gets the engine's own - // required-field validation error — don't mask it. - if (rows.some((r: any) => !r || typeof r !== 'object' || !r.name || typeof r.name !== 'string')) { - return next(); - } - const results: any[] = []; - for (const row of rows) { - const name = String(row.name); - // [2026-08-24 ruling — lock the base, clone to customize] A name an - // installed package DECLARES is not available for an environment - // definition: with the `OS_METADATA_WRITABLE=permission` operator hatch - // open, `saveMetaItem` below would mint a fresh `sys_metadata` overlay - // of a packaged set, and `reconcilePermissionSetProjection` re-projects - // that overlay onto the record on every boot, unconditionally, forever. - // Refused here, before the write, with a message that names the clone - // path. Fail-closed: unresolvable provenance refuses too. - // - // [commit 8f6d83147] ⭐ It runs BEFORE the duplicate-name check below, and the - // order is the fix rather than a tidy-up. A package-declared set has a - // PROJECTED ROW, so its name is duplicate AND locked at once — and the - // admin most likely to arrive here is the one who opened the Clone - // dialog on a packaged set and typed the base set's own name, which is - // the single most likely thing to type. Duplicate-first answered that - // caller `already exists`: true, and the less useful of two true - // refusals — it names no remedy, while `NOT_OVERRIDABLE` is the one - // that explains the actual situation and teaches the clone path. So - // the refusal that carries the remedy speaks first, and the ordinary - // duplicate (verdict `org`) still falls through to the check below - // unchanged. - // - // ⚠️ One case moves besides the packaged one: an ordinary duplicate - // attempted while NO artifact source can answer now takes the lock's - // fail-closed `unknown` refusal (403, retry when the metadata layer is - // readable) instead of the 409. Both are refusals and neither writes, - // which is why the ordering is judged on the case that is reachable on - // purpose; pinned so it is declared rather than incidental. - assertPermissionSetNotPackageDeclared( - name, ql, 'insert', (await probeLayered(protocol, name)).probe, - ); - // [commit 8f6d83147] The duplicate-name refusal carries `UNIQUE_VIOLATION` — the - // wire identity this collision already has when the `name` index - // catches it instead. See `PermissionSetNameConflictError`. - const dup = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; - if (dup) throw new PermissionSetNameConflictError(name); - // The metadata write is the authoritative one; spec validation - // (PermissionSetSchema) runs inside saveMetaItem and rejects an - // off-contract body with a structured 422. - await protocol.saveMetaItem({ type: 'permission', name, item: permissionSetBodyFromRow(row), ...actorArg }); - const record: any = (await projectAndFetch(protocol, name)) ?? { name }; - // [#4669] Row state does not round-trip through metadata — the - // projector created the record with the column default, so an explicit - // `active` in the payload (the Clone action sends one) is applied here. - const rowState = pickRowStateColumns(row); - if (rowState && record.id && await tryUpdate(ql, 'sys_permission_set', { id: record.id, ...rowState })) { - Object.assign(record, rowState); - } - results.push(record); - } - opCtx.result = Array.isArray(opCtx.data) ? results : results[0]; - return; // driver write intentionally skipped — the record is projector-owned - } - - const targets = await resolveTargetRows(ql, opCtx); - if (targets.length === 0 || targets.some((t: any) => !t?.name)) return next(); - - if (op === 'update') { - const patch = Array.isArray(opCtx.data) ? null : opCtx.data; - if (!patch || typeof patch !== 'object') return next(); - if (typeof patch.name === 'string' && targets.some((t: any) => t.name !== patch.name)) { - const err: any = new Error( - `[Security] renaming a permission set through the data door is not supported — the name is its ` + - `metadata identity (ADR-0094). Clone to a new name and delete the old set instead.`, - ); - err.status = 400; - throw err; - } - // [#4669] A patch that touches ONLY row state (`active` — what the - // activate/deactivate actions send as `bodyExtra`) is not a definition - // write at all: it goes to the driver untouched, so the column write - // keeps its ordinary engine semantics (history, updated_at, FLS) and no - // spurious "customization" overlay is minted on a packaged set. Routing - // it through the metadata store is what #4001's strict schema rejects. - if (!touchesDefinition(patch)) return next(); - // [2026-08-24 ruling — lock the base, clone to customize] THE write door - // the ruling names. A Studio/API save that targets a package-declared set - // is refused loudly, naming the sanctioned path, so no silent overlay row - // is minted again. Two properties of the placement are load-bearing: - // - // - it runs AFTER the row-state carve-out above, so the activate / - // deactivate actions (a bare `{ active }` patch) keep writing their - // column — switching a packaged set off is not a customization of it - // (#4669), and a lock that swallowed it would break the surface it - // exists to protect; - // - it is a PRE-PASS over every target, not a per-row check inside the - // write loop below: a filtered update spanning several rows must not - // write the first few and then refuse the last, leaving the caller - // with a half-applied edit and one error. - // - // This deliberately pre-empts the producer's own ADR-0005 tier gate, - // reversing the D5-R disposition recorded in this file's header ("that - // refusal is deliberately LEFT TO THE PRODUCER"). Two measured reasons: - // the producer's gate is exactly what the documented - // `OS_METADATA_WRITABLE=permission` hatch switches off — so on the - // deployments this card came from there is no refusal at all — and its - // message then ("the type has not opted into per-org overlay writes") - // named nothing an admin can act on. It is NOT a second copy of - // `isArtifactBacked` (Prime Directive #8): it reads the engine - // SchemaRegistry, the one source this plugin already calls - // "package-declared" in `readDeclaredBody` above. See `packaged-permission-set-lock.ts`. - const layeredByName = new Map(); - for (const row of targets) { - const name = String(row.name); - if (layeredByName.has(name)) continue; - const { probe, envelope } = await probeLayered(protocol, name); - assertPermissionSetNotPackageDeclared(name, ql, 'update', probe); - layeredByName.set(name, envelope); - } - const rowState = pickRowStateColumns(patch); - const results: any[] = []; - for (const row of targets) { - const envelope = layeredByName.get(String(row.name)); - const base = await effectiveBodyForRow(protocol, ql, row, envelope); - const body = mergeRowPatchIntoBody(base, patch); - body.name = row.name; - // [#21861] Into the row the base came from — see `storedRowPackageArg`. - const packageArg = await storedRowPackageArg(protocol, String(row.name), envelope); - await protocol.saveMetaItem({ type: 'permission', name: row.name, item: body, ...packageArg, ...actorArg }); - // Row state rides along on the same patch but lands on the record, not - // in the definition (the projector above never touches these columns). - if (rowState) await tryUpdate(ql, 'sys_permission_set', { id: row.id, ...rowState }); - results.push((await projectAndFetch(protocol, row.name)) ?? { id: row.id, name: row.name }); - } - opCtx.result = results.length === 1 ? results[0] : results; - return; - } - - // delete: remove the definition from the metadata store. Runtime-only - // definitions hard-delete (the projector then retires the record, trash - // semantics apply); artifact-backed definitions tombstone their overlay — - // an ADR-0005 RESET — and the record re-projects to the declared body - // instead of vanishing (a packaged definition cannot be deleted from the - // environment). - // - // The RESET is the designed outcome and stays exactly as it is. What this - // loop owes the CALLER is the ability to tell the two endings apart: the - // outcome below used to start at `true` and could only ever be assigned - // `true` again, so a reset answered the data door with the same - // `{"success":true}` a real deletion does — the difference was named only - // in the `logger.info` line above, which no caller can read. A machine - // surface that reports a refusal as a deletion is the one thing - // "machine-readable surfaces must not lie" forbids, and on a - // security-configuration write it tells an operator a permission set is - // gone while it is still being enforced. - // - // The outcome is therefore the COUNT of target RECORDS that actually went - // — the number arm of this engine method's declared result ("how many rows - // the delete removed"), which the data door turns into an honest - // `success: false` on a 200. Every target removed keeps answering `true`, - // the boolean arm's value for a completed by-id delete, so a real deletion - // is unchanged on the wire. - // - // ⛔ NOT `false` for the reset: the boolean arm's `false` is the driver - // contract's "no row matched", which the data door turns into a - // `404 RECORD_NOT_FOUND` — and the record is still right there, so that - // would trade this lie for a louder one and hand a UI a reason to drop the - // row it must keep showing. - // - // Zero also covers every other way a record survives this loop: a metadata - // delete that lands on a package-owned RECORD (left to the package door), - // a retire whose `ql.delete` failed, and a read-back that could not answer - // at all. All three leave the row in place — or leave us unable to say it - // does not — and none is a deletion the caller should be told happened. - // - // ⛔ The outcome is read off the RECORD, never off the re-projection's own - // `deleted` counter. On a kernel wired to the ADR-0094 AWAITED projector — - // the production shape — `deleteMetaItem` has already retired the record - // by the time control returns here, so the idempotent re-projection below - // finds no row and legitimately reports `deleted: 0` for a delete that - // really happened. Reading that counter would have answered - // `success: false` for EVERY delete, which is the same defect with its - // sign flipped: honest about the packaged set and a fresh lie about the - // ordinary one. `permission-set-projection.test.ts` holds both legs. - let removed = 0; - for (const row of targets) { - await protocol.deleteMetaItem({ type: 'permission', name: row.name, ...actorArg }); - const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name: row.name, state: 'deleted', - }); - if (res && (res.seeded + res.updated) > 0) { - logger?.info?.('[security] permission set reset to its declared baseline (artifact-backed; ADR-0094)', { name: row.name }); - } - // The caller addressed a RECORD by id, so that is the question this - // answers: is it still there? A row re-projected under the same id is - // the reset; a row that is gone is the deletion. - // - // ⛔ The probe has THREE outcomes and this loop counts only ONE of them. - // Probing through `tryFind` — whose catch returns `[]` — would have made - // a FAILED read indistinguishable from "the row is gone", and answered - // `success: true` with the record sitting exactly where it was: this - // card's own lie with a different trigger (an outage instead of a - // packaged set). `unknown` is therefore counted as a SURVIVOR, the - // fail-closed direction: a caller told "not deleted" re-reads the record - // and finds out, a caller told "deleted" has no reason to look again. - // It is also the only direction that cannot be produced by a broken - // read — `gone` now requires a read that actually ANSWERED. - const survival = await probeRecordSurvival(ql, 'sys_permission_set', row.id); - if (survival === 'unknown') { - // Durability channel: the write half of this delete already ran, so - // the row's fate is decided and only our READING of it failed. Say so - // once, naming what the caller was told and how to settle it. - logger?.warn?.( - '[security] could not read the sys_permission_set record back after its metadata delete, so the ' + - 'data-door answer reports it as NOT deleted (fail-closed). The delete itself already ran; re-read the ' + - 'record to see whether it was removed or reset to its declared baseline.', - { name: row.name, id: row.id }, - ); - } - if (survival === 'gone') removed += 1; - } - // `targets` is non-empty here — the early return above sends an empty - // target set to the driver — so this is never the vacuous `true`. - opCtx.result = removed === targets.length ? true : removed; - return; - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Boot reconciliation + one-time backfill (ADR-0094 D4) -// ───────────────────────────────────────────────────────────────────────────── - -export interface ProjectionReconcileOutcome { - /** Records created/updated from env-scope metadata overlays. */ - projectedFromMetadata: number; - /** Legacy data-door-only records migrated into the metadata store. */ - backfilledIntoMetadata: number; - /** Records re-projected because they drifted from the effective body. */ - driftHealed: number; - /** - * Records whose backfill FAILED — each one is a definition the metadata - * store does not have and now never got. Counted (not just logged) so a - * caller/test can see the degradation without grepping a log: #4669 stayed - * invisible for a whole release precisely because the only signal was a - * `warn` and the counters stayed at zero. - */ - backfillFailed: number; -} - -/** Compare a record's projected columns against a body — true when they differ. */ -export function recordDiffersFromBody(row: any, body: any): boolean { - const want = permissionSetRowFields(body); - const norm = (v: any) => JSON.stringify(parseMaybeJson(v, null)); - for (const key of ['object_permissions', 'field_permissions', 'system_permissions', 'row_level_security', 'tab_permissions', 'admin_scope'] as const) { - if (norm(row?.[key]) !== norm(want[key])) return true; - } - if ((row?.label ?? null) !== (want.label ?? null)) return true; - if ((row?.description ?? null) !== (want.description ?? null)) return true; - // [#4669] `active` is row state, not a spec key — a definition body cannot - // declare it, so a record is never "drifted" on account of it. - return false; -} - -/** - * Converge `sys_permission_set` with the metadata layer at boot (idempotent): - * - * 1. every ACTIVE env-scope `permission` overlay projects onto its record - * (creating missing ones) — metadata wins; - * 2. an env-authored record whose name has NO metadata presence (no - * declaration, no overlay) is a legacy data-door creation — its body is - * backfilled into the metadata store ONCE (enforcement unchanged: the - * evaluator's db fallback already resolved exactly this body); - * 3. an env-authored record that drifted from an EXISTING effective body is - * re-projected from metadata, loudly — THAT record, by its id, so every - * row of a duplicated name converges (#22169) — for such names the evaluator - * already resolved the metadata body, so the record drift was - * display-only and never enforced (promoting it would silently change - * effective permissions at upgrade). - */ -export async function reconcilePermissionSetProjection( - protocol: any, - deps: ProjectionDeps, -): Promise { - const out: ProjectionReconcileOutcome = { - projectedFromMetadata: 0, backfilledIntoMetadata: 0, driftHealed: 0, backfillFailed: 0, - }; - const failedNames: string[] = []; - const { ql, logger } = deps; - if (!ql || typeof ql.find !== 'function' || !protocol || typeof protocol.getMetaItemLayered !== 'function') { - return out; - } - - // 1. env-scope overlays → records. - const overlayNames = new Set(); - for (const type of ['permission', 'permissions']) { - const rows = await tryFind(ql, 'sys_metadata', { type, state: 'active' }, 1000); - for (const r of rows) { - if ((r?.organization_id ?? null) !== null || !r?.name) continue; // env-wide overlays only - overlayNames.add(String(r.name)); - } - } - // [#11097] ONE existence read for every overlay name, hoisted out of the loop - // below. Each iteration used to issue its own `SELECT … WHERE name = ? LIMIT - // 1` inside `upsertEnvPermissionSet` — one sequential HTTP request per overlay - // on the remote libsql/Turso database every hosted environment runs. - // - // ⚠️ Deliberately NOT reusing the `tryFind(ql, 'sys_permission_set', {}, 1000)` - // page read a few lines below, tempting though that is (it reads the same - // table and would cost nothing extra). That page is CAPPED at 1000 rows, and a - // truncated page reads as "absent" — which INSERTS. An environment past the - // cap would re-attempt an insert for every overlay beyond it on every boot. - // The chunked `$in` read has no such cap by construction. - const existingByName = await buildExistingByName( - ql, - 'sys_permission_set', - [...overlayNames], - logger, - ); - for (const name of overlayNames) { - const res = await projectPermissionMutation(protocol, deps, { - type: 'permission', name, state: 'active', - }, { existing: existingByName }); - // ⚠️ `unchanged` is deliberately NOT summed in. This counter reports records - // this pass CREATED OR CHANGED, and now that an already-matching record is - // no longer re-written, a steady-state boot honestly reports 0 here where it - // used to report one per overlay. Reading the old number as "N overlays are - // healthy" was always reading a write count as a health count. - out.projectedFromMetadata += (res?.seeded ?? 0) + (res?.updated ?? 0); - } - - // 2 + 3. env-authored records: backfill or heal. - // - // [#22169] The layered read is LAZY and asked ONCE PER NAME. The trust rule - // below consults it only when no SchemaRegistry answers, yet it used to be - // awaited for every candidate ROW before that rule ran — four serial - // statements per row on the protocol's read, measured on a real - // `ObjectStackProtocolImplementation`, every one of them discarded on a - // kernel whose registry is readable (every hosted one). Keyed by name because - // a name's answer does not depend on which of its rows asked; dropped again - // after this pass backfills that name, because the backfill is a write that - // changes the answer. - const layeredByName = new Map(); - const layeredFor = async (name: string): Promise => { - if (layeredByName.has(name)) return layeredByName.get(name); - let layered: any = null; - try { - layered = await protocol.getMetaItemLayered({ type: 'permission', name }); - } catch { layered = null; } - layeredByName.set(name, layered); - return layered; - }; - const records = await tryFind(ql, 'sys_permission_set', {}, 1000); - for (const row of records) { - if (!row?.name || row.managed_by === 'package') continue; - if (overlayNames.has(String(row.name))) continue; // governed + projected above - // Same trust rule as the projector: with a readable SchemaRegistry the - // declared body is the whole truth for overlay-less names — the layered - // `code`/`effective` layers can echo tombstoned rows or runtime shadows - // and would suppress a legitimate backfill. - let effective: any = readDeclaredBody(ql, row.name); - if (!effective?.name && !hasSchemaRegistry(ql)) { - const layered = await layeredFor(String(row.name)); - effective = (isProjectionEcho(layered?.effective) ? null : layered?.effective) - ?? (isProjectionEcho(layered?.code) ? null : layered?.code) - ?? null; - } - if (!effective?.name) { - const canSave = typeof protocol.saveMetaItem === 'function'; - if (!canSave) continue; - try { - await protocol.saveMetaItem({ - type: 'permission', name: row.name, item: permissionSetBodyFromRow(row), actor: 'system', - }); - out.backfilledIntoMetadata += 1; - // The name has metadata presence now, so a later row of it must see - // the definition this write created rather than the pre-write answer. - layeredByName.delete(String(row.name)); - } catch (e) { - out.backfillFailed += 1; - failedNames.push(String(row.name)); - // [#4632 — AGENTS.md "Degradation log levels"] DURABILITY degradation: - // the record keeps listing and keeps resolving, so nothing looks - // broken — while the definition it is supposed to project stays absent - // from the only authoritative store. Said ONCE, at the first failure, - // with the consequence and the fix; the rest are counted and named in - // the summary line below. #4669: this was a `warn` with no counter, - // which is why a 100%-failing backfill sat green for a release. - if (out.backfillFailed === 1) { - const message = - '[security] permission-set backfill into metadata FAILED (ADR-0094 D4) — this environment has ' + - '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + - 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + - 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + - 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + - 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + - '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + - 'off-contract — then reboot to re-run reconciliation, or delete the orphan record.'; - // `ProjectionLogger.error` is OPTIONAL, so `logger?.error?.(…)` printed - // NOTHING against a sink that has only `warn` — the durability - // degradation described above would then be reported by nobody at all - // (#9657). Reach for `error`, fall back to `warn`, never to silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754), so no TS caller can - // hand over a sink this line evaporates against. The `?.` below is - // the backstop for untyped hosts only — see the interface. - if (logger?.error) logger.error(message, e as Error, { name: row.name }); - else logger?.warn?.(message, { name: row.name, error: String((e as Error)?.message ?? e) }); - } - } - } else if (recordDiffersFromBody(row, effective)) { - // These names have NO env overlay (skipped above), so the effective - // body IS the declared one — the registry already enforces it; only - // the record needs healing. No registry sync (it would clobber the - // pristine declared entry with a projection copy). - // - // [#22169] ⛔ The row THIS LOOP READ is healed, by its `id`, and nothing - // else. Handing the body to `upsertEnvPermissionSet` instead re-resolved - // the record BY NAME (first row, lowest id), so with more than one row - // per name only that one was ever written: every later row was reported - // "re-projected" and left drifted, on every boot, forever — measured on a - // real engine as 16 of 24 rows still drifted and warned about on boot 2, - // 3, … . The by-name read also had an INSERT branch that a refused read - // reached (`tryFind` answers `[]` for a throw), minting a duplicate of a - // row this loop was holding. A by-id update has no existence question to - // get wrong. Facets only (`permissionSetRowFields`), exactly the columns - // the upsert's update leg wrote: identity, `active` and provenance are - // never touched by a heal. - const healed = await tryUpdate(ql, 'sys_permission_set', { - id: row.id, - ...permissionSetRowFields(effective), - }); - // Said AFTER the write, so the line reports what happened — the old - // order announced a re-projection the by-name write then never made. - if (healed) { - out.driftHealed += 1; - logger?.warn?.( - '[security] sys_permission_set record drifted from its metadata definition — re-projected (metadata wins; ADR-0094 D4)', - { name: row.name, id: row.id }, - ); - } else { - logger?.warn?.( - '[security] sys_permission_set record drifted from its metadata definition — the re-projection write was ' + - 'REFUSED, so the record keeps its drifted facets until a boot whose write lands. Enforcement is ' + - 'unaffected: the evaluator resolves the metadata definition, never this record (ADR-0094 D4).', - { name: row.name, id: row.id }, - ); - } - } - } - - if (out.backfillFailed > 0) { - // The summary carries the same level as the degradation it summarizes — - // an `info` "reconciled" line over a failed backfill is the reassuring - // half-truth this rule exists to remove. - const summary = - `[security] sys_permission_set projection reconciled with ${out.backfillFailed} FAILED backfill(s) ` + - '(ADR-0094 D4) — those records have no metadata definition and will not survive a re-provision. ' + - 'See the first-failure error above for the offending key and the fix.'; - const summaryMeta = { ...out, failedNames: failedNames.slice(0, 10) }; - // Same defect as the first-failure report above, one scope out (#9748). No - // `catch` guards this line, so `check:durability-log-level` could not see - // it and #9657 left it spelled `logger?.error?.(…)` — which printed NOTHING - // against a sink that has only `warn`. Worse here than a plain omission: - // the `else` below is skipped too, so such a sink heard neither the count - // nor the reassuring "reconciled" line, while the first-failure report - // (repaired by #9657) still arrived. Fall back to `warn`, not silence. - // The fallback itself is now GUARANTEED BY THE TYPE: `warn` is - // non-optional on `ProjectionLogger` (#9754). The `?.` is the backstop for - // untyped hosts only — see the interface. - if (logger?.error) logger.error(summary, undefined, summaryMeta); - else logger?.warn?.(summary, summaryMeta); - } else { - logger?.info?.('[security] sys_permission_set projection reconciled (ADR-0094 D4)', { ...out }); - } - return out; -} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 691eeef3992..3110a63b328 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2296,14 +2296,6 @@ export class SecurityPlugin implements Plugin { }); } - // [#20013] The writes the ADR-0094 permission-set data door executed - // ITSELF — it returned without calling `next()`, so no engine write ran. - // Filled by the wrapper that registers the door (below), read by the - // security middleware's post-`next()` assertion. Keyed by the operation - // context the two middlewares share, and private to this plugin, so no - // other middleware can put a write in it. - const writesExecutedByDataDoor = new WeakSet(); - // Register security middleware ql.registerMiddleware(async (opCtx: any, chainNext: () => Promise) => { // [#21771] Whether THIS operation was issued from inside another engine @@ -4258,27 +4250,7 @@ export class SecurityPlugin implements Plugin { // that silently stops gating and a deployment that never finds out. // ⛔ Do not soften this into a warning: a middleware that cannot say a // write was checked must not report that it was. - // - // [#20013] It covers the Layer 0 tenant wall's stored-row judgement too, - // with ONE stand-down, and only for a seam that carries the wall alone: - // the ADR-0094 permission-set data door (registered below, inside this - // middleware) executes an insert or update of `sys_permission_set` - // ITSELF, through the metadata protocol, and never calls `next()`. No - // engine write runs there, so no hook chain runs, and the only - // `organization_id` such a write can carry is the one the payload - // judgement above already cleared; the stored-row half has nothing left - // to judge. Measured on the base: a platform administrator's insert - // there, under the `isolated` posture, is admitted while Layer 0 walls - // the object, so without this stand-down the guard would refuse a write - // it has no reason to. The fact is OBSERVED by the wrapper that - // registers the data door (it records a write the door never passed on - // to `next()`), never declared by an operation field another middleware - // could set. A seam carrying a row-level `check` is NOT stood down: that - // judgement has no payload half for an insert, and the data door keeps - // failing closed under one exactly as before. - const standsDown = - !businessCheckSeamInstalled && tenantWallSeamInstalled && writesExecutedByDataDoor.has(opCtx); - if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true && !standsDown) { + if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true) { const developerMessage = businessCheckSeamInstalled ? `[Security] Access denied: the ${opCtx.operation} on '${opCtx.object}' was executed without the row-level CHECK ` + `being evaluated — the engine did not run OperationContext.postHookWriteImageCheck. ` + 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 deleted file mode 100644 index 1c185efeaf6..00000000000 --- a/packages/plugins/plugin-security/src/seed-name-lookup-degradation-audible.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18570] The batched existence read's OWN failure reaches the author even - * when the caller injected no logger — the sixth instance of the - * doubly-optional `logger?.warn?.(…)` shape, repaired through the ONE - * derivation #18091 landed for it (`reportThroughSink`). - * - * ## Why every case here is DIFFERENTIAL, never a bare "did it print" - * - * The card that filed this site did not read it off the code — it drove an - * unreadable-database pass twice and compared: with NO logger injected the run - * produced exactly ONE author-visible line (an already-repaired #18091 site) - * while this one stayed silent, and with a logger injected the same run - * produced BOTH. A test that only asserts "an injected logger was called" - * reproduces none of that: the old `logger?.warn?.(…)` passes it unchanged, - * because the mute was never on the injected arm. - * - * So case 1 runs a **lit control on the same subject in the same run** — - * `reportThroughSink` with no sink, the one derivation every repaired site - * reaches the console through. A zero from this site is a reading only while that - * control fires; if the harness ever stops capturing `console.warn`, the - * control goes to zero too and the case fails instead of passing vacuously. - * - * ## The three halves that must not move while the mute goes away - * - * - **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`, - * `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. - * This is the discriminating control #18023 established: routing a healthy - * boot's output to the console would turn every such control into noise. - * ⛔ Without this case, "always `console.warn`" would pass case 1. - * - **Case 4** — a host whose `warn` is not callable (a plain-JS embedder, or - * a cast) must not THROW, and must still reach the author. That is the third - * spelling `seed-refusal-sink.ts` refuses (`if (logger) logger.warn(…)`), - * and it is the reason this call site asks `typeof` rather than truthiness. - */ - -import { describe, expect, it, vi } from 'vitest'; - -import { buildExistingByName } from './seed-name-lookup.js'; -import { reportThroughSink } from './seed-refusal-sink.js'; - -/** A driver seam that cannot answer at all — the outage arm. */ -function unreadableQl() { - return { - find: async () => { - throw new Error('database is unreachable'); - }, - }; -} - -/** A driver seam that answers, and answers "none of these names exist". */ -function emptyPageQl() { - const calls: unknown[][] = []; - return { - calls, - find: async (...args: unknown[]) => { - calls.push(args); - return [] as any[]; - }, - }; -} - -/** - * A driver seam whose page is a PREFIX of the answer — `readNamePage` asks for - * `budget + 1` rows, so handing back that many is what truncation looks like. - * Unscoped, a one-name read budgets `UNSCOPED_PAGE_FLOOR` (20) rows. - */ -function truncatedPageQl() { - return { - find: async () => Array.from({ length: 21 }, (_, i) => ({ id: `row-${i}`, name: 'alpha' })), - }; -} - -/** Collect `console.warn` for the duration of one run. */ -async function captureConsoleWarn(run: () => Promise): Promise { - const seen: unknown[][] = []; - const spy = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { - seen.push(args); - }); - try { - await run(); - } finally { - spy.mockRestore(); - } - return seen; -} - -const BATCHED_READ_FAILED = 'batched seed existence read failed'; -const BATCHED_READ_TRUNCATED = 'batched seed existence read TRUNCATED'; - -describe('[#18570] batched seed existence read — its own failure is audible with no logger', () => { - it('reports on the console beside a LIT CONTROL in the same run (the differential the card was graded on)', async () => { - const seen = await captureConsoleWarn(async () => { - // The site under repair, driven exactly as the card drove it: an - // unreadable database and NO logger argument at all. - await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha', 'beta']); - - // THE LIT CONTROL, same run, same subject (author-visible output with no - // sink injected): the derivation every repaired site reaches the console through. - reportThroughSink(undefined, '[control] lit control', { unreadable: 2, total: 2 }); - }); - - const lines = seen.map((args) => String(args[0])); - - // The control fires — so a zero from the site under repair would be a - // reading rather than a dead harness. - expect(lines.filter((l) => l.includes('[control] lit control'))).toHaveLength(1); - - // And the site under repair is no longer the silent one. Pre-#18570 this - // run produced the control line ALONE. - expect(lines.filter((l) => l.includes(BATCHED_READ_FAILED))).toHaveLength(1); - expect(lines).toHaveLength(2); - - // The structured half travels with it — the console arm is the same report, - // not a degraded restatement of it. - const meta = seen.find((args) => String(args[0]).includes(BATCHED_READ_FAILED))?.[1] as Record< - string, - unknown - >; - expect(meta).toMatchObject({ object: 'sys_capability', names: 2 }); - }); - - it('reports the TRUNCATED cause the same way, budget and all', async () => { - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(truncatedPageQl() as any, 'sys_capability', ['alpha']); - }); - - expect(seen).toHaveLength(1); - expect(String(seen[0]?.[0])).toContain(BATCHED_READ_TRUNCATED); - expect(seen[0]?.[1]).toMatchObject({ object: 'sys_capability', names: 1, rowBudget: 20 }); - }); - - it('still hands the line to an injected sink — with its receiver — and leaves the console alone', async () => { - const received: Array<{ self: unknown; message: string; meta: unknown }> = []; - // A CLASS-based sink, because the hazard `seed-refusal-sink.ts` records is a - // delivery form that drops the receiver: `(logger?.warn ?? console.warn)(…)` - // calls a bare function and a host reaching for `this` throws. - class HostSink { - readonly tag = 'host'; - warn(message: string, meta?: Record) { - received.push({ self: this, message, meta }); - } - } - const sink = new HostSink(); - - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(unreadableQl() as any, 'sys_permission_set', ['alpha'], sink, 'org_1'); - }); - - expect(received).toHaveLength(1); - expect(received[0]?.self).toBe(sink); - expect(received[0]?.message).toContain(BATCHED_READ_FAILED); - expect(received[0]?.meta).toMatchObject({ - object: 'sys_permission_set', - names: 1, - organization: 'org_1', - }); - - // ⛔ Not both channels: an injected host owns the report. - expect(seen).toHaveLength(0); - }); - - it('stays silent on every channel when the read ANSWERED — the discriminating control', async () => { - const ql = emptyPageQl(); - let verdict: string | undefined; - - const seen = await captureConsoleWarn(async () => { - const index = await buildExistingByName(ql as any, 'sys_capability', ['alpha']); - verdict = (await index.get('alpha')).status; - }); - - // The read really happened and really answered — `absent` is a fact here, - // never a swallowed failure. - expect(ql.calls).toHaveLength(1); - expect(verdict).toBe('absent'); - - // ⛔ A healthy pass buys no console noise. This is what keeps case 1 from - // being satisfiable by an unconditional `console.warn`. - expect(seen).toHaveLength(0); - }); - - it('reaches the author — without throwing — for a host whose `warn` is not callable', async () => { - // The shape a plain-JS embedder or a cast produces, and the one - // `if (logger) logger.warn(…)` turns into `logger.warn is not a function` - // inside a degradation path. - const lyingHost = { warn: 'not a function' } as unknown as { warn: (m: string) => void }; - - const seen = await captureConsoleWarn(async () => { - await buildExistingByName(unreadableQl() as any, 'sys_capability', ['alpha'], lyingHost as any); - }); - - expect(seen).toHaveLength(1); - expect(String(seen[0]?.[0])).toContain(BATCHED_READ_FAILED); - }); -}); - -/** - * [#11451, commit c33f18592] A caller that passes no `equals` predicate emits - * exactly the `where` key set it emitted before the predicate existed: - * `['name']`, not `['name']` plus `undefined`-valued keys that `toEqual` - * would quietly accept. `permission-set-projection.ts`' overlay pass is such - * a caller. The fence used to ride the declared-permission seeder's suite and - * is pinned here, at the function, now that the seeder is retiring. - * - * Both reads are pinned: the batched page and the per-item degradation read - * it falls back to. The predicate-carrying case is the positive control: it - * proves this instrument sees a spread key when one is there. - */ -describe('#11451: a caller that passes no predicate emits its old key set', () => { - it('the batched read emits only `name`', async () => { - const ql = emptyPageQl(); - await buildExistingByName(ql as any, 'sys_permission_set', ['alpha', 'beta']); - expect(ql.calls).toHaveLength(1); - expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name']); - }); - - it('the per-item degradation read emits only `name`', async () => { - const wheres: Record[] = []; - // The batched `$in` read is refused, so the per-item path is taken; each - // per-item read is recorded and answered "no such row". - const ql = { - find: async (_object: string, q?: { where?: Record }) => { - const name = q?.where?.name; - if (name && typeof name === 'object' && '$in' in (name as object)) throw new Error('batched read refused (trigger)'); - wheres.push(q?.where ?? {}); - return [] as any[]; - }, - }; - const index = await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], { warn: () => {} }); - expect((await index.get('alpha')).status).toBe('absent'); - expect(wheres).toHaveLength(1); - expect(Object.keys(wheres[0]!)).toEqual(['name']); - }); - - it('positive control: a caller that passes a predicate emits it', async () => { - const ql = emptyPageQl(); - await buildExistingByName(ql as any, 'sys_permission_set', ['alpha'], undefined, undefined, { managed_by: 'platform' }); - expect(Object.keys((ql.calls[0]![1] as any).where)).toEqual(['name', 'managed_by']); - }); -}); diff --git a/packages/plugins/plugin-security/src/seed-name-lookup.ts b/packages/plugins/plugin-security/src/seed-name-lookup.ts deleted file mode 100644 index 966c6602879..00000000000 --- a/packages/plugins/plugin-security/src/seed-name-lookup.ts +++ /dev/null @@ -1,506 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ONE batched existence read for a boot seeder whose input set is known in - * full before its loop starts (#10946). - * - * ## Why this exists - * - * `bootstrapDeclaredPermissions` and `bootstrapDeclaredPositions` are - * read-then-write reconcilers over a list the caller already holds. Written - * as a per-item `SELECT … WHERE name = ? LIMIT 1` inside a `for await` loop, - * each declared item costs its own database ROUND TRIP — invisible on a local - * file database, one sequential HTTP request per leg on a remote libsql/Turso - * database, i.e. on every hosted environment. Measured on a real per-environment - * kernel build with every `@libsql/client` call counted, the two loops together - * grew a REBUILD (tables present, rows already seeded, nothing to change) by - * **exactly 4.0000 round trips per declared item, R² = 1.000000** on both axes. - * - * Schema sync had already been batched (`TursoDriver.supports.batchSchemaSync`), - * which is why objects/views/artifact seeds add 0.00 round trips each on the - * same rig; identity content was the one content axis still paying per item. - * - * ## The seam that has to be judged carefully - * - * ⛔ **A read that CANNOT ANSWER is not the answer "none of them exist."** That - * conflation is the whole risk of hoisting the read: one swallowed failure - * would make every boot conclude that nothing is seeded and re-create - * everything — a far worse defect than the round trips being removed. The - * per-item shape was accidentally immune to it (a failed read fell through to - * an insert that failed too, for that one item only); a batched read is not, - * because one failure now speaks for the entire set. - * - * So the seam is judged on **whether the driver returned a result set**, never - * on whether the array came back empty: - * - * - a thrown read → could not answer - * - a response that is neither an array - * nor `{ records: [...] }` → could not answer - * - a page carrying MORE rows than it - * budgeted for (commit e1d773eb7) → could not answer - * - `[]` → ANSWERED: none of these names exist - * - * "Could not answer" degrades — loudly warned — to the per-item read the loops - * used before, which is the case that matters for a driver that simply does not - * do `$in`: everything then proceeds exactly as it did pre-#10946, only slower. - * When the per-item read cannot answer either (the database is genuinely - * unreachable), the oracle reports {@link ExistingLookupResult} `unknown` and - * the seeder declines to touch that name at all. - * - * ⚠️ That last step is STRICTER than the code it replaces, deliberately. The old - * loop turned a failed read into an insert attempt and relied on the `name` - * unique index to refuse it — a database constraint standing in for a decision - * the seeder should have been making. On any deployment where that index is - * absent or not yet created, the old shape DUPLICATED rows instead of declining. - * - * None of this is a lenient fallback for off-contract input (AGENTS.md Prime - * Directive #12): the batched and per-item reads ask the driver the same - * question, and the answer has one meaning. - * - * ## The page budget, and why it is measured rather than trusted (commit e1d773eb7) - * - * A `LIMIT` cannot express "one row per name", so the page needs a cap and no - * cap is CORRECT. `limit: names.length` was exact only while one row could exist - * per name; #10103 found that and repaired the SCOPED arm, where the honest - * bound really is two rows per name. The unscoped arm kept the old cap, and its - * bound is not two — it is the number of organizations, because - * `sys_capability.name` and `sys_permission_set.name` are unique PER - * ORGANIZATION (#8461 / ADR-0120 D1) and ADR-0066 D1 encourages admins to EXTEND - * the registry inside their own organization. `names.length * 2` would have been - * the same defect with a larger constant. - * - * So the cap stopped being a promise and became a MEASUREMENT: 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 rather than the answer. It joins the two - * causes above — could not answer — and degrades to the same per-item read. - * - * ⚠️ That is a BEHAVIOUR CHANGE for the two callers that read unscoped - * (`bootstrapDeclaredCapabilities`, `permission-set-projection`'s overlay pass), - * and the direction is deliberate: in the truncating case they used to get a - * SILENT WRONG answer — names reported `absent` whose rows were sitting in the - * table, sending each to its insert branch — and now get a LOUD SLOW one, N - * round trips and a warning naming the budget. An install that does not truncate - * (every stock one: one row per name) reads exactly the same page it read - * before, in exactly one round trip. - * - * ## Chunking - * - * `$in` binds one parameter per name, and SQLite builds cap bound parameters - * (`SQLITE_MAX_VARIABLE_NUMBER`, historically 999). {@link NAME_CHUNK_SIZE} - * keeps a single read well under every such cap, so the cost is - * `ceil(N / 500)` reads — constant for every realistic declaration count and, - * unlike an unchunked read, incapable of turning a large environment's boot - * into a hard driver error. - * - * ## Organization scope (#10103) - * - * The catalog these seeders write is materialized PER ORGANIZATION under a - * walled posture, so this oracle answers "does THIS organization have a row for - * this name" — not "does any row anywhere carry this name". Two consequences, - * and the second is the one that bites: - * - * - the read is threaded with the organization, so it routes through - * `SqlDriver.applyTenantScope` (the governed chokepoint) rather than - * re-implementing a wall predicate here; - * - "first row wins" stops being right. `applyTenantScope` returns the - * caller's rows AND organization-less ones, so a page can carry two rows for - * one name, in driver order. Taking whichever came first would let a PRE-FIX - * organization-less row answer for this organization, the loop would take its - * update branch, and no per-organization copy would ever be created — a - * silent no-op that leaves a walled deployment exactly as broken as before - * while reporting success. It was measured. So the two are separated: - * {@link ExistingLookupResult} `present` means THIS organization's own row, - * and an organization-less leftover comes back on the `absent` result as - * {@link ExistingLookupResult} `organizationLessResidue`, so the caller both - * creates the copy and can report the leftover loudly. - * - * A pass with no organization (the `single`-posture carve-out) is unchanged in - * every respect: nothing is threaded, and the first row is the row. - * - * ## Where the degradation line goes (#18570) - * - * The degradation above is only worth writing if somebody READS it, and the - * doubly-optional `logger?.warn?.(…)` this site used to spell evaluates to - * NOTHING when the caller injected no sink: the batched read failed, the oracle - * quietly switched to the slow path, and no human was told. That is the same - * shape repaired on the permission-set axis (#17516), the capability axis - * (#18023) and the two declared-metadata seeders' five sites (#18091) — this is - * its sixth instance, and it takes the derivation #18091 landed for exactly - * this rule ({@link reportThroughSink}) rather than a sixth hand-written copy. - * - * ⚠️ Delivery only. The wording, the structured meta and the two named causes - * are axis-specific and stay here, which is the split `seed-refusal-sink.ts` - * documents. Nothing about WHEN this site reports changes: a read that answered - * is as silent as it always was, on every channel. - */ - -import { resolveOwnOrganizationRow, seedCtx as lookupCtx } from './organization-scope.js'; -import { reportThroughSink, type CollisionReportSink } from './seed-refusal-sink.js'; - - - -/** Names bound into one `$in` read. See the chunking note in the module header. */ -export const NAME_CHUNK_SIZE = 500; - -export interface SeedLookupLogger { - info?: (m: string, meta?: Record) => void; - warn?: (m: string, meta?: Record) => void; -} - -/** - * What the oracle knows about one name. THREE outcomes, not two: `absent` is a - * fact the driver reported, `unknown` is the absence of any fact at all. - * - * ⛔ Collapsing `unknown` into `absent` is the whole hazard of hoisting the - * read, and the reason this is a union rather than `row | undefined`. A caller - * that treats "I could not find out" as "it is not there" INSERTS — and does so - * for every name at once, because a batched read fails for the whole set. The - * per-item shape hid this behind the unique index (the blind insert was refused - * by the database, which is a guard, not a design); the tri-state makes the - * seeder decline on its own. - */ -export type ExistingLookupResult = - | { status: 'present'; row: any } - /** - * No row for this name IN THIS ORGANIZATION. `organizationLessResidue` names a - * pre-fix row that belongs to no organization and is visible here only through - * the driver's compatibility arm — the caller creates its own copy anyway and - * reports the leftover (#10103). - */ - | { status: 'absent'; organizationLessResidue?: any } - | { status: 'unknown' }; - -const ABSENT: ExistingLookupResult = { status: 'absent' }; -const UNKNOWN: ExistingLookupResult = { status: 'unknown' }; - -/** - * The existence oracle a seed loop consults in place of its own per-item read. - * - * ⚠️ {@link ExistingByNameIndex.remember} is not an optimization — it is what - * keeps hoisting the read out of the loop behaviour-preserving. The per-item - * read saw rows the SAME loop had just inserted, so a name declared twice in - * one batch resolved as present on the second pass and took the caller's - * collision branch (which, for permission sets, is the loud ADR-0086 D4 - * "owned by another package" refusal). A snapshot taken before the loop cannot - * see those inserts: without `remember`, the second declaration would attempt - * an insert instead, the unique index would refuse it, and a refusal that used - * to be reported would become a silent nothing. So every caller that inserts - * records the row it created. - */ -export interface ExistingByNameIndex { - /** What is known about `name` — see {@link ExistingLookupResult}. */ - get(name: string): Promise; - /** Record a row the calling loop just created under `name`. */ - remember(name: string, row: any): void; -} - -/** - * [commit e1d773eb7] Rows per requested name an UNSCOPED page is willing to hold before - * it stops trying to answer in one read. - * - * ⚠️ A BUDGET, not a bound — the distinction is the whole of commit e1d773eb7. Nothing - * bounds rows-per-name here: `sys_capability.name` and `sys_permission_set.name` - * are unique PER ORGANIZATION (#8461 / ADR-0120 D1) 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. Any constant - * multiplier is therefore the same defect with a larger number, which is why - * this one buys nothing except SPEED: {@link readNamePage} asks for one row more - * than it, so exceeding it is DETECTED rather than silently truncated. - * - * The shape (`names.length * 4`, floored) matches the sibling generous cap on - * the same table in `security-plugin.ts`'s permission-set loader. - */ -const UNSCOPED_ROWS_PER_NAME = 4; - -/** Floor for the unscoped budget, so a one-name read is not budgeted at four. */ -const UNSCOPED_PAGE_FLOOR = 20; - -/** - * [#10103] Rows per requested name a SCOPED page must hold — and here the - * number IS a proven bound rather than a budget. `applyTenantScope` returns - * `organization_id = :tenant OR organization_id IS NULL`, and the declared name - * index is unique per organization, so each name has at most this organization's - * row plus one organization-less leftover. Kept exact deliberately: a scoped - * page that overflows it means the uniqueness the catalog is built on is not - * holding, and commit e1d773eb7's probe turns that into a loud degradation instead of a - * silent truncation. - */ -const SCOPED_ROWS_PER_NAME = 2; - -/** How many rows this page is willing to hold. See the two constants above. */ -function pageRowBudget(names: string[], organizationId?: string): number { - return organizationId - ? names.length * SCOPED_ROWS_PER_NAME - : Math.max(names.length * UNSCOPED_ROWS_PER_NAME, UNSCOPED_PAGE_FLOOR); -} - -/** - * One page, or WHY there is none — the two `cause`s are different events with - * the same consequence (see {@link readNamePage}). - */ -type NamePage = - | { readonly ok: true; readonly rows: any[] } - | { readonly ok: false; readonly cause: 'unreadable' | 'truncated'; readonly budget: number }; - -/** - * Read one page of names — `ok: false`, distinct from an empty page, when this - * read cannot answer. - * - * ## [commit e1d773eb7] Truncation is "could not answer", not "none of them exist" - * - * A `LIMIT` cannot say "one row per name", so any cap this read picks can be - * exceeded by a healthy install (see {@link UNSCOPED_ROWS_PER_NAME}). The rows - * that fall off are the highest `id`s under #4363's `ORDER BY id ASC` tie- - * breaker, so WHOLE NAMES vanish from the page — and a name missing from the - * page reads as `absent`, which routes its caller to the INSERT branch. That is - * the same conflation the module header refuses for a failed read, arriving - * through a page that succeeded. - * - * So the cap is turned into a MEASUREMENT: ask for `budget + 1` rows and - * compare. - * - * - `page.length <= budget` — the driver had no `budget + 1`-th row to give, - * so this page is provably the COMPLETE set for these names; - * - `page.length > budget` — there is at least one more row than this read is - * willing to hold, so the page is a PREFIX of the answer and cannot be read - * as one. `ok: false`, and the caller degrades to the per-item read exactly - * as it does for a driver without `$in`. - * - * Both directions are exact, which is what makes the budget a free choice: no - * legitimate page is ever mistaken for a truncated one, and no truncated page is - * ever mistaken for a complete one. - */ -async function readNamePage( - ql: any, - object: string, - names: string[], - organizationId?: string, - equals?: Readonly>, -): Promise { - const budget = pageRowBudget(names, organizationId); - let rows: any; - try { - rows = await ql.find( - object, - { - // [commit c33f18592] `...(equals ?? {})` spreads NOTHING when no predicate was - // given, so a caller that passes none emits the exact key set it - // emitted before — not the same keys plus `undefined`-valued ones, - // which `toEqual` would have quietly accepted. - where: { name: { $in: names }, ...(equals ?? {}) }, - // [commit e1d773eb7] ONE MORE than the budget, always — the extra row is the - // probe, and reading it back is how truncation is told from a page that - // merely happens to be full. - limit: budget + 1, - }, - { context: lookupCtx(organizationId) }, - ); - } catch { - return { ok: false, cause: 'unreadable', budget }; - } - // `ObjectQL.find` resolves a BARE array — MEASURED, not read off the - // declared type. `IDataEngine.find` says `Promise`, and a declared type - // is not proof here: this repo's own `ObjectStackAdapter.find()` resolves a - // `QueryResult` envelope and never an array. So the engine behind this seam - // was booted for real (a real `ObjectQL` over a real `SqlDriver`) and driven, - // and it answered `[object Array]` with no own `records` key on a populated - // page and on an empty one alike — see `engine-find-bare-array.pin.test.ts`, - // which pins this seam. The `{ records }` limb this used to carry was - // therefore unreachable: dead code that read as a contract. - // - // What is NOT changed: a non-array is still `null` here, and `null` is still - // `unreadable` below. Removing a dead limb must not quietly convert a seam - // that GAPS into one that invents an empty answer. - const page: any[] | null = Array.isArray(rows) ? rows : null; - if (page === null) return { ok: false, cause: 'unreadable', budget }; - if (page.length > budget) return { ok: false, cause: 'truncated', budget }; - return { ok: true, rows: page }; -} - -/** - * The per-item read the loops used before #10946 — the degradation path. - * - * `remember` is a deliberate no-op: this oracle re-reads the database on every - * call, so it already sees rows the loop inserted a moment ago. - */ -function perItemIndex( - ql: any, - object: string, - organizationId?: string, - equals?: Readonly>, -): ExistingByNameIndex { - return { - async get(name: string): Promise { - let rows: any; - try { - // Limit 5, not 1, when scoped: a single row would be whichever the - // driver ordered first, and this read must be able to tell this - // organization's row from an organization-less leftover. - // - // [commit c33f18592] The predicate rides the DEGRADATION read too. A fallback - // that dropped it would ask a WIDER question than the batched read it - // is standing in for — and for the caller that needs one, wider is not - // "slower but the same": it is a different row. - rows = await ql.find( - object, - { where: { name, ...(equals ?? {}) }, limit: organizationId ? 5 : 1 }, - { context: lookupCtx(organizationId) }, - ); - } catch { - return UNKNOWN; - } - // Bare array, driven — the same measurement `readNamePage` above records, - // taken at THIS seam too (`engine-find-bare-array.pin.test.ts` drives the - // per-item path separately, because one seam's reading is not the other's). - // A non-array stays `UNKNOWN`: a read that did not answer is never "absent". - const list = Array.isArray(rows) ? rows : null; - if (list === null) return UNKNOWN; - return resolveForOrganization(list, organizationId); - }, - remember() { /* re-read every call — nothing to cache */ }, - }; -} - -/** - * Which of the rows a read returned for ONE name is this organization's, - * expressed as this module's tri-state. - * - * The organization split itself is NOT decided here — it delegates to - * {@link resolveOwnOrganizationRow}, the one spelling of that question the - * catalog has. Two spellings of "which row is mine" is exactly the shape that - * produced the defect this scoping repairs (one question, two implementations, - * the ungoverned copy winning), so this function only translates that answer - * into `present` / `absent` + leftover. - * - * Unscoped (the `single`-posture pass) the first row is the row, exactly as - * before. - */ -function resolveForOrganization(rows: any[], organizationId?: string): ExistingLookupResult { - const { own, organizationLessResidue } = resolveOwnOrganizationRow(rows, organizationId); - if (own) return { status: 'present', row: own }; - return organizationLessResidue ? { status: 'absent', organizationLessResidue } : ABSENT; -} - -/** - * Build the existence lookup a seed loop should use: ONE batched read for the - * whole name set, degrading to the per-item read when that read cannot answer. - * - * `names` may contain duplicates and blanks; both are dropped before the read. - */ -export async function buildExistingByName( - ql: any, - object: string, - names: readonly (string | null | undefined)[], - logger?: SeedLookupLogger, - /** - * Answer for THIS organization (#10103). Omitted = the pre-existing - * installation-wide question, which is what a `single`-posture pass wants. - */ - organizationId?: string, - /** - * [commit c33f18592] An extra EQUALITY predicate ANDed onto the `$in`, for a caller - * whose existence question is narrower than "a row with this name". - * - * `bootstrapSystemCapabilities`' curated half asks for the platform's OWN - * organization-less row (`managed_by: 'platform'` + `organization_id: null`, - * #8470), not the first row that happens to share the name. Post-#8461 those - * are different questions: `sys_capability.name` is unique per ORGANIZATION, - * so one name can have a row per organization plus the platform's. - * - * ⚠️ The predicate should keep the result a SINGLETON per name, and the - * curated one discharges that by construction: the declared unique key is - * `(COALESCE(organization_id, '__global__'), name)` (ADR-0120 D3), so the - * NULL-organization bucket admits at most one row per name — "exactly the - * bucket this key part keeps a singleton", as `sys-capability.object.ts` puts - * it. Narrowing can only SHRINK a page, so passing a predicate never makes - * truncation likelier than the unpredicated read it replaces. - * - * [commit e1d773eb7] That used to be a CORRECTNESS precondition the caller had to - * discharge — an unscoped page was capped at `names.length`, so a - * non-singleton question truncated, and a truncated page read as `absent`, - * which INSERTS. {@link readNamePage} now measures its own truncation, so a - * caller that breaks the singleton property gets the per-item read (slower, - * and it says so) rather than a wrong answer. What the predicate still buys is - * WHICH row answers: unscoped, the first row of the page is the row, so a - * question wide enough to match somebody else's copy resolves to it — the - * separate harm commit c33f18592 closed, and one no page budget can repair. - */ - equals?: Readonly>, -): Promise { - // Every row the page carried for a name, so the organization split can be - // judged per name rather than by arrival order. - const index = new Map(); - const fromIndex: ExistingByNameIndex = { - async get(name: string): Promise { - // The batched read ANSWERED for every requested name, so a miss here is - // the driver's own "no such row", not a gap in what we know. - return resolveForOrganization(index.get(name) ?? [], organizationId); - }, - remember(name: string, row: any) { - if (!name || !row) return; - const rows = index.get(name); - if (rows) rows.push(row); else index.set(name, [row]); - }, - }; - - const wanted: string[] = []; - const seen = new Set(); - for (const raw of names) { - if (raw == null) continue; - const name = String(raw); - if (!name || seen.has(name)) continue; - seen.add(name); - wanted.push(name); - } - if (wanted.length === 0) return fromIndex; - - for (let i = 0; i < wanted.length; i += NAME_CHUNK_SIZE) { - const outcome = await readNamePage(ql, object, wanted.slice(i, i + NAME_CHUNK_SIZE), organizationId, equals); - if (!outcome.ok) { - // ⛔ NOT "none of them exist" — see the module header. Fall back to the - // per-item read so behaviour is exactly what it was before the hoist. - // - // [commit e1d773eb7] TWO events, ONE consequence. A truncated page is not a broken - // driver — the read worked and the answer is simply wider than one page — - // so it is named separately, because the remedies differ: an unreadable - // database is an outage, while a truncated page is an install whose - // catalog carries more rows per name than this read budgets for, and the - // only cost is the round trips the batching removed. - // - // [#18570] Delivered through the ONE derivation, so a caller that injected - // no sink still hears it. `SeedLookupLogger.warn` is OPTIONAL where - // `CollisionReportSink.warn` is not, so the host is handed over only once - // it really carries a callable `warn` — the same `typeof` question - // `reportThroughSink` itself asks, moved one frame up so the declared - // types stay honest rather than being asserted past. A host with no usable - // `warn` therefore takes the console arm instead of buying silence. - reportThroughSink( - typeof logger?.warn === 'function' ? (logger as CollisionReportSink) : undefined, - outcome.cause === 'truncated' - ? '[security] batched seed existence read TRUNCATED — more rows carry these names than one page holds, so the page cannot answer; falling back to one read per item' - : '[security] batched seed existence read failed — falling back to one read per item', - { - object, - names: wanted.length, - ...(outcome.cause === 'truncated' ? { rowBudget: outcome.budget } : {}), - ...(organizationId ? { organization: organizationId } : {}), - }, - ); - return perItemIndex(ql, object, organizationId, equals); - } - for (const row of outcome.rows) { - const name = row?.name; - if (name == null) continue; - // [#10103] EVERY row is kept, not just the first. A scoped page can carry - // this organization's row and an organization-less leftover for the same - // name, in driver order, and `resolveForOrganization` — not arrival order - // — decides which one answers. Unscoped, the first row is still the row: - // the caller's own uniqueness rules decide what a duplicate name means and - // this read does not reorder that judgement. - const key = String(name); - const rows = index.get(key); - if (rows) rows.push(row); else index.set(key, [row]); - } - } - return fromIndex; -} diff --git a/packages/plugins/plugin-security/src/seed-refusal-sink.ts b/packages/plugins/plugin-security/src/seed-refusal-sink.ts deleted file mode 100644 index b92e7554007..00000000000 --- a/packages/plugins/plugin-security/src/seed-refusal-sink.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#18091] `reportThroughSink` — the ONE derivation of "a seeder refusal - * reaches the author even when no logger was injected". - * - * ## Why this is a module and not a third hand-written copy - * - * The doubly-optional call `logger?.warn?.(…)` evaluates to NOTHING when the - * caller passed no logger: the declaration is dropped, one internal counter - * moves, and no human is told. That shape has now been repaired - * instance-by-instance TWICE — once on the permission-set axis (#17516) and - * once on the capability axis (#18023) — and each repair restated the same two - * lines at its own call site. A shape repaired one instance at a time is a - * CLASS that has not been fixed, which is why the five remaining refusal sites - * in the two declared-metadata seeders share ONE delivery derivation instead of - * gaining five more copies of it. - * - * What is shared here is exactly the part that is axis-INDEPENDENT: WHERE the - * line goes. ⛔ What is deliberately NOT shared is the wording, the token, the - * record and the consequence — those are axis-specific (four of the six parts a - * refusal report is made of), and folding them into one generic sentence is the - * failure mode `seed-refusal-diagnostics.ts` is written to avoid. - * - * ## The three spellings this replaces, and why each is wrong - * - * - ⛔ `logger?.warn?.(…)` — silent with no sink. THE defect. - * - ⛔ `(logger?.warn ?? console.warn)(…)` — evaluates to a bare function and - * calls it with `this === undefined`; a class-based host sink - * (`@objectstack/core`'s `ObjectLogger`) reaches for `this` and throws. The - * property-access call form below keeps the receiver — the same measured - * conclusion `logSeedDurabilityFailure`, `reportPermissionSetNameCollisions` - * and `reportCapabilityNameCollisions` all record. - * - ⛔ `if (logger) logger.warn(…)` — correct for a sink whose `warn` the TYPE - * guarantees, but it THROWS for a host the type cannot reach (a plain-JS - * embedder, or a cast). `permission-set-projection.ts` records that measured - * hazard on `ProjectionLogger.warn`: a sink that lies about its shape - * throws `logger?.warn is not a function` inside a per-row durability catch - * and aborts the very batch that function promises never to stop. - * - * So the guard below is a `typeof` check rather than a truthiness test, and it - * is strictly better than BOTH halves of the old trade-off: a lying host no - * longer buys its silence with a throw — it gets the console, and the author - * still hears the refusal. - * - * ⚠️ This is the REFUSAL channel only. The end-of-pass `logger?.info?.(…)` - * summary in each seeder keeps its outer `?.` deliberately: a pass that did its - * work and refused nothing MUST stay silent on every console channel with no - * sink injected, which is the discriminating control #18023 landed. Routing a - * healthy boot's info line to `console.info` would turn every one of those - * controls into noise and buy no author anything. - */ - -import type { CollisionReportSink } from './permission-set-name-collision.js'; - -/** - * Re-exported for the reason #18088 gave when it did the same: a consumer of - * this delivery rule never declares a second structural copy of the sink. - */ -export type { CollisionReportSink }; - -/** - * Print one refusal so it reaches the author. - * - * `warn`, not `error`: every caller here reports FUNCTIONAL degradation (#4632) - * — the deployment is visibly less than it was authored to be. Nothing claimed - * to be persisted silently failed to land; the write was deliberately never - * attempted, or the read that would have decided it never answered. - * - * @param logger the host sink, or `undefined` when none was injected - * @param message the fully worded, axis-specific line — ⛔ never assembled here - * @param meta the structured half of the same finding - */ -export function reportThroughSink( - logger: CollisionReportSink | undefined, - message: string, - meta?: Record, -): void { - // ⛔ `typeof`, not truthiness — see the module header's third bullet. The - // property-access call form keeps the receiver for a class-based sink. - if (logger && typeof logger.warn === 'function') logger.warn(message, meta); - else console.warn(message, meta); -} diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index f5aa8eb1fda..fb100d080c5 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -154,7 +154,7 @@ "status": "live", "verifiedAt": "2026-08-28", "proof": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts#me-apps-and-everyone-baseline", - "evidence": "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#registerCurrentUserEndpoints (both consumers live in this registrar's route closures: /auth/me/permissions builds the rank table and the most-visible merge across the caller's resolved sets and projects the map onto the response, and /me/apps applies the same merge and DROPS an app whose merged value is 'hidden' — a filter that runs before the fail-open branch, so it survives a missing SecurityPlugin); packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabRank (the four-value rank table itself — hidden 0 < default_off 1 < default_on 2 < visible 3, built once per handler; an unrecognised value is skipped rather than defaulted); packages/plugins/plugin-security/src/permission-set-projection.ts#permissionSetBodyFromRow (the sys_permission_set.tab_permissions column parsed onto the resolved set, with `permissionSetRowFields` writing it back and `mergeRowPatchIntoBody` patching it); packages/core/src/security/assemble-execution-context.ts#entryFields (`tabPermissions: authz.tabPermissions` onto ExecutionContext)", + "evidence": "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#registerCurrentUserEndpoints (both consumers live in this registrar's route closures: /auth/me/permissions builds the rank table and the most-visible merge across the caller's resolved sets and projects the map onto the response, and /me/apps applies the same merge and DROPS an app whose merged value is 'hidden' — a filter that runs before the fail-open branch, so it survives a missing SecurityPlugin); packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabRank (the four-value rank table itself — hidden 0 < default_off 1 < default_on 2 < visible 3, built once per handler; an unrecognised value is skipped rather than defaulted); packages/core/src/security/assemble-execution-context.ts#entryFields (`tabPermissions: authz.tabPermissions` onto ExecutionContext)", "note": "Re-verified 2026-08-23 (commit 905019b1b) and every pointer REPLACED — all three of the previous ones were dead, in two different ways, and the gate could see neither. (a) `plugin-hono-server/src/hono-plugin.ts:1200` was past EOF: that file is 717 lines and contains no `tabPermissions` reference at all — the same code movement that killed this entry's sibling `systemPermissions` pointer (commit de6e2bfcb, PR #11209) moved /me/apps and /auth/me/permissions into `current-user-endpoints.ts`. (b) `runtime/src/security/resolve-execution-context.ts:205` and `rest/src/rest-server.ts:1551` were dead WITHIN bounds — both files are long enough for the cited line, and neither mentions `tabPermissions` anywhere (0 occurrences, measured); the handler's own comment at current-user-endpoints.ts:922-926 records why, namely that resolveCtx() carries neither systemPermissions nor tabPermissions, so /me/apps re-resolves the sets itself. THE VERDICT IS UNCHANGED and was never in question — `live` with a bound dogfood proof, and the behaviour still ships; what was dead is the citation, which is exactly the rot commit 905019b1b's line bound now makes impossible to leave behind silently. The 2026-07-30 reading survives the re-measurement verbatim: the rank merge reads all four values, not only 'hidden'. Two consumers, deliberately both cited: /me/apps ENFORCES (a hidden app is dropped), /auth/me/permissions PROJECTS the merged map for the renderer. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED. This entry had SEVEN line citations, more than any other in the batch, and the re-closure is a study in how that ages: `assemble-execution-context.ts:328` was still exact; the five hono lines had all slid a few lines each (`:947` onto `await resolvePermissionSets(...)`, `:963` onto the `const apps = [...]` filter head, `:772` onto the `systemPermissions` set one line above the rank table) — near-misses that read as right to anyone who opens the file; and `permission-set-projection.ts:287` had rotted 30 lines onto `cachedSpecBodyKeys`, a different mechanism entirely. ANCHOR RESIDUAL, stated because it is the batch's weakest: both hono consumers are anonymous route closures inside ONE exported registrar, so the distinction the seven lines carried survives only in this prose. `tabRank` is cited beside the registrar for that reason — it is specific to the tab machinery, so deleting the merge reds the anchor even though the registrar would survive. Re-closed by hand against c459da6bc." }, "rowLevelSecurity": { diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json deleted file mode 100644 index 43d5b6592d2..00000000000 --- a/scripts/adr-anchors/packages__plugins__plugin-security__src__permission-set-projection.ts.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "file": "packages/plugins/plugin-security/src/permission-set-projection.ts", - "adrs": [ - "ADR-0094", - "ADR-0005", - "ADR-0086" - ], - "invariant": "This file IS ADR-0094's machinery (write-through + awaited projector + boot reconciliation), and since 2026-08-09 it is also the site of ADR-0094 D5-R. The 2026-07-14 direction confirmation that once sat in its header — package sets are customized through an ADR-0005 env overlay — is RETIRED: #6483 rolled `permission` back to `allowOrgOverride: false` (the flag is `allowEnvironmentOverlay` since protocol 18), so a metadata write against a CODE-DECLARED (artifact-backed) set is refused 403 NOT_OVERRIDABLE, while a set whose definition lives only in `sys_metadata` rides `allowRuntimeCreate` and still works. Two rules follow and must not be quietly undone: (1) the refusal is LEFT TO THE PRODUCER — never re-derive artifact-backing here to pre-empt it, and never add a tolerant fallback (`??`, catch-and-continue) around the refused `saveMetaItem`; `isArtifactBacked` is the protocol's rule and the `managed_by` column is measurably not that fact; (2) the two write points that DO catch (the `restore` leg and the reconcile backfill) catch because the record already exists by then — they report on the durability channel (#4632) and must keep doing so rather than being 'fixed' into throwing. Restoring the overlay channel needs an ADR-0005 revision ratifying an admission pair, not an edit here or in the registry." -} diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index f6af9a4bac3..2f9468962f2 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -391,14 +391,7 @@ const MAX_HELPER_DEPTH = 4; * read off the output would only prove the instrument agrees with itself. */ const POSITIVE_CONTROLS = [ - { - file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', - why: - 'The SHARED `tryInsert`/`tryUpdate` #12923 repaired. `catch (e) { refusals?.record(object, e); return null; }` ' - + '-- silent by the log axis, a write in the try, and answering through the #12923 accumulator. It must be a ' - + 'member AND it must come back CHANNELLED, or the instrument cannot tell a repair from a swallow.', - tier: 'channelled', - }, + { file: 'packages/plugins/plugin-sharing/src/primary-bu-projection.ts', why: diff --git a/scripts/tenant-audit-census.mjs b/scripts/tenant-audit-census.mjs index 04e0104f698..0237c0441a5 100644 --- a/scripts/tenant-audit-census.mjs +++ b/scripts/tenant-audit-census.mjs @@ -1531,12 +1531,7 @@ export const UNTYPED_RECEIVERS = [ engine: true, what: '`ql: any` seed helper writing `schema.name` -- a runtime object name off the registered schema', }, - { - file: 'packages/plugins/plugin-security/src/permission-set-projection.ts', - receiver: 'ql', - engine: true, - what: '`ql: any` writing `sys_permission_set` -- a catalog row object retired by C3 stage 8 (ADR-0131 D3), so the name is no longer a declared object; the module is unwired and deleted with stage 7a', - }, + { file: 'packages/plugins/plugin-security/src/position-write-through.ts', receiver: 'ql', From bb4760b9ba7f4ab30bd5c9543eb926d7959a07a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:23:34 +0000 Subject: [PATCH 2/3] test(cli): no writer of sys_permission_set remains; the census, ledgers and liveness row follow the projector's deletion A CLI integration test pins the C3 epic's zero-row acceptance on both kernel shapes: on a fresh database, a package-door publish, a writable base's direct publish and a boot over the stored definitions leave no sys_permission_set row, counted from the file through the test's own connection; a control proves the counter sees a row a writer leaves. The tenant-audit census and its counts are regenerated with the census script, with the prose figures it holds to the census updated; the engine-double ledger is regenerated, and the two baselines drop the entries of the deleted suites. The liveness README's unbound permission-set-projection proof row retires with the invariant, and the uninstall dogfood header no longer describes a projector row. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...04-7a-permission-set-projection-retired.md | 4 +- .../docs/permissions/tenant-audit-census.mdx | 44 ++--- ...08-tenant-audit-write-call-sites.counts.md | 23 ++- ...ermission-set-zero-row.integration.test.ts | 161 ++++++++++++++++++ ...stall-revokes-named-grants.dogfood.test.ts | 9 +- packages/spec/liveness/README.md | 1 - scripts/engine-double-contract.baseline.json | 16 -- scripts/engine-double-contract.pinned.json | 40 ----- scripts/objectql-double-limit.baseline.json | 6 - 9 files changed, 198 insertions(+), 106 deletions(-) create mode 100644 packages/cli/test/permission-set-zero-row.integration.test.ts diff --git a/.changeset/15204-7a-permission-set-projection-retired.md b/.changeset/15204-7a-permission-set-projection-retired.md index 778210e2e31..f924809020e 100644 --- a/.changeset/15204-7a-permission-set-projection-retired.md +++ b/.changeset/15204-7a-permission-set-projection-retired.md @@ -24,6 +24,6 @@ What a consumer sees: - FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. - FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. -Unchanged: the packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. +Unchanged: the security catalog's echo of a metadata-door permission set or position on an environment kernel, which lives in its own module since #23308. The packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. -**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection. +**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection, and the liveness README's registered-but-unbound `permission-set-projection` proof row is retired with the invariant it described (no `@proof` tag carries it any more). diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a84d1ca73c7..b0d7b799cae 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 40 of them — just under a fifth +typed `any` have no type to read, and there are 37 of them — just under 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 225 sites are spelled that way**. A +forwarding shim cannot, and **53 of the 222 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 | **225** | +| 175 write call sites | quoted in the merged changeset | **222** | | 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 | **153 of 225** decidable, **72** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 120 decidably elevated, 0 decidably not, 97 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **152 of 222** decidable, **70** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 119 decidably elevated, 0 decidably not, 95 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 40 sites reached +one, and both are counted in the generated tables below: the 37 sites reached through an erased (`any`) receiver, and the 50 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 -120 of 225 (53%) as decidably elevated, with 97 more whose elevation is a +119 of 222 (54%) as decidably elevated, with 95 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 / 225`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 222`, 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,30 +223,30 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **225** | -| …whose object name is statically decidable | 153 | -| …whose object name is chosen at run time | 72 | +| write call sites on the application surface | **222** | +| …whose object name is statically decidable | 152 | +| …whose object name is chosen at run time | 70 | | …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 164 | +| threading a tenant context | 161 | | 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 | 120 | +| threading a decidably ELEVATED (`isSystem`) context | 119 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 97 | +| threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 185 | | receiver erased, placed by the object NAME | 27 | -| receiver erased, placed by an `object: string` PARAMETER | 7 | -| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 6 | +| receiver erased, placed by an `object: string` PARAMETER | 5 | +| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 103 | +| object name spelled inline | 102 | | object name spelled through a `const` | 50 | -| object name is an `object: string` parameter | 9 | +| object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 63 | ### 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-13 at `66949b772`. +Measured on 2026-10-14 at `c0f645856`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 648 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 646 | +| engine-shaped types recognised | 73 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 159 | {/* 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 c5af45e04ff..b752b250cf1 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,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 225 | -| Object name statically decidable | 153 | -| Object name chosen at run time | 72 | +| Write call sites | 222 | +| Object name statically decidable | 152 | +| Object name chosen at run time | 70 | | Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 164 | +| Threading a tenant context | 161 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 53 | | …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 120 | +| Threading a decidably elevated context | 119 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 97 | +| Threading a context of undecidable elevation | 95 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ 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-13 at `66949b772`. +Measured on 2026-10-14 at `c0f645856`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 648 | -| engine-shaped types recognised | 72 | +| tracked non-test sources scanned | 646 | +| engine-shaped types recognised | 73 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 160 | +| same-named calls subtracted as non-engine | 159 | ## Every site @@ -161,9 +161,6 @@ Measured on 2026-10-13 at `66949b772`. | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/grant-permission-set-name-backfill.ts` | `update` | `GRANT_OBJECT` | undecidable | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/invitation-placement.ts` | `insert` | `sys_user_position` | enabled | elevated | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | -| `packages/plugins/plugin-security/src/permission-set-projection.ts` | `delete` | `sys_permission_set` | undeclared-name | elevated | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `delete` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `insert` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts` | `update` | `sys_audience_binding_suggestion` | enabled | context, elevation undecidable | 3 | diff --git a/packages/cli/test/permission-set-zero-row.integration.test.ts b/packages/cli/test/permission-set-zero-row.integration.test.ts new file mode 100644 index 00000000000..7cd292ed4da --- /dev/null +++ b/packages/cli/test/permission-set-zero-row.integration.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * C3 (ADR-0131 D3) epic acceptance for stage 7a: no writer of + * `sys_permission_set` remains. On a fresh database, a package-door publish + * of a `permission`, a writable base's direct publish of one, and the boot + * over the stored definitions leave ZERO rows in that table. + * + * ## Why it is measured this way + * + * The last writer was the ADR-0094 projector (`projectPermissionMutation` → + * `upsertEnvPermissionSet`). Stage 6b-2 deleted the seeders and the publish + * materializer, after which the projector alone wrote an `admin` row on every + * publish and re-projected stored sets at boot; stage 8 unwired it and retired + * the row object; stage 7a deletes it. A row could only come back through a + * writer someone wires again, so the count is taken from the FILE, through a + * connection of this test's own, after real publishes through the real + * protocol and a real second boot — not from the engine, whose answer for an + * unregistered object would be an error rather than a count. A table that + * does not exist counts as zero rows: that is the expected state on a fresh + * database, and it is the same answer the authorization path gets (none). + * + * The counter's own control writes one row the way a writer would and reads + * it back as one, so "zero" is never a reader that cannot see a row. + * + * Both kernel shapes run: the host (`os serve`) shape, and the environment + * kernel a hosted tenant runs, whose protocol is delegated to + * `createMetadataProtocolPlugin`. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { createMetadataProtocolPlugin } from '@objectstack/metadata-protocol'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; + +const ENV = 'env_zero_row'; +const TABLE = 'sys_permission_set'; +/** A package whose drafts the package door publishes. */ +const DRAFT_PKG = 'app.zero_row_drafts'; +/** A writable base that publishes its set directly (ADR-0070). */ +const BASE_PKG = 'app.zero_row_base'; + +const stack: any = { + manifest: { id: 'com.example.zero_row', name: 'zero_row', version: '0.0.1', type: 'app' }, + objects: [{ name: 'zero_doc', label: 'Zero Doc', fields: { title: { type: 'text', label: 'Title' } } }], + permissions: [{ name: 'zero_declared_reader', label: 'Declared reader', objects: { zero_doc: { allowRead: true } } }], +}; + +type Kind = 'environment' | 'host'; + +async function boot(kind: Kind, dbFile: string, rootDir: string): Promise { + const kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin(kind === 'environment' ? { environmentId: ENV, registerProtocol: false } : {}) as any); + await kernel.use(new (DefaultDatasourcePlugin as any)({ driver: 'sqlite', config: { filename: dbFile } }, { dev: false })); + if (kind === 'environment') await kernel.use(createMetadataProtocolPlugin({ environmentId: ENV }) as any); + await kernel.use(new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' }, rootDir }) as any); + await kernel.use(new AppPlugin(stack) as any); + await kernel.use(new PlatformObjectsPlugin() as any); + await kernel.use(new AuthPlugin({ secret: 'zero-row-secret-zero-row-secret-zero' }) as any); + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(stack)) as any); + await kernel.bootstrap(); + return kernel; +} + +/** Rows in `sys_permission_set`, read from the file; a missing table is zero rows. */ +async function rowsInFile(dbFile: string): Promise { + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (probe as any).knex; + try { + if (!(await knex.schema.hasTable(TABLE))) return 0; + const [{ n }] = await knex(TABLE).count({ n: '*' }); + return Number(n); + } finally { + await knex.destroy(); + } +} + +/** Stored, active, env-wide `permission` definitions — the publishes really landed. */ +async function storedPermissionNames(dbFile: string): Promise { + const probe = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (probe as any).knex; + try { + const rows = await knex('sys_metadata').select('name').where({ type: 'permission', state: 'active' }).whereNull('organization_id'); + return rows.map((r: any) => String(r.name)).sort(); + } finally { + await knex.destroy(); + } +} + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +const fresh = (label: string) => { + const dir = mkdtempSync(join(tmpdir(), `zero-row-${label}-`)); + dirs.push(dir); + return { dir, dbFile: join(dir, 'zero.db') }; +}; + +for (const kind of ['host', 'environment'] as Kind[]) { + describe(`${kind} kernel: no writer of sys_permission_set remains (C3 stage 7a acceptance)`, () => { + it('a package-door publish, a writable base\'s direct publish and the boot over them leave zero rows', async () => { + const { dir, dbFile } = fresh(kind); + expect(await rowsInFile(dbFile), 'a fresh database').toBe(0); + + let kernel = await boot(kind, dbFile, dir); + try { + const protocol = await kernel.getServiceAsync('protocol'); + const set = (name: string) => ({ name, label: name, isDefault: true, objects: { zero_doc: { allowRead: true } } }); + + // The package door: a draft into the package, then its atomic publish. + await protocol.saveMetaItem({ type: 'permission', name: 'zero_pkg_default', item: set('zero_pkg_default'), packageId: DRAFT_PKG, mode: 'draft' }); + const published = await protocol.publishPackageDrafts({ packageId: DRAFT_PKG, actor: 'system' }); + expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] }); + + // A writable base's direct publish. + await protocol.saveMetaItem({ type: 'permission', name: 'zero_base_set', item: set('zero_base_set'), packageId: BASE_PKG, mode: 'publish' }); + } finally { + await kernel.shutdown(); + } + + // Non-vacuity: both publishes landed as stored definitions. + expect(await storedPermissionNames(dbFile)).toEqual(expect.arrayContaining(['zero_base_set', 'zero_pkg_default'])); + expect(await rowsInFile(dbFile), 'after the two publishes').toBe(0); + + // The boot over the stored definitions — where the retired reconcile + // re-projected every stored set into a row. + kernel = await boot(kind, dbFile, dir); + await kernel.shutdown(); + expect(await rowsInFile(dbFile), 'after a boot over the stored definitions').toBe(0); + }, 180_000); + }); +} + +describe('control: the counter sees a row a writer leaves', () => { + it('one row written into sys_permission_set reads back as one', async () => { + const { dbFile } = fresh('control'); + const writer = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const knex = (writer as any).knex; + try { + await knex.schema.createTable(TABLE, (t: any) => { + t.string('id').primary(); + t.string('name'); + t.string('managed_by'); + }); + await knex(TABLE).insert({ id: 'ps_1', name: 'zero_pkg_default', managed_by: 'admin' }); + } finally { + await knex.destroy(); + } + expect(await rowsInFile(dbFile)).toBe(1); + }); +}); diff --git a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts index 196ea52b24c..0d2bb182af8 100644 --- a/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-uninstall-revokes-named-grants.dogfood.test.ts @@ -1,12 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0090 D5 ("no ghost grants") on the protocol's own uninstall door, -// `deletePackage` — after ADR-0131 C3 stage 6b-2, where no seeder or -// materializer writes a `sys_permission_set` row: a package's set is served by -// the security catalog from the registry, and a grant names it. (Until stage 7 -// retires the ADR-0094 projector, the publish below still has it write an -// `admin` projection row that no authorization path reads; the uninstall -// removes it.) +// `deletePackage`. No `sys_permission_set` row exists to write or remove +// (ADR-0131 C3: no seeder, materializer or projector writes one): a package's +// set is served by the security catalog, and a grant names it. // // The withdrawal (`registry.uninstallPackage`) runs before the uninstall // cleanups and releases the package's names, so the door captures the diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index a941585edc6..62aac8e7053 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -528,7 +528,6 @@ still registered — otherwise its `@proof:` tag reads as an orphan — and reco | Proof | Why unbound | |---|---| | `form-widget-resolution` | no proof written yet (ADR-0054 Phase 2); `view.form.*` is governed and can carry it | -| `permission-set-projection` | a storage invariant (ADR-0094), not an authorable property | | `flow-runas-schedule` | guards `flow.runAs`, already bound to `flow-runas-identity` — one entry carries one `proof` | | `showcase-scope-depth-fallback` | guards `permission.objects.readScope`, already bound — this is the CLI-wiring sibling | | `showcase-agent-intersection` | a runtime principal-resolution invariant (`onBehalfOf`), not authorable | diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 1d7795819a7..db86142a923 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -376,22 +376,6 @@ "why": "HAND-MIRRORS the guard already (a copy of the #4434 fix's `if`), which is the second copy of the contract this gate exists to remove — but @objectstack/plugin-security does not depend on @objectstack/objectql, so replacing the copy with the producer's predicate needs a devDependency change.", "closes": "add @objectstack/objectql to devDependencies, then replace the mirrored `if` with assertEngineDeleteDispatch(options)" }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", - "verb": "delete", - "unguarded": 1, - "kind": "DEBT", - "why": "@objectstack/plugin-security does not depend on @objectstack/objectql. Pinning needs a devDependency + lockfile change, which is a separate reviewable act.", - "closes": "add @objectstack/objectql to devDependencies, then open the fake's delete with assertEngineDeleteDispatch(opts)" - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.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 35. 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/security-plugin.test.ts", "verb": "update", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 41ab95df765..5f2231c8da2 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3341,31 +3341,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts", "verb": "delete", @@ -3386,21 +3361,6 @@ "verb": "findOne", "pinned": 1 }, - { - "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", - "verb": "findOne", - "pinned": 1 - }, { "file": "packages/plugins/plugin-security/src/permission-set-resolution-memo.test.ts", "verb": "findOne", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 29f86e9182c..08ff34574f5 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -585,15 +585,9 @@ "packages/plugins/plugin-security/src/packaged-permission-set-overlay-detection.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-security/src/packaged-permission-set-restore-leg.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/permission-denied-user-copy.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/permission-set-projection.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-security/src/resolve-permission-sets-for-context.pin.test.ts": { "unjudged": 1 }, From df605286cab1f9a9b09f1bff7c12f60527ddb33d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 03:33:08 +0000 Subject: [PATCH 3/3] fix(spec,plugin-security): the permission-set-projection proof registry entry retires with its README row; the lock's non-list registry answer keeps a pin The proof registry still carried the permission-set-projection HIGH_RISK_CLASSES entry, pointing at a dogfood file that no longer exists; only its README mirror row had been removed. The entry is removed, so the README matches its source. The classifier's refusal of a SchemaRegistry that answers with a non-list lost its only direct pin with the projection suite. It is re-pinned on the metadata door beside the throwing-registry case, and measured red with the branch ablated. The #23233 changeset no longer says the set keeps an admin sys_permission_set row: no row is written once the projector is gone. Claude-Session: https://claude.ai/code/session_01287UbjvrigpWwNfGDmdwpS Co-authored-by: Claude --- ...3-permission-echo-names-runtime-package.md | 2 +- .../src/packaged-permission-set-lock.test.ts | 13 +++++++++++ .../spec/scripts/liveness/proof-registry.mts | 22 ------------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/.changeset/23233-permission-echo-names-runtime-package.md b/.changeset/23233-permission-echo-names-runtime-package.md index 5988aed20d3..64dcd076085 100644 --- a/.changeset/23233-permission-echo-names-runtime-package.md +++ b/.changeset/23233-permission-echo-names-runtime-package.md @@ -9,5 +9,5 @@ Clause-②: no - **What was wrong.** A writable runtime package's permission set marked `isDefault: true`, published through the package door with no `packageId` in its body, recorded no audience-binding suggestion on an environment kernel, so ADR-0090 D5's "packages suggest, admins confirm" never happened for it. On that kernel the protocol hydrates no stored row into the engine registry, so the security catalog finds the set in the metadata service. The only body there is the permission-set projection's in-memory copy, and that copy carried no `_packageId`. The catalog served the set as unowned while `GET /api/v1/meta/permission/NAME` named the package, and the suggestion collector skipped it. - **What changed.** The projection's copy of a tenant-authored set now names the package its stored row is bound to. It reads that binding from the protocol's by-name read (the `_packageId` that read serves) and derives none of its own. The catalog, the by-name read and the layered read (`?layers=true`, whose `packageId` comes from the same copy) now name the same owner. `GET /api/v1/security/suggested-bindings` lists one pending suggestion under that package. A `manage_metadata` holder still has to accept it: the publish grants nothing. - **When the body's `packageId` disagrees.** A set whose body declares a `packageId` other than the package it is published into is now owned, for the catalog and the suggestions, by the package it is published into, which is what the by-name read already served. On an environment kernel, a suggestion recorded under the body's package is pruned at the next sync while pending, and withheld from `everyone` with its reason logged if it was accepted. A pending suggestion under the publishing package replaces it. -- **Unchanged.** The `sys_permission_set` row (still `managed_by: 'admin'`, with no `package_id`), the packaged-permission-set lock and every edit door. A code package's set, whose owner the catalog reads from its artifact. A set saved into no package, which stays unowned and records no suggestion. A copy of a code-shipped set's legacy overlay, which carries no owner, as before. +- **Unchanged.** The packaged-permission-set lock and every edit door. (No `sys_permission_set` row is written for the set: C3 stage 7a retires the last writer.) A code package's set, whose owner the catalog reads from its artifact. A set saved into no package, which stays unowned and records no suggestion. A copy of a code-shipped set's legacy overlay, which carries no owner, as before. - **If the binding cannot be read**, the copy is registered without an owner, as before, and one warning says so. It still enforces the new body, and the next save, publish or boot reads the binding again. diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts index 41a649751e6..a57a476fb56 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts @@ -224,4 +224,17 @@ describe('the lock refusal carries its guidance as `userMessage` on the wire env // The reason stays where it was: the diagnostic channel. expect(String(rejection.message)).toContain('registry unavailable'); }); + + it('a SchemaRegistry that answers with a NON-LIST is the same ambiguity: the save is refused, not read as "nothing declared"', async () => { + const ql = makeQl([]); + ql.registry = { listItems: () => undefined as any }; + expect(classifyPackagedPermissionSet('unknown_provenance', ql)).toMatchObject({ status: 'unknown' }); + + const gate = lockGate(ql, async () => { throw new Error('metadata store unavailable'); }); + const rejection = await gate({ type: 'permission', name: 'unknown_provenance', body: {} }) + .then(() => null, (e: any) => e); + expect(rejection).toBeInstanceOf(PackagedPermissionSetProvenanceUnknownError); + expect(resolveThrownHttpError(rejection)).toMatchObject({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(String(rejection.message)).toContain('schema registry returned no list of declared permission sets'); + }); }); diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts index 9843686e582..a9350f82603 100644 --- a/packages/spec/scripts/liveness/proof-registry.mts +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -199,28 +199,6 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [ blockedReason: 'the form layout/section/widget surface is not yet governed and has no runtime proof (ADR-0054 Phase 2).', }, - { - id: 'permission-set-projection', - label: 'sys_permission_set pure projection', - summary: - 'the metadata layer is the sole authoritative store for a permission-set definition; the ' - + 'sys_permission_set record is a derived projection (data-door write-through + awaited ' - + 'projection). A record that could drift from — or independently authorize against — its ' - + 'metadata is the two-store split-brain ADR-0094 retires.', - proofId: 'permission-set-projection', - proofRef: 'packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts#permission-set-projection', - // Unbound: this is a STORAGE/architecture invariant, not an authorable - // `type.path` property the ledger ratchet can key on — there is no - // permission-set field whose `live` status this proof gates. The proof is - // still registered here (so the tag is not an orphan) and runs - // unconditionally in the dogfood suite; it simply has no ledger property to - // bind. Kept honest per ADR-0054 §3 rather than faking a binding. - bound: false, - ledgerBindings: [], - blockedReason: - 'projection is a storage invariant (ADR-0094), not an authorable spec property — no ledger ' - + 'entry to ratchet; the proof runs unconditionally in the dogfood suite instead.', - }, // ───────────────────────────────────────────────────────────────────────── // Proofs that existed in the dogfood tree but were never registered here —