From ebbd4d0a172c5774f049b469e6bd4e03d30df025 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:59:46 +0000 Subject: [PATCH 1/5] feat(core): one by-name read of the security catalog over the engine registry and the metadata service ADR-0131 D2-D4: positions, permission sets and capabilities resolve by name from the environment registry. No single in-process reader holds the whole catalog (the engine registry has no stack-declared position; the metadata service lacks the platform permission sets), so the read is their union in one order, registry first. No consumer is switched. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- packages/core/src/security/index.ts | 15 + .../src/security/security-catalog.test.ts | 239 ++++++++++++ .../core/src/security/security-catalog.ts | 359 ++++++++++++++++++ 3 files changed, 613 insertions(+) create mode 100644 packages/core/src/security/security-catalog.test.ts create mode 100644 packages/core/src/security/security-catalog.ts diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5cf405b01f2..07851fdeec2 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -219,6 +219,21 @@ export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant // enforces it and the break-glass guard that simulates a write to it. export { isRowActive, type ActivatableRow } from './row-active.js'; +// ADR-0131 D2–D4 — the ONE by-name read of the security catalog (positions, +// permission sets, capabilities) over the engine registry and the metadata +// service. It says a definition EXISTS under a name, never that it is in +// effect: the row `active` flag above stays the authority for that. +export { + createSecurityCatalogReader, + type SecurityCatalogType, + type SecurityCatalogSourceName, + type SecurityCatalogRegistry, + type SecurityCatalogMetadataService, + type SecurityCatalogSources, + type SecurityCatalogEntry, + type SecurityCatalogReader, +} from './security-catalog.js'; + // [commit f8eb73601] The measured read surface of the administrator derivation — the // single source `plugin-auth`'s break-glass standing-key lists correspond to. export { diff --git a/packages/core/src/security/security-catalog.test.ts b/packages/core/src/security/security-catalog.test.ts new file mode 100644 index 00000000000..716b794eb0c --- /dev/null +++ b/packages/core/src/security/security-catalog.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + createSecurityCatalogReader, + type SecurityCatalogMetadataService, + type SecurityCatalogRegistry, +} from './security-catalog.js'; +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +/** + * ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers. + * + * What the real readers answer (the registry's by-name precedence for a name + * two packages ship, the sets a booted showcase holds) is pinned against the + * real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts` + * and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. + * Here: the read order, the union, the disabled-package rule, and that a read + * which did not happen is never reported as "no such item". + */ + +type Def = Record; + +interface RegistryOptions { + disabled?: readonly string[]; + getItem?: (type: string, name: string) => unknown; + listItems?: (type: string) => readonly unknown[]; +} + +/** First-registered-wins by name, and a list that hides disabled packages — the registry's own two rules. */ +function registry(items: Record, opts: RegistryOptions = {}): SecurityCatalogRegistry { + const disabled = new Set(opts.disabled ?? []); + const isPackageDisabled = (pkg?: string) => (pkg ? disabled.has(pkg) : false); + return { + getItem: opts.getItem ?? ((type, name) => (items[type] ?? []).find((d) => d.name === name)), + listItems: + opts.listItems + ?? ((type) => (items[type] ?? []).filter((d) => !isPackageDisabled(d._packageId as string | undefined))), + isPackageDisabled, + }; +} + +function metadata(items: Record, over: Partial = {}): SecurityCatalogMetadataService { + return { + async get(type, name) { + return (items[type] ?? []).find((d) => d.name === name); + }, + async list(type) { + return items[type] ?? []; + }, + ...over, + }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('expected the read to reject, and it resolved'); +} + +describe('security catalog read — the read order and the union', () => { + it('a name both readers hold is answered by the engine registry', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales', label: 'from the registry', _packageId: 'com.acme' }] }), + metadata: metadata({ permission: [{ name: 'sales', label: 'from the metadata service' }] }), + }); + const entry = await reader.resolve('permission', 'sales'); + expect(entry?.source).toBe('registry'); + expect(entry?.definition.label).toBe('from the registry'); + expect(entry?.packageId).toBe('com.acme'); + }); + + it('a name only the metadata service holds is answered by it (stack-declared positions)', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({ position: [{ name: 'manager', label: 'Manager' }] }), + }); + const entry = await reader.resolve('position', 'manager'); + expect(entry).toMatchObject({ type: 'position', name: 'manager', source: 'metadata' }); + expect(entry?.packageId).toBeUndefined(); + }); + + it('a name neither reader holds resolves to nothing and is not listed', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ capability: [{ name: 'export_data' }] }), + metadata: metadata({ capability: [{ name: 'export_data' }] }), + }); + expect(await reader.resolve('capability', 'nobody_declared_this')).toBeUndefined(); + expect((await reader.list('capability')).map((e) => e.name)).toEqual(['export_data']); + }); + + it('list gives one entry per name across both readers, each the entry resolve answers', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ + permission: [ + { name: 'admin_full_access', _packageId: 'com.objectstack.plugin-security' }, + { name: 'shared', label: 'registry body', _packageId: 'com.acme' }, + ], + }), + metadata: metadata({ + permission: [ + { name: 'shared', label: 'metadata body', _packageId: 'com.acme' }, + { name: 'declared_only', label: 'metadata only' }, + ], + }), + }); + const listed = await reader.list('permission'); + expect(listed.map((e) => [e.name, e.source])).toEqual([ + ['admin_full_access', 'registry'], + ['shared', 'registry'], + ['declared_only', 'metadata'], + ]); + for (const entry of listed) { + expect(await reader.resolve('permission', entry.name)).toEqual(entry); + } + }); + + it('an entry states existence only: no activation member, whatever the definition carries', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales', _packageId: 'com.acme' }] }), + metadata: metadata({}), + }); + const entry = await reader.resolve('permission', 'sales'); + expect(Object.keys(entry ?? {}).sort()).toEqual(['definition', 'name', 'packageId', 'source', 'type']); + }); +}); + +describe('security catalog read — a disabled package\'s definition answers neither read', () => { + it('the registry\'s list rule holds for the by-name read too', async () => { + const reader = createSecurityCatalogReader({ + registry: registry( + { permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }, { name: 'kept', _packageId: 'com.acme' }] }, + { disabled: ['com.vendor'] }, + ), + metadata: metadata({}), + }); + expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined(); + expect((await reader.list('permission')).map((e) => e.name)).toEqual(['kept']); + }); + + it('a metadata-service copy owned by the disabled package is not served either', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }, { disabled: ['com.vendor'] }), + metadata: metadata({ permission: [{ name: 'vendor_set', _packageId: 'com.vendor' }] }), + }); + expect(await reader.resolve('permission', 'vendor_set')).toBeUndefined(); + expect(await reader.list('permission')).toEqual([]); + }); +}); + +describe('security catalog read — a read that did not happen is loud, never "no such item"', () => { + const expectUnavailable = (error: unknown) => { + expect(isAuthzStoreUnavailableError(error)).toBe(true); + expect((error as { code?: unknown }).code).toBe('SERVICE_UNAVAILABLE'); + expect((error as { status?: unknown }).status).toBe(503); + }; + + it('a registry read that throws', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}, { + getItem: () => { throw new Error('registry torn'); }, + listItems: () => { throw new Error('registry torn'); }, + }), + metadata: metadata({}), + }); + expectUnavailable(await rejection(reader.resolve('permission', 'admin_full_access'))); + expectUnavailable(await rejection(reader.list('permission'))); + }); + + it('a metadata read that throws', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { + get: async () => { throw new Error('loader down'); }, + list: async () => { throw new Error('loader down'); }, + }), + }); + expectUnavailable(await rejection(reader.resolve('position', 'manager'))); + expectUnavailable(await rejection(reader.list('position'))); + }); + + it('a metadata read that lost a loader and found nothing', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { + getDiagnosed: async () => ({ data: undefined, degraded: true, errors: ['db loader: connection refused'] }), + listDiagnosed: async () => ({ items: [{ name: 'manager' }], degraded: true, errors: ['db loader: connection refused'] }), + }), + }); + expectUnavailable(await rejection(reader.resolve('position', 'manager'))); + // A list that lost a loader is partial even when another loader answered. + expectUnavailable(await rejection(reader.list('position'))); + }); + + it('control: a clean miss is a miss, and a degraded read that found the item serves it', async () => { + const clean = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { getDiagnosed: async () => ({ data: undefined, degraded: false, errors: [] }) }), + }); + expect(await clean.resolve('position', 'manager')).toBeUndefined(); + + const found = createSecurityCatalogReader({ + registry: registry({}), + metadata: metadata({}, { getDiagnosed: async () => ({ data: { name: 'manager' }, degraded: true, errors: ['x'] }) }), + }); + expect((await found.resolve('position', 'manager'))?.name).toBe('manager'); + }); +}); + +describe('security catalog read — refusals at the edge', () => { + it('construction refuses a missing reader, naming what that reader holds', () => { + const md = metadata({}); + expect(() => createSecurityCatalogReader({ registry: undefined as never, metadata: md })).toThrow(TypeError); + expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: undefined as never })).toThrow( + TypeError, + ); + expect(() => createSecurityCatalogReader({ registry: registry({}), metadata: { get: md.get } as never })).toThrow( + TypeError, + ); + }); + + it('a type outside the catalog is refused, not answered empty', async () => { + const reader = createSecurityCatalogReader({ registry: registry({}), metadata: metadata({}) }); + await expect(reader.resolve('view' as never, 'x')).rejects.toBeInstanceOf(TypeError); + await expect(reader.list('object' as never)).rejects.toBeInstanceOf(TypeError); + }); + + it('an empty or non-string name names nothing', async () => { + const reader = createSecurityCatalogReader({ + registry: registry({ permission: [{ name: 'sales' }] }), + metadata: metadata({}), + }); + expect(await reader.resolve('permission', '')).toBeUndefined(); + expect(await reader.resolve('permission', undefined as never)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts new file mode 100644 index 00000000000..72246cf6226 --- /dev/null +++ b/packages/core/src/security/security-catalog.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D2–D4 — the ONE read of the security catalog: positions, permission + * sets and capabilities, looked up BY NAME in the environment registry. + * + * ## What this is, and what it is not yet + * + * ADR-0131 D3 gives the catalog one home — the environment registry, fed by + * code-declared packages and by environment metadata a metadata author saved + * (`sys_metadata`, hydrated into the same registry by `loadMetaFromDb`) — and + * D4 makes every reference to a catalog item a NAME that "resolution reads the + * registry" for. This module is that read and nothing else. It is the seam the + * later stages of the C2 execution card switch readers onto; on its own it + * changes no grant, because nothing calls it yet. + * + * ## Two in-process readers, read in a fixed order + * + * No single in-process reader holds the whole catalog today. Measured on a + * booted showcase in three postures (`single`, `single` with the Default + * Organization, and walled), at objectstack `3d9188502e`, names per reader: + * + * | type | engine registry | metadata service | metadata door | + * |--------------|-----------------|------------------|---------------| + * | `position` | 0 | 10 | 10 | + * | `permission` | 17 | 9 | 17 | + * | `capability` | 2 | 2 | 2 | + * + * - The ENGINE REGISTRY (ObjectQL's `SchemaRegistry`) carries every package + * manifest's `permissions` and `capabilities` — the platform's bootstrap + * permission sets among them (`admin_full_access`, `member_default`, …, + * which `plugin-security` ships on its own manifest) — and every + * environment-authored item hydrated from `sys_metadata`. It carries no + * stack-declared position: the engine's stack-collection list does not + * decompose `positions`. + * - The METADATA SERVICE carries the stack-declared security collections an + * app registers in memory (`positions`, `permissions`, `capabilities`) plus + * whatever its loaders hold — but not the platform's bootstrap permission + * sets. + * - The metadata DOOR (`GET /api/v1/meta/:type`) merges both with the stored + * rows, and its names equal the union of the first two in every posture + * measured. It is a serving surface (decorations, per-request row reads), + * not something an in-process resolver reads. + * + * So the reader is the union, in ONE order: the engine registry first, then the + * metadata service ({@link SECURITY_CATALOG_READ_ORDER}). A name the registry + * answers is answered by the registry — which is also where an ADR-0005 + * overlay of a packaged item lives — and the metadata service answers only the + * names the registry does not hold. ⛔ Never `metadataService.list` alone: it + * misses the platform's own permission sets, so a resolver reading it would + * fail the platform administrator anchor closed. + * + * ## A name two packages ship — today's answer, pinned until it is ruled + * + * The by-name read takes no package context, because an assignment carries + * only the name (ADR-0131 D4). So which body a name two installed packages both + * ship resolves to is decided by the registry's own by-name precedence: a + * stored override in the bare slot first (ADR-0005), else the FIRST-registered + * package's body. Measured: with no override every by-name read answers the + * first-registered package; once one package stores an override bound to + * itself, every by-name read answers that override. Whether the catalog should + * refuse a shared name instead is an open maintainer question; until it is + * ruled, the pins beside this module hold today's answer, and a change to it + * is a decision, not a refactor. + * + * ## What this read does NOT answer + * + * - ⛔ **Whether an item is in effect.** Deactivation lives on the catalog + * ROW (`sys_position.active`, `sys_permission_set.active`; standing ruling: + * the row flag stays authoritative), and no definition carries it. An entry + * here says a definition EXISTS under that name — never that it grants. A + * caller that drops the row flag because it now reads definitions would let + * a deactivated set grant again: the one widening this card must never + * open. Read the flag with `isRowActive` (`row-active.ts`) as before. + * - **The position → permission-set binding.** It stays on its junction rows + * until the position definition carries it. + * - **Organization scope.** The catalog is environment-level (ADR-0131 D3); + * this read takes no organization and never filters by one. + * + * ## The one rule it does apply: a disabled package's item is not served + * + * The registry hides every item of a disabled package from its list + * (`listItems`), and the by-name read applies the same rule, so the two reads + * of this module cannot disagree about whether a name exists: a definition + * whose `_packageId` names a disabled package answers neither. An item that + * names no package (an environment-authored row, a position registered in + * memory without an owner) has no package to be disabled. + * + * ## A read that did not happen is loud + * + * A miss and an outage are different facts with opposite security meanings + * (ADR-0110 D3). When a reader throws, or the metadata service reports a read + * that lost a loader and nothing answered, this module raises + * {@link AuthzStoreUnavailableError} instead of answering "no such item" — the + * same loud failure the resolver raises for an unreadable permission store. + */ + +import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; + +/** The three catalog types ADR-0131 D3 gives one home. */ +const SECURITY_CATALOG_TYPES = ['position', 'permission', 'capability'] as const; + +/** A security catalog type, spelled as the registry keys it. */ +export type SecurityCatalogType = (typeof SECURITY_CATALOG_TYPES)[number]; + +/** The readers, in the order a by-name read asks them (module doc). */ +const SECURITY_CATALOG_READ_ORDER = ['registry', 'metadata'] as const; + +/** Which reader answered for an entry. */ +export type SecurityCatalogSourceName = (typeof SECURITY_CATALOG_READ_ORDER)[number]; + +/** + * The engine registry, as this read uses it — ObjectQL's `SchemaRegistry` + * (`engine.registry`) satisfies it as it stands. + */ +export interface SecurityCatalogRegistry { + /** The registry's by-name read: bare-slot overlay first, else the first-registered package's item. */ + getItem(type: string, name: string): unknown; + /** Every item of a type, a disabled package's items already hidden. */ + listItems(type: string): readonly unknown[]; + /** Whether the package an item names as its owner is disabled. */ + isPackageDisabled(packageId?: string): boolean; +} + +/** + * The kernel `metadata` service, as this read uses it — the `MetadataManager` + * satisfies it as it stands. The two `*Diagnosed` members are optional; when + * present they are what lets a degraded read be told apart from a miss. + */ +export interface SecurityCatalogMetadataService { + get(type: string, name: string): unknown; + list(type: string): unknown; + getDiagnosed?(type: string, name: string): Promise<{ data?: unknown; degraded?: boolean; errors?: readonly string[] }>; + listDiagnosed?(type: string): Promise<{ items?: readonly unknown[]; degraded?: boolean; errors?: readonly string[] }>; +} + +/** Where the catalog read looks. Both readers are required: neither holds the whole catalog. */ +export interface SecurityCatalogSources { + /** ObjectQL's `SchemaRegistry` — `engine.registry`. */ + registry: SecurityCatalogRegistry; + /** The kernel `metadata` service. */ + metadata: SecurityCatalogMetadataService; +} + +/** One catalog definition, as the reader that answered holds it. */ +export interface SecurityCatalogEntry { + readonly type: SecurityCatalogType; + readonly name: string; + /** + * The definition itself — the reader's own object, shared with every other + * reader of the registry. ⛔ Never mutate it. It carries no activation + * verdict (module doc). + */ + readonly definition: Readonly>; + /** Which reader answered. */ + readonly source: SecurityCatalogSourceName; + /** The package the definition names as its owner (`_packageId`), when it names one. */ + readonly packageId?: string; +} + +/** The catalog read. Both members answer the same question, so they never disagree. */ +export interface SecurityCatalogReader { + /** The definition a name resolves to, or `undefined` when no reader holds one. */ + resolve(type: SecurityCatalogType, name: string): Promise; + /** One entry per name — each the entry {@link SecurityCatalogReader.resolve} answers for it. */ + list(type: SecurityCatalogType): Promise; +} + +type Definition = Record; + +function isDefinition(value: unknown): value is Definition { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function ownerOf(definition: Definition): string | undefined { + const owner = definition._packageId; + return typeof owner === 'string' && owner !== '' ? owner : undefined; +} + +function nameOf(definition: Definition): string | undefined { + const name = definition.name; + return typeof name === 'string' && name !== '' ? name : undefined; +} + +function assertCatalogType(type: unknown): asserts type is SecurityCatalogType { + if (!(SECURITY_CATALOG_TYPES as readonly unknown[]).includes(type)) { + throw new TypeError( + `security catalog: "${String(type)}" is not a catalog type; the catalog holds ${SECURITY_CATALOG_TYPES.join(', ')}.`, + ); + } +} + +/** The loud failure for a catalog read that did not happen (module doc). */ +function unreadable(source: SecurityCatalogSourceName, type: SecurityCatalogType, cause?: unknown): AuthzStoreUnavailableError { + return new AuthzStoreUnavailableError(`security catalog: ${source} (${type})`, cause); +} + +/** + * Build the catalog read over the two in-process readers. + * + * Refuses, at construction, a source that lacks a member the read calls — a + * read with a reader missing would answer "no such item" for every name only + * that reader holds, which is a fact it never established. + */ +export function createSecurityCatalogReader(sources: SecurityCatalogSources): SecurityCatalogReader { + const registry = sources?.registry; + const metadata = sources?.metadata; + if ( + !registry + || typeof registry.getItem !== 'function' + || typeof registry.listItems !== 'function' + || typeof registry.isPackageDisabled !== 'function' + ) { + throw new TypeError( + 'security catalog: the engine registry is required (getItem, listItems, isPackageDisabled) — ' + + 'it holds the platform permission sets and every package manifest\'s catalog items.', + ); + } + if (!metadata || typeof metadata.get !== 'function' || typeof metadata.list !== 'function') { + throw new TypeError( + 'security catalog: the metadata service is required (get, list) — ' + + 'it holds the stack-declared positions the engine registry does not.', + ); + } + + /** A definition the reader may serve: an object, named as asked, of an enabled package. */ + const servable = (value: unknown, name?: string): Definition | undefined => { + if (!isDefinition(value)) return undefined; + const own = nameOf(value); + if (own === undefined || (name !== undefined && own !== name)) return undefined; + const owner = ownerOf(value); + if (owner !== undefined && registry.isPackageDisabled(owner)) return undefined; + return value; + }; + + const entry = ( + type: SecurityCatalogType, + definition: Definition, + source: SecurityCatalogSourceName, + ): SecurityCatalogEntry => { + const owner = ownerOf(definition); + return { + type, + name: nameOf(definition) as string, + definition, + source, + ...(owner !== undefined ? { packageId: owner } : {}), + }; + }; + + const fromRegistry = (type: SecurityCatalogType, name: string): Definition | undefined => { + let found: unknown; + try { + found = registry.getItem(type, name); + } catch (error) { + throw unreadable('registry', type, error); + } + return servable(found, name); + }; + + const fromMetadata = async (type: SecurityCatalogType, name: string): Promise => { + let data: unknown; + let degraded = false; + let errors: readonly string[] = []; + try { + if (typeof metadata.getDiagnosed === 'function') { + const diagnosed = await metadata.getDiagnosed(type, name); + data = diagnosed?.data; + degraded = diagnosed?.degraded === true; + errors = Array.isArray(diagnosed?.errors) ? diagnosed.errors : []; + } else { + data = await metadata.get(type, name); + } + } catch (error) { + throw unreadable('metadata', type, error); + } + if ((data === undefined || data === null) && degraded) { + throw unreadable('metadata', type, new Error(errors.length > 0 ? errors.join('; ') : 'a metadata loader could not be read')); + } + return servable(data, name); + }; + + const listMetadata = async (type: SecurityCatalogType): Promise => { + let items: unknown; + let degraded = false; + let errors: readonly string[] = []; + try { + if (typeof metadata.listDiagnosed === 'function') { + const diagnosed = await metadata.listDiagnosed(type); + items = diagnosed?.items; + degraded = diagnosed?.degraded === true; + errors = Array.isArray(diagnosed?.errors) ? diagnosed.errors : []; + } else { + items = await metadata.list(type); + } + } catch (error) { + throw unreadable('metadata', type, error); + } + // A list that lost a loader is a PARTIAL catalog presented as a whole one. + if (degraded) { + throw unreadable('metadata', type, new Error(errors.length > 0 ? errors.join('; ') : 'a metadata loader could not be read')); + } + return Array.isArray(items) ? items : []; + }; + + return { + async resolve(type, name) { + assertCatalogType(type); + if (typeof name !== 'string' || name === '') return undefined; + const registered = fromRegistry(type, name); + if (registered) return entry(type, registered, 'registry'); + const declared = await fromMetadata(type, name); + return declared ? entry(type, declared, 'metadata') : undefined; + }, + + async list(type) { + assertCatalogType(type); + let registered: readonly unknown[]; + try { + registered = registry.listItems(type) ?? []; + } catch (error) { + throw unreadable('registry', type, error); + } + const declared = await listMetadata(type); + + // The metadata service's own body per name, read once — the answer its + // by-name read gives for a name the registry does not serve. + const declaredByName = new Map(); + for (const item of declared) { + const definition = servable(item); + if (definition && !declaredByName.has(nameOf(definition) as string)) { + declaredByName.set(nameOf(definition) as string, definition); + } + } + + const out: SecurityCatalogEntry[] = []; + const seen = new Set(); + const names = [ + ...registered.filter(isDefinition).map(nameOf), + ...declaredByName.keys(), + ]; + for (const name of names) { + if (name === undefined || seen.has(name)) continue; + seen.add(name); + // Each name gets the answer the by-name read gives it: the registry's + // own precedence first, the metadata service's body only where the + // registry serves nothing under that name. + const fromReg = fromRegistry(type, name); + if (fromReg) { + out.push(entry(type, fromReg, 'registry')); + continue; + } + const fromMeta = declaredByName.get(name); + if (fromMeta) out.push(entry(type, fromMeta, 'metadata')); + } + return out; + }, + }; +} From 9433ce3272c9713a5bb81aefd1c2de8205000da7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:02:27 +0000 Subject: [PATCH 2/5] test(objectql): pin the catalog read's answer for a name two packages ship Today's answer, until shared catalog names are ruled: the first-registered package's body with no stored override; the override one package stored for itself, for every caller, once hydrated. The metadata door's by-name read is asserted beside each answer. A position name two stacks declare shares one metadata-service slot; the later registration holds it. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../protocol-boot-hydration-scoped.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 67f1487414b..531da0fd6da 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -21,6 +21,8 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { MetadataManager } from '@objectstack/metadata'; +import { createSecurityCatalogReader } from '@objectstack/core'; import { SchemaRegistry } from './registry.js'; import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; @@ -198,3 +200,104 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# expect(direct._lock).toBe('full'); }); }); + +/** + * ADR-0131 D4 — which body the security catalog read + * (`createSecurityCatalogReader`, `@objectstack/core`) resolves for a name two + * installed packages both ship. TODAY'S answer, pinned until the maintainer + * rules on shared catalog names: a change here is that ruling landing, never a + * refactor. + * + * It lives beside the #4624 cases because it is their consequence: the row a + * package stored for itself is hydrated into the bare slot, and the catalog + * read asks the registry's by-name precedence first. An assignment carries + * only the NAME (ADR-0131 D4), so there is no package context to prefer one + * package's body over another's: + * + * - no stored override: the FIRST-registered package's body; + * - an override one package stored, bound to itself and hydrated at boot: + * that override, for every caller. + * + * The metadata door's by-name read (`getMetaItem`) is asserted beside each + * answer, so the pin cannot drift from what the door serves. A name the + * registry does not hold falls to the metadata service, where two stacks + * declaring one position name share ONE in-memory slot: the later + * registration holds it. + */ +describe('security catalog read — a name two packages ship (ADR-0131 D4, today\'s answer)', () => { + /** The package whose stored override the second case hydrates. */ + const OVERRIDE_PACKAGE = PKG_B; + + /** A body each catalog type's schema accepts, so hydration reports it valid. */ + const catalogBody = (type: 'permission' | 'position', name: string, label: string) => + type === 'permission' ? { name, label, objects: {} } : { name, label }; + + function bootWithSharedName(type: 'permission' | 'position', name: string, rows: Row[]) { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + // Package A registers FIRST. + registry.registerItem(type, catalogBody(type, name, `${PKG_A} body`), 'name', PKG_A); + registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B); + const protocol = new ObjectStackProtocolImplementation(makeEngine(registry, rows)); + const reader = createSecurityCatalogReader({ + registry, + metadata: new MetadataManager({ formats: ['json'], loaders: [] }), + }); + return { protocol, reader }; + } + + describe.each(['permission', 'position'] as const)('%s', (type) => { + const name = `shared_${type}`; + + it('no stored override: the first-registered package\'s body', async () => { + const { protocol, reader } = bootWithSharedName(type, name, []); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); + + const entry = await reader.resolve(type, name); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); + expect(entry?.definition.label).toBe(`${PKG_A} body`); + // One entry for the name, and it is the by-name answer. + expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); + + const door: any = await protocol.getMetaItem({ type, name }); + expect(door.item?.label).toBe(`${PKG_A} body`); + }); + + it('an override one package stored for itself: that override, for every caller', async () => { + const rows = [ + overlayRow({ + type, + name, + package_id: OVERRIDE_PACKAGE, + metadata: catalogBody(type, name, 'stored override'), + }), + ]; + const { protocol, reader } = bootWithSharedName(type, name, rows); + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + + const entry = await reader.resolve(type, name); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_B }); + expect(entry?.definition.label).toBe('stored override'); + expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); + + const door: any = await protocol.getMetaItem({ type, name }); + expect(door.item?.label).toBe('stored override'); + expect(door.item?._packageId).toBe(PKG_B); + }); + }); + + it('a position name two stacks declare: one metadata-service slot, the later registration holds it', async () => { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const metadata = new MetadataManager({ formats: ['json'], loaders: [] }); + // What two app stacks declaring the same position name do at boot. + metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'first stack' }); + metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'second stack' }); + const reader = createSecurityCatalogReader({ registry, metadata }); + + const entry = await reader.resolve('position', 'regional_manager'); + expect(entry).toMatchObject({ name: 'regional_manager', source: 'metadata' }); + expect(entry?.definition.label).toBe('second stack'); + expect(await reader.list('position')).toEqual([entry]); + }); +}); From 0482468f8ec0403e92abdb44f794a59bfdd87017 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:04:17 +0000 Subject: [PATCH 3/5] test(dogfood): pin the catalog read's names on showcase in three postures Per type, the read lists exactly the declared names (the showcase stack's positions, permissions and capabilities, plus the platform's bootstrap permission sets) and exactly the names the metadata door lists, in single, single with the Default Organization, and walled postures; every listed name resolves by name to the entry the list gave. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../security-catalog-showcase.dogfood.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts diff --git a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts new file mode 100644 index 00000000000..3b240cbed24 --- /dev/null +++ b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0131 D2–D4 — the security catalog read (`createSecurityCatalogReader`, +// `@objectstack/core`) over a booted showcase, in three postures: `single`, +// `single` with the Default Organization, and walled. +// +// What it pins: per catalog type, the names the read lists are EXACTLY the +// names the declarations declare — the showcase stack's `positions`, +// `permissions` and `capabilities`, plus the platform's own bootstrap +// permission sets that `plugin-security` ships — and exactly the names the +// metadata door lists (`GET /api/v1/meta/:type`). Every listed name resolves, +// by name, to the entry the list gave for it. +// +// Why both halves. The expected sets are derived from the PRODUCERS (the stack +// object and `securityDefaultPermissionSets`), never from a registry, so a +// reader that stops seeing one source goes red here even when the door loses +// the same source alongside it. The door half says the read serves the +// catalog a metadata author sees, no more and no less. +// +// What it does NOT pin: which in-process reader holds which name (the engine +// registry holds the permission sets, the metadata service the stack-declared +// positions, both the capabilities — see the module doc). That is today's +// distribution, not the read's contract; a position reaching the engine +// registry one day must not turn this file red. +// +// The walled arm boots `multiTenant: 'posture-only'`: a real, non-degraded +// `isolated` posture with no organization wall. The catalog is +// environment-level (ADR-0131 D3), so the wall is not this read's subject; the +// same sets were measured on a boot with the real organizations package +// declared by its host root. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import showcaseStack from '@objectstack/example-showcase'; +import { securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { createSecurityCatalogReader, type SecurityCatalogType } from '@objectstack/core'; + +type Named = { name?: unknown }; +const namesOf = (items: unknown): string[] => + (Array.isArray(items) ? (items as Named[]) : []) + .map((i) => i?.name) + .filter((n): n is string => typeof n === 'string') + .sort(); + +const stack = showcaseStack as unknown as { + positions?: Named[]; + permissions?: Named[]; + capabilities?: Named[]; +}; + +/** The declared catalog, read off the producers — never off a registry. */ +const DECLARED: Record = { + position: namesOf(stack.positions), + permission: [...new Set([...namesOf(stack.permissions), ...namesOf(securityDefaultPermissionSets)])].sort(), + capability: namesOf(stack.capabilities), +}; + +const TYPES: SecurityCatalogType[] = ['position', 'permission', 'capability']; + +const POSTURES = [ + { label: 'single', opts: {}, posture: 'single' }, + { label: 'single with the Default Organization', opts: { orgContext: true }, posture: 'single' }, + { label: 'walled', opts: { multiTenant: 'posture-only' as const }, posture: 'isolated' }, +] as const; + +it('PRECONDITION: the declarations name a catalog in every type, the platform administrator set among them', () => { + for (const type of TYPES) expect(DECLARED[type].length, type).toBeGreaterThan(0); + expect(DECLARED.permission).toContain('admin_full_access'); +}); + +describe.each(POSTURES)('showcase, $label: the security catalog read', ({ opts, posture }) => { + let booted: VerifyStack; + let admin: string; + let reader: ReturnType; + + beforeAll(async () => { + booted = await bootStack(showcaseStack as Parameters[0], opts); + admin = await booted.signIn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ql: any = await booted.kernel.getServiceAsync('objectql'); + reader = createSecurityCatalogReader({ registry: ql.registry, metadata: booted.kernel.getService('metadata') }); + }, 180_000); + + afterAll(async () => { + await booted?.stop(); + }); + + it('PRECONDITION: the boot runs the posture this arm names', () => { + expect(booted.tenancy().requestedPosture).toBe(posture); + }); + + it.each(TYPES)('%s: lists exactly the declared names', async (type) => { + const listed = (await reader.list(type)).map((e) => e.name).sort(); + expect(listed).toEqual(DECLARED[type]); + }); + + it.each(TYPES)('%s: lists exactly the names the metadata door lists', async (type) => { + const res = await booted.apiAs(admin, 'GET', `/meta/${type}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { items?: unknown } | unknown[]; + const door = namesOf(Array.isArray(body) ? body : body.items); + expect((await reader.list(type)).map((e) => e.name).sort()).toEqual(door); + }); + + it.each(TYPES)('%s: every listed name resolves by name to the entry the list gave', async (type) => { + for (const entry of await reader.list(type)) { + expect(await reader.resolve(type, entry.name)).toEqual(entry); + } + }); +}); From 46fe89abad593a81fc32dce692f1ece551e76c67 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:17:30 +0000 Subject: [PATCH 4/5] chore(changeset): minor for @objectstack/core, the security catalog read Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .changeset/15196-core-security-catalog-read.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/15196-core-security-catalog-read.md diff --git a/.changeset/15196-core-security-catalog-read.md b/.changeset/15196-core-security-catalog-read.md new file mode 100644 index 00000000000..64c69aafb9a --- /dev/null +++ b/.changeset/15196-core-security-catalog-read.md @@ -0,0 +1,14 @@ +--- +"@objectstack/core": minor +--- + +feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`) + +Clause-②: yes (widening) + +- **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it. +- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items but no stack-declared position, and the metadata service carries the stack-declared positions but not the platform's permission sets. Both are required; construction refuses a missing one. +- **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body. +- **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level. +- **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member. +- Nothing calls the reader yet; no grant changes. From b70dd032be84fbfcdcf4af58c5dbe485408a3152 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:21:47 +0000 Subject: [PATCH 5/5] test(objectql): hand the stored override row its metadata as JSON text The row's metadata column is text; passing the object added a type error the test-typecheck ledger does not record. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- packages/objectql/src/protocol-boot-hydration-scoped.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 531da0fd6da..ca9944d6b5c 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -269,7 +269,7 @@ describe('security catalog read — a name two packages ship (ADR-0131 D4, today type, name, package_id: OVERRIDE_PACKAGE, - metadata: catalogBody(type, name, 'stored override'), + metadata: JSON.stringify(catalogBody(type, name, 'stored override')), }), ]; const { protocol, reader } = bootWithSharedName(type, name, rows);