From 9a0e40ab6968103ac1bf844107e947df72edc40c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:40:27 +0000 Subject: [PATCH 1/6] wip(security): resolve current_user.queues kernel-side and reserve the key Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA Co-Authored-By: Claude --- packages/lint/src/rule-explanations.ts | 8 +- ...idate-rls-predicate-enforceability.test.ts | 5 +- .../validate-rls-predicate-enforceability.ts | 2 +- .../src/kernel-queue-membership.test.ts | 192 ++++++++++++++++++ .../src/kernel-queue-membership.ts | 115 +++++++++++ .../src/object-membership-keys.ts | 21 +- .../plugin-security/src/rls-compiler.ts | 14 ++ .../src/rls-reserved-membership-keys.test.ts | 20 +- .../src/security-plugin-rls-filter.ts | 13 +- .../plugin-security/src/security-plugin.ts | 20 +- .../src/ownership-queue.test.ts | 25 +++ .../src/contracts/rls-membership-resolver.ts | 18 +- packages/spec/src/data/object.zod.ts | 2 +- packages/spec/src/security/rls.zod.ts | 3 +- skills/objectstack-data/rules/security.md | 2 +- 15 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 packages/plugins/plugin-security/src/kernel-queue-membership.test.ts create mode 100644 packages/plugins/plugin-security/src/kernel-queue-membership.ts diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 0e39da06874..87c8950f1f3 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1422,8 +1422,8 @@ const RLS_PREDICATE_UNENFORCEABLE_EXPLANATION: RuleExplanation = { RLS_UNCOMPILABLE_DROP, // The two key lists are held equal to the rule's per-key probe types by the rule's test. 'A predicate can pass that shape check and still be refused for the TYPE of the value a `current_user` ' + - 'reference holds on every request. `ExecutionContext` declares, and the kernel resolves, the membership ' + - 'sets `accessible_org_ids`, `org_user_ids` and `positions` as LISTS and `email`, `id` and ' + + 'reference holds on every request. The kernel resolves the membership sets `accessible_org_ids`, ' + + '`org_user_ids`, `positions` and `queues` as LISTS and `email`, `id` and ' + '`organization_id` as one value each, and `current_user` alone is the whole caller context object. A ' + 'list under `==` / `!=` or handed to a string method, one value on the right of `in`, or the whole ' + 'context object where one value or one set belongs is refused on EVERY request, so `RLSCompiler` drops ' + @@ -1478,7 +1478,7 @@ const RLS_PREDICATE_UNENFORCEABLE_EXPLANATION: RuleExplanation = { '(`startsWith` / `endsWith` / `contains`) runs only on a field that holds a string, and a date or datetime ' + 'field only against a value its storage can read. A `{…}` placeholder is saved-filter syntax, and an RLS ' + 'predicate does not use it: the predicate is CEL, and the caller\'s values are the `current_user` keys ' + - '(accessible_org_ids, email, id, org_user_ids, organization_id, positions).', + '(accessible_org_ids, email, id, org_user_ids, organization_id, positions, queues).', ], }; @@ -1523,7 +1523,7 @@ const RLS_PREDICATE_UNKNOWN_USER_VARIABLE_EXPLANATION: RuleExplanation = { paragraphs: [ // Held equal to `RESERVED_RLS_MEMBERSHIP_KEYS` by the rule's test. 'The kernel-resolved `current_user` keys are exactly accessible_org_ids, email, id, org_user_ids, ' + - 'organization_id, positions. The only other keys that can EVER appear are §7.3.1 membership sets, which ' + + 'organization_id, positions, queues. The only other keys that can EVER appear are §7.3.1 membership sets, which ' + 'the runtime stages as ARRAYS and which are therefore usable only as `field in current_user.KEY`. A key ' + 'outside that list in a scalar position — compared with `==` / `!=` / `<` / `>` or handed to a string ' + 'method — can therefore never be supplied by any request.', diff --git a/packages/lint/src/validate-rls-predicate-enforceability.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.test.ts index a9270b93442..fbf1145ebd1 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.test.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.test.ts @@ -811,7 +811,9 @@ describe('validateRlsPredicateEnforceability — the messages name the COST, not * `userId`, `organization_id` ← `tenantId`, the rest by name), and * `ExecutionContextSchema` declares `userId`, `tenantId` and `email` as strings * and `positions`, `org_user_ids` and `accessible_org_ids` as string arrays; - * `resolveAuthzContext` produces the same types. + * `resolveAuthzContext` produces the same types. `queues` (ADR-0142 D3) has no + * `ExecutionContext` field: the security plugin stages it as a string array of + * queue names, and the probe binds it as one. */ const KERNEL_KEY_RUNTIME_TYPE: Record = { id: 'scalar', @@ -820,6 +822,7 @@ const KERNEL_KEY_RUNTIME_TYPE: Record = { positions: 'array', org_user_ids: 'array', accessible_org_ids: 'array', + queues: 'array', }; describe('validateRlsPredicateEnforceability — the `current_user` set is DERIVED, not transcribed', () => { diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 182577437df..fef9458a79c 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -561,7 +561,7 @@ export function refusalVerdict(detail: string): string { * list `IRlsMembershipResolver` is forbidden to supply *because the kernel * already owns them* — "keys … must not collide with the named context fields * (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, - * `email`) — the compiler never lets a membership key clobber those." That is + * `email`, `queues`) — the compiler never lets a membership key clobber those." That is * the same set `RLSCompiler.compileFilter` builds its `RLSUserContext` from, * and it is in `@objectstack/spec`, which this package may read (the RLS * compiler itself is a runtime it may not). diff --git a/packages/plugins/plugin-security/src/kernel-queue-membership.test.ts b/packages/plugins/plugin-security/src/kernel-queue-membership.test.ts new file mode 100644 index 00000000000..c0a031b6a2a --- /dev/null +++ b/packages/plugins/plugin-security/src/kernel-queue-membership.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D3] `current_user.queues` is resolved by the security plugin from + * the ownership-queue service, and by nothing else: a member reads the records + * sitting in their queues, a non-member reads none, and an app resolver that + * answers `queues` is screened off the reserved key and fails closed. + * + * Driven through the whole plugin (`getReadFilter`), so the staging, the + * resolver screen and the compiler merge are the real ones. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { OWNERSHIP_QUEUE_SERVICE, RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; + +import { RLS_DENY_FILTER } from './rls-compiler.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { kernelQueuesOf, stageKernelQueues } from './kernel-queue-membership.js'; + +const bytes = (v: unknown): string => JSON.stringify(v); + +const QUEUE_POLICY = { name: 'sitting', object: 'case', operation: 'select', using: 'queue in current_user.queues' }; +const OWNER_POLICY = { name: 'own', object: 'case', operation: 'select', using: 'owner_id == current_user.id' }; + +/** Who sits in which queue, per organization — the fake service's one answer. */ +const MEMBERSHIP: Record> = { + org_a: { u_member: ['billing', 'support_tier1'] }, + org_b: { u_member: ['org_b_only'] }, +}; + +function queueService(overrides: Partial<{ queuesOf: (u: string, o: string | null) => Promise }> = {}) { + const queuesOf = vi.fn( + overrides.queuesOf ?? (async (userId: string, org: string | null) => MEMBERSHIP[org ?? '']?.[userId] ?? []), + ); + return { + queuesOf, + resolveQueue: async () => null, + isMember: async (userId: string, name: string, org: string | null) => + (MEMBERSHIP[org ?? '']?.[userId] ?? []).includes(name), + }; +} + +function makeHost(opts: { queues?: unknown; resolver?: unknown; warn?: (...a: unknown[]) => void; policies?: unknown[] }) { + const schema = { + name: 'case', + fields: { id: { name: 'id' }, queue: { name: 'queue' }, owner_id: { name: 'owner_id' } }, + }; + const permSet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true } }, + rowLevelSecurity: opts.policies ?? [QUEUE_POLICY], + } as never; + const services: Record = { + manifest: { register: () => {} }, + objectql: { registerMiddleware: () => {}, getSchema: () => schema, findOne: async () => null }, + metadata: { get: async () => schema, list: async () => [permSet] }, + }; + if (opts.queues) services[OWNERSHIP_QUEUE_SERVICE] = opts.queues; + if (opts.resolver) services['rls-membership-resolver'] = opts.resolver; + return { + logger: { info: () => {}, warn: opts.warn ?? (() => {}), error: () => {}, debug: () => {} }, + registerService: () => {}, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + } as never; +} + +async function startedPlugin(opts: Parameters[0]) { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const host = makeHost(opts); + await plugin.init(host); + await plugin.start(host); + return plugin as unknown as { getReadFilter(object: string, ctx: unknown): Promise }; +} + +async function readFilter(opts: Parameters[0], context: Record) { + return (await startedPlugin(opts)).getReadFilter('case', context); +} + +describe('current_user.queues — the kernel resolves it from the ownership-queue service', () => { + it('is a reserved key', () => { + expect(RESERVED_RLS_MEMBERSHIP_KEYS).toContain('queues'); + }); + + it('⭐ a member reads the records sitting in their queues, in the active organization', async () => { + const queues = queueService(); + const filter = await readFilter({ queues }, { userId: 'u_member', tenantId: 'org_a' }); + expect(filter).toEqual({ queue: { $in: ['billing', 'support_tier1'] } }); + expect(queues.queuesOf).toHaveBeenCalledWith('u_member', 'org_a'); + }); + + it('⭐ the organization decides the set: the same member in another organization reads that one\'s queues', async () => { + const filter = await readFilter({ queues: queueService() }, { userId: 'u_member', tenantId: 'org_b' }); + expect(filter).toEqual({ queue: { $in: ['org_b_only'] } }); + }); + + it('⭐ a non-member reads no sitting record — byte-identical to no queue service at all', async () => { + const nonMember = await readFilter({ queues: queueService() }, { userId: 'u_outsider', tenantId: 'org_a' }); + const noService = await readFilter({}, { userId: 'u_outsider', tenantId: 'org_a' }); + expect(nonMember).toEqual(RLS_DENY_FILTER); + expect(bytes(nonMember)).toBe(bytes(noService)); + }); + + it('⭐ the set is the same answer the claim gate\'s isMember gives', async () => { + const queues = queueService(); + const filter = (await readFilter({ queues }, { userId: 'u_member', tenantId: 'org_a' })) as { + queue: { $in: string[] }; + }; + for (const name of filter.queue.$in) expect(await queues.isMember('u_member', name, 'org_a')).toBe(true); + }); + + it('resolves once per request, and only when a policy reads the key', async () => { + const queues = queueService(); + const plugin = await startedPlugin({ queues }); + const ctx = { userId: 'u_member', tenantId: 'org_a' }; + await plugin.getReadFilter('case', ctx); + await plugin.getReadFilter('case', ctx); + expect(queues.queuesOf).toHaveBeenCalledTimes(1); + + const unread = queueService(); + await readFilter({ queues: unread, policies: [OWNER_POLICY] }, { userId: 'u_member', tenantId: 'org_a' }); + expect(unread.queuesOf).not.toHaveBeenCalled(); + }); + + it('⛔ fails closed with no signed-in user, and when the service throws (one WARN)', async () => { + const anonymous = queueService(); + expect(await readFilter({ queues: anonymous }, { tenantId: 'org_a' })).toEqual(RLS_DENY_FILTER); + expect(anonymous.queuesOf).not.toHaveBeenCalled(); + + const warn = vi.fn(); + const throwing = queueService({ queuesOf: async () => { throw new Error('sys_queue unavailable'); } }); + expect(await readFilter({ queues: throwing, warn }, { userId: 'u_member', tenantId: 'org_a' })).toEqual( + RLS_DENY_FILTER, + ); + expect(warn.mock.calls.some(([m]) => String(m).includes('queue membership could not be resolved'))).toBe(true); + }); +}); + +describe('current_user.queues — no source but the kernel', () => { + /** An app resolver that claims the reserved key and seats every caller in a VIP queue. */ + const hostileResolver = { + keys: ['queues', 'territory_ids'], + resolve: async () => ({ queues: ['vip_escalations'], territory_ids: ['t1'] }), + }; + + it('⭐ an app resolver answering `queues` is screened (WARN) — the kernel set stands', async () => { + const warn = vi.fn(); + const withResolver = await readFilter( + { queues: queueService(), resolver: hostileResolver, warn }, + { userId: 'u_member', tenantId: 'org_a' }, + ); + const without = await readFilter({ queues: queueService() }, { userId: 'u_member', tenantId: 'org_a' }); + expect(bytes(withResolver)).toBe(bytes(without)); + expect(withResolver).toEqual({ queue: { $in: ['billing', 'support_tier1'] } }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('RESERVED context key'), { key: 'queues' }); + }); + + it('⭐ …and a non-member it tried to seat still reads nothing — fail closed', async () => { + const filter = await readFilter( + { queues: queueService(), resolver: hostileResolver }, + { userId: 'u_outsider', tenantId: 'org_a' }, + ); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('⭐ with no queue service, the resolver\'s `queues` does not fill the gap', async () => { + const filter = await readFilter({ resolver: hostileResolver }, { userId: 'u_member', tenantId: 'org_a' }); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('⛔ a `queues` field or bag entry the caller puts on the context is never read', async () => { + const filter = await readFilter( + {}, + { userId: 'u_outsider', tenantId: 'org_a', queues: ['vip_escalations'], rlsMembership: { queues: ['vip_escalations'] } }, + ); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('⛔ a context copied for another principal or organization does not carry the staged set', async () => { + const host = { logger: undefined, ownershipQueueMembership: () => queueService() }; + const ctx: Record = { userId: 'u_member', tenantId: 'org_a' }; + await stageKernelQueues(host, ctx); + expect(kernelQueuesOf(ctx)).toEqual(['billing', 'support_tier1']); + expect(kernelQueuesOf({ ...ctx })).toEqual(['billing', 'support_tier1']); + expect(kernelQueuesOf({ ...ctx, userId: 'u_delegator' })).toBeUndefined(); + expect(kernelQueuesOf({ ...ctx, tenantId: 'org_b' })).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/kernel-queue-membership.ts b/packages/plugins/plugin-security/src/kernel-queue-membership.ts new file mode 100644 index 00000000000..afa62831094 --- /dev/null +++ b/packages/plugins/plugin-security/src/kernel-queue-membership.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0142 D3] `current_user.queues` — the machine names of the ownership + * queues the caller belongs to in the active organization, resolved by the + * platform and by nothing else. + * + * ## One producer + * + * `queues` is a {@link RESERVED_RLS_MEMBERSHIP_KEYS} key: the registered + * `rls-membership-resolver` is screened off it, the compiler's merge refuses it + * from any `rlsMembership` bag, and `membershipKeys` may not declare it. The + * value comes from here alone, and here it comes from the ownership-queue + * service's `queuesOf` — the same membership answer (`plugin-sharing`'s one + * expander, `access` purpose) the claim gate's `isMember` and the sharing + * owner match read, so an RLS policy and the claim gate cannot disagree about + * who sits in a queue. + * + * ## Where the value lives + * + * On the execution context, under a module-private symbol, together with the + * principal and organization it was resolved for. A caller cannot forge it (no + * string key reaches it, and `context.queues` is never read), and a context + * copied for ANOTHER principal (a delegator's, say) does not carry the first + * one's queues: {@link kernelQueuesOf} answers only for the identity the set + * was resolved for. + * + * ## Lazily, and fail closed + * + * Resolved once per request, and only when a policy about to compile reads the + * key (`stageRlsMembership`): a request that reads none pays nothing. With no + * signed-in user, no ownership-queue service, or a `queuesOf` that throws, the + * key stays unresolved, so every policy reading it drops on the compiler's + * existing path and fails closed. A caller in no queue resolves to `[]`, which + * matches nothing. + */ + +import type { IOwnershipQueueService } from '@objectstack/spec/contracts'; + +/** The members of the security plugin the kernel queue read uses. */ +export interface KernelQueueStagingHost { + readonly logger: { warn?: (message: string, meta?: Record) => void } | undefined; + /** The ownership-queue service, or `null` when no plugin registers it. */ + ownershipQueueMembership(): Pick | null; +} + +interface StagedQueues { + readonly userId: string; + readonly organizationId: string | null; + readonly queues: readonly string[]; +} + +const KERNEL_QUEUES = Symbol('objectstack.security.kernelQueues'); + +/** Per execution context: the one resolution in flight or settled. */ +const PENDING = new WeakMap>(); + +function organizationOf(context: any): string | null { + return context?.tenantId ? String(context.tenantId) : null; +} + +/** + * The kernel's `current_user.queues` for this context, or `undefined` when it + * was not resolved for this context's principal and organization. + */ +export function kernelQueuesOf(context: unknown): string[] | undefined { + if (!context || typeof context !== 'object') return undefined; + const staged = (context as Record)[KERNEL_QUEUES]; + if (!staged) return undefined; + const userId = (context as { userId?: unknown }).userId; + if (userId == null || staged.userId !== String(userId)) return undefined; + if (staged.organizationId !== organizationOf(context)) return undefined; + return [...staged.queues]; +} + +/** Resolve `current_user.queues` onto `context`, once per request. */ +export async function stageKernelQueues(host: KernelQueueStagingHost, context: any): Promise { + if (!context || typeof context !== 'object' || context.userId == null) return; + if (kernelQueuesOf(context) !== undefined) return; + let pending = PENDING.get(context); + if (!pending) { + pending = resolveOnto(host, context); + PENDING.set(context, pending); + } + await pending; +} + +async function resolveOnto(host: KernelQueueStagingHost, context: any): Promise { + const service = host.ownershipQueueMembership(); + if (!service) return; + const userId = String(context.userId); + const organizationId = organizationOf(context); + let names: unknown; + try { + names = await service.queuesOf(userId, organizationId); + } catch (e) { + host.logger?.warn?.( + '[security/ADR-0142] queue membership could not be resolved — policies reading current_user.queues ' + + 'fail closed for this request', + { userId, error: (e as Error)?.message }, + ); + return; + } + if (!Array.isArray(names)) return; + const staged: StagedQueues = { + userId, + organizationId, + queues: Object.freeze(names.filter((n): n is string => typeof n === 'string')), + }; + try { + context[KERNEL_QUEUES] = staged; + } catch { + // A frozen context keeps the key unresolved: fail closed. + } +} diff --git a/packages/plugins/plugin-security/src/object-membership-keys.ts b/packages/plugins/plugin-security/src/object-membership-keys.ts index 71e6d356a53..6f2a5e475ef 100644 --- a/packages/plugins/plugin-security/src/object-membership-keys.ts +++ b/packages/plugins/plugin-security/src/object-membership-keys.ts @@ -167,6 +167,25 @@ const KEYS_OF_PREDICATE_MAX = 2000; * one. */ export function membershipKeyReadsOf(predicate: string): readonly string[] { + return currentUserReadsOf(predicate).filter((k) => !RESERVED_RLS_MEMBERSHIP_KEYS.includes(k)); +} + +/** + * [ADR-0142 D3] Does any of these policies read `current_user.KEY`, in either + * clause? Asked for a kernel key the security plugin stages lazily + * (`queues`), so a request that reads none pays nothing. + */ +export function policiesReadCurrentUserKey(policies: readonly RowLevelSecurityPolicy[], key: string): boolean { + for (const policy of policies) { + for (const clause of [policy.using, (policy as { check?: unknown }).check]) { + if (typeof clause === 'string' && clause !== '' && currentUserReadsOf(clause).includes(key)) return true; + } + } + return false; +} + +/** Every `current_user.KEY` name a predicate reads, reserved kernel names included. */ +function currentUserReadsOf(predicate: string): readonly string[] { const cached = KEYS_OF_PREDICATE.get(predicate); if (cached) return cached; const out = new Set(); @@ -204,7 +223,7 @@ export function membershipKeyReadsOf(predicate: string): readonly string[] { } catch { // Not parseable: the compiler refuses it on its own path. } - const keys = [...out].filter((k) => !RESERVED_RLS_MEMBERSHIP_KEYS.includes(k)); + const keys = [...out]; if (KEYS_OF_PREDICATE.size >= KEYS_OF_PREDICATE_MAX) KEYS_OF_PREDICATE.clear(); KEYS_OF_PREDICATE.set(predicate, keys); return keys; diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index a03a5f915d3..8d0e1f95c41 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -6,6 +6,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; // supply. Consumed — not redeclared — so this merge and `stageRlsMembership`'s // resolver screen can never disagree about which keys are reserved. import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; +import { kernelQueuesOf } from './kernel-queue-membership.js'; // [ADR-0056 D4 / ADR-0058 D1] `isSupportedRlsExpression` and `sqlPredicateToCel` // used to be DEFINED in this file. #4983 hoisted them into `@objectstack/formula` // — verbatim, behaviour-preserving — because `@objectstack/lint` must ask the @@ -143,6 +144,16 @@ interface RLSUserContext { * that shape survived three green static gates. */ accessible_org_ids?: string[]; + /** + * [ADR-0142 D3] The machine names of the ownership queues the caller belongs + * to in the active organization — what `owner_id == null && queue in + * current_user.queues` reads. Kernel-resolved from the ownership-queue + * service (`kernel-queue-membership.ts`) and reserved like the sets above: + * `RESERVED_RLS_MEMBERSHIP_KEYS` refuses it from any `rlsMembership` bag, + * so an app cannot seat a caller in a queue. Unresolved (no user, no + * service, a failed read) it stays absent and the predicate fails closed. + */ + queues?: string[]; /** * The caller's unique, auth-enforced email. RLS expressions reference it as * `current_user.email` for human-readable, *seedable* owner scoping @@ -924,6 +935,9 @@ export class RLSCompiler { accessible_org_ids: (executionContext as any)?.accessible_org_ids, // Unique identifier — safe for ownership predicates (see RLSUserContext). email: (executionContext as any)?.email, + // [ADR-0142 D3] Read from the kernel's own staging only — never from a + // `queues` field a caller put on the context. + queues: kernelQueuesOf(executionContext), }; // §7.3.1 dynamic membership: the runtime pre-resolves arbitrary diff --git a/packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts b/packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts index baaf9bd0165..fd9e9eb7c3d 100644 --- a/packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts +++ b/packages/plugins/plugin-security/src/rls-reserved-membership-keys.test.ts @@ -42,11 +42,12 @@ * have left wide open. */ -import { describe, it, expect } from 'vitest'; +import { beforeAll, describe, it, expect } from 'vitest'; import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; import type { PermissionSet } from '@objectstack/spec/security'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; +import { stageKernelQueues } from './kernel-queue-membership.js'; import { SecurityPlugin } from './security-plugin.js'; const policy = (using: string): never => @@ -125,11 +126,28 @@ const CELLS: Record< kernelCtx: { userId: 'usr_a', email: 'real@e.example' }, kernelWins: { owner_email: 'real@e.example' }, }, + // [ADR-0142 D3] No `ExecutionContext` field carries it: the kernel value is + // the security plugin's own staging, put on `kernelCtx` in `beforeAll` below. + // A `queues` field a caller sets on the context is never read either. + queues: { + using: 'queue IN (current_user.queues)', + bagValue: ['vip_escalations'], + absentCtx: { userId: 'usr_a', queues: ['vip_escalations'] }, + kernelCtx: { userId: 'usr_a' }, + kernelWins: { queue: { $in: ['support_tier1'] } }, + }, }; describe('RESERVED_RLS_MEMBERSHIP_KEYS — refused BY NAME at the compiler merge', () => { const compiler = new RLSCompiler(); + beforeAll(async () => { + await stageKernelQueues( + { logger: undefined, ownershipQueueMembership: () => ({ queuesOf: async () => ['support_tier1'] }) }, + CELLS.queues!.kernelCtx, + ); + }); + /** * The cell table is the acceptance criterion "one cell per reserved key", and * this is what keeps it true. A seventh reserved key added to the contract diff --git a/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts b/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts index 088204af8a5..c5e6cd7c251 100644 --- a/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts +++ b/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts @@ -17,7 +17,8 @@ * ask before they run. Since #25234 (ruling A) it also stages the declared * membership keys the policies about to compile read * (`object-membership-keys.ts`), so its resolver half is - * `stageResolverMembership`. + * `stageResolverMembership`; and per ADR-0142 D3 the kernel's own + * `current_user.queues` (`kernel-queue-membership.ts`). * * A MOVE, not a redesign. Each function below is the body of the * `SecurityPlugin` method of the same name as it stood, re-indented one level @@ -86,7 +87,8 @@ import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails } from '@objectstack/core'; import { RLS_DENY_FILTER, policyDeclaresClause } from './rls-compiler.js'; import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; -import { noteResolverStagedKeys, stageDeclaredMembershipKeys } from './object-membership-keys.js'; +import { noteResolverStagedKeys, policiesReadCurrentUserKey, stageDeclaredMembershipKeys } from './object-membership-keys.js'; +import { stageKernelQueues } from './kernel-queue-membership.js'; import { computeTenantLayer0Verdict, tenantLayer0FilterOf, @@ -130,6 +132,7 @@ export interface RlsFilterHost { getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; hasPlatformAdminPosture: SecurityPlugin['hasPlatformAdminPosture']; isVerifiedPlatformOwnerSession: SecurityPlugin['isVerifiedPlatformOwnerSession']; + ownershipQueueMembership: SecurityPlugin['ownershipQueueMembership']; platformFloorYieldsToObjectWriteModel: SecurityPlugin['platformFloorYieldsToObjectWriteModel']; stageRlsMembership: SecurityPlugin['stageRlsMembership']; } @@ -668,12 +671,16 @@ export async function computeWriteCheckFilter( /** The body of `SecurityPlugin#stageRlsMembership`; the method's docblock there is its contract. */ export async function stageRlsMembership( - this: Pick, + this: Pick, context: any, policies?: readonly RowLevelSecurityPolicy[], object?: string, ): Promise { if (!context || typeof context !== 'object') return; + // [ADR-0142 D3] The kernel's own `current_user.queues`, from the + // ownership-queue service — staged only when a policy about to compile reads + // it. A reserved key: no resolver or declaration below can supply it. + if (policies && policiesReadCurrentUserKey(policies, 'queues')) await stageKernelQueues(this, context); await stageResolverMembership.call(this, context); // [#25234 ruling A] The declarative second source: the keys these policies // read that an object declares (`object-membership-keys.ts`), per key and diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b4f24a58621..677adf30311 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2614,6 +2614,19 @@ export class SecurityPlugin implements Plugin { return svc && typeof svc.resolveQueue === 'function' && typeof svc.isMember === 'function' ? svc : null; } + /** + * [ADR-0142 D3] The same ownership-queue service, for the caller's queue set + * (`queuesOf`) — the one producer of `current_user.queues` + * (`kernel-queue-membership.ts`). `null` with no service, and the key then + * stays unresolved: every policy reading it fails closed. + */ + private ownershipQueueMembership(): Pick | null { + const svc = this.resolveKernelService?.(OWNERSHIP_QUEUE_SERVICE) as + | Pick + | undefined; + return svc && typeof svc.queuesOf === 'function' ? svc : null; + } + /** * [ADR-0090 D10] The composition knobs the DELEGATOR's own direct by-id * write of `recordId` would get: its floor decision @@ -4202,6 +4215,10 @@ export class SecurityPlugin implements Plugin { * with every miss failing closed. A key both the resolver and a declaration * claim resolves from neither. Called with the context alone, it stages the * resolver's keys only, as before. + * + * [ADR-0142 D3] When those policies read `current_user.queues`, it first + * resolves that reserved key from the ownership-queue service + * (`kernel-queue-membership.ts`), once per request. */ private stageRlsMembership( context: any, @@ -4215,13 +4232,14 @@ export class SecurityPlugin implements Plugin { const readRlsCompiler = () => this.rlsCompiler; const readRlsMembershipResolver = () => this.rlsMembershipResolver; const readTenancyPosture = () => this.tenancyPosture; - const host: Pick = { + const host: Pick = { datetimeFieldNamesCache: this.datetimeFieldNamesCache, getObjectFieldNames: (...a) => this.getObjectFieldNames(...a), getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), get logger() { return readLogger(); }, get metadata() { return readMetadata(); }, numberComparandFieldsCache: this.numberComparandFieldsCache, + ownershipQueueMembership: () => this.ownershipQueueMembership(), get ql() { return readQl(); }, get rlsCompiler() { return readRlsCompiler(); }, get rlsMembershipResolver() { return readRlsMembershipResolver(); }, diff --git a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts index df2ac2751a5..66e60fb6b23 100644 --- a/packages/plugins/plugin-sharing/src/ownership-queue.test.ts +++ b/packages/plugins/plugin-sharing/src/ownership-queue.test.ts @@ -223,6 +223,31 @@ describe('[ADR-0142 D2] OwnershipQueueService — the one expander', () => { expect(await svc.queuesOf('u_nobody', 'org_a')).toEqual([]); }); + it('queuesOf agrees with isMember for every user, queue and organization — `current_user.queues` and the claim gate read one answer', async () => { + // The security plugin stages `current_user.queues` from `queuesOf`; the + // claim gate admits through `isMember`. A row-level policy and the claim + // gate must never disagree about who sits in a queue — including a + // switched-off position, which confers nothing on either path. + const tables = BASE_TABLES(); + tables[METADATA_ACTIVATION_TABLE] = [{ metadata_type: 'position', name: 'billing_clerk', active: false }]; + const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables })); + const users = ['u_a', 'u_b', 'u_c', 'u_d', 'u_e', 'u_b_only', 'u_other_org', 'u_nobody']; + const queues = ['support_tier1', 'billing', 'nowhere']; + const answers = new Set(); + for (const org of ['org_a', 'org_b']) { + for (const user of users) { + const listed = await svc.queuesOf(user, org); + for (const queue of queues) { + const member = await svc.isMember(user, queue, org); + answers.add(member); + expect({ user, org, queue, member: listed.includes(queue) }).toEqual({ user, org, queue, member }); + } + } + } + // Both answers occur, so the agreement is not vacuous. + expect([...answers].sort()).toEqual([false, true]); + }); + it('queuesNaming looks through from a position or a team to the queues it staffs', async () => { const svc = service(makeEngine({ declared: SUPPORT_DECLARED, tables: BASE_TABLES() })); expect(await svc.queuesNaming('position', new Set(['support_agent']), 'org_a')).toEqual(['support_tier1']); diff --git a/packages/spec/src/contracts/rls-membership-resolver.ts b/packages/spec/src/contracts/rls-membership-resolver.ts index 24638ba9658..ff1a80804ba 100644 --- a/packages/spec/src/contracts/rls-membership-resolver.ts +++ b/packages/spec/src/contracts/rls-membership-resolver.ts @@ -35,7 +35,9 @@ * `accessible_org_ids` (ADR-0105 D2) is CORE-resolved, not an app resolver: it * is the `group` posture's Layer 0 wall, and a wall an app could redefine would * not be a wall. The same goes for `org_user_ids` — the one well-known - * membership set, resolved by `resolveAuthzContext`. This seam is for + * membership set, resolved by `resolveAuthzContext`, and for `queues` — the + * ownership queues the caller sits in, which the security plugin resolves from + * the ownership-queue service, never from a registered resolver. This seam is for * app-shaped sets: the accounts in a rep's territories, the records a case team * can touch, the cost centres a controller reviews. */ @@ -67,8 +69,8 @@ export interface IRlsMembershipResolver { * is auditable and a policy referencing an unowned key is distinguishable * from a typo. Keys are `snake_case` and must not collide with the named * context fields (`id`, `organization_id`, `positions`, `org_user_ids`, - * `accessible_org_ids`, `email`) — the compiler never lets a membership key - * clobber those. + * `accessible_org_ids`, `email`, `queues`) — the compiler never lets a + * membership key clobber those. */ readonly keys: readonly string[]; @@ -85,6 +87,15 @@ export interface IRlsMembershipResolver { /** * Context keys a membership resolver may never supply — they are resolved by * the kernel and carry authorization meaning an app must not be able to redefine. + * + * `queues` (ADR-0142 D3) is the machine names of the ownership queues the + * caller belongs to in the active organization — directly, or through a + * position, team or unit, as the queue's member specifications define. The + * security plugin resolves it from the ownership-queue service + * (`IOwnershipQueueService.queuesOf`, the same membership answer the claim + * gate's `isMember` reads), so records sitting in a queue are reachable only + * through the platform's own answer: a resolver that could supply it could + * seat any caller in any queue. */ export const RESERVED_RLS_MEMBERSHIP_KEYS: readonly string[] = [ 'id', @@ -93,4 +104,5 @@ export const RESERVED_RLS_MEMBERSHIP_KEYS: readonly string[] = [ 'org_user_ids', 'accessible_org_ids', 'email', + 'queues', ] as const; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 46ee70bcc89..ccb40733e21 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3203,7 +3203,7 @@ const ObjectSchemaBase = strictObject( + 'userField: \'user\', valueField: \'supplier\', filter: \'active == true\' }`). Declare a key on the ' + 'one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is ' + 'refused, and the kernel\'s own names (`id`, `organization_id`, `positions`, `org_user_ids`, ' - + '`accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads ' + + '`accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads ' + 'the key, as a system read inside the caller\'s organization and regardless of what the caller may read on ' + 'this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed ' + 'read, a `filter` that does not compile for this object, or a key a registered membership resolver also ' diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 085118906db..a90a55277a6 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -54,7 +54,8 @@ import { strictObject } from '../shared/strict-object'; * ## Where a membership key comes from * * The kernel resolves `current_user.id`, `organization_id`, `positions`, - * `org_user_ids`, `accessible_org_ids` and `email` itself. Any other + * `org_user_ids`, `accessible_org_ids`, `email` and `queues` (the ownership + * queues the caller sits in, ADR-0142 D3) itself. Any other * `current_user.KEY` read with `in` is a membership set the APP supplies, * through one of two sources: * diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index 5f5834ddd39..2955a946489 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -154,7 +154,7 @@ rowLevelSecurity: [ columns, no `current_user.*`. - One name per stack: a key on two objects, a kernel name (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, - `email`) and a key on an object extension are refused. + `email`, `queues`) and a key on an object extension are refused. - Read once per request as a system read in the caller's organization (no read on the roster needed), at most 1,000 values; more, a failed read or a `filter` that does not compile matches **no rows**. Whoever writes roster From 7f4c13eb12e0890c7b146caff2e69c71a845bcc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:51:42 +0000 Subject: [PATCH 2/6] wip: tests and changeset Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA Co-Authored-By: Claude --- .../27016-queues-reserved-membership-key.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/27016-queues-reserved-membership-key.md diff --git a/.changeset/27016-queues-reserved-membership-key.md b/.changeset/27016-queues-reserved-membership-key.md new file mode 100644 index 00000000000..33d771fff33 --- /dev/null +++ b/.changeset/27016-queues-reserved-membership-key.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +'@objectstack/lint': minor +'@objectstack/skills': minor +--- + +feat(security)!: `current_user.queues` is resolved by the platform from the ownership-queue service, and `queues` joins the reserved `current_user` keys (#27016) + +Clause-②: no (narrowing) — `queues` joins `RESERVED_RLS_MEMBERSHIP_KEYS`, so an object's `membershipKeys` may no longer declare a key named `queues`, and a registered membership resolver's `queues` answer is screened off. Nothing is widened past what the platform itself resolves: where no app supplied the key, a policy reading `current_user.queues` matched no rows before, and now it matches the caller's own queues. +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing in `@objectstack/spec` (`MembershipKeyNameSchema`, read through `ObjectSchema.membershipKeys`) and at the membership resolver screen in `@objectstack/plugin-security`. + +- **Now.** A row-level security policy can read `current_user.queues`: the machine names of the ownership queues the caller belongs to in the active organization, directly or through a position, team or unit, as each queue's members define. The security plugin resolves it from the ownership-queue service's `queuesOf`, the same membership answer the claim gate's `isMember` reads, once per request and only when a policy about to compile reads the key. For example `owner_id == null && queue in current_user.queues` lets queue members read the unowned records sitting in their queues. +- **One source.** No other source can supply the key. A registered `rls-membership-resolver` that answers `queues` has that answer dropped with a WARN, a `rlsMembership` bag entry or a `queues` field on the execution context is never read, and a context copied for another user or organization does not carry the set. With no signed-in user, no ownership-queue service, or a membership read that fails, the key stays unresolved and every policy reading it matches no rows. +- **Who moves.** An object that declares `membershipKeys: { queues: … }`, or a membership resolver that lists `queues` among its keys. Census on this repository at 7eb613c3: none in `packages`, `examples`, `qa`, `skills` or `content`. + +**The remedy.** Rename the membership key. `queues` is resolved by the platform; choose a name that says what your set holds. From 38a151a18caccbe55bdaf8272551746d1400adc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:53:52 +0000 Subject: [PATCH 3/6] docs(spec): regenerate references for the queues reserved key Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA Co-Authored-By: Claude --- content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/security/rls.mdx | 3 ++- content/docs/references/system/migration.mdx | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 48e39be9d02..c531de8b813 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -966,7 +966,7 @@ Metadata query with filtering, sorting, and pagination | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9590237f929..e0c72980991 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -173,7 +173,7 @@ const result = ApiMethod.parse(data); | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 99ab6568045..b6e4fded1f5 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -53,7 +53,8 @@ object-level permissions (CRUD), RLS provides record-level filtering. ### Where a membership key comes from The kernel resolves `current_user.id`, `organization_id`, `positions`, -`org_user_ids`, `accessible_org_ids` and `email` itself. Any other +`org_user_ids`, `accessible_org_ids`, `email` and `queues` (the ownership +queues the caller sits in, ADR-0142 D3) itself. Any other `current_user.KEY` read with `in` is a membership set the APP supplies, through one of two sources: diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index d4b40cce5c5..148179f0d46 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -356,7 +356,7 @@ Create a new object | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -651,7 +651,7 @@ Create a new object | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **sharing** | `{ controlledByParent?: object }` | optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`, `queues`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | From 8660c53ef566e869a3da97d3afdac7f511e25b25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:03:42 +0000 Subject: [PATCH 4/6] fix(lint): keep the kernel-key fix line inside 200 with queues; pay the skill line's tokens Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA Co-Authored-By: Claude --- .../validate-rls-predicate-enforceability.engine-judge.test.ts | 2 +- packages/lint/src/validate-rls-predicate-enforceability.ts | 2 +- skills/objectstack-data/rules/security.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts index a55178f95c4..ad15e55c746 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts @@ -143,7 +143,7 @@ describe('validateRlsPredicateEnforceability — the engine judge is an INPUT (# expect(hintFor(code).length, code).toBeLessThanOrEqual(200); } expect(explainRule(RLS_PREDICATE_UNENFORCEABLE)?.paragraphs.join('\n')).toContain( - 'the caller\'s values are the `current_user` keys (accessible_org_ids, email, id, org_user_ids, organization_id, positions)', + 'the caller\'s values are the `current_user` keys (accessible_org_ids, email, id, org_user_ids, organization_id, positions, queues)', ); }); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index fef9458a79c..e329b5b8a3c 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -1559,7 +1559,7 @@ function referenceFindings( // nearest key, so no author text can lengthen it. What each key holds, // and how an app stages a membership set, are the explanation's. hint: - `Read a pre-resolved key (${listNames(PRERESOLVED_USER_KEYS)}), or test an app-staged membership ` + + `Read a kernel key (${listNames(PRERESOLVED_USER_KEYS)}), or test an app-staged membership ` + `set with \`in\`, never \`==\`.${suggestName(key, PRERESOLVED_USER_KEYS)}`, }); } diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index 2955a946489..5af7c653ddf 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -120,7 +120,7 @@ bridge (warns). The compiler resolves these Rows keyed on a **named record** (an account, a supplier) rather than on a person or a position: declare the set on the **roster** object whose rows -define it — not a stamp on every data row, not one policy per record. +define it, not one policy per record. ```typescript From e37497434c3983744185c320e40e5f698d1e3251 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:14:24 +0000 Subject: [PATCH 5/6] docs(adr-0142): amend D3 for the kernel-side queues key; re-lock the security.md token ceiling Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA Co-Authored-By: Claude --- ...nership-queue-membership-claim-and-assignment-rules.md | 8 ++++++-- scripts/check-skills-token-ratchet.mjs | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md index 1ae8c57162d..bb645d33ee6 100644 --- a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md +++ b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md @@ -353,8 +353,12 @@ seed claim, to ADR-0117's future stamp. Merge question 1 states what the two oth **Who sees it.** The members of Q read and edit the records sitting in Q through one more pre-resolved membership set, `current_user.queues` — the queue names the caller belongs to in the active organization, computed by the queue service -from the caller's positions, teams and units against the queues' member specifications; the queue service is the -registered `IRlsMembershipResolver` for that one key, so an authored policy or view can read it too. The disjunct +from the caller's positions, teams and units against the queues' member specifications; `queues` is a reserved key +(`RESERVED_RLS_MEMBERSHIP_KEYS`) that `@objectstack/plugin-security` resolves kernel-side from the queue service, the way +it resolves `positions`, so an authored policy or view can read it too, and an app `IRlsMembershipResolver` that answers +`queues` is screened out and fails closed (**Amended** 2026-10-28, ruling +[`6457475657`](https://github.com/objectstack-ai/objectstack/issues/25769#issuecomment-6457475657) on #25769, letter A; +this sentence first made the queue service the registered `IRlsMembershipResolver` for the key). The disjunct `owner_id IS NULL AND queue IN (current_user.queues)` lands at four sites: beside the `ownerMatch` of `buildReadFilter`; in `buildWriteFilter`'s update arm beside the share ids, never its delete arm; in `checkEdit` as a positive basis of its own, tested before the public-OWD `abstain` and apart from `matchesOwnerScope`, the fast path `checkDelete` shares; and diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 053eb781e4b..d0181210cd9 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -690,7 +690,10 @@ export const CEILINGS = new Map([ // 2956 at headroom 0, ceiling +737. No repeated passage in the file to // cut first (measured: no sentence repeats). Maintainer ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天 // 花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. - ['skills/objectstack-data/rules/security.md', 3693], + // 3693 -> 3687 (#27016): RE-LOCK at the landed count. `queues` joins the + // kernel names a membership key may not take (+3 tokens), paid by cutting + // "not a stamp on every data row," from the membership-keys intro; net -6. + ['skills/objectstack-data/rules/security.md', 3687], // 3024 -> 3109 (2026-08-31 app-repo-principles raise, see the block above). // Severity Levels listed the three values and left the CHOICE unstated: a // block rests on a judgement a person made, so a machine-inferred signal — a From 78f82fa52d3f8df9e4911826382b52176694e99c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:04:01 +0000 Subject: [PATCH 6/6] docs(adr-0142): mark the D3 amendment on the status line; annotate plan item 1 as superseded Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA --- ...2-ownership-queue-membership-claim-and-assignment-rules.md | 4 ++-- docs/adr/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md index bb645d33ee6..b37d62d13ce 100644 --- a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md +++ b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md @@ -1,6 +1,6 @@ # ADR-0142: An ownership queue is a platform primitive — a queue entity, membership through the existing recipient kinds, claim through the ownership-transfer gate, and declarative assignment rules evaluated on insert -**Status**: Proposed (2026-10-25). `docs/adr/**` is Tier H (Prime Directive #14): this record becomes **Accepted** by the +**Status**: Proposed (2026-10-25) · **Amended** (2026-10-28, ruling [`6457475657`](https://github.com/objectstack-ai/objectstack/issues/25769#issuecomment-6457475657) on #25769, letter A) — D3's `current_user.queues` producer is `@objectstack/plugin-security`, kernel-side, under the reserved key `queues`; see the note in D3. `docs/adr/**` is Tier H (Prime Directive #14): this record becomes **Accepted** by the maintainer's merge, and ⛔ the one PR under [Execution plan](#execution-plan-after-acceptance) is cut after that merge, never before it. Until a published `@objectstack/*` release carries that PR, HotCRM keeps the STOPGAP module this record exists to retire. **This record declares; it implements nothing** — no schema, no code, no changeset. @@ -803,7 +803,7 @@ objection names the number. `packages/spec/liveness/queue.json` and `packages/spec/liveness/assignment_rule.json`, every property classified with evidence (the liveness gate has no generator); regenerated artifacts per AGENTS.md's spec table for the rest. Server: `sys_queue`, `sys_queue_member`, `sys_assignment_cursor`, the queue service and its `queues` membership-resolver key - in `plugin-sharing`, with the access path at D3's four sites; the `queue` column in `applySystemFields` with its + in `plugin-sharing` (*superseded 2026-10-28 by D3 as amended: plugin-security resolves `queues` kernel-side*), with the access path at D3's four sites; the `queue` column in `applySystemFields` with its writers and the registration refusal for a declared `queue`; the gate's claim / release / move admissions and explain reasons in `plugin-security`; in `objectql`, the by-id write precondition with its readback, the assignment seam and the two strategies behind the contract, the two keys in `METADATA_ARRAY_KEYS`, the `kernel:ready` boot refusal and diff --git a/docs/adr/README.md b/docs/adr/README.md index e4065f06f6c..8fd40dd93f6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -209,7 +209,7 @@ A bare `ADR-0006` names the decision; its current text is [0006-project-environm | [ADR-0138](./0138-guest-model-anonymous-principal-doors-grants-and-organization.md) | The guest model — one anonymous principal, a closed list of doors, one grants channel, one organization rule,… | Accepted — 2026-10-13, accepted by the maintainer's approval of the pull r… | 2026-10-13 | — | — | | [ADR-0139](./0139-engine-internal-semi-join-leaf-readable-parent-ids.md) | One engine-internal semi-join leaf — "the ids of object X this caller may read" — pushed down by ObjectQL, ne… | Accepted — 2026-10-11, accepted by the merge that landed it on `main` | 2026-10-11 | — | — | | [ADR-0141](./0141-inter-app-dependency-declared-checked-before-install.md) | An app may depend on another installed app — declared in `manifest.dependencies`, checked once before install… | Proposed — 2026-10-25; first draft 2026-10-24. `docs/adr/` is Tier H (Prim… | 2026-10-25 | — | — | -| [ADR-0142](./0142-ownership-queue-membership-claim-and-assignment-rules.md) | An ownership queue is a platform primitive — a queue entity, membership through the existing recipient kinds,… | Proposed — 2026-10-25. `docs/adr/` is Tier H (Prime Directive #14): this r… | 2026-10-25 | — | — | +| [ADR-0142](./0142-ownership-queue-membership-claim-and-assignment-rules.md) | An ownership queue is a platform primitive — a queue entity, membership through the existing recipient kinds,… | Proposed — 2026-10-25 · Amended (2026-10-28, ruling `6457475657` on #25769… | 2026-10-25 | — | — | | [ADR-0143](./0143-outbound-connector-auth-is-open-tier.md) | Outbound connector auth is open tier — OAuth2 grants, vendor app-credential exchange and request signing in o… | Proposed — 2026-10-27. `docs/adr/` is Tier H (Prime Directive #14): the ma… | 2026-10-27 | — | — | ## ObjectUI registry — `ui/docs/adr/` (`ui-` prefix)