From f7d8d0e172db08106300e6a56559c622cde98237 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:01:26 +0000 Subject: [PATCH 1/3] fix(objectql): register stack-declared positions under their package The metadata save door refuses a save over a package-declared item of a type with no overlay channel, and decides "a package ships this" from the engine SchemaRegistry entry the package registered. METADATA_ARRAY_KEYS carried permissions and capabilities but still carried the retired `roles` spelling instead of `positions`, so a stack-declared position had no such entry and a save over it took the runtime-create tier. Adds `positions` to the provenance seam, drops the stale waiver row in check-stack-collection-maps, re-measures the seeder declaration-copy pin on a real artifact boot, and pins every security-domain allowOrgOverride:false type at the door on both topologies. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../15196-core-security-catalog-read.md | 2 +- .changeset/22203-position-package-door.md | 13 + .../engine-nested-plugin-collections.test.ts | 2 +- ...gine-security-catalog-package-door.test.ts | 274 ++++++++++++++++++ packages/objectql/src/engine.ts | 14 +- ...lone-stack-seeder-declaration-copy.test.ts | 27 +- scripts/check-stack-collection-maps.mjs | 10 +- 7 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 .changeset/22203-position-package-door.md create mode 100644 packages/objectql/src/engine-security-catalog-package-door.test.ts diff --git a/.changeset/15196-core-security-catalog-read.md b/.changeset/15196-core-security-catalog-read.md index 64c69aafb9a..c6550bdff83 100644 --- a/.changeset/15196-core-security-catalog-read.md +++ b/.changeset/15196-core-security-catalog-read.md @@ -7,7 +7,7 @@ feat(core): one by-name read of the security catalog (`createSecurityCatalogRead 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. +- **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, stack-declared positions included, and the metadata service carries the security collections an app registers in memory 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. diff --git a/.changeset/22203-position-package-door.md b/.changeset/22203-position-package-door.md new file mode 100644 index 00000000000..7a0085e6504 --- /dev/null +++ b/.changeset/22203-position-package-door.md @@ -0,0 +1,13 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a stack-declared position is registered under its package, so the metadata save door refuses a save over it like every other non-overridable security type + +Clause-②: no + +- **What was wrong.** `PUT /api/v1/meta/position/NAME` naming a position an installed package declares answered `200`, and the saved environment row then won the by-name read (`GET /api/v1/meta/position/NAME`). The type registry declares `position` `allowOrgOverride: false`, as it declares `permission` and `capability`, so the save door should refuse it. +- **Why.** The save door decides "a code package ships this item" from the engine's SchemaRegistry entry that a package registered. `ObjectQL.registerApp()` puts a stack collection into that registry under its package only for the collections it enumerates. That list carried `permissions` and `capabilities`, but still carried the retired `roles` instead of `positions`. So no package-declared position had an entry, and the save took the runtime-create path. +- **What changes.** `registerApp()` (and the nested-plugin seam) now registers a stack's `positions` under the owning package, with the same ADR-0010 provenance as its permission sets. A save over a package-declared position is refused `403 NOT_OVERRIDABLE` on every topology. A save that names the read-only package (`?package=`) is refused `403 ITEM_LOCKED`, which is how a save naming a read-only package is refused for any non-overridable type. The by-name read keeps serving the package's position. +- **What does not change.** A position no package declares still saves (`allowRuntimeCreate`). Permission sets and capabilities are refused as before. The declared-positions seeder in `@objectstack/plugin-security` now reads the stack's positions from the engine registry rather than the metadata service, as it does for permission sets. It seeds the same names, labels and descriptions. +- **To customize a packaged position,** create a position with a different name. diff --git a/packages/objectql/src/engine-nested-plugin-collections.test.ts b/packages/objectql/src/engine-nested-plugin-collections.test.ts index 6b0ab169a70..5a5881a127e 100644 --- a/packages/objectql/src/engine-nested-plugin-collections.test.ts +++ b/packages/objectql/src/engine-nested-plugin-collections.test.ts @@ -188,7 +188,7 @@ describe('the two registration seams enumerate ONE collection list (#7049)', () const CANDIDATES = [ 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', 'flows', 'webhooks', 'jobs', - 'permissions', 'capabilities', 'sharingRules', + 'positions', 'permissions', 'capabilities', 'sharingRules', 'agents', 'tools', 'skills', 'apis', 'hooks', 'mappings', 'analyticsCubes', 'connectors', 'emailTemplates', 'docs', 'books', diff --git a/packages/objectql/src/engine-security-catalog-package-door.test.ts b/packages/objectql/src/engine-security-catalog-package-door.test.ts new file mode 100644 index 00000000000..7f2f46d1269 --- /dev/null +++ b/packages/objectql/src/engine-security-catalog-package-door.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22203] Every package-declared item of a `security`-domain type that the + * type registry declares `allowOrgOverride: false` is refused at the metadata + * save door — and the refusal is the type-level one, fed by the provenance + * seam, not a lock written for one type. + * + * ## What was wrong, measured + * + * The save door's packaged-base check is already type-level: it refuses a write + * onto an item a code package ships when the type has no overlay channel + * (`refusePackagedBaseOverride` in `saveMetaItem` on an environment-scoped + * kernel, `SysMetadataRepository.assertAllowed`'s `override-artifact` intent on + * a host-config one), and both read `allowOrgOverride` off + * `DEFAULT_METADATA_TYPE_REGISTRY`. What decides "a code package ships it" is + * `isArtifactBacked`, which asks this engine's SchemaRegistry for an entry a + * package registered (`getArtifactItem`, `_packageId` provenance). + * + * The only seam that puts a stack collection into that registry under its + * package is `registerMetadataCollections` over `METADATA_ARRAY_KEYS`. That list + * carried `permissions` and `capabilities`, and the retired `roles` instead of + * `positions` (ADR-0090 D3 renamed the collection; the rename reached the + * artifact door's map and never this one). So a stack-declared position had no + * registry entry, `isArtifactBacked('position', name)` answered false, the save + * took the `runtime-only` intent, and `allowRuntimeCreate: true` let it through: + * `PUT /api/v1/meta/position/NAME` over a package's position answered 200 and + * the saved row then won the by-name read. A permission set was refused on the + * same door because its collection was on the list. + * + * ## What this file pins + * + * The set under test is ENUMERATED FROM THE REGISTRY, not written out: every + * `domain: 'security'` row with `allowOrgOverride: false`. For each, a real + * `ObjectQL` registers a manifest that declares one item through the type's + * stack collection, and a real `ObjectStackProtocolImplementation` over that + * engine is asked to save over it — on both topologies, because the two answer + * at different layers. The rejection is asserted on the envelope (`code` + + * `status`), and nothing may be stored. + * + * No security plugin is composed here, so the permission-set refusal below is + * the protocol's own type-level door, not plugin-security's packaged + * permission-set lock (which stays a stricter layer on top of it). + * + * Controls, so the refusals cannot pass for the wrong reason: a position no + * package declares still saves (the `allowRuntimeCreate` tier is untouched); an + * `allowOrgOverride: true` type still saves over its packaged item; and the + * by-name read after a refusal still serves the package's declaration. + */ + +import { describe, expect, it } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { singularToPlural } from '@objectstack/spec/shared'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +const PKG = 'com.acme.security_catalog'; +const NAME = 'probe_item'; + +/** The pin's population, read off the registry: every non-overridable `security` type. */ +const SECURITY_NON_OVERRIDABLE = DEFAULT_METADATA_TYPE_REGISTRY + .filter((entry) => entry.domain === 'security' && entry.allowOrgOverride === false) + .map((entry) => entry.type); + +/** + * A schema-valid body per type. A type that joins the population without one + * fails the first case below by name, rather than dropping out of the pin. + */ +const BODIES: Record> = { + permission: { name: NAME, label: 'Probe', objects: {} }, + position: { name: NAME, label: 'Probe' }, + capability: { name: NAME, label: 'Probe' }, +}; + +/** The overlayable control: `allowOrgOverride: true`, declared through a stack collection. */ +const OVERLAYABLE_TYPE = 'email_template'; +const OVERLAYABLE_BODY = { name: NAME, label: 'Probe', subject: 'Probe', bodyHtml: '

Probe

' }; + +const sysMetadataObject: ServiceObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** In-memory driver, copied from `save-meta-response-conformance.test.ts`; equality-only WHERE. */ +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them (#7620). + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** One manifest declaring `NAME` through every security collection under test, plus the control. */ +function securityManifest(): Record { + const manifest: Record = { id: PKG, name: 'security_catalog' }; + for (const type of SECURITY_NON_OVERRIDABLE) { + if (BODIES[type]) manifest[singularToPlural(type)] = [BODIES[type]]; + } + manifest[singularToPlural(OVERLAYABLE_TYPE)] = [OVERLAYABLE_BODY]; + return manifest; +} + +type Topology = 'environment-scoped' | 'host-config'; +const TOPOLOGIES: readonly Topology[] = ['environment-scoped', 'host-config']; + +async function boot(topology: Topology) { + const engine = new ObjectQL(); + const { driver, stores } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject, 'test-package'); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + engine.registry.registerObject(o as any, 'test-package'); + } + engine.registerApp(securityManifest()); + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + topology === 'environment-scoped' ? 'env_prod' : undefined, + ); + const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []); + return { engine, protocol, storedRows }; +} + +describe('[#22203] security-domain allowOrgOverride:false — the package door is type-level', () => { + it('the population is read from the registry, and every member has a fixture', () => { + // The three the registry declares today. Asserted as a superset so a + // fourth `security` type joins the pin instead of breaking it — and the + // per-type loop below then demands a body for it by name. + expect(SECURITY_NON_OVERRIDABLE).toEqual(expect.arrayContaining(['permission', 'position', 'capability'])); + for (const type of SECURITY_NON_OVERRIDABLE) { + expect(BODIES[type], `no fixture body for security type '${type}'`).toBeDefined(); + } + }); + + it.each(SECURITY_NON_OVERRIDABLE)('the provenance seam registers a stack-declared %s under its package', async (type) => { + const { engine } = await boot('host-config'); + // The input `isArtifactBacked` reads: an entry a package registered. + const artifact = engine.registry.getArtifactItem(type, NAME); + expect(artifact?._packageId).toBe(PKG); + expect(artifact?._provenance).toBe('package'); + }); + + for (const topology of TOPOLOGIES) { + it.each(SECURITY_NON_OVERRIDABLE)(`${topology}: a save over a package-declared %s is refused 403 NOT_OVERRIDABLE and stores nothing`, async (type) => { + const { protocol, storedRows } = await boot(topology); + + const err: any = await protocol + .saveMetaItem({ type, name: NAME, item: { ...BODIES[type], label: 'Environment fork' } }) + .then(() => undefined, (e: unknown) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(storedRows()).toHaveLength(0); + }); + } + + it('the by-name read after the refusal still serves the package\'s position', async () => { + const { protocol } = await boot('host-config'); + await protocol + .saveMetaItem({ type: 'position', name: NAME, item: { name: NAME, label: 'Environment fork' } }) + .catch(() => undefined); + + const read: any = await protocol.getMetaItem({ type: 'position', name: NAME }); + expect(read?.item?.label).toBe('Probe'); + }); + + // ── controls ───────────────────────────────────────────────────────── + + it.each(TOPOLOGIES)('%s: a position no package declares still saves (the allowRuntimeCreate tier)', async (topology) => { + const { protocol, storedRows } = await boot(topology); + + const result: any = await protocol.saveMetaItem({ + type: 'position', name: 'env_only_position', item: { name: 'env_only_position', label: 'Env only' }, + }); + + expect(result?.success).toBe(true); + expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual(['position/env_only_position']); + }); + + it.each(TOPOLOGIES)(`%s: an allowOrgOverride:true type (${OVERLAYABLE_TYPE}) still saves over its packaged item`, async (topology) => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERLAYABLE_TYPE); + expect(entry?.allowOrgOverride).toBe(true); + const { engine, protocol, storedRows } = await boot(topology); + // The control is only a control if its item is package-declared too. + expect(engine.registry.getArtifactItem(OVERLAYABLE_TYPE, NAME)?._packageId).toBe(PKG); + + const result: any = await protocol.saveMetaItem({ + type: OVERLAYABLE_TYPE, name: NAME, item: { ...OVERLAYABLE_BODY, label: 'Environment overlay' }, + }); + + expect(result?.success).toBe(true); + expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual([`${OVERLAYABLE_TYPE}/${NAME}`]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b0ab6aac69d..8465f68a50a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3093,7 +3093,19 @@ const METADATA_ARRAY_KEYS = [ // `readDeclared(ql, 'capability')` returned nothing, which made the // author-side `packageId` — documented as the FALLBACK — mandatory, // and its omission a silent, unenforced authorization declaration. - 'roles', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', + // + // [#22203] `positions` for the same reason, and with a write-door + // consequence: ADR-0090 D3 renamed `roles` to `positions`, and the rename + // reached the artifact door's map but never this list, so a stack-declared + // position had no registry entry under its package. The metadata save door + // decides "a code package ships this" from exactly that entry + // (`isArtifactBacked` → `getArtifactItem`), so a save over a package's + // position took the runtime-create tier and was accepted, although the type + // registry declares `position` `allowOrgOverride: false`. With the entry + // present the door's type-level refusal covers it like every other + // `security`-domain type (`engine-security-catalog-package-door.test.ts`). + // `roles` stays below as the inert retired spelling it already was. + 'roles', 'positions', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', // AI Protocol 'agents', 'tools', 'skills', 'ragPipelines', // API Protocol diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index 5c454efb66c..e6af0df4719 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -137,6 +137,9 @@ const ARTIFACT = { }, ], capabilities: [{ name: 'probe.export', label: 'Export probe data' }], + // [#22203] One declared position, so the registry-copy case below measures a + // stack declaration rather than an empty collection. + positions: [{ name: 'probe_lead', label: 'Probe Lead' }], sharingRules: [ { name: 'share_legacy_deals', @@ -283,15 +286,23 @@ describe('#14491 — the third copy exists, and both seeder spellings read it', expect(doorCopy.sharing_rule).toHaveLength(3); }); - it('`positions` is absent from METADATA_ARRAY_KEYS, so no stack-declared `position` has a registry copy and each comes from the door', async () => { - // The card's asymmetry, re-measured. `roles:` is not in - // `PLURAL_TO_SINGULAR` either, so nothing lands under `roles` in the - // registry: both reads are empty from both spellings. + it('[#22203] a stack-declared `position` has a registry copy under the artifact\'s package, beside the six built-ins', async () => { + // Re-measured after `positions` joined METADATA_ARRAY_KEYS. It used to be + // absent, so this read held the six built-ins and none of the stack's — + // and the metadata save door decides "a code package ships this" from + // this registry, so a save over a package's position was accepted. + // `roles:` is not in `PLURAL_TO_SINGULAR`, so nothing lands under `roles` + // from either spelling. const ql = kernel.getService('objectql'); - // [ADR-0131 D2] The registry's positions are exactly the six built-ins - // `SecurityPlugin` declares under its own package id — none of the stack's. - expect((ql.registry.listItems('position') ?? []).filter(Boolean).map((i: any) => i.name).sort()) - .toEqual([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS].sort()); + const positions = (ql.registry.listItems('position') ?? []).filter(Boolean); + // [ADR-0131 D2] The six built-ins `SecurityPlugin` declares under its own + // package id, plus the stack's one under the artifact's. + expect(positions.map((i: any) => i.name).sort()) + .toEqual([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS, 'probe_lead'].sort()); + expect(byName(positions, 'probe_lead')).toMatchObject({ + _packageId: ARTIFACT.manifest.id, + _provenance: 'package', + }); expect((ql.registry.listItems('roles') ?? []).filter(Boolean)).toEqual([]); }); }); diff --git a/scripts/check-stack-collection-maps.mjs b/scripts/check-stack-collection-maps.mjs index b14df4578f1..5a14fb99cd7 100644 --- a/scripts/check-stack-collection-maps.mjs +++ b/scripts/check-stack-collection-maps.mjs @@ -612,13 +612,9 @@ const SITES = [ + 'SeedLoaderService. This row is why the gate reconciles both directions with reasons instead of ' + 'demanding equality.', }, - { - direction: 'missing', - keys: ['positions'], - reason: - 'ADR-0090 D3 positions reach the registry through the security bootstrap, which reads them off the ' - + 'stack directly; the loop\'s sibling `permissions` entry is what makes the absence look like a gap.', - }, + // `positions` left this table at #22203: its waiver said positions reach "the registry" through the + // security bootstrap, but that is the metadata service's registry, not the SchemaRegistry this loop + // stamps provenance into — and the metadata save door reads the latter, so the absence WAS the gap. ], }, { From 6db2239b9a5b8680ba8814d404c7ad2653ddeabb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:17:09 +0000 Subject: [PATCH 2/3] docs(objectql): say where the retired roles spelling stays Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 8465f68a50a..a4fb3ba07df 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3104,7 +3104,7 @@ const METADATA_ARRAY_KEYS = [ // registry declares `position` `allowOrgOverride: false`. With the entry // present the door's type-level refusal covers it like every other // `security`-domain type (`engine-security-catalog-package-door.test.ts`). - // `roles` stays below as the inert retired spelling it already was. + // `roles` stays in the list as the inert retired spelling it already was. 'roles', 'positions', 'permissions', 'capabilities', 'profiles', 'sharingRules', 'policies', // AI Protocol 'agents', 'tools', 'skills', 'ragPipelines', From 5188b2ad45432de7198be1d3a0058ab8e780644c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:35:51 +0000 Subject: [PATCH 3/3] test(objectql): the door pin's driver double holds the caller's bound Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../objectql/src/engine-security-catalog-package-door.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine-security-catalog-package-door.test.ts b/packages/objectql/src/engine-security-catalog-package-door.test.ts index 7f2f46d1269..005bc3a9bef 100644 --- a/packages/objectql/src/engine-security-catalog-package-door.test.ts +++ b/packages/objectql/src/engine-security-catalog-package-door.test.ts @@ -132,7 +132,9 @@ function makeMemoryDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;