From 7bb420792dc961c468d97f5e452211b4f3a64c31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:24:01 +0000 Subject: [PATCH 01/12] feat(spec): the RLS deny sentinel, the composed ReadableIds leaf and its driver case-set, declared once in the data entry (ADR-0139 D2, D3) RLS_DENY_FILTER is declared in packages/spec/src/data/readable-ids.ts, frozen, with its docblock naming ADR-0139 D2 step 2 as the reader that recognises it by identity. ReadableIds.compose builds the composed leaf { object, scope } under its own brand; isComposedReadableIds and isReadableIds answer disjointly, and the compose factory refuses the deny sentinel by identity. READABLE_IDS_CASES is the per-driver case-set D3 names, executed here against a reference evaluator. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/spec/src/data/index.ts | 7 + .../src/data/readable-ids-conformance.test.ts | 198 +++++++++++++ .../spec/src/data/readable-ids-conformance.ts | 268 ++++++++++++++++++ packages/spec/src/data/readable-ids.test.ts | 147 +++++++++- packages/spec/src/data/readable-ids.ts | 189 +++++++++++- 5 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/data/readable-ids-conformance.test.ts create mode 100644 packages/spec/src/data/readable-ids-conformance.ts diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index ec11876efba..51308a29998 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -45,7 +45,14 @@ export * from './filter-subtree-provenance'; // operator, so no request can spell it; its brand is a `Symbol.for` key on the // provenance mark's device, so a copy that crossed JSON or a structured clone // arrives unbranded and the comparand-shape door refuses it as a non-list. +// [ADR-0139 D2] Beside it, the two things the engine's composition reads: the +// RLS deny sentinel (`RLS_DENY_FILTER`, recognised by identity, D2 step 2) and +// the composed leaf `{ object, scope }` (`ReadableIds.compose`, +// `isComposedReadableIds`, D2 step 3). export * from './readable-ids'; +// [ADR-0139 D3] Canonical conformance cases for the composed `ReadableIds` leaf +// — the per-driver case-set a driver compiles (measured) or refuses (recorded). +export * from './readable-ids-conformance'; // [#8371] The FILTER axis' dotted-path verdict — ONE head-type classification // consumed by both doors (metadata-protocol's ingress gate and objectql's // engine seam), so the two cannot drift into answering one spelling two ways. diff --git a/packages/spec/src/data/readable-ids-conformance.test.ts b/packages/spec/src/data/readable-ids-conformance.test.ts new file mode 100644 index 00000000000..5a0f45eae7e --- /dev/null +++ b/packages/spec/src/data/readable-ids-conformance.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3] `READABLE_IDS_CASES` lands ahead of every driver that runs it + * (E3 and E4 enrol the drivers), so between now and then nothing executes the + * table — and a miscounted `expected` list would sit quietly wrong until a + * driver author trusted it. This file executes it, against a reference + * evaluator written from the declared semantics: the composed leaf is the + * semi-join `field IN (SELECT id FROM P WHERE scope AND tenant)`, `$not` is + * NULL-safe, and an uncomposed leaf is refused. + * + * The second job is proving the table is not VACUOUS: each of the three ways a + * driver can get the leaf wrong — evaluating it as "no restriction", ignoring + * its scope, compiling its subquery without the tenant wall — must fail at + * least one case, so a driver that makes that mistake cannot pass the table. + */ + +import { describe, it, expect } from 'vitest'; +import type { FilterCondition } from './filter.zod'; +import { isComposedReadableIds, isReadableIds, type ComposedReadableIds } from './readable-ids'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + type ReadableIdsCase, + type ReadableIdsMatchesCase, +} from './readable-ids-conformance'; + +// ── A reference evaluator, from the declared semantics ─────────────────────── + +type Row = Record; + +/** The ways a driver can get the composed leaf wrong — `faithful` is the declared semantics. */ +type Reading = 'faithful' | 'leaf-as-no-restriction' | 'scope-ignored' | 'subquery-unwalled'; + +interface EvalContext { + readonly tenantId?: string; + readonly reading: Reading; +} + +class UncomposedLeafRefusal extends Error { + readonly code = READABLE_IDS_REFUSAL.code; + readonly status = READABLE_IDS_REFUSAL.status; +} + +/** The ids of the parent rows the composed leaf admits under `ctx`. */ +function readableParentIds(leaf: ComposedReadableIds, ctx: EvalContext): Set { + const walled = ctx.tenantId !== undefined && ctx.reading !== 'subquery-unwalled'; + const ids = new Set(); + for (const parent of READABLE_IDS_PARENT_ROWS as readonly Row[]) { + if (walled && parent.organization_id !== ctx.tenantId) continue; + if (leaf.scope !== undefined && ctx.reading !== 'scope-ignored' && !matches(parent, leaf.scope, ctx)) continue; + ids.add(parent.id); + } + return ids; +} + +/** One field constraint against one row: the subset of operators the table uses. */ +function fieldMatches(row: Row, field: string, spec: unknown, ctx: EvalContext): boolean { + const value = row[field]; + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec) && '$in' in (spec as Row)) { + const comparand = (spec as Row).$in; + if (isReadableIds(comparand)) throw new UncomposedLeafRefusal(`uncomposed ReadableIds leaf at ${field}.$in`); + if (isComposedReadableIds(comparand)) { + if (ctx.reading === 'leaf-as-no-restriction') return true; + return value != null && readableParentIds(comparand, ctx).has(value); + } + if (Array.isArray(comparand)) return comparand.includes(value); + throw new Error(`the reference evaluator has no reading for ${field}.$in = ${String(comparand)}`); + } + // Implicit equality, the null predicate included. + return spec === null ? value == null : value === spec; +} + +function matches(row: Row, where: FilterCondition, ctx: EvalContext): boolean { + for (const [key, spec] of Object.entries(where)) { + if (key === '$and') { + if (!(spec as FilterCondition[]).every((child) => matches(row, child, ctx))) return false; + } else if (key === '$or') { + if (!(spec as FilterCondition[]).some((child) => matches(row, child, ctx))) return false; + } else if (key === '$not') { + // NULL-safe: "not P" holds wherever P is not TRUE, a row with no value included. + if (matches(row, spec as FilterCondition, ctx)) return false; + } else if (!fieldMatches(row, key, spec, ctx)) { + return false; + } + } + return true; +} + +/** What `find` on the child object answers for `c` under `reading`: the ascending ids. */ +function answer(c: ReadableIdsMatchesCase, reading: Reading): string[] { + const ctx: EvalContext = { tenantId: c.options?.tenantId, reading }; + const where = c.where(); + return (READABLE_IDS_CHILD_ROWS as readonly Row[]) + // The OUTER read's tenant wall, which every driver already applies. + .filter((row) => ctx.tenantId === undefined || row.organization_id === ctx.tenantId) + .filter((row) => matches(row, where, ctx)) + .map((row) => String(row.id)) + .sort(); +} + +const matchesCases = READABLE_IDS_CASES.filter((c): c is ReadableIdsMatchesCase => c.verdict === 'matches'); + +/** Does `where` hold a composed leaf anywhere? */ +const holdsComposedLeaf = (where: unknown): boolean => { + if (isComposedReadableIds(where)) return true; + if (where === null || typeof where !== 'object') return false; + return (Array.isArray(where) ? where : Object.values(where)).some(holdsComposedLeaf); +}; + +// ── The table, executed ────────────────────────────────────────────────────── + +describe('READABLE_IDS_CASES — the table agrees with the declared semantics', () => { + for (const c of matchesCases) { + it(c.name, () => { + expect(answer(c, 'faithful')).toEqual([...c.expected]); + }); + } + + for (const c of READABLE_IDS_CASES.filter((x) => x.verdict === 'refuses-uncomposed')) { + it(c.name, () => { + const where = c.where(); + let refusal: unknown; + try { + (READABLE_IDS_CHILD_ROWS as readonly Row[]).forEach((row) => matches(row, where, { reading: 'faithful' })); + } catch (e) { + refusal = e; + } + expect(refusal).toBeInstanceOf(UncomposedLeafRefusal); + expect({ code: (refusal as UncomposedLeafRefusal).code, status: (refusal as UncomposedLeafRefusal).status }) + .toEqual({ code: 'INTERNAL_ERROR', status: 500 }); + }); + } +}); + +describe('READABLE_IDS_CASES — each wrong reading of the leaf fails the table', () => { + const failing = (reading: Reading) => + matchesCases.filter((c) => JSON.stringify(answer(c, reading)) !== JSON.stringify([...c.expected])).map((c) => c.name); + + it('a driver that evaluates the composed leaf as "no restriction" fails, on the deleted and the absent parent', () => { + expect(failing('leaf-as-no-restriction')).toContain('no row restriction: every child whose parent exists'); + }); + + it('a driver that compiles the subquery without its scope fails', () => { + expect(failing('scope-ignored')).toContain('a scope narrows the parents whose children match'); + expect(failing('scope-ignored')).toContain('a scope that admits no parent matches no child'); + }); + + it('a driver that compiles the subquery without its tenant wall fails exactly the tenant case', () => { + expect(failing('subquery-unwalled')).toEqual( + matchesCases.filter((c) => c.requiresTenantWall === true).map((c) => c.name), + ); + expect(failing('subquery-unwalled')).toHaveLength(1); + }); +}); + +describe('READABLE_IDS_CASES — the table is well-formed', () => { + it('names are unique, expected ids are ascending and name fixture rows', () => { + const names = READABLE_IDS_CASES.map((c) => c.name); + expect(new Set(names).size).toBe(names.length); + const childIds = new Set(READABLE_IDS_CHILD_ROWS.map((r) => r.id)); + for (const c of matchesCases) { + expect([...c.expected].sort(), c.name).toEqual([...c.expected]); + for (const id of c.expected) expect(childIds.has(id), `${c.name}: ${id}`).toBe(true); + } + }); + + it('holdsComposedLeaf says what each where holds, so a refusing driver refuses exactly those cases', () => { + for (const c of matchesCases) expect(holdsComposedLeaf(c.where()), c.name).toBe(c.holdsComposedLeaf); + expect(matchesCases.filter((c) => !c.holdsComposedLeaf).map((c) => c.name)).toEqual([ + 'the engine\'s denial answer, $in: [], matches no child', + ]); + }); + + it('the refusal case holds an uncomposed leaf and no composed one', () => { + const refusals = READABLE_IDS_CASES.filter((c) => c.verdict === 'refuses-uncomposed'); + expect(refusals).toHaveLength(1); + const where = refusals[0].where() as { record_id: { $in: unknown } }; + expect(isReadableIds(where.record_id.$in)).toBe(true); + expect(holdsComposedLeaf(where)).toBe(false); + }); + + it('every where factory builds fresh leaves: no two suites judge one object', () => { + for (const c of READABLE_IDS_CASES as readonly ReadableIdsCase[]) { + const a = c.where(); + const b = c.where(); + expect(a, c.name).not.toBe(b); + expect(a, c.name).toEqual(b); + } + }); + + it('the refusal envelope is D3\'s composition fault, not an author error', () => { + expect(READABLE_IDS_REFUSAL).toEqual({ code: 'INTERNAL_ERROR', status: 500 }); + expect(Object.isFrozen(READABLE_IDS_REFUSAL)).toBe(true); + }); +}); diff --git a/packages/spec/src/data/readable-ids-conformance.ts b/packages/spec/src/data/readable-ids-conformance.ts new file mode 100644 index 00000000000..91d98b823ee --- /dev/null +++ b/packages/spec/src/data/readable-ids-conformance.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for the **composed `ReadableIds` leaf** — ADR-0139 + * D3: "The leaf joins the per-driver conformance case-sets, so a driver that + * compiles it is measured and one that refuses is recorded as refusing." + * + * # The property + * + * `{ record_id: { $in: ReadableIds.compose(ReadableIds.of(P), scope) } }` + * matches exactly the rows whose `record_id` is the id of a row of `P` that + * `scope` admits, inside the driver's own tenant wall: the semi-join + * `record_id IN (SELECT id FROM P WHERE scope AND tenant)` (D3, `driver-sql`), + * however a driver evaluates it. `scope: undefined` is "no row restriction", + * and the rows of `P` that exist are then the whole set. + * + * # Who builds the leaf, and why a driver never sees `ReadableIds.of` + * + * The engine composes every leaf at its executor boundary, immediately before + * the driver call (D2), so a driver is handed composed leaves only. An + * UNCOMPOSED leaf reaching a driver means something bypassed that seam, and + * the driver refuses it with D3's envelope ({@link READABLE_IDS_REFUSAL}) — + * ⛔ never evaluated as "no restriction". The cases build both shapes with the + * spec's own factory (`./readable-ids.ts`), exactly as the engine does, and a + * driver tells them apart with `isComposedReadableIds` / `isReadableIds`, + * which answer disjointly. + * + * A caller the parent denies is answered `$in: []` by the engine (D2 steps 1 + * and 2, the deny sentinel included) and never reaches a driver as a composed + * leaf, so the table carries that answer as an ordinary empty list. + * + * # How a driver suite consumes this table + * + * Seed {@link READABLE_IDS_PARENT_ROWS} as {@link READABLE_IDS_PARENT_OBJECT} + * and {@link READABLE_IDS_CHILD_ROWS} as {@link READABLE_IDS_CHILD_OBJECT}, both + * declaring `organization_id` as their tenant field, then for each case call + * the driver's own `find` on the child object with `where: c.where()` and the + * case's `options`: + * + * - a driver that COMPILES the leaf (D3: the SQL family, both Turso + * transports, `driver-memory`) asserts the child ids of each `matches` case + * — ascending — and D3's envelope on the `refuses-uncomposed` case; + * - a driver that REFUSES the leaf (D3: `driver-mongodb`, until a card + * measures a pipeline form) asserts D3's envelope on every case that holds a + * composed leaf ({@link readableIdsCaseHoldsComposedLeaf}), and the row ids on + * the rest. That is what "recorded as refusing" means. + * + * A case with {@link ReadableIdsMatchesCase.requiresTenantWall} reads under a + * tenant: it is the one that fails when the subquery is compiled without the + * driver's tenant scope. A driver that refuses multi-tenant boot + * (`driver-memory`, `memory-tenancy-guard.ts`) owes no wall (D3) and records + * that refusal for the case instead of rows. + * + * # What belongs here + * + * Only what a driver decides: which rows the composed leaf admits, how it + * composes under `$and` / `$or` / `$not`, and the refusal of the uncomposed + * leaf. What the ENGINE decides — the composition itself, the write-verb and + * `having` refusals, D5's three classes — is pinned in `@objectstack/objectql`. + * The cases build every leaf from a factory, because the leaf is frozen and + * branded and one shared instance across driver suites would let one suite's + * run judge another's object. + * + * @see ./readable-ids.ts for the leaf, the composed leaf and the deny sentinel + */ + +import type { FilterCondition } from './filter.zod'; +import { ReadableIds } from './readable-ids'; + +/** The parent object — `P` in ADR-0139's notation. */ +export const READABLE_IDS_PARENT_OBJECT = 'rid_parent'; + +/** The child object every case reads; its `record_id` points at the parent. */ +export const READABLE_IDS_CHILD_OBJECT = 'rid_child'; + +/** A row of the parent object. `owner` is what the cases' scopes narrow on. */ +export interface ReadableIdsParentRow { + id: string; + owner: string; + organization_id: string; +} + +/** + * A row of the child object: the ActivityPointer pair the six consumers key on + * (`object_name`, `record_id`), plus a column for the `$and` case to narrow on. + */ +export interface ReadableIdsChildRow { + id: string; + object_name: string; + record_id: string | null; + label: string; + organization_id: string; +} + +/** + * Four parents. `p4` belongs to another tenant, so only a read under a tenant + * can tell a walled subquery from an unwalled one. + */ +export const READABLE_IDS_PARENT_ROWS: readonly ReadableIdsParentRow[] = [ + { id: 'p1', owner: 'u1', organization_id: 'org_a' }, + { id: 'p2', owner: 'u2', organization_id: 'org_a' }, + { id: 'p3', owner: 'u1', organization_id: 'org_a' }, + { id: 'p4', owner: 'u1', organization_id: 'org_b' }, +]; + +/** + * Seven children, all in `org_a`, so the OUTER read's tenant wall excludes none + * of them and every difference a tenant makes comes from the subquery's wall. + * + * - `c4` points at a parent that no longer exists (excluded: the subquery + * finds no such id); + * - `c5` names no parent (excluded by `$in`, admitted by the NULL-safe `$not`); + * - `c6` points across the tenant wall, at `p4`; + * - `c7` names ANOTHER object with an id that `rid_parent` also carries: the + * leaf matches ids, and pairing them with `object_name` is the consumer's + * branch (ADR-0139 D4), so `c7` is in every case that does not say + * `object_name`. + */ +export const READABLE_IDS_CHILD_ROWS: readonly ReadableIdsChildRow[] = [ + { id: 'c1', object_name: 'rid_parent', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, + { id: 'c2', object_name: 'rid_parent', record_id: 'p2', label: 'keep', organization_id: 'org_a' }, + { id: 'c3', object_name: 'rid_parent', record_id: 'p3', label: 'drop', organization_id: 'org_a' }, + { id: 'c4', object_name: 'rid_parent', record_id: 'p9', label: 'keep', organization_id: 'org_a' }, + { id: 'c5', object_name: 'rid_parent', record_id: null, label: 'keep', organization_id: 'org_a' }, + { id: 'c6', object_name: 'rid_parent', record_id: 'p4', label: 'keep', organization_id: 'org_a' }, + { id: 'c7', object_name: 'rid_other', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, +]; + +/** + * D3's refusal envelope (ADR-0112): a composition fault, not an author error, + * so ⛔ not `INVALID_FILTER`. A suite asserts `code` AND `status`; the message + * (it names the driver, the leaf and the remedy) is not pinned here. + */ +export const READABLE_IDS_REFUSAL = Object.freeze({ code: 'INTERNAL_ERROR', status: 500 } as const); + +interface ReadableIdsCaseBase { + /** Stable identifier, usable as a test name. */ + readonly name: string; + /** + * Builds the `where` for a `find` on {@link READABLE_IDS_CHILD_OBJECT}. A + * FACTORY: every call builds fresh leaves. + */ + readonly where: () => FilterCondition; + /** Why the case is here — surfaced in failure output. */ + readonly note?: string; +} + +/** A case whose `where` the driver must EVALUATE, matching exactly {@link expected}. */ +export interface ReadableIdsMatchesCase extends ReadableIdsCaseBase { + readonly verdict: 'matches'; + /** Ids of the matching child rows, ascending. */ + readonly expected: readonly string[]; + /** + * `where` holds a composed leaf. `false` only where the case carries the + * engine's denial answer instead; a driver that refuses the leaf answers + * {@link READABLE_IDS_REFUSAL} exactly on the cases where this is `true`. + */ + readonly holdsComposedLeaf: boolean; + /** The read's driver options; absent means an unscoped read. */ + readonly options?: { readonly tenantId: string }; + /** + * The read is under a tenant, and the case fails when the subquery is + * compiled without the driver's tenant scope (D3). + */ + readonly requiresTenantWall?: true; +} + +/** A case the driver must REFUSE with {@link READABLE_IDS_REFUSAL}: an uncomposed leaf reached it. */ +export interface ReadableIdsRefusesUncomposedCase extends ReadableIdsCaseBase { + readonly verdict: 'refuses-uncomposed'; +} + +export type ReadableIdsCase = ReadableIdsMatchesCase | ReadableIdsRefusesUncomposedCase; + +const P = READABLE_IDS_PARENT_OBJECT; + +/** The composed leaf for {@link READABLE_IDS_PARENT_OBJECT} under `scope`, built as the engine builds it. */ +const composed = (scope: FilterCondition | undefined) => ReadableIds.compose(ReadableIds.of(P), scope); + +/** + * The cases: the leaf alone (no restriction, a narrowing scope, a scope that + * admits nothing, the engine's denial answer), the leaf under each combinator, + * the tenant wall, the consumers' branch shape, and the uncomposed leaf. + */ +export const READABLE_IDS_CASES: readonly ReadableIdsCase[] = [ + { + name: 'no row restriction: every child whose parent exists', + where: () => ({ record_id: { $in: composed(undefined) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c6', 'c7'], + note: 'c4 points at a parent that no longer exists and c5 names none: neither id is in the subquery. ' + + 'A driver that evaluates the leaf as "no restriction" returns them.', + }, + { + name: 'a scope narrows the parents whose children match', + where: () => ({ record_id: { $in: composed({ owner: 'u1' }) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c3', 'c6', 'c7'], + }, + { + name: 'a scope that admits no parent matches no child', + where: () => ({ record_id: { $in: composed({ owner: 'nobody' }) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: [], + }, + { + name: 'the engine\'s denial answer, $in: [], matches no child', + where: () => ({ record_id: { $in: [] } }), + verdict: 'matches', + holdsComposedLeaf: false, + expected: [], + note: 'ADR-0139 D2 steps 1 and 2: a caller the parent denies (canReadObject false, or the deny sentinel ' + + 'as its scope) is answered with an empty list, never a composed leaf. Every driver already answers it.', + }, + { + name: 'the leaf ANDs with a predicate on the child', + where: () => ({ $and: [{ label: 'keep' }, { record_id: { $in: composed({ owner: 'u1' }) } }] }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c6', 'c7'], + }, + { + name: 'the leaf is one arm of an $or', + where: () => ({ $or: [{ record_id: null }, { record_id: { $in: composed({ owner: 'u2' }) } }] }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c2', 'c5'], + }, + { + name: '$not over the leaf is NULL-safe: it admits the child that names no parent', + where: () => ({ $not: { record_id: { $in: composed(undefined) } } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c4', 'c5'], + note: 'ADR-0139 D1: the NULL-safe $not of the shared lowering applies to the leaf unchanged. ' + + 'A three-valued NOT IN drops c5.', + }, + { + name: 'the tenant wall binds the subquery: a parent in another tenant is not readable', + where: () => ({ record_id: { $in: composed(undefined) } }), + options: { tenantId: 'org_a' }, + requiresTenantWall: true, + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c7'], + note: 'ADR-0139 D2 step 3: the tenant wall is not in the scope; the driver applies its own tenant scope to ' + + 'the parent table with the outer read\'s options. c6 points at p4, in org_b: an unwalled subquery admits it.', + }, + { + name: 'the consumers\' branch: the leaf paired with the object it names', + where: () => ({ object_name: P, record_id: { $in: composed(undefined) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c6'], + note: 'The branch shape ADR-0139 D4 has each consumer emit per parent object. c7 carries an id that ' + + 'rid_parent also carries, under another object name.', + }, + { + name: 'an uncomposed leaf is refused, never evaluated as no restriction', + where: () => ({ record_id: { $in: ReadableIds.of(P) } }), + verdict: 'refuses-uncomposed', + note: 'ADR-0139 D2\'s last paragraph and D3: a leaf that reached the driver without passing the engine\'s ' + + 'seam fails closed, loudly.', + }, +]; diff --git a/packages/spec/src/data/readable-ids.test.ts b/packages/spec/src/data/readable-ids.test.ts index cd6779ea4c4..5ce6b541314 100644 --- a/packages/spec/src/data/readable-ids.test.ts +++ b/packages/spec/src/data/readable-ids.test.ts @@ -18,7 +18,15 @@ import { describe, it, expect } from 'vitest'; import { StandardErrorCode } from '../api/errors.zod'; import { FieldOperatorsSchema, FilterConditionSchema, parseFilterAST } from './filter.zod'; import { assertListComparandShapes } from './filter-comparand-shape'; -import { READABLE_IDS_BRAND, ReadableIds, isReadableIds } from './readable-ids'; +import { filterSubtreeProvenanceOf, markFilterSubtreeProvenance } from './filter-subtree-provenance'; +import { + COMPOSED_READABLE_IDS_BRAND, + READABLE_IDS_BRAND, + RLS_DENY_FILTER, + ReadableIds, + isComposedReadableIds, + isReadableIds, +} from './readable-ids'; type Refusal = Error & { code?: string; status?: number }; @@ -165,3 +173,140 @@ describe('the comparand-shape door admits a branded leaf only for a composing ca expect(() => assertListComparandShapes(where, undefined, 'where', ADMIT)).not.toThrow(); }); }); + +// ── [ADR-0139 D2] What the engine's composition reads ──────────────────────── + +describe('RLS_DENY_FILTER — the deny sentinel, declared once and recognised by identity', () => { + it('is the published value, frozen, and an ordinary zero-row predicate to anyone who never heard of it', () => { + // The value is published (explain's readFilter, a bound SQL parameter in + // analytics), so it is pinned; what D2 step 2 reads is the reference. + expect(RLS_DENY_FILTER).toEqual({ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }); + expect(Object.keys(RLS_DENY_FILTER)).toEqual(['id']); + expect(Object.isFrozen(RLS_DENY_FILTER)).toBe(true); + expect(() => { (RLS_DENY_FILTER as { id: string }).id = 'widened'; }).toThrow(TypeError); + expect(RLS_DENY_FILTER.id).toBe('__rls_deny__:00000000-0000-0000-0000-000000000000'); + }); + + it('no read-scope merge boundary can stamp a provenance mark on it — frozen, so it stays unmarked, which withholds', () => { + expect(markFilterSubtreeProvenance(RLS_DENY_FILTER, 'author')).toBe(RLS_DENY_FILTER); + expect(filterSubtreeProvenanceOf(RLS_DENY_FILTER)).toBeNull(); + }); + + it('a copy carries the same value and is NOT the sentinel — the reason every producer returns the reference', () => { + const copy = { ...RLS_DENY_FILTER }; + expect(copy).toEqual(RLS_DENY_FILTER); + expect(copy).not.toBe(RLS_DENY_FILTER); + }); +}); + +describe('ReadableIds.compose — the composed leaf of D2 step 3', () => { + const scope = { owner_id: 'u1' }; + + it('builds a frozen leaf carrying { object, scope }, the scope by reference, branded under its own global-registry symbol, invisibly to enumeration and JSON', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), scope); + expect(isComposedReadableIds(composed)).toBe(true); + expect(composed.object).toBe('cpz_doc'); + expect(composed.scope).toBe(scope); + expect(Object.isFrozen(composed)).toBe(true); + expect(COMPOSED_READABLE_IDS_BRAND).toBe(Symbol.for('objectstack.filter.readableIds.composed')); + expect(COMPOSED_READABLE_IDS_BRAND).not.toBe(READABLE_IDS_BRAND); + expect(Object.getOwnPropertyDescriptor(composed, COMPOSED_READABLE_IDS_BRAND)).toEqual({ + value: true, enumerable: false, writable: false, configurable: false, + }); + expect(Object.keys(composed)).toEqual(['object', 'scope']); + expect(JSON.stringify(composed)).toBe('{"object":"cpz_doc","scope":{"owner_id":"u1"}}'); + }); + + it('composes "no row restriction" as an own scope key holding undefined', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), undefined); + expect(isComposedReadableIds(composed)).toBe(true); + expect(Object.prototype.hasOwnProperty.call(composed, 'scope')).toBe(true); + expect(composed.scope).toBeUndefined(); + }); + + it('a holder of the reference cannot re-point the object or swap the scope', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), scope); + expect(() => { (composed as { object: string }).object = 'sys_user'; }).toThrow(TypeError); + expect(() => { (composed as { scope: unknown }).scope = undefined; }).toThrow(TypeError); + expect(composed.object).toBe('cpz_doc'); + expect(composed.scope).toBe(scope); + }); + + it('refuses to compose anything but a branded ReadableIds leaf', () => { + const leaf = ReadableIds.of('cpz_doc'); + for (const bad of [{ ...leaf }, { object: 'cpz_doc' }, ReadableIds.compose(leaf, undefined), null, 'cpz_doc'] as unknown[]) { + expect(() => ReadableIds.compose(bad as never, undefined), String(bad)).toThrow(TypeError); + } + }); + + it('refuses a scope that is neither undefined nor a plain filter object', () => { + const leaf = ReadableIds.of('cpz_doc'); + for (const bad of [null, [], [{ owner_id: 'u1' }], 'owner_id = u1', 0, false] as unknown[]) { + expect(() => ReadableIds.compose(leaf, bad as never), JSON.stringify(bad)).toThrow(TypeError); + } + }); + + it('refuses the deny sentinel BY IDENTITY: D2 step 2 answers it $in: [], never a composed leaf', () => { + const leaf = ReadableIds.of('cpz_doc'); + expect(() => ReadableIds.compose(leaf, RLS_DENY_FILTER)).toThrow(TypeError); + expect(() => ReadableIds.compose(leaf, RLS_DENY_FILTER)).toThrow(/\$in: \[\] \(ADR-0139 D2 step 2\)/); + // Identity, not value: a copy is an ordinary scope (one that matches zero + // rows), exactly as D2 step 2 would see it — ⛔ no structural recognition. + const copy = { ...RLS_DENY_FILTER }; + expect(ReadableIds.compose(leaf, copy).scope).toBe(copy); + }); +}); + +describe('isComposedReadableIds — disjoint from isReadableIds, and every copy answers false', () => { + const leaf = ReadableIds.of('cpz_doc'); + const composed = ReadableIds.compose(leaf, { owner_id: 'u1' }); + + it('the two recognisers are disjoint: a composed leaf is not a ReadableIds leaf, and an uncomposed one is not composed', () => { + expect(isComposedReadableIds(composed)).toBe(true); + expect(isReadableIds(composed)).toBe(false); + expect(isReadableIds(leaf)).toBe(true); + expect(isComposedReadableIds(leaf)).toBe(false); + }); + + it('a JSON round-trip, a structured clone, a spread and Object.assign all drop the brand', () => { + const copies = { + json: JSON.parse(JSON.stringify(composed)), + structuredClone: structuredClone(composed), + spread: { ...composed }, + assign: Object.assign({}, composed), + }; + for (const [how, copy] of Object.entries(copies)) { + expect(copy, how).toEqual({ object: 'cpz_doc', scope: { owner_id: 'u1' } }); + expect(isComposedReadableIds(copy), how).toBe(false); + expect(isReadableIds(copy), how).toBe(false); + } + }); + + it('a brand with any value but true, an empty object name, a missing or malformed scope, inherited, or on an array is not a composed leaf', () => { + const branded = (value: unknown, fields: Record) => + Object.defineProperty({ ...fields }, COMPOSED_READABLE_IDS_BRAND, { value }); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: undefined })), 'the declared shape, built by hand').toBe(true); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: { a: 1 } })), 'the declared shape, built by hand').toBe(true); + expect(isComposedReadableIds(branded('true', { object: 'cpz_doc', scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: '', scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 7, scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc' })), 'no scope key at all').toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: null }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: [] }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: 'owner_id = u1' }))).toBe(false); + expect(isComposedReadableIds(Object.create(composed))).toBe(false); + expect(isComposedReadableIds(Object.defineProperty([], COMPOSED_READABLE_IDS_BRAND, { value: true }))).toBe(false); + for (const value of [{ object: 'cpz_doc', scope: undefined }, null, undefined, 'cpz_doc', 0, true, []]) { + expect(isComposedReadableIds(value), String(value)).toBe(false); + } + }); +}); + +describe('the comparand-shape door never admits a composed leaf — not even for the caller that composes', () => { + it('a composed leaf handed to the engine from outside is refused 400 as a non-list comparand, never trusted as a resolved scope', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), undefined); + const err = refusalOf(() => assertListComparandShapes({ record_id: { $in: composed } }, undefined, 'where', ADMIT)); + expect(envelopeOf(err)).toEqual(ENVELOPE); + expect(err.message).toMatch(/^Operator "\$in" on field "record_id" requires an ARRAY of values\. Received object/); + }); +}); diff --git a/packages/spec/src/data/readable-ids.ts b/packages/spec/src/data/readable-ids.ts index 6f1880a82ac..9d2d2e9442e 100644 --- a/packages/spec/src/data/readable-ids.ts +++ b/packages/spec/src/data/readable-ids.ts @@ -55,8 +55,32 @@ * Like `./filter-subtree-provenance.ts`, the module is tolerant of bare, * un-parsed input: {@link isReadableIds} accepts `unknown` and answers `false` * for every degraded shape. + * + * ## Two more declarations the composition reads (ADR-0139 D2) + * + * The engine composes each leaf from the parent object's read scope, + * `getReadFilter(P, caller)`, immediately before the driver call. Two things + * that composition needs live here, beside the leaf, because the engine, the + * security service and the drivers all import this package and none of them + * can import each other: + * + * - {@link RLS_DENY_FILTER}, the deny sentinel `getReadFilter` answers when + * the caller may read no row of `P`. D2 step 2 answers it `$in: []`, and it + * recognises it BY IDENTITY, so every producer returns this one object. + * - {@link ComposedReadableIds}, the composed leaf of D2 step 3, + * `{ object: P, scope }`, built only by {@link ReadableIds.compose} and + * told apart from the uncomposed leaf by {@link isComposedReadableIds}. The + * two recognisers are DISJOINT: {@link isReadableIds} answers `false` for a + * composed leaf, so "an uncomposed leaf reached this driver" is the one + * question `isReadableIds` asks, and a driver refuses on it (D2's last + * paragraph, D3). + * + * The per-driver case-set the composed leaf joins (D3) is + * `./readable-ids-conformance.ts`. */ +import type { FilterCondition } from './filter.zod'; + /** * The symbol key the `ReadableIds` brand lives under. * @@ -96,9 +120,124 @@ export function isReadableIds(value: unknown): value is ReadableIds { return typeof object === 'string' && object.length > 0; } +/** + * [ADR-0139 D2 step 2] The RLS deny sentinel — the read scope that means "this + * caller may read no row of this object", declared once for the whole tree. + * + * **Its reader is ADR-0139 D2 step 2.** When the engine composes a + * {@link ReadableIds} leaf for parent object `P`, it asks + * `getReadFilter(P, caller)` (`../contracts/security-service.ts`); an answer + * that IS this object is answered `$in: []` — FALSE on every driver — and never + * becomes a {@link ComposedReadableIds} ({@link ReadableIds.compose} refuses + * it). The engine recognises it **by identity** (`scope === RLS_DENY_FILTER`), + * never by comparing its value or its shape. + * + * **So every producer returns this object by reference.** The security service + * answers it from `getReadFilter`'s fail-closed branches, from the tenant wall's + * deny (Layer 0) and from the RLS compiler's "every applicable policy dropped" + * path; `@objectstack/plugin-security` re-exports it under the same name and + * holds no second literal. ⛔ Never spread or clone it (`{ ...RLS_DENY_FILTER }`): + * a copy still matches zero rows, so nothing visibly breaks, but it is no + * longer the sentinel, and D2 step 2 then composes a subquery that matches + * nothing instead of answering `$in: []`. + * + * **It is also an ordinary predicate, and that is the fail direction.** The + * value is an `id` equality against a UUID-shaped string no record carries, so + * a consumer that never heard of the sentinel — the analytics raw-SQL compiler, + * a driver, the explain engine's record pass — evaluates it to zero rows. The + * value is published (the explain engine's `readFilter`, a bound SQL parameter + * in analytics) and is unchanged by this declaration. + * + * Frozen, so no holder of the reference can edit it, and so no read-scope merge + * boundary can stamp a provenance mark on it + * (`./filter-subtree-provenance.ts#markFilterSubtreeProvenance` leaves a + * frozen subtree unmarked, which withholds). + */ +export const RLS_DENY_FILTER: Readonly<{ id: string }> = Object.freeze({ + id: '__rls_deny__:00000000-0000-0000-0000-000000000000', +}); + +/** + * The symbol key the {@link ComposedReadableIds} brand lives under — a key of + * its own, never {@link READABLE_IDS_BRAND}, so the composed and the uncomposed + * leaf are told apart by which brand they carry. + * + * Exported for the same reason as {@link READABLE_IDS_BRAND}. Everything else + * asks {@link isComposedReadableIds}. + */ +export const COMPOSED_READABLE_IDS_BRAND: unique symbol = Symbol.for('objectstack.filter.readableIds.composed'); + +/** + * [ADR-0139 D2 step 3] A COMPOSED `ReadableIds` leaf: "the ids of the records + * of {@link ComposedReadableIds.object} matching + * {@link ComposedReadableIds.scope}", which is what the engine hands a driver + * in place of the {@link ReadableIds} leaf it composed. + * + * Built only by {@link ReadableIds.compose}, at the engine's executor boundary, + * after the comparand doors have run; no door admits it (the comparand-shape + * door's `admitReadableIds` admits the uncomposed leaf only), so a composed + * leaf handed to the engine from outside is refused as a non-list comparand, + * never trusted as a scope someone else already resolved. + * + * Still branded, as D2 says, under {@link COMPOSED_READABLE_IDS_BRAND}: a JSON + * round-trip, a structured clone or a spread produce the unbranded + * `{ object, scope }`, which no driver may evaluate as a composed leaf. + * + * **The tenant wall is not in `scope`** (D2 step 3): the driver applies its own + * tenant scope to `object`'s table with the outer read's options (D3). + */ +export interface ComposedReadableIds { + /** The machine name of the parent object whose ids the leaf stands for. */ + readonly object: string; + /** + * The parent's read scope, lowered and marked `'policy'` by the engine + * (D2 step 2), or `undefined`: the caller has no row restriction on + * `object`. Never the {@link RLS_DENY_FILTER} sentinel, which D2 step 2 + * answers `$in: []` instead. + */ + readonly scope: FilterCondition | undefined; + /** The brand. Non-enumerable at runtime, so a copy of the leaf drops it. */ + readonly [COMPOSED_READABLE_IDS_BRAND]: true; +} + +/** + * Is `value` a branded {@link ComposedReadableIds} leaf? + * + * Own symbol key only, and only the declared value `true`; `object` a + * non-empty string; `scope` an own key holding `undefined` or a plain, + * non-array object. Every copy that lost the brand, every hand-built shape that + * breaks one of those, an uncomposed {@link ReadableIds} leaf, `null` and every + * primitive answer `false`. + * + * Disjoint from {@link isReadableIds}: no value answers `true` to both. + */ +export function isComposedReadableIds(value: unknown): value is ComposedReadableIds { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + if (!Object.prototype.hasOwnProperty.call(value, COMPOSED_READABLE_IDS_BRAND)) return false; + if ((value as Record)[COMPOSED_READABLE_IDS_BRAND] !== true) return false; + const object = (value as { object?: unknown }).object; + if (typeof object !== 'string' || object.length === 0) return false; + if (!Object.prototype.hasOwnProperty.call(value, 'scope')) return false; + return isComposableScope((value as { scope?: unknown }).scope); +} + +/** `undefined` (no restriction) or a plain, non-array object — the scope's two declared spellings. */ +function isComposableScope(scope: unknown): boolean { + return scope === undefined || (scope !== null && typeof scope === 'object' && !Array.isArray(scope)); +} + +/** How a refused argument is named in a factory's `TypeError`. */ +function describeArgument(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + if (typeof value === 'string') return value.length === 0 ? 'an empty string' : 'a string'; + return typeof value; +} + /** * The `ReadableIds` factory. `ReadableIds.of(object)` builds the frozen, - * branded leaf for `object`. + * branded leaf for `object`; `ReadableIds.compose(leaf, scope)` builds the + * composed leaf the engine hands a driver in its place. */ export const ReadableIds: { /** @@ -110,6 +249,26 @@ export const ReadableIds: { * names no object can never be composed. */ readonly of: (object: string) => ReadableIds; + /** + * [ADR-0139 D2 step 3] The composed leaf for `leaf` under the parent's read + * scope: frozen, branded under {@link COMPOSED_READABLE_IDS_BRAND}, carrying + * `{ object: leaf.object, scope }`. + * + * The engine's executor boundary is the caller (ADR-0139 D2); a driver suite + * builds one the same way to run `./readable-ids-conformance.ts`. The + * function judges its arguments and nothing else: it asks no security + * service, lowers nothing and marks nothing. + * + * @param leaf the uncomposed leaf being composed; its `object` is the + * composed leaf's `object`. + * @param scope the parent's lowered read scope, or `undefined` for "no row + * restriction" (`getReadFilter`'s own spelling of it). + * @throws TypeError when `leaf` is not a branded {@link ReadableIds} leaf; + * when `scope` is neither `undefined` nor a plain, non-array object; + * or when `scope` IS {@link RLS_DENY_FILTER}, which D2 step 2 + * answers `$in: []` and never composes. + */ + readonly compose: (leaf: ReadableIds, scope: FilterCondition | undefined) => ComposedReadableIds; } = Object.freeze({ of(object: string): ReadableIds { if (typeof object !== 'string' || object.length === 0) { @@ -128,4 +287,32 @@ export const ReadableIds: { }); return Object.freeze(leaf) as ReadableIds; }, + compose(leaf: ReadableIds, scope: FilterCondition | undefined): ComposedReadableIds { + if (!isReadableIds(leaf)) { + throw new TypeError( + `ReadableIds.compose() needs a leaf built by ReadableIds.of(), received ${describeArgument(leaf)}` + + `${leaf !== null && typeof leaf === 'object' ? ' without the ReadableIds brand' : ''}.`, + ); + } + if (!isComposableScope(scope)) { + throw new TypeError( + `ReadableIds.compose() needs the read scope of '${leaf.object}' as a filter object, or undefined for ` + + `no row restriction, received ${describeArgument(scope)}.`, + ); + } + if ((scope as unknown) === RLS_DENY_FILTER) { + throw new TypeError( + `ReadableIds.compose() was handed the RLS deny sentinel as the read scope of '${leaf.object}'. A caller ` + + 'who may read no row of the parent is answered $in: [] (ADR-0139 D2 step 2), never a composed leaf.', + ); + } + const composed = { object: leaf.object, scope }; + Object.defineProperty(composed, COMPOSED_READABLE_IDS_BRAND, { + value: true, + enumerable: false, + writable: false, + configurable: false, + }); + return Object.freeze(composed) as ComposedReadableIds; + }, }); From d61c34e5699332d62842cf4d79ec390f00c3c05f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:27:25 +0000 Subject: [PATCH 02/12] fix(plugin-security): every getReadFilter deny is the spec's RLS_DENY_FILTER by reference, and the tenant-wall recogniser decides by identity (ADR-0139 D2 step 2) rls-compiler.ts re-exports @objectstack/spec/data's RLS_DENY_FILTER under the same name and holds no second literal. The five getReadFilter fail-closed returns, the tenant wall's deny, the dropped-policy Layer 1 deny and the unresolvable-master controlled_by_parent deny return the frozen object itself instead of a spread copy, so the engine's composition can recognise it by identity. isTenantWallDenial compares the reference. Identity pins (toBe) cover each return. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../plugin-security/src/rls-compiler.ts | 16 +- .../src/rls-deny-sentinel-identity.test.ts | 227 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 58 +++-- .../plugin-security/src/tenant-layer.ts | 12 +- 4 files changed, 278 insertions(+), 35 deletions(-) create mode 100644 packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index ebab98d8268..d3d4f33af5a 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -49,6 +49,11 @@ import { booleanComparandRefusalMessage, type BooleanComparandDoorFieldMeta, } from '@objectstack/spec/data'; +// [ADR-0139 D2 step 2] The RLS deny sentinel is declared ONCE, in the spec's +// `data` entry, because the engine recognises it by identity and cannot import +// this plugin. This file holds no second literal: it returns the spec's object +// and re-exports it under the name every consumer here already imports. +import { RLS_DENY_FILTER } from '@objectstack/spec/data'; /** * Why a policy's predicate produced no filter — the compiler's OWN answer, @@ -674,12 +679,13 @@ function rlsLowering(fieldGuard: RlsFieldGuard | undefined): FilterLoweringOptio * handle a thrown `PermissionDeniedError` for what is conceptually an * empty result set. * - * Exposed for the SecurityPlugin's optional short-circuit path and for - * tests; see {@link RLSCompiler.compileFilter}. + * [ADR-0139 D2 step 2] It IS `@objectstack/spec/data`'s `RLS_DENY_FILTER`, + * re-exported — the same object, not a copy — because the engine's + * composition recognises it by identity. Every producer in this plugin + * returns this reference ({@link RLSCompiler.compileFilter}, the tenant wall's + * deny, `getReadFilter`'s fail-closed branches); ⛔ never spread it. */ -export const RLS_DENY_FILTER: Record = Object.freeze({ - id: '__rls_deny__:00000000-0000-0000-0000-000000000000', -}); +export { RLS_DENY_FILTER }; /** * [#20355] Which policy each compiled policy filter came from — recorded by diff --git a/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts new file mode 100644 index 00000000000..02b85e76cd2 --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D2 step 2] Every deny `getReadFilter` answers IS the spec's + * `RLS_DENY_FILTER` — the same object, by reference — so the engine's + * composition can recognise it by identity and answer `$in: []`. + * + * The sentinel is declared once, in `@objectstack/spec/data`; this plugin + * re-exports it under the name every consumer here imports and holds no second + * literal. Each case below drives one deny return through the public method and + * pins it with `toBe` against the spec's export, never `toEqual`: a spread copy + * carries the same value and matches zero rows too, which is exactly why a + * value assertion cannot tell the two apart, and why the copies stood for so + * long. + * + * The returns, by where they are produced: + * + * - in `getReadFilter` itself: a principal-less context, a sharing-scope + * resolution failure, an on-behalf-of context, a caller who resolves no + * permission set, and a permission-resolution throw; + * - in a layer whose answer comes up unchanged when it is the only one that + * constrains: the tenant wall's deny (Layer 0, `tenantLayer0FilterOf`), the + * "every applicable policy dropped for a missing column" deny (Layer 1), + * the RLS compiler's own deny, and the unresolvable-master deny of a + * `controlled_by_parent` object. + * + * Plus the one downstream property the by-reference return leans on: the CRUD + * middleware injects the frozen sentinel into `ast.where` without mutating it. + * + * Fixtures are synthetic. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; +import { RLS_DENY_FILTER as PACKAGE_RLS_DENY_FILTER } from './index.js'; +import { computeTenantLayer0Filter, tenantLayer0FilterOf } from './tenant-layer.js'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + +/** Object access everywhere, and no row-level policy: no Layer 1 at all. */ +const OPEN_SET = { name: 'member_default', label: 'Synthetic member', objects: { '*': CRUD } } as unknown as PermissionSet; + +/** The platform's wildcard tenant policy shape, which needs `organization_id`. */ +const TENANT_POLICY_SET = { + name: 'member_default', + label: 'Synthetic member', + objects: { '*': CRUD }, + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id == current_user.organization_id' }, + ], +} as unknown as PermissionSet; + +/** A policy the compiler cannot evaluate for a member: a `current_user` key nothing resolves. */ +const UNRESOLVABLE_POLICY_SET = { + name: 'member_default', + label: 'Synthetic member', + objects: { '*': CRUD }, + rowLevelSecurity: [ + { name: 'region_scope', object: 'task', operation: 'select', using: 'owner_id == current_user.synthetic_unresolved_key' }, + ], +} as unknown as PermissionSet; + +const MEMBER = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +interface Deployment { + sets: PermissionSet[]; + fields?: string[]; + schemaExtra?: Record; + orgScoping?: boolean; + sharing?: unknown; + noBaseline?: boolean; +} + +async function boot(deployment: Deployment) { + const fields: Record = {}; + for (const f of deployment.fields ?? ['id', 'organization_id', 'owner_id', 'name']) fields[f] = { name: f, type: 'text' }; + const schema = { name: 'task', fields, ...(deployment.schemaExtra ?? {}) }; + let middleware: ((opCtx: any, next: () => Promise) => Promise) | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + // An engine double that holds no rows; its `findOne` refuses what the + // real engine refuses (`check:engine-double-contract`). + findOne: vi.fn(async (object: string, query: unknown) => { + assertEngineFindOnePredicate(object, query as EngineFindOneQueryInput); + return null; + }), + find: vi.fn(async () => [] as Record[]), + }, + metadata: { get: async () => schema, list: async () => deployment.sets }, + }; + if (deployment.orgScoping) services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + if (deployment.sharing) services['sharing'] = deployment.sharing; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx: any = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin( + deployment.noBaseline + ? { defaultPermissionSets: [], fallbackPermissionSet: null } + : { fallbackPermissionSet: 'member_default' }, + ); + await plugin.init(ctx); + await plugin.start(ctx); + if (!middleware) throw new Error('SecurityPlugin registered no middleware'); + return { plugin, middleware, logger }; +} + +describe('[ADR-0139 D2 step 2] one sentinel in the tree: this plugin re-exports the spec object', () => { + it('rls-compiler and the package entry export the spec\'s object itself, not a copy', () => { + expect(RLS_DENY_FILTER).toBe(SPEC_RLS_DENY_FILTER); + expect(PACKAGE_RLS_DENY_FILTER).toBe(SPEC_RLS_DENY_FILTER); + expect(Object.isFrozen(RLS_DENY_FILTER)).toBe(true); + }); + + it('the tenant wall\'s deny is the spec object, from the projection and from the computation', () => { + expect(tenantLayer0FilterOf({ kind: 'deny' })).toBe(SPEC_RLS_DENY_FILTER); + const walledWithoutOrganization = computeTenantLayer0Filter({ + tenancyPosture: 'isolated', + organizationId: undefined, + objectHasOrgIdField: true, + tenancyDisabled: false, + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); + expect(walledWithoutOrganization).toBe(SPEC_RLS_DENY_FILTER); + // CONTROL: a wall with an organization is a fresh predicate, never the sentinel. + expect(tenantLayer0FilterOf({ kind: 'organization', organizationId: 'org-1' })).toEqual({ organization_id: 'org-1' }); + }); + + it('the RLS compiler\'s "every applicable policy dropped" deny is the spec object', () => { + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: vi.fn() }); + const policy = { name: 'region_scope', object: 'task', operation: 'select', using: 'owner_id == current_user.synthetic_unresolved_key' }; + expect(compiler.compileFilter([policy as never], { userId: 'u1' } as never)).toBe(SPEC_RLS_DENY_FILTER); + }); +}); + +describe('[ADR-0139 D2 step 2] every getReadFilter deny return is the spec sentinel BY REFERENCE', () => { + it('a principal-less context (ADR-0096 D5)', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { positions: [], permissions: [] })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a sharing (OWD) scope that cannot be resolved', async () => { + const sharing = { buildReadFilter: vi.fn(async () => { throw new Error('synthetic sharing outage'); }) }; + const { plugin, logger } = await boot({ sets: [OPEN_SET], sharing }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + expect(sharing.buildReadFilter).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('an on-behalf-of context (the D10 intersection is not on this path)', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER, onBehalfOf: { userId: 'u_delegator' } })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a caller who resolves no permission set (ADR-0056 D2 deny baseline)', async () => { + const { plugin } = await boot({ sets: [], noBaseline: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a permission-resolution throw', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + (plugin as any).permissionEvaluator.resolvePermissionSets = async () => { + throw new Error('synthetic metadata outage'); + }; + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('the tenant wall\'s deny, when it is the only layer that constrains', async () => { + const { plugin } = await boot({ sets: [OPEN_SET], orgScoping: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER, tenantId: undefined })).toBe(SPEC_RLS_DENY_FILTER); + // CONTROL: the same caller WITH an organization is walled, not denied. + expect(await plugin.getReadFilter('task', { ...MEMBER })).toEqual({ organization_id: 'org-1' }); + }); + + it('every applicable policy dropped for a column the object lacks (Layer 1)', async () => { + const { plugin } = await boot({ sets: [TENANT_POLICY_SET], fields: ['id', 'owner_id', 'name'], orgScoping: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('the RLS compiler\'s deny, when every applicable policy fails to compile', async () => { + const { plugin } = await boot({ sets: [UNRESOLVABLE_POLICY_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a controlled_by_parent object whose master cannot be resolved', async () => { + const { plugin } = await boot({ sets: [OPEN_SET], schemaExtra: { sharingModel: 'controlled_by_parent' } }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('CONTROL: an open member with no wall and no policy has no row restriction', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBeUndefined(); + }); +}); + +describe('[ADR-0139 D2 step 2] the CRUD middleware carries the frozen sentinel without mutating it', () => { + it('a walled read with no organization injects the spec object itself as the where, and leaves it unedited', async () => { + const { middleware } = await boot({ sets: [OPEN_SET], orgScoping: true }); + const opCtx: any = { + object: 'task', + operation: 'find', + context: { ...MEMBER, tenantId: undefined }, + options: {}, + ast: { where: undefined }, + }; + let ran = false; + await middleware(opCtx, async () => { ran = true; }); + expect(ran).toBe(true); + expect(opCtx.ast.where).toBe(SPEC_RLS_DENY_FILTER); + expect(SPEC_RLS_DENY_FILTER).toEqual({ id: expect.stringMatching(/^__rls_deny__:/) }); + expect(Object.keys(SPEC_RLS_DENY_FILTER)).toEqual(['id']); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0986060dcc5..42e9fa777bd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -656,19 +656,6 @@ function callerHasOrganizationScope(context: any, posture: TenancyPosture): bool return tenantId != null && tenantId !== ''; } -/** - * [ADR-0123 D2] Is this Layer 0 filter the fail-closed DENY sentinel — i.e. "a - * walled posture on a tenant object, and the context carries no organization - * scope to enforce with"? - * - * Identity is decided on the sentinel's own value, not on shape: `RLS_DENY_FILTER` - * is a single `id` equality against a string no record can carry, and - * `computeTenantLayer0Filter` SPREADS it (`{ ...RLS_DENY_FILTER }`) rather than - * returning the frozen object, so a reference check would silently answer `false` - * for every real denial. Every other Layer 0 return is a real tenant predicate - * (`organization_id = …` / `organization_id $in […]`) or `null`, so no legitimate - * wall can collide with this test. - */ /** * [#21771] Ruling A's read question, judged on the answer of a caller-context * by-id read: is the row ABSENT to this caller? @@ -786,10 +773,21 @@ function copyOfCallerPredicate(where: unknown): unknown { } } +/** + * [ADR-0123 D2] Is this Layer 0 filter the fail-closed DENY sentinel — i.e. "a + * walled posture on a tenant object, and the context carries no organization + * scope to enforce with"? + * + * [ADR-0139 D2 step 2] Decided BY IDENTITY, the way the engine's composition + * decides it: the wall's `deny` returns `RLS_DENY_FILTER` itself — the spec's + * frozen object, never a spread copy (`tenantLayer0FilterOf`) — so the + * reference is the whole test, and no value or shape is compared. Every other + * Layer 0 return is a real tenant predicate (`organization_id = …` / + * `organization_id $in […]`) or `null`, which no reference check can confuse + * with it. + */ function isTenantWallDenial(filter: Record | null | undefined): boolean { - if (!filter) return false; - const keys = Object.keys(filter); - return keys.length === 1 && keys[0] === 'id' && filter.id === RLS_DENY_FILTER.id; + return filter === RLS_DENY_FILTER; } const SYSTEM_ROW_PROVENANCE: Record< @@ -5838,6 +5836,14 @@ export class SecurityPlugin implements Plugin { * ({@link isPrincipalLessContext}, ADR-0096 D5 strict mode), which the * middleware refuses before it resolves anything: it no longer keeps the * sharing predicate alone. + * + * [ADR-0139 D2 step 2] Every deny this method answers is `RLS_DENY_FILTER` + * ITSELF — `@objectstack/spec/data`'s frozen object, by reference — whether + * it is returned here or comes up unchanged from a layer (the tenant wall's + * deny, the RLS compiler's, the dropped-policy and unresolvable-master + * denies below): the engine's composition recognises it by identity and + * answers `$in: []`. ⛔ Never return a spread copy; a copy still matches zero + * rows, but it is no longer the sentinel. */ async getReadFilter( object: string, @@ -5848,7 +5854,7 @@ export class SecurityPlugin implements Plugin { // [#21908] ADR-0096 D5 strict mode — the middleware's principal-less // refusal, mirrored: zero rows, read off the same predicate, before any // layer resolves. This used to answer the sharing predicate alone. - if (isPrincipalLessContext(context)) return { ...RLS_DENY_FILTER }; + if (isPrincipalLessContext(context)) return RLS_DENY_FILTER; // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller, // ahead of the RLS branches below, because it is a SEPARATE middleware in // the chain this method mirrors: none of the RLS stand-downs below is a @@ -5864,7 +5870,7 @@ export class SecurityPlugin implements Plugin { `the engine middleware never runs without the owner and share scope a direct read applies)`, e instanceof Error ? e : new Error(String(e)), ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } // [#2852] D10 delegator intersection is NOT implemented on this path. // The engine middleware (find/count/aggregate) intersects an on-behalf-of @@ -5886,7 +5892,7 @@ export class SecurityPlugin implements Plugin { `implemented on the read-scope path; denying (fail-closed: a delegated read is never scoped ` + `wider than its delegator's own)`, ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } try { const permissionSets = await this.resolvePermissionSetsForContext(context); @@ -5897,7 +5903,7 @@ export class SecurityPlugin implements Plugin { // from the layers instead, it was the sharing predicate alone — every // row of an object whose sharing model is not private. Not a failure, // so nothing is logged. - if (permissionSets.length === 0) return { ...RLS_DENY_FILTER }; + if (permissionSets.length === 0) return RLS_DENY_FILTER; const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); // [#5815] ADR-0055 — the SECOND thing the middleware ANDs into `ast.where` // for a read. Resolved from the sets already resolved above, exactly as @@ -5924,7 +5930,7 @@ export class SecurityPlugin implements Plugin { `(user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, e instanceof Error ? e : new Error(String(e)), ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } } @@ -8185,9 +8191,10 @@ export class SecurityPlugin implements Plugin { } : undefined, ); - // Every applicable policy dropped for a missing field → deny sentinel. + // Every applicable policy dropped for a missing field → deny sentinel, + // by reference (ADR-0139 D2 step 2: recognised by identity). if (layer1 == null && dropped > 0) { - layer1 = { ...RLS_DENY_FILTER }; + layer1 = RLS_DENY_FILTER; } } } @@ -8618,7 +8625,7 @@ export class SecurityPlugin implements Plugin { // header names those doors). So here the alias is genuinely REACHABLE. // • And here a miss is not a quiet wrong answer, it is a DENIAL. // `resolveCbpRelation` returning null is FAIL-CLOSED: the read leg - // answers `{ ...RLS_DENY_FILTER }` (zero rows for every non-admin + // answers `RLS_DENY_FILTER` (zero rows for every non-admin // caller) and the write leg throws `MasterDetailRelationMissingError`. // Narrowing this reader would take a raw-registered, alias-spelled // `controlled_by_parent` object from "access derived from its master" @@ -8839,7 +8846,8 @@ export class SecurityPlugin implements Plugin { if (sharingModel !== 'controlled_by_parent') return null; const rel = this.resolveCbpRelation(object); - if (!rel) return { ...RLS_DENY_FILTER }; + // No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2). + if (!rel) return RLS_DENY_FILTER; // [ADR-0055 amendment] Chain guards, BEFORE any store work. Both answer with the empty // master set — the same shape the #5386 sharing-resolution failure answers diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index f33e0947e6c..8794bbbc995 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -162,17 +162,19 @@ export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLaye * is `tenantLayer0FilterOf(computeTenantLayer0Verdict(input))` and nothing * else. * - * `deny` SPREADS {@link RLS_DENY_FILTER} rather than returning the frozen - * object (`isTenantWallDenial` in `security-plugin.ts` compares by value for - * exactly this reason); `organizations` copies the set so a later mutation of - * the verdict cannot reach a compiled filter. + * [ADR-0139 D2 step 2] `deny` returns {@link RLS_DENY_FILTER} itself — the + * spec's frozen object, BY REFERENCE, never a spread copy — because the + * engine's composition and `isTenantWallDenial` in `security-plugin.ts` + * recognise the sentinel by identity; frozen, no holder can edit it. + * `organizations` copies the set so a later mutation of the verdict cannot + * reach a compiled filter. */ export function tenantLayer0FilterOf(verdict: TenantLayer0Verdict): Record | null { switch (verdict.kind) { case 'none': return null; case 'deny': - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; case 'organization': return { organization_id: verdict.organizationId }; case 'organizations': From 3b7b7ea82baf6eb51f015085f94ef046ed6b05bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:33:46 +0000 Subject: [PATCH 03/12] test(spec): the ReadableIds conformance oracle reads the fixture through typed copies (test-typecheck) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../spec/src/data/readable-ids-conformance.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/readable-ids-conformance.test.ts b/packages/spec/src/data/readable-ids-conformance.test.ts index 5a0f45eae7e..dc1b474f669 100644 --- a/packages/spec/src/data/readable-ids-conformance.test.ts +++ b/packages/spec/src/data/readable-ids-conformance.test.ts @@ -31,6 +31,10 @@ import { type Row = Record; +/** The fixture, as rows the evaluator reads by column name. */ +const PARENTS: readonly Row[] = READABLE_IDS_PARENT_ROWS.map((row) => ({ ...row })); +const CHILDREN: readonly Row[] = READABLE_IDS_CHILD_ROWS.map((row) => ({ ...row })); + /** The ways a driver can get the composed leaf wrong — `faithful` is the declared semantics. */ type Reading = 'faithful' | 'leaf-as-no-restriction' | 'scope-ignored' | 'subquery-unwalled'; @@ -48,7 +52,7 @@ class UncomposedLeafRefusal extends Error { function readableParentIds(leaf: ComposedReadableIds, ctx: EvalContext): Set { const walled = ctx.tenantId !== undefined && ctx.reading !== 'subquery-unwalled'; const ids = new Set(); - for (const parent of READABLE_IDS_PARENT_ROWS as readonly Row[]) { + for (const parent of PARENTS) { if (walled && parent.organization_id !== ctx.tenantId) continue; if (leaf.scope !== undefined && ctx.reading !== 'scope-ignored' && !matches(parent, leaf.scope, ctx)) continue; ids.add(parent.id); @@ -93,7 +97,7 @@ function matches(row: Row, where: FilterCondition, ctx: EvalContext): boolean { function answer(c: ReadableIdsMatchesCase, reading: Reading): string[] { const ctx: EvalContext = { tenantId: c.options?.tenantId, reading }; const where = c.where(); - return (READABLE_IDS_CHILD_ROWS as readonly Row[]) + return CHILDREN // The OUTER read's tenant wall, which every driver already applies. .filter((row) => ctx.tenantId === undefined || row.organization_id === ctx.tenantId) .filter((row) => matches(row, where, ctx)) @@ -124,7 +128,7 @@ describe('READABLE_IDS_CASES — the table agrees with the declared semantics', const where = c.where(); let refusal: unknown; try { - (READABLE_IDS_CHILD_ROWS as readonly Row[]).forEach((row) => matches(row, where, { reading: 'faithful' })); + CHILDREN.forEach((row) => matches(row, where, { reading: 'faithful' })); } catch (e) { refusal = e; } From 9e0bd3b8438218e36c5ce9235e3b64f974e05b1a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:37:55 +0000 Subject: [PATCH 04/12] chore(spec): regenerate api-surface and export-origins for the new data-entry exports Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/spec/api-surface/data.json | 15 +++++++++++++++ packages/spec/export-origins/data.json | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1bc71a3972c..9072b3536b3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -86,6 +86,7 @@ "CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES (const)", + "COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS (const)", "CONTEXT_TOKEN_DESCRIPTIONS (const)", @@ -109,6 +110,7 @@ "ComparandTypeRow (interface)", "ComparisonOperatorSchema (const)", "Compatibility (type)", + "ComposedReadableIds (interface)", "ConditionalValidation (type)", "ConditionalValidationParsed (type)", "ConditionalValidationSchema (const)", @@ -602,17 +604,29 @@ "QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND (const)", + "READABLE_IDS_CASES (const)", + "READABLE_IDS_CHILD_OBJECT (const)", + "READABLE_IDS_CHILD_ROWS (const)", + "READABLE_IDS_PARENT_OBJECT (const)", + "READABLE_IDS_PARENT_ROWS (const)", + "READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS (const)", + "RLS_DENY_FILTER (const)", "RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema (const)", "ReadableIds (const)", "ReadableIds (interface)", + "ReadableIdsCase (type)", + "ReadableIdsChildRow (interface)", + "ReadableIdsMatchesCase (interface)", + "ReadableIdsParentRow (interface)", + "ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken (type)", "RecordFlow (type)", "RecordFlowContainer (type)", @@ -868,6 +882,7 @@ "isApiPrimitive (function)", "isAppResolvedDefaultToken (function)", "isCompatible (function)", + "isComposedReadableIds (function)", "isContextToken (function)", "isCurrentUserDefaultToken (function)", "isDateMacroToken (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 63a37f65bc5..d96d7453d9c 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -83,6 +83,7 @@ "CALENDAR_DATE_TYPES": "src/data/field-value.zod.ts#CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS": "src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES": "src/data/field-value.zod.ts#CLOCK_TIME_TYPES (const)", + "COMPOSED_READABLE_IDS_BRAND": "src/data/readable-ids.ts#COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES": "src/data/field-value.zod.ts#COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#CONTEXT_TOKENS (const)", "CONTEXT_TOKEN_DESCRIPTIONS": "src/data/context-tokens.zod.ts#CONTEXT_TOKEN_DESCRIPTIONS (const)", @@ -106,6 +107,7 @@ "ComparandTypeRow": "src/data/filter-comparand-type-conformance.ts#ComparandTypeRow (interface)", "ComparisonOperatorSchema": "src/data/filter.zod.ts#ComparisonOperatorSchema (const)", "Compatibility": "src/data/type-compat.ts#Compatibility (type)", + "ComposedReadableIds": "src/data/readable-ids.ts#ComposedReadableIds (interface)", "ConditionalValidation": "src/data/validation.zod.ts#ConditionalValidation (type)", "ConditionalValidationParsed": "src/data/validation.zod.ts#ConditionalValidationParsed (type)", "ConditionalValidationSchema": "src/data/validation.zod.ts#ConditionalValidationSchema (const)", @@ -591,16 +593,28 @@ "QueryWithTransportSchema": "src/data/data-engine.zod.ts#QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY": "src/data/field-value.zod.ts#RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND": "src/data/readable-ids.ts#READABLE_IDS_BRAND (const)", + "READABLE_IDS_CASES": "src/data/readable-ids-conformance.ts#READABLE_IDS_CASES (const)", + "READABLE_IDS_CHILD_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_OBJECT (const)", + "READABLE_IDS_CHILD_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_ROWS (const)", + "READABLE_IDS_PARENT_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_OBJECT (const)", + "READABLE_IDS_PARENT_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_ROWS (const)", + "READABLE_IDS_REFUSAL": "src/data/readable-ids-conformance.ts#READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE": "src/data/driver/common.zod.ts#READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD": "src/data/record-surface.ts#RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES": "src/data/field-value.zod.ts#REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)", + "RLS_DENY_FILTER": "src/data/readable-ids.ts#RLS_DENY_FILTER (const)", "RPC_QUERY_ALIAS_SLOTS": "src/data/data-engine.zod.ts#RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", "ReadableIds": "src/data/readable-ids.ts#ReadableIds (interface)", + "ReadableIdsCase": "src/data/readable-ids-conformance.ts#ReadableIdsCase (type)", + "ReadableIdsChildRow": "src/data/readable-ids-conformance.ts#ReadableIdsChildRow (interface)", + "ReadableIdsMatchesCase": "src/data/readable-ids-conformance.ts#ReadableIdsMatchesCase (interface)", + "ReadableIdsParentRow": "src/data/readable-ids-conformance.ts#ReadableIdsParentRow (interface)", + "ReadableIdsRefusesUncomposedCase": "src/data/readable-ids-conformance.ts#ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken": "src/data/context-tokens.zod.ts#RecordContextToken (type)", "RecordFlow": "src/data/record-surface.ts#RecordFlow (type)", "RecordFlowContainer": "src/data/record-surface.ts#RecordFlowContainer (type)", @@ -854,6 +868,7 @@ "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", "isAppResolvedDefaultToken": "src/data/default-value-tokens.ts#isAppResolvedDefaultToken (function)", "isCompatible": "src/data/type-compat.ts#isCompatible (function)", + "isComposedReadableIds": "src/data/readable-ids.ts#isComposedReadableIds (function)", "isContextToken": "src/data/context-tokens.zod.ts#isContextToken (function)", "isCurrentUserDefaultToken": "src/data/default-value-tokens.ts#isCurrentUserDefaultToken (function)", "isDateMacroToken": "src/data/date-macros.zod.ts#isDateMacroToken (function)", From 5e292f4da9731ed59febdc6f55074d566f41590c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:40:03 +0000 Subject: [PATCH 05/12] =?UTF-8?q?refactor(spec):=20park=20READABLE=5FIDS?= =?UTF-8?q?=5FCASES=20off=20this=20change=20=E2=80=94=20the=20driver-confo?= =?UTF-8?q?rmance=20gate=20makes=20it=20land=20with=20the=20driver=20suite?= =?UTF-8?q?s=20or=20a=20maintainer-agreed=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classifying the case-set reds check-driver-conformance with five CONSUMED problems, one per driver; the author-takeable fix is the driver suites (ADR-0139 E3/E4, packages/drivers), and the ledger path is maintainer-only. The table and its oracle stay on claude/issue-23009-readable-ids-conformance. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/spec/src/data/index.ts | 3 - .../src/data/readable-ids-conformance.test.ts | 202 ------------- .../spec/src/data/readable-ids-conformance.ts | 268 ------------------ packages/spec/src/data/readable-ids.ts | 11 +- 4 files changed, 4 insertions(+), 480 deletions(-) delete mode 100644 packages/spec/src/data/readable-ids-conformance.test.ts delete mode 100644 packages/spec/src/data/readable-ids-conformance.ts diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 51308a29998..a557a552558 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -50,9 +50,6 @@ export * from './filter-subtree-provenance'; // the composed leaf `{ object, scope }` (`ReadableIds.compose`, // `isComposedReadableIds`, D2 step 3). export * from './readable-ids'; -// [ADR-0139 D3] Canonical conformance cases for the composed `ReadableIds` leaf -// — the per-driver case-set a driver compiles (measured) or refuses (recorded). -export * from './readable-ids-conformance'; // [#8371] The FILTER axis' dotted-path verdict — ONE head-type classification // consumed by both doors (metadata-protocol's ingress gate and objectql's // engine seam), so the two cannot drift into answering one spelling two ways. diff --git a/packages/spec/src/data/readable-ids-conformance.test.ts b/packages/spec/src/data/readable-ids-conformance.test.ts deleted file mode 100644 index dc1b474f669..00000000000 --- a/packages/spec/src/data/readable-ids-conformance.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0139 D3] `READABLE_IDS_CASES` lands ahead of every driver that runs it - * (E3 and E4 enrol the drivers), so between now and then nothing executes the - * table — and a miscounted `expected` list would sit quietly wrong until a - * driver author trusted it. This file executes it, against a reference - * evaluator written from the declared semantics: the composed leaf is the - * semi-join `field IN (SELECT id FROM P WHERE scope AND tenant)`, `$not` is - * NULL-safe, and an uncomposed leaf is refused. - * - * The second job is proving the table is not VACUOUS: each of the three ways a - * driver can get the leaf wrong — evaluating it as "no restriction", ignoring - * its scope, compiling its subquery without the tenant wall — must fail at - * least one case, so a driver that makes that mistake cannot pass the table. - */ - -import { describe, it, expect } from 'vitest'; -import type { FilterCondition } from './filter.zod'; -import { isComposedReadableIds, isReadableIds, type ComposedReadableIds } from './readable-ids'; -import { - READABLE_IDS_CASES, - READABLE_IDS_CHILD_ROWS, - READABLE_IDS_PARENT_ROWS, - READABLE_IDS_REFUSAL, - type ReadableIdsCase, - type ReadableIdsMatchesCase, -} from './readable-ids-conformance'; - -// ── A reference evaluator, from the declared semantics ─────────────────────── - -type Row = Record; - -/** The fixture, as rows the evaluator reads by column name. */ -const PARENTS: readonly Row[] = READABLE_IDS_PARENT_ROWS.map((row) => ({ ...row })); -const CHILDREN: readonly Row[] = READABLE_IDS_CHILD_ROWS.map((row) => ({ ...row })); - -/** The ways a driver can get the composed leaf wrong — `faithful` is the declared semantics. */ -type Reading = 'faithful' | 'leaf-as-no-restriction' | 'scope-ignored' | 'subquery-unwalled'; - -interface EvalContext { - readonly tenantId?: string; - readonly reading: Reading; -} - -class UncomposedLeafRefusal extends Error { - readonly code = READABLE_IDS_REFUSAL.code; - readonly status = READABLE_IDS_REFUSAL.status; -} - -/** The ids of the parent rows the composed leaf admits under `ctx`. */ -function readableParentIds(leaf: ComposedReadableIds, ctx: EvalContext): Set { - const walled = ctx.tenantId !== undefined && ctx.reading !== 'subquery-unwalled'; - const ids = new Set(); - for (const parent of PARENTS) { - if (walled && parent.organization_id !== ctx.tenantId) continue; - if (leaf.scope !== undefined && ctx.reading !== 'scope-ignored' && !matches(parent, leaf.scope, ctx)) continue; - ids.add(parent.id); - } - return ids; -} - -/** One field constraint against one row: the subset of operators the table uses. */ -function fieldMatches(row: Row, field: string, spec: unknown, ctx: EvalContext): boolean { - const value = row[field]; - if (spec !== null && typeof spec === 'object' && !Array.isArray(spec) && '$in' in (spec as Row)) { - const comparand = (spec as Row).$in; - if (isReadableIds(comparand)) throw new UncomposedLeafRefusal(`uncomposed ReadableIds leaf at ${field}.$in`); - if (isComposedReadableIds(comparand)) { - if (ctx.reading === 'leaf-as-no-restriction') return true; - return value != null && readableParentIds(comparand, ctx).has(value); - } - if (Array.isArray(comparand)) return comparand.includes(value); - throw new Error(`the reference evaluator has no reading for ${field}.$in = ${String(comparand)}`); - } - // Implicit equality, the null predicate included. - return spec === null ? value == null : value === spec; -} - -function matches(row: Row, where: FilterCondition, ctx: EvalContext): boolean { - for (const [key, spec] of Object.entries(where)) { - if (key === '$and') { - if (!(spec as FilterCondition[]).every((child) => matches(row, child, ctx))) return false; - } else if (key === '$or') { - if (!(spec as FilterCondition[]).some((child) => matches(row, child, ctx))) return false; - } else if (key === '$not') { - // NULL-safe: "not P" holds wherever P is not TRUE, a row with no value included. - if (matches(row, spec as FilterCondition, ctx)) return false; - } else if (!fieldMatches(row, key, spec, ctx)) { - return false; - } - } - return true; -} - -/** What `find` on the child object answers for `c` under `reading`: the ascending ids. */ -function answer(c: ReadableIdsMatchesCase, reading: Reading): string[] { - const ctx: EvalContext = { tenantId: c.options?.tenantId, reading }; - const where = c.where(); - return CHILDREN - // The OUTER read's tenant wall, which every driver already applies. - .filter((row) => ctx.tenantId === undefined || row.organization_id === ctx.tenantId) - .filter((row) => matches(row, where, ctx)) - .map((row) => String(row.id)) - .sort(); -} - -const matchesCases = READABLE_IDS_CASES.filter((c): c is ReadableIdsMatchesCase => c.verdict === 'matches'); - -/** Does `where` hold a composed leaf anywhere? */ -const holdsComposedLeaf = (where: unknown): boolean => { - if (isComposedReadableIds(where)) return true; - if (where === null || typeof where !== 'object') return false; - return (Array.isArray(where) ? where : Object.values(where)).some(holdsComposedLeaf); -}; - -// ── The table, executed ────────────────────────────────────────────────────── - -describe('READABLE_IDS_CASES — the table agrees with the declared semantics', () => { - for (const c of matchesCases) { - it(c.name, () => { - expect(answer(c, 'faithful')).toEqual([...c.expected]); - }); - } - - for (const c of READABLE_IDS_CASES.filter((x) => x.verdict === 'refuses-uncomposed')) { - it(c.name, () => { - const where = c.where(); - let refusal: unknown; - try { - CHILDREN.forEach((row) => matches(row, where, { reading: 'faithful' })); - } catch (e) { - refusal = e; - } - expect(refusal).toBeInstanceOf(UncomposedLeafRefusal); - expect({ code: (refusal as UncomposedLeafRefusal).code, status: (refusal as UncomposedLeafRefusal).status }) - .toEqual({ code: 'INTERNAL_ERROR', status: 500 }); - }); - } -}); - -describe('READABLE_IDS_CASES — each wrong reading of the leaf fails the table', () => { - const failing = (reading: Reading) => - matchesCases.filter((c) => JSON.stringify(answer(c, reading)) !== JSON.stringify([...c.expected])).map((c) => c.name); - - it('a driver that evaluates the composed leaf as "no restriction" fails, on the deleted and the absent parent', () => { - expect(failing('leaf-as-no-restriction')).toContain('no row restriction: every child whose parent exists'); - }); - - it('a driver that compiles the subquery without its scope fails', () => { - expect(failing('scope-ignored')).toContain('a scope narrows the parents whose children match'); - expect(failing('scope-ignored')).toContain('a scope that admits no parent matches no child'); - }); - - it('a driver that compiles the subquery without its tenant wall fails exactly the tenant case', () => { - expect(failing('subquery-unwalled')).toEqual( - matchesCases.filter((c) => c.requiresTenantWall === true).map((c) => c.name), - ); - expect(failing('subquery-unwalled')).toHaveLength(1); - }); -}); - -describe('READABLE_IDS_CASES — the table is well-formed', () => { - it('names are unique, expected ids are ascending and name fixture rows', () => { - const names = READABLE_IDS_CASES.map((c) => c.name); - expect(new Set(names).size).toBe(names.length); - const childIds = new Set(READABLE_IDS_CHILD_ROWS.map((r) => r.id)); - for (const c of matchesCases) { - expect([...c.expected].sort(), c.name).toEqual([...c.expected]); - for (const id of c.expected) expect(childIds.has(id), `${c.name}: ${id}`).toBe(true); - } - }); - - it('holdsComposedLeaf says what each where holds, so a refusing driver refuses exactly those cases', () => { - for (const c of matchesCases) expect(holdsComposedLeaf(c.where()), c.name).toBe(c.holdsComposedLeaf); - expect(matchesCases.filter((c) => !c.holdsComposedLeaf).map((c) => c.name)).toEqual([ - 'the engine\'s denial answer, $in: [], matches no child', - ]); - }); - - it('the refusal case holds an uncomposed leaf and no composed one', () => { - const refusals = READABLE_IDS_CASES.filter((c) => c.verdict === 'refuses-uncomposed'); - expect(refusals).toHaveLength(1); - const where = refusals[0].where() as { record_id: { $in: unknown } }; - expect(isReadableIds(where.record_id.$in)).toBe(true); - expect(holdsComposedLeaf(where)).toBe(false); - }); - - it('every where factory builds fresh leaves: no two suites judge one object', () => { - for (const c of READABLE_IDS_CASES as readonly ReadableIdsCase[]) { - const a = c.where(); - const b = c.where(); - expect(a, c.name).not.toBe(b); - expect(a, c.name).toEqual(b); - } - }); - - it('the refusal envelope is D3\'s composition fault, not an author error', () => { - expect(READABLE_IDS_REFUSAL).toEqual({ code: 'INTERNAL_ERROR', status: 500 }); - expect(Object.isFrozen(READABLE_IDS_REFUSAL)).toBe(true); - }); -}); diff --git a/packages/spec/src/data/readable-ids-conformance.ts b/packages/spec/src/data/readable-ids-conformance.ts deleted file mode 100644 index 91d98b823ee..00000000000 --- a/packages/spec/src/data/readable-ids-conformance.ts +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Canonical conformance cases for the **composed `ReadableIds` leaf** — ADR-0139 - * D3: "The leaf joins the per-driver conformance case-sets, so a driver that - * compiles it is measured and one that refuses is recorded as refusing." - * - * # The property - * - * `{ record_id: { $in: ReadableIds.compose(ReadableIds.of(P), scope) } }` - * matches exactly the rows whose `record_id` is the id of a row of `P` that - * `scope` admits, inside the driver's own tenant wall: the semi-join - * `record_id IN (SELECT id FROM P WHERE scope AND tenant)` (D3, `driver-sql`), - * however a driver evaluates it. `scope: undefined` is "no row restriction", - * and the rows of `P` that exist are then the whole set. - * - * # Who builds the leaf, and why a driver never sees `ReadableIds.of` - * - * The engine composes every leaf at its executor boundary, immediately before - * the driver call (D2), so a driver is handed composed leaves only. An - * UNCOMPOSED leaf reaching a driver means something bypassed that seam, and - * the driver refuses it with D3's envelope ({@link READABLE_IDS_REFUSAL}) — - * ⛔ never evaluated as "no restriction". The cases build both shapes with the - * spec's own factory (`./readable-ids.ts`), exactly as the engine does, and a - * driver tells them apart with `isComposedReadableIds` / `isReadableIds`, - * which answer disjointly. - * - * A caller the parent denies is answered `$in: []` by the engine (D2 steps 1 - * and 2, the deny sentinel included) and never reaches a driver as a composed - * leaf, so the table carries that answer as an ordinary empty list. - * - * # How a driver suite consumes this table - * - * Seed {@link READABLE_IDS_PARENT_ROWS} as {@link READABLE_IDS_PARENT_OBJECT} - * and {@link READABLE_IDS_CHILD_ROWS} as {@link READABLE_IDS_CHILD_OBJECT}, both - * declaring `organization_id` as their tenant field, then for each case call - * the driver's own `find` on the child object with `where: c.where()` and the - * case's `options`: - * - * - a driver that COMPILES the leaf (D3: the SQL family, both Turso - * transports, `driver-memory`) asserts the child ids of each `matches` case - * — ascending — and D3's envelope on the `refuses-uncomposed` case; - * - a driver that REFUSES the leaf (D3: `driver-mongodb`, until a card - * measures a pipeline form) asserts D3's envelope on every case that holds a - * composed leaf ({@link readableIdsCaseHoldsComposedLeaf}), and the row ids on - * the rest. That is what "recorded as refusing" means. - * - * A case with {@link ReadableIdsMatchesCase.requiresTenantWall} reads under a - * tenant: it is the one that fails when the subquery is compiled without the - * driver's tenant scope. A driver that refuses multi-tenant boot - * (`driver-memory`, `memory-tenancy-guard.ts`) owes no wall (D3) and records - * that refusal for the case instead of rows. - * - * # What belongs here - * - * Only what a driver decides: which rows the composed leaf admits, how it - * composes under `$and` / `$or` / `$not`, and the refusal of the uncomposed - * leaf. What the ENGINE decides — the composition itself, the write-verb and - * `having` refusals, D5's three classes — is pinned in `@objectstack/objectql`. - * The cases build every leaf from a factory, because the leaf is frozen and - * branded and one shared instance across driver suites would let one suite's - * run judge another's object. - * - * @see ./readable-ids.ts for the leaf, the composed leaf and the deny sentinel - */ - -import type { FilterCondition } from './filter.zod'; -import { ReadableIds } from './readable-ids'; - -/** The parent object — `P` in ADR-0139's notation. */ -export const READABLE_IDS_PARENT_OBJECT = 'rid_parent'; - -/** The child object every case reads; its `record_id` points at the parent. */ -export const READABLE_IDS_CHILD_OBJECT = 'rid_child'; - -/** A row of the parent object. `owner` is what the cases' scopes narrow on. */ -export interface ReadableIdsParentRow { - id: string; - owner: string; - organization_id: string; -} - -/** - * A row of the child object: the ActivityPointer pair the six consumers key on - * (`object_name`, `record_id`), plus a column for the `$and` case to narrow on. - */ -export interface ReadableIdsChildRow { - id: string; - object_name: string; - record_id: string | null; - label: string; - organization_id: string; -} - -/** - * Four parents. `p4` belongs to another tenant, so only a read under a tenant - * can tell a walled subquery from an unwalled one. - */ -export const READABLE_IDS_PARENT_ROWS: readonly ReadableIdsParentRow[] = [ - { id: 'p1', owner: 'u1', organization_id: 'org_a' }, - { id: 'p2', owner: 'u2', organization_id: 'org_a' }, - { id: 'p3', owner: 'u1', organization_id: 'org_a' }, - { id: 'p4', owner: 'u1', organization_id: 'org_b' }, -]; - -/** - * Seven children, all in `org_a`, so the OUTER read's tenant wall excludes none - * of them and every difference a tenant makes comes from the subquery's wall. - * - * - `c4` points at a parent that no longer exists (excluded: the subquery - * finds no such id); - * - `c5` names no parent (excluded by `$in`, admitted by the NULL-safe `$not`); - * - `c6` points across the tenant wall, at `p4`; - * - `c7` names ANOTHER object with an id that `rid_parent` also carries: the - * leaf matches ids, and pairing them with `object_name` is the consumer's - * branch (ADR-0139 D4), so `c7` is in every case that does not say - * `object_name`. - */ -export const READABLE_IDS_CHILD_ROWS: readonly ReadableIdsChildRow[] = [ - { id: 'c1', object_name: 'rid_parent', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, - { id: 'c2', object_name: 'rid_parent', record_id: 'p2', label: 'keep', organization_id: 'org_a' }, - { id: 'c3', object_name: 'rid_parent', record_id: 'p3', label: 'drop', organization_id: 'org_a' }, - { id: 'c4', object_name: 'rid_parent', record_id: 'p9', label: 'keep', organization_id: 'org_a' }, - { id: 'c5', object_name: 'rid_parent', record_id: null, label: 'keep', organization_id: 'org_a' }, - { id: 'c6', object_name: 'rid_parent', record_id: 'p4', label: 'keep', organization_id: 'org_a' }, - { id: 'c7', object_name: 'rid_other', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, -]; - -/** - * D3's refusal envelope (ADR-0112): a composition fault, not an author error, - * so ⛔ not `INVALID_FILTER`. A suite asserts `code` AND `status`; the message - * (it names the driver, the leaf and the remedy) is not pinned here. - */ -export const READABLE_IDS_REFUSAL = Object.freeze({ code: 'INTERNAL_ERROR', status: 500 } as const); - -interface ReadableIdsCaseBase { - /** Stable identifier, usable as a test name. */ - readonly name: string; - /** - * Builds the `where` for a `find` on {@link READABLE_IDS_CHILD_OBJECT}. A - * FACTORY: every call builds fresh leaves. - */ - readonly where: () => FilterCondition; - /** Why the case is here — surfaced in failure output. */ - readonly note?: string; -} - -/** A case whose `where` the driver must EVALUATE, matching exactly {@link expected}. */ -export interface ReadableIdsMatchesCase extends ReadableIdsCaseBase { - readonly verdict: 'matches'; - /** Ids of the matching child rows, ascending. */ - readonly expected: readonly string[]; - /** - * `where` holds a composed leaf. `false` only where the case carries the - * engine's denial answer instead; a driver that refuses the leaf answers - * {@link READABLE_IDS_REFUSAL} exactly on the cases where this is `true`. - */ - readonly holdsComposedLeaf: boolean; - /** The read's driver options; absent means an unscoped read. */ - readonly options?: { readonly tenantId: string }; - /** - * The read is under a tenant, and the case fails when the subquery is - * compiled without the driver's tenant scope (D3). - */ - readonly requiresTenantWall?: true; -} - -/** A case the driver must REFUSE with {@link READABLE_IDS_REFUSAL}: an uncomposed leaf reached it. */ -export interface ReadableIdsRefusesUncomposedCase extends ReadableIdsCaseBase { - readonly verdict: 'refuses-uncomposed'; -} - -export type ReadableIdsCase = ReadableIdsMatchesCase | ReadableIdsRefusesUncomposedCase; - -const P = READABLE_IDS_PARENT_OBJECT; - -/** The composed leaf for {@link READABLE_IDS_PARENT_OBJECT} under `scope`, built as the engine builds it. */ -const composed = (scope: FilterCondition | undefined) => ReadableIds.compose(ReadableIds.of(P), scope); - -/** - * The cases: the leaf alone (no restriction, a narrowing scope, a scope that - * admits nothing, the engine's denial answer), the leaf under each combinator, - * the tenant wall, the consumers' branch shape, and the uncomposed leaf. - */ -export const READABLE_IDS_CASES: readonly ReadableIdsCase[] = [ - { - name: 'no row restriction: every child whose parent exists', - where: () => ({ record_id: { $in: composed(undefined) } }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c1', 'c2', 'c3', 'c6', 'c7'], - note: 'c4 points at a parent that no longer exists and c5 names none: neither id is in the subquery. ' - + 'A driver that evaluates the leaf as "no restriction" returns them.', - }, - { - name: 'a scope narrows the parents whose children match', - where: () => ({ record_id: { $in: composed({ owner: 'u1' }) } }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c1', 'c3', 'c6', 'c7'], - }, - { - name: 'a scope that admits no parent matches no child', - where: () => ({ record_id: { $in: composed({ owner: 'nobody' }) } }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: [], - }, - { - name: 'the engine\'s denial answer, $in: [], matches no child', - where: () => ({ record_id: { $in: [] } }), - verdict: 'matches', - holdsComposedLeaf: false, - expected: [], - note: 'ADR-0139 D2 steps 1 and 2: a caller the parent denies (canReadObject false, or the deny sentinel ' - + 'as its scope) is answered with an empty list, never a composed leaf. Every driver already answers it.', - }, - { - name: 'the leaf ANDs with a predicate on the child', - where: () => ({ $and: [{ label: 'keep' }, { record_id: { $in: composed({ owner: 'u1' }) } }] }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c1', 'c6', 'c7'], - }, - { - name: 'the leaf is one arm of an $or', - where: () => ({ $or: [{ record_id: null }, { record_id: { $in: composed({ owner: 'u2' }) } }] }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c2', 'c5'], - }, - { - name: '$not over the leaf is NULL-safe: it admits the child that names no parent', - where: () => ({ $not: { record_id: { $in: composed(undefined) } } }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c4', 'c5'], - note: 'ADR-0139 D1: the NULL-safe $not of the shared lowering applies to the leaf unchanged. ' - + 'A three-valued NOT IN drops c5.', - }, - { - name: 'the tenant wall binds the subquery: a parent in another tenant is not readable', - where: () => ({ record_id: { $in: composed(undefined) } }), - options: { tenantId: 'org_a' }, - requiresTenantWall: true, - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c1', 'c2', 'c3', 'c7'], - note: 'ADR-0139 D2 step 3: the tenant wall is not in the scope; the driver applies its own tenant scope to ' - + 'the parent table with the outer read\'s options. c6 points at p4, in org_b: an unwalled subquery admits it.', - }, - { - name: 'the consumers\' branch: the leaf paired with the object it names', - where: () => ({ object_name: P, record_id: { $in: composed(undefined) } }), - verdict: 'matches', - holdsComposedLeaf: true, - expected: ['c1', 'c2', 'c3', 'c6'], - note: 'The branch shape ADR-0139 D4 has each consumer emit per parent object. c7 carries an id that ' - + 'rid_parent also carries, under another object name.', - }, - { - name: 'an uncomposed leaf is refused, never evaluated as no restriction', - where: () => ({ record_id: { $in: ReadableIds.of(P) } }), - verdict: 'refuses-uncomposed', - note: 'ADR-0139 D2\'s last paragraph and D3: a leaf that reached the driver without passing the engine\'s ' - + 'seam fails closed, loudly.', - }, -]; diff --git a/packages/spec/src/data/readable-ids.ts b/packages/spec/src/data/readable-ids.ts index 9d2d2e9442e..eff470ee134 100644 --- a/packages/spec/src/data/readable-ids.ts +++ b/packages/spec/src/data/readable-ids.ts @@ -74,9 +74,6 @@ * composed leaf, so "an uncomposed leaf reached this driver" is the one * question `isReadableIds` asks, and a driver refuses on it (D2's last * paragraph, D3). - * - * The per-driver case-set the composed leaf joins (D3) is - * `./readable-ids-conformance.ts`. */ import type { FilterCondition } from './filter.zod'; @@ -254,10 +251,10 @@ export const ReadableIds: { * scope: frozen, branded under {@link COMPOSED_READABLE_IDS_BRAND}, carrying * `{ object: leaf.object, scope }`. * - * The engine's executor boundary is the caller (ADR-0139 D2); a driver suite - * builds one the same way to run `./readable-ids-conformance.ts`. The - * function judges its arguments and nothing else: it asks no security - * service, lowers nothing and marks nothing. + * The engine's executor boundary is the caller (ADR-0139 D2); a driver's + * conformance suite (D3) builds one the same way. The function judges its + * arguments and nothing else: it asks no security service, lowers nothing + * and marks nothing. * * @param leaf the uncomposed leaf being composed; its `object` is the * composed leaf's `object`. From 41501725a11f3ae27688f9341026a83fcfbc401c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:49:52 +0000 Subject: [PATCH 06/12] chore(spec): regenerate api-surface and export-origins after parking the case-set Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/spec/api-surface/data.json | 11 ----------- packages/spec/export-origins/data.json | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 9072b3536b3..352e1dc29f4 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -604,12 +604,6 @@ "QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND (const)", - "READABLE_IDS_CASES (const)", - "READABLE_IDS_CHILD_OBJECT (const)", - "READABLE_IDS_CHILD_ROWS (const)", - "READABLE_IDS_PARENT_OBJECT (const)", - "READABLE_IDS_PARENT_ROWS (const)", - "READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", @@ -622,11 +616,6 @@ "RangeOperatorSchema (const)", "ReadableIds (const)", "ReadableIds (interface)", - "ReadableIdsCase (type)", - "ReadableIdsChildRow (interface)", - "ReadableIdsMatchesCase (interface)", - "ReadableIdsParentRow (interface)", - "ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken (type)", "RecordFlow (type)", "RecordFlowContainer (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index d96d7453d9c..b0bb55da9d1 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -593,12 +593,6 @@ "QueryWithTransportSchema": "src/data/data-engine.zod.ts#QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY": "src/data/field-value.zod.ts#RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND": "src/data/readable-ids.ts#READABLE_IDS_BRAND (const)", - "READABLE_IDS_CASES": "src/data/readable-ids-conformance.ts#READABLE_IDS_CASES (const)", - "READABLE_IDS_CHILD_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_OBJECT (const)", - "READABLE_IDS_CHILD_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_ROWS (const)", - "READABLE_IDS_PARENT_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_OBJECT (const)", - "READABLE_IDS_PARENT_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_ROWS (const)", - "READABLE_IDS_REFUSAL": "src/data/readable-ids-conformance.ts#READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE": "src/data/driver/common.zod.ts#READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD": "src/data/record-surface.ts#RECORD_SURFACE_PAGE_THRESHOLD (const)", @@ -610,11 +604,6 @@ "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", "ReadableIds": "src/data/readable-ids.ts#ReadableIds (interface)", - "ReadableIdsCase": "src/data/readable-ids-conformance.ts#ReadableIdsCase (type)", - "ReadableIdsChildRow": "src/data/readable-ids-conformance.ts#ReadableIdsChildRow (interface)", - "ReadableIdsMatchesCase": "src/data/readable-ids-conformance.ts#ReadableIdsMatchesCase (interface)", - "ReadableIdsParentRow": "src/data/readable-ids-conformance.ts#ReadableIdsParentRow (interface)", - "ReadableIdsRefusesUncomposedCase": "src/data/readable-ids-conformance.ts#ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken": "src/data/context-tokens.zod.ts#RecordContextToken (type)", "RecordFlow": "src/data/record-surface.ts#RecordFlow (type)", "RecordFlowContainer": "src/data/record-surface.ts#RecordFlowContainer (type)", From 839351791a66ec3f748367e8efea909eadf0e46f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:51:22 +0000 Subject: [PATCH 07/12] chore: changeset for the spec-declared RLS deny sentinel and composed ReadableIds leaf; plugin-security binds the sentinel under its published type Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- ...-rls-deny-sentinel-composed-readable-ids.md | 13 +++++++++++++ .../plugin-security/src/rls-compiler.ts | 18 ++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 .changeset/23009-rls-deny-sentinel-composed-readable-ids.md diff --git a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md new file mode 100644 index 00000000000..34ab99fbb6b --- /dev/null +++ b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": patch +--- + +The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every deny `getReadFilter` answers is that one object (ADR-0139 D2) + +Clause-②: yes (widening: new exported members of the published spec `data` entry) + +- **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. +- **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself on every deny path: a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set, a resolution failure, the tenant wall's deny, every applicable policy dropped, and a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. The engine's `ReadableIds` composition (ADR-0139 D2 step 2) recognises the deny by `scope === RLS_DENY_FILTER`. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. A deny ANDed with another layer is still returned as the composed `$and`. +- **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is not a plain filter object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. +- **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index d3d4f33af5a..fee12defce9 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -52,8 +52,8 @@ import { // [ADR-0139 D2 step 2] The RLS deny sentinel is declared ONCE, in the spec's // `data` entry, because the engine recognises it by identity and cannot import // this plugin. This file holds no second literal: it returns the spec's object -// and re-exports it under the name every consumer here already imports. -import { RLS_DENY_FILTER } from '@objectstack/spec/data'; +// and exports it under the name every consumer here already imports. +import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; /** * Why a policy's predicate produced no filter — the compiler's OWN answer, @@ -679,13 +679,15 @@ function rlsLowering(fieldGuard: RlsFieldGuard | undefined): FilterLoweringOptio * handle a thrown `PermissionDeniedError` for what is conceptually an * empty result set. * - * [ADR-0139 D2 step 2] It IS `@objectstack/spec/data`'s `RLS_DENY_FILTER`, - * re-exported — the same object, not a copy — because the engine's - * composition recognises it by identity. Every producer in this plugin - * returns this reference ({@link RLSCompiler.compileFilter}, the tenant wall's - * deny, `getReadFilter`'s fail-closed branches); ⛔ never spread it. + * [ADR-0139 D2 step 2] It IS `@objectstack/spec/data`'s `RLS_DENY_FILTER` — + * the same object, not a copy — because the engine's composition recognises + * it by identity. Bound under this plugin's published name and type, so the + * package's export reads exactly as it did before the declaration moved. + * Every producer in this plugin returns this reference + * ({@link RLSCompiler.compileFilter}, the tenant wall's deny, `getReadFilter`'s + * fail-closed branches); ⛔ never spread it. */ -export { RLS_DENY_FILTER }; +export const RLS_DENY_FILTER: Record = SPEC_RLS_DENY_FILTER; /** * [#20355] Which policy each compiled policy filter came from — recorded by From 41ce7b89eda5a3c2ed7dce317c3fbe2d50346835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:08:12 +0000 Subject: [PATCH 08/12] test(plugin-security): the tenant wall's deny projection is pinned to the sentinel's reference, not a copy (ADR-0139 D2 step 2) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/plugins/plugin-security/src/tenant-layer.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts index a292ffc3e42..f8057beb015 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -348,10 +348,10 @@ describe('computeTenantLayer0Verdict — what the wall decided, per exit (#15813 } }); - it('projecting `deny` spreads the sentinel — equal by value, never the frozen object itself', () => { + it('[ADR-0139 D2 step 2] projecting `deny` returns the frozen sentinel itself, by reference — never a spread copy', () => { const projected = tenantLayer0FilterOf({ kind: 'deny' }); - expect(projected).toEqual(RLS_DENY_FILTER); - expect(projected).not.toBe(RLS_DENY_FILTER); + expect(projected).toBe(RLS_DENY_FILTER); + expect(Object.isFrozen(projected)).toBe(true); }); it('projecting `organizations` copies the set (a later mutation cannot reach the compiled filter)', () => { From ea300e19d6c6c7d396f54df929bf88223a4c0b81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:21:16 +0000 Subject: [PATCH 09/12] docs(spec): the deny sentinel's provenance anchor names its file from the repo root (check-spec-docblock-symbol-anchors) Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- packages/spec/src/data/readable-ids.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/readable-ids.ts b/packages/spec/src/data/readable-ids.ts index eff470ee134..41eca1e4fcb 100644 --- a/packages/spec/src/data/readable-ids.ts +++ b/packages/spec/src/data/readable-ids.ts @@ -146,9 +146,9 @@ export function isReadableIds(value: unknown): value is ReadableIds { * in analytics) and is unchanged by this declaration. * * Frozen, so no holder of the reference can edit it, and so no read-scope merge - * boundary can stamp a provenance mark on it - * (`./filter-subtree-provenance.ts#markFilterSubtreeProvenance` leaves a - * frozen subtree unmarked, which withholds). + * boundary can stamp a provenance mark on it: a frozen subtree stays unmarked, + * which withholds + * (`packages/spec/src/data/filter-subtree-provenance.ts#markFilterSubtreeProvenance`). */ export const RLS_DENY_FILTER: Readonly<{ id: string }> = Object.freeze({ id: '__rls_deny__:00000000-0000-0000-0000-000000000000', From 828798a9a879cdbae9d0257b07af9021058ddde5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:45:28 +0000 Subject: [PATCH 10/12] =?UTF-8?q?test(plugin-security):=20the=20identity?= =?UTF-8?q?=20pins'=20engine=20double=20carries=20no=20findOne=20=E2=80=94?= =?UTF-8?q?=20no=20deny=20path=20reads=20a=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../src/rls-deny-sentinel-identity.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts index 02b85e76cd2..d59eb539f98 100644 --- a/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts +++ b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts @@ -33,7 +33,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { PermissionSet } from '@objectstack/spec/security'; import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; -import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; import { SecurityPlugin } from './security-plugin.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { RLS_DENY_FILTER as PACKAGE_RLS_DENY_FILTER } from './index.js'; @@ -85,12 +84,7 @@ async function boot(deployment: Deployment) { objectql: { registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, getSchema: () => schema, - // An engine double that holds no rows; its `findOne` refuses what the - // real engine refuses (`check:engine-double-contract`). - findOne: vi.fn(async (object: string, query: unknown) => { - assertEngineFindOnePredicate(object, query as EngineFindOneQueryInput); - return null; - }), + // No row is ever read here: none of the deny paths reaches the store. find: vi.fn(async () => [] as Record[]), }, metadata: { get: async () => schema, list: async () => deployment.sets }, From e7221cba7058cb0e9c4a3f541e56f52d1003d0e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:36:31 +0000 Subject: [PATCH 11/12] chore: the changeset's headline and identity bullet say which getReadFilter answers are the sentinel, and that a composed $and and the sharing layer's own deny are not Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .changeset/23009-rls-deny-sentinel-composed-readable-ids.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md index 34ab99fbb6b..16009c305f5 100644 --- a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md +++ b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md @@ -3,11 +3,11 @@ "@objectstack/plugin-security": patch --- -The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every deny `getReadFilter` answers is that one object (ADR-0139 D2) +The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) Clause-②: yes (widening: new exported members of the published spec `data` entry) - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. -- **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself on every deny path: a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set, a resolution failure, the tenant wall's deny, every applicable policy dropped, and a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. The engine's `ReadableIds` composition (ADR-0139 D2 step 2) recognises the deny by `scope === RLS_DENY_FILTER`. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. A deny ANDed with another layer is still returned as the composed `$and`. +- **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. - **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is not a plain filter object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. From 2b176bd2e614b180ddd7767f850f11a52f34463e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:06:18 +0000 Subject: [PATCH 12/12] fix(spec): isComposedReadableIds refuses a value that also carries the uncomposed brand, and the scope wording says non-null, non-array object (contract review A1, A2) A hand-built value carrying both global-registry brands answered true to both recognisers, so the docblock's "no value answers true to both" was false. It now reads as an uncomposed leaf: isComposedReadableIds answers false and isReadableIds (unchanged) answers true. A pin holds the double-branded value and the comparand door's measured verdict on it. "Plain" is replaced by what isComposableScope checks, in readable-ids.ts and in the changeset. Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- ...rls-deny-sentinel-composed-readable-ids.md | 2 +- packages/spec/src/data/readable-ids.test.ts | 15 +++++++++++++ packages/spec/src/data/readable-ids.ts | 22 ++++++++++++------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md index 16009c305f5..5bd26623bbd 100644 --- a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md +++ b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md @@ -9,5 +9,5 @@ Clause-②: yes (widening: new exported members of the published spec `data` ent - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. - **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. -- **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is not a plain filter object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. +- **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. diff --git a/packages/spec/src/data/readable-ids.test.ts b/packages/spec/src/data/readable-ids.test.ts index 5ce6b541314..d985100cd22 100644 --- a/packages/spec/src/data/readable-ids.test.ts +++ b/packages/spec/src/data/readable-ids.test.ts @@ -268,6 +268,21 @@ describe('isComposedReadableIds — disjoint from isReadableIds, and every copy expect(isComposedReadableIds(leaf)).toBe(false); }); + it('a hand-built value carrying BOTH brands is not composed: it reads as an uncomposed leaf, whichever recogniser is asked first', () => { + // Both keys are global-registry symbols, so in-process code can build this. + const both = { object: 'x', scope: undefined }; + Object.defineProperty(both, READABLE_IDS_BRAND, { value: true }); + Object.defineProperty(both, COMPOSED_READABLE_IDS_BRAND, { value: true }); + expect(isComposedReadableIds(both)).toBe(false); + expect(isReadableIds(both)).toBe(true); + // The comparand door, as measured: it admits the value as an UNCOMPOSED leaf + // for the composing caller (so the engine composes it from the caller's + // access) and refuses it for every other caller. + expect(() => assertListComparandShapes({ record_id: { $in: both } }, undefined, 'where', ADMIT)).not.toThrow(); + const err = refusalOf(() => assertListComparandShapes({ record_id: { $in: both } })); + expect(envelopeOf(err)).toEqual(ENVELOPE); + }); + it('a JSON round-trip, a structured clone, a spread and Object.assign all drop the brand', () => { const copies = { json: JSON.parse(JSON.stringify(composed)), diff --git a/packages/spec/src/data/readable-ids.ts b/packages/spec/src/data/readable-ids.ts index 41eca1e4fcb..d7c5fcde3a9 100644 --- a/packages/spec/src/data/readable-ids.ts +++ b/packages/spec/src/data/readable-ids.ts @@ -200,25 +200,31 @@ export interface ComposedReadableIds { /** * Is `value` a branded {@link ComposedReadableIds} leaf? * - * Own symbol key only, and only the declared value `true`; `object` a - * non-empty string; `scope` an own key holding `undefined` or a plain, - * non-array object. Every copy that lost the brand, every hand-built shape that - * breaks one of those, an uncomposed {@link ReadableIds} leaf, `null` and every - * primitive answer `false`. + * Own symbol key only, and only the declared value `true`; no own + * {@link READABLE_IDS_BRAND} beside it; `object` a non-empty string; `scope` + * an own key holding `undefined` or a non-null, non-array object. Every copy + * that lost the brand, every hand-built shape that breaks one of those, an + * uncomposed {@link ReadableIds} leaf, `null` and every primitive answer + * `false`. * - * Disjoint from {@link isReadableIds}: no value answers `true` to both. + * Disjoint from {@link isReadableIds}: no value answers `true` to both. A + * value that carries both brands answers `false` here, so it is never a + * composed leaf: when its uncomposed brand is valid, {@link isReadableIds} + * answers `true` and the value is refused as an uncomposed leaf, whichever of + * the two recognisers is asked first. */ export function isComposedReadableIds(value: unknown): value is ComposedReadableIds { if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; if (!Object.prototype.hasOwnProperty.call(value, COMPOSED_READABLE_IDS_BRAND)) return false; if ((value as Record)[COMPOSED_READABLE_IDS_BRAND] !== true) return false; + if (Object.prototype.hasOwnProperty.call(value, READABLE_IDS_BRAND)) return false; const object = (value as { object?: unknown }).object; if (typeof object !== 'string' || object.length === 0) return false; if (!Object.prototype.hasOwnProperty.call(value, 'scope')) return false; return isComposableScope((value as { scope?: unknown }).scope); } -/** `undefined` (no restriction) or a plain, non-array object — the scope's two declared spellings. */ +/** `undefined` (no restriction) or a non-null, non-array object — the scope's two declared spellings. */ function isComposableScope(scope: unknown): boolean { return scope === undefined || (scope !== null && typeof scope === 'object' && !Array.isArray(scope)); } @@ -261,7 +267,7 @@ export const ReadableIds: { * @param scope the parent's lowered read scope, or `undefined` for "no row * restriction" (`getReadFilter`'s own spelling of it). * @throws TypeError when `leaf` is not a branded {@link ReadableIds} leaf; - * when `scope` is neither `undefined` nor a plain, non-array object; + * when `scope` is neither `undefined` nor a non-null, non-array object; * or when `scope` IS {@link RLS_DENY_FILTER}, which D2 step 2 * answers `$in: []` and never composes. */