From a5ea8c55f83f70e6f81f70a8fc015ad104d284e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:32:47 +0000 Subject: [PATCH 1/3] feat(verify): per-declared-position RLS probe personas (#7978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base probe persona holds no positions by construction, so an app policy carrying `positions: [...]` is never applicable to it — the app's own narrowing went unexercised while only the platform gate underneath it was proven. That is the authoring shape the real #7665 defect wore. `objectstack verify --rls` now fans out: one persona per position the app DECLARES (derived from `config.positions`, never a transcribed list), each holding that position and nothing else. Probe targets are established once and shared, each persona writes a distinct short marker, and a position that yielded no verdict is reported rather than dropped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V --- packages/cli/src/commands/verify.ts | 40 +- packages/qa/dogfood/test/rls-runner.test.ts | 180 ++++++- packages/verify/README.md | 30 ++ packages/verify/src/index.ts | 8 + packages/verify/src/rls.ts | 530 +++++++++++++++++--- 5 files changed, 721 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index c27f1965a4..41d431805e 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -11,10 +11,13 @@ import { runRlsProofs, formatRlsReport, provisionRlsProbePersona, + provisionRlsPositionPersona, + declaredPositionNames, rlsProbeSecurity, type VerifyReport, type RlsReport, type RlsProbeDescriptor, + type RlsPositionPersonaInput, } from '@objectstack/verify'; import { loadConfig } from '../utils/config.js'; @@ -138,7 +141,31 @@ export default class Verify extends Command { degraded: `probe persona provisioning failed: ${(e as Error).message}`, }; } - rls = await runRlsProofs(rlsStack, adminToken, probeToken, config, { probe }); + // [#7978] The base persona holds no positions by construction, so an + // app policy carrying `positions: [...]` is never applicable to it and + // the app's OWN narrowing goes unexercised. Mint one persona per + // DECLARED position — derived from the config, never a list written + // here — so the position-gated half is probed too. Provisioning lives + // on this side because it needs the live stack; the runner re-derives + // the intended reach from the config, so a position missing from this + // loop reports as `positionCoverage.notRun` instead of quietly + // shrinking the run. + const positionPersonas: RlsPositionPersonaInput[] = []; + const positionFailures: Array<{ position: string; error: string }> = []; + for (const position of declaredPositionNames(config)) { + try { + const persona = await provisionRlsPositionPersona(rlsStack, position); + positionPersonas.push({ position, token: persona.token, label: persona.email }); + } catch (e) { + positionFailures.push({ position, error: (e as Error).message }); + } + } + + rls = await runRlsProofs(rlsStack, adminToken, probeToken, config, { + probe, + positionPersonas, + positionFailures, + }); } finally { await rlsStack.stop(); } @@ -147,12 +174,19 @@ export default class Verify extends Command { // Failure contract: a "real" runtime break the app's author must see. // A degraded RLS probe counts: the run reported verdicts it could not have // established, which is worse than no verifier at all. + // + // [#7978] `totals`, not `summary`: a hole a POSITION persona found is + // exactly as real as one the base persona found — reading `summary` here + // would run the fan-out and then throw its findings away. A declared + // position that could not be provisioned counts for the same reason a + // degraded base persona does: the run covered less than its numbers read. const hardFailures = crud.summary.createFailed + crud.summary.readFailed + crud.summary.fidelityGaps + - (rls?.summary.holes ?? 0) + - (rls?.probe.degraded ? 1 : 0); + (rls?.totals.holes ?? 0) + + (rls?.probe.degraded ? 1 : 0) + + (rls?.positionCoverage.notRun.length ?? 0); if (flags.json) { this.log(JSON.stringify({ app: crud.app, config: absolutePath, multiTenant, crud, rls, hardFailures }, null, 2)); diff --git a/packages/qa/dogfood/test/rls-runner.test.ts b/packages/qa/dogfood/test/rls-runner.test.ts index 511f0718de..b31469bb8e 100644 --- a/packages/qa/dogfood/test/rls-runner.test.ts +++ b/packages/qa/dogfood/test/rls-runner.test.ts @@ -20,9 +20,15 @@ // verdicts, i.e. green that no platform change could have turned red. // • target adoption — an admin create the app's own validation rejects no // longer cascades its dependents out of the run. +// +// [#7978] And by the POSITION fan-out, which this file is the liveness oracle +// for in exactly the same sense: the live apps report 0 holes for every position +// persona too, so only a scripted stack can answer "can a position persona still +// SAY `rls-hole`". A fan-out that cannot fail is decoration, and this file is +// what stops it becoming that. import { describe, it, expect } from 'vitest'; -import { runRlsProofs } from '@objectstack/verify'; +import { runRlsProofs, declaredPositionNames } from '@objectstack/verify'; import type { VerifyStack } from '@objectstack/verify'; const CONFIG = { @@ -30,6 +36,19 @@ const CONFIG = { objects: [{ name: 'note', fields: { name: { type: 'text', required: true } } }], }; +/** [#7978] The same app, declaring one position — the fan-out's input. */ +const CONFIG_WITH_POSITION = { + ...CONFIG, + positions: [{ name: 'contributor', label: 'Contributor' }], +}; + +/** How one persona behaves. The member token uses the top-level scenario fields. */ +interface PersonaScript { + canRead: boolean; + writeMutates: boolean; + objectGateDenies?: boolean; +} + interface FakeOpts { memberCanRead: boolean; memberWriteMutates: boolean; // does member's PATCH actually change the row? @@ -39,6 +58,10 @@ interface FakeOpts { adminCreateStatus?: number; /** Rows that already exist on the object, as seed data would. */ seeded?: Array>; + /** [#7978] Per-token scripts — one per position persona. */ + personas?: Record; + /** [#7978] Call log, so cost and marker-distinctness are assertable. */ + calls?: { posts: number; patches: Array<{ token: string; value: unknown }> }; } /** A fake stack: admin always sees/owns; member behaviour is scripted per scenario. */ @@ -47,18 +70,26 @@ function fakeStack(opts: FakeOpts): VerifyStack { for (const row of opts.seeded ?? []) store[String(row.id)] = { ...row }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + const scriptFor = (token: string): PersonaScript => + opts.personas?.[token] ?? { + canRead: opts.memberCanRead, + writeMutates: opts.memberWriteMutates, + objectGateDenies: opts.objectGateDenies, + }; const apiAs: VerifyStack['apiAs'] = async (token, method, path, body) => { const isAdmin = token === 'admin'; + const script = scriptFor(token); // `/data/[?query]` (list) or `/data//` (by id) const [, , objectSegment, id] = path.split('/'); const object = String(objectSegment).split('?')[0]; // The object-level gate answers first, for every verb — exactly what made // the grant-less persona's verdicts meaningless (#7685). - if (!isAdmin && opts.objectGateDenies) return json({ code: 'PERMISSION_DENIED' }, 403); + if (!isAdmin && script.objectGateDenies) return json({ code: 'PERMISSION_DENIED' }, 403); if (method === 'POST') { + if (opts.calls) opts.calls.posts += 1; if (opts.adminCreateStatus) return json({ error: 'VALIDATION_FAILED' }, opts.adminCreateStatus); const newId = 'rec1'; store[newId] = { id: newId, ...(body as object) }; @@ -68,17 +99,18 @@ function fakeStack(opts: FakeOpts): VerifyStack { if (id === undefined) { // LIST — the runner's reachability probe, and the admin-side source the // cascade-stopper adopts a target from. - if (!isAdmin && !opts.memberCanRead) return json({ records: [] }); + if (!isAdmin && !script.canRead) return json({ records: [] }); return json({ records: Object.values(store) }); } - if (!isAdmin && !opts.memberCanRead) return json({ error: 'not found' }, 404); + if (!isAdmin && !script.canRead) return json({ error: 'not found' }, 404); return json({ object, id, record: store[id] ?? null }); } if (method === 'PATCH') { + if (opts.calls && !isAdmin) opts.calls.patches.push({ token, value: (body as any)?.name }); // Admin always writes. Member writes only "land" when the scenario says so // (i.e. RLS failed to scope the by-id write — the #1994 bug). - if (isAdmin || opts.memberWriteMutates) Object.assign(store[id], body as object); - return json({ object, id, record: store[id] }, isAdmin || opts.memberWriteMutates ? 200 : 403); + if (isAdmin || script.writeMutates) Object.assign(store[id], body as object); + return json({ object, id, record: store[id] }, isAdmin || script.writeMutates ? 200 : 403); } return json({}, 405); }; @@ -176,3 +208,139 @@ describe('[#7685] an unsatisfiable admin create does not cascade objects out of expect(report.summary.proven).toBe(0); }); }); + +describe('[#7978] declaredPositionNames — the fan-out reads the app, never a list', () => { + it('derives the position names from the config, in order, deduplicated', () => { + expect( + declaredPositionNames({ + positions: [{ name: 'contributor' }, { name: 'manager' }, { name: 'contributor' }], + }), + ).toEqual(['contributor', 'manager']); + }); + + it('covers a position added to the app WITHOUT any change here — the point of deriving', () => { + const tomorrow = { positions: [{ name: 'contributor' }, { name: 'field_ops_delegate' }] }; + expect(declaredPositionNames(tomorrow)).toContain('field_ops_delegate'); + }); + + it('excludes the built-in audience anchors and tolerates an app with no positions', () => { + // No app declares `everyone`/`guest` (ADR-0090 D5/D9): every authenticated + // member already holds `everyone` — the base persona's own baseline — and + // `guest` is the anonymous audience no signed-up persona can hold. + expect(declaredPositionNames({ positions: [{ name: 'everyone' }, { name: 'guest' }, { name: 'ops' }] })) + .toEqual(['ops']); + expect(declaredPositionNames({})).toEqual([]); + expect(declaredPositionNames(undefined)).toEqual([]); + }); +}); + +describe('[#7978] a POSITION persona can still say `rls-hole` — fan-out detector liveness', () => { + it('flags a HOLE found by a position persona, and rolls it into `totals` where the CLI reads it', async () => { + // The base persona is clean; only the persona holding the app's position + // can see the defect, because only its policies are position-gated. This is + // the whole class #7978 exists to reach — and the assertion that keeps the + // fan-out falsifiable rather than decorative. + const stack = fakeStack({ + memberCanRead: false, + memberWriteMutates: false, + personas: { 'tok-contributor': { canRead: false, writeMutates: true } }, + }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG_WITH_POSITION, { + positionPersonas: [{ position: 'contributor', token: 'tok-contributor', label: 'pos@test' }], + }); + + expect(report.summary.holes).toBe(0); // base persona: consistent + expect(report.positionRuns).toHaveLength(1); + expect(report.positionRuns[0].position).toBe('contributor'); + expect(report.positionRuns[0].results[0].status).toBe('rls-hole'); + expect(report.positionRuns[0].summary.holes).toBe(1); + // `totals` is what `objectstack verify` counts as a hard failure: a hole a + // position persona found is exactly as real as one the base persona found. + expect(report.totals.holes).toBe(1); + expect(report.totals.proven).toBe(2); // base consistent + position hole + expect(report.positionCoverage).toMatchObject({ declared: ['contributor'], ran: ['contributor'], notRun: [] }); + }); + + it('reports probe-blocked — NOT a pass — when the object gate refuses a position persona', async () => { + // A declared position the app binds no object grants to. Honest: the + // by-id-write class was not exercised for it, and it must not read as reach. + const stack = fakeStack({ + memberCanRead: false, + memberWriteMutates: false, + personas: { 'tok-finance': { canRead: false, writeMutates: true, objectGateDenies: true } }, + }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG_WITH_POSITION, { + positionPersonas: [{ position: 'contributor', token: 'tok-finance', label: 'pos@test' }], + }); + expect(report.positionRuns[0].results[0].status).toBe('probe-blocked'); + expect(report.positionRuns[0].summary).toMatchObject({ consistent: 0, proven: 0, probeBlocked: 1, unproven: 1 }); + expect(report.positionRuns[0].unproven.map((u) => u.object)).toEqual(['note']); + expect(report.totals.holes).toBe(0); + expect(report.totals.proven).toBe(1); // the base persona's verdict, and only that + }); + + it('gives each persona a DISTINCT marker and creates the probe target only once', async () => { + // Two properties in one run. Shared targets are what keeps the fan-out + // affordable (one admin create for N personas, not N). Distinct markers are + // what keeps it correct: with one shared marker, persona 2's refused write + // would re-read persona 1's successful mutation as its own — a fabricated + // hole on a platform that is behaving. + const calls = { posts: 0, patches: [] as Array<{ token: string; value: unknown }> }; + const stack = fakeStack({ + memberCanRead: false, + memberWriteMutates: false, + calls, + personas: { + 'tok-a': { canRead: false, writeMutates: true }, + 'tok-b': { canRead: false, writeMutates: false }, + }, + }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG_WITH_POSITION, { + positionPersonas: [ + { position: 'contributor', token: 'tok-a', label: 'a@test' }, + { position: 'manager', token: 'tok-b', label: 'b@test' }, + ], + }); + + expect(calls.posts).toBe(1); + const markers = calls.patches.map((p) => p.value); + expect(new Set(markers).size).toBe(markers.length); + // `tok-b` writes after `tok-a` mutated the row, and is still judged correctly. + expect(report.positionRuns[0].results[0].status).toBe('rls-hole'); + expect(report.positionRuns[1].results[0].status).toBe('rls-consistent'); + }); +}); + +describe('[#7978] a position that produced no verdict never reads as a pass', () => { + it('says so distinctly when the app declares NO positions — and adds nothing to `proven`', async () => { + const stack = fakeStack({ memberCanRead: false, memberWriteMutates: false }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG); + expect(report.positionRuns).toEqual([]); + expect(report.positionCoverage.declared).toEqual([]); + expect(report.positionCoverage.note).toMatch(/no position personas to run/); + // No silent contribution: the totals are exactly the base persona's. + expect(report.totals).toEqual(report.summary); + }); + + it('records a declared position whose persona could not be provisioned, with the reason', async () => { + const stack = fakeStack({ memberCanRead: false, memberWriteMutates: false }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG_WITH_POSITION, { + positionFailures: [{ position: 'contributor', error: 'no sys_user row for pos@test' }], + }); + expect(report.positionRuns).toEqual([]); + expect(report.positionCoverage.notRun).toEqual([ + { position: 'contributor', reason: 'no sys_user row for pos@test' }, + ]); + // No `note` here: the app DOES declare positions, so "none ran" is a gap, + // not the app-declares-nothing case. + expect(report.positionCoverage.note).toBeUndefined(); + }); + + it('reports a declared position the caller simply never provisioned a persona for', async () => { + const stack = fakeStack({ memberCanRead: false, memberWriteMutates: false }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG_WITH_POSITION); + expect(report.positionCoverage.notRun).toHaveLength(1); + expect(report.positionCoverage.notRun[0]).toMatchObject({ position: 'contributor' }); + expect(report.positionCoverage.notRun[0].reason).toMatch(/NOT exercised/); + }); +}); diff --git a/packages/verify/README.md b/packages/verify/README.md index dd3669bfbb..fd85b812a7 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -86,11 +86,35 @@ await stack.stop(); | `rls-consistent` | member can't read **and** can't write — good | | `rls-hole` | member can't read **yet** wrote it by id — RLS bypass **(failure)** | | `member-visible` | member *can* read it — not a cross-owner scenario (inconclusive) | +| `probe-blocked` | the **object** gate refused the persona, so record scope was never consulted — never a pass (inconclusive) | `member-visible` everywhere usually means the app is single-tenant; pass `--multi-tenant` (or `{ multiTenant: true }`) to register org-scoping so tenant isolation policies actually apply. +### Personas: who the invariant is run as + +The invariant is run once per **persona**, and a report separates them because +they prove different things: + +- The **base probe persona** authors its own capability (object read+edit on every + declared object, plus an owner-scoped `select` narrowing). It proves the + *platform's* by-id write gate — a refusal is attributable to the record gate + rather than to the object gate. +- One **position persona per position the app declares** (`config.positions`, + read from the app — never a list kept here). Each holds that position and + nothing else, so its whole capability is what the app itself binds to the + position. This is what exercises narrowing authored with `positions: [...]`, + which is invisible to the base persona: a policy gated on a position the caller + does not hold is never applicable to it. + +`RlsReport.summary` is the base persona; `positionRuns[]` carries one entry per +position; `totals` sums them all (unit: one *object × persona* probe) and is what +a CI gate should read. `positionCoverage` reports the reach honestly: `declared` +vs `ran`, plus `notRun` for any declared position whose persona could not be +provisioned, and a `note` when the app declares no positions at all — "nothing to +run" must never read like "nothing to find". + ## API - `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`). @@ -110,6 +134,12 @@ for owner-scoped fixtures), `multiTenant`. fails the run, even though the app behaves as designed. The report shows the exact `wrote → read` diff so it's diagnosable; letting an app declare such fields so the verifier can allow them is a planned enhancement. +- **A position persona holds the bare position, unanchored.** Narrowing that + gates on a position held *together with* something else — a business-unit + anchor, an organization membership, a sharing-rule grant — is still out of + reach, and a position bound to a view-all set reads every row by design and so + reports `member-visible`. Coverage is therefore reported per position; the + fan-out is never N× the reach. - **The auto-derived sweep is coarser than a hand-written matrix.** It exercises one synthesized record per object and skips fields it can't synthesize (required lookups / master-detail, media, computed). It's a broad runtime diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index f742f4ad77..de55ba4e21 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -20,16 +20,24 @@ export { runRlsProofs, formatRlsReport, provisionRlsProbePersona, + provisionRlsPositionPersona, + declaredPositionNames, rlsProbePermissionSet, rlsProbeSecurity, + rlsPositionProbeEmail, RLS_PROBE_EMAIL, } from './rls.js'; export type { RlsReport, RlsResult, RlsStatus, + RlsSummary, RlsProbeDescriptor, RlsProbePersona, + RlsPositionPersona, + RlsPositionPersonaInput, + RlsPositionRun, + RlsPositionCoverage, RlsProofOptions, } from './rls.js'; diff --git a/packages/verify/src/rls.ts b/packages/verify/src/rls.ts index 17073124cb..8e509ff695 100644 --- a/packages/verify/src/rls.ts +++ b/packages/verify/src/rls.ts @@ -40,16 +40,47 @@ // from NOT-PROVEN ones (`member-visible` / `probe-blocked` / `skipped`) in both // the structured summary and the formatted output. // -// ⛔ What this runner still cannot reach, stated so its green is not over-read: -// an object whose narrowing is authored on a POSITION the probe does not hold -// (the showcase's `positions: ['contributor']` rules) is unnarrowed for this -// persona, so the probe reads every row and the object reports `member-visible` -// — honest, but not a proof. Reaching those needs a per-declared-position -// persona; tracked as a follow-on. +// ## [#7978] Position-authored narrowing — the class the base persona cannot reach +// +// The persona above holds NO positions by construction, so an app policy carrying +// `positions: [...]` is never APPLICABLE to it (`getApplicablePolicies` filters by +// the caller's positions): the object reads `member-visible`, and the app's own +// narrowing goes unexercised while only the platform gate underneath it is proven. +// That is precisely the authoring shape the real #7665 defect wore — the ordinary +// `showcase_contributor` persona, a `contributor` POSITION plus the matching set, +// against `positions: ['contributor']` rules. +// +// So a run now FANS OUT: one persona per position the app DECLARES +// (`declaredPositionNames`, read from `config.positions` — never a transcribed +// list, so a position added next month is covered without touching this file), +// each holding that position and nothing else, i.e. exactly the capability the app +// itself binds to it. The same invariant then runs unchanged for each. +// +// Three properties of the fan-out are deliberate: +// • Probe TARGETS are established once and shared by every persona, so a +// position costs 4 HTTP calls per object rather than re-deriving and +// re-creating a record per persona. +// • Each persona mutates with a DISTINCT marker, so "did the row change" stays +// attributable to the persona that wrote it — and a SHORT one, because a probe +// field's `maxLength` would truncate a long marker into a false negative. +// • A position that yields no verdict is REPORTED, never dropped: an app +// declaring no positions says so (`positionCoverage.note`), and a declared +// position whose persona could not be provisioned lands in +// `positionCoverage.notRun`. Neither contributes to `proven`. +// +// ⛔ What is STILL out of reach, stated so this green is not over-read either: +// narrowing that gates on a position held TOGETHER WITH some other condition — a +// business-unit anchor, an organization membership, a sharing-rule grant — since +// these personas hold the bare position, unanchored. And a position bound to a +// VAMA-carrying set (`showcase_auditor`, `showcase_ops`) reads every row by +// design, so it reports `member-visible`: honest, and not a proof. Per-position +// coverage is reported per position for exactly that reason — the fan-out must +// never be read as N× the reach. /* eslint-disable @typescript-eslint/no-explicit-any */ import { SecurityPlugin, securityDefaultPermissionSets, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity'; import type { PermissionSet } from '@objectstack/spec/security'; import type { VerifyStack } from './harness.js'; @@ -58,10 +89,58 @@ import { deriveCrudCases, fillRelationalRefs } from './derive.js'; const PROBE_TYPES = new Set(['text', 'textarea', 'string']); const MUTATION = 'rls-mutated-by-B'; +/** + * The marker a POSITION persona writes. Distinct per persona so a changed row is + * attributable to the persona that changed it — and deliberately SHORT: a probe + * field declaring a small `maxLength` truncates a long marker, the ground-truth + * re-read then fails to match, and a real hole reads as "row unchanged". A false + * negative in this runner is the one outcome worse than no runner at all. + */ +function positionMutation(index: number): string { + return `rls-mut-p${index + 1}`; +} + /** Default identity of the object-granted probe persona (`provisionRlsProbePersona`). */ export const RLS_PROBE_EMAIL = 'verify-rls-probe@objectstack.test'; const RLS_PROBE_PASSWORD = 'Rls-Probe-Pass-123'; const RLS_PROBE_PERMISSION_SET = 'verify_rls_probe'; +const RLS_POSITION_PROBE_PASSWORD = 'Rls-Position-Probe-123'; + +/** Identity of the persona minted for one declared position. */ +export function rlsPositionProbeEmail(position: string): string { + return `verify-rls-pos-${String(position).toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}@objectstack.test`; +} + +/** + * Machine names of the positions the app DECLARES (`config.positions`), in + * declaration order, deduplicated. + * + * ⛔ DERIVED, never transcribed. A hand-written roster is how a verifier quietly + * stops covering the position someone adds next month — it keeps passing, over a + * surface it no longer looks at. Reading the app's own declaration is also what + * makes the fan-out app-agnostic: it is the same premise the rest of this runner + * stands on (derive from metadata, interpret nothing). + * + * The two built-in AUDIENCE ANCHORS (`everyone`, `guest`) are excluded: no app + * declares them (ADR-0090 D5/D9), every authenticated member already holds + * `everyone` — so the base persona above covers it — and `guest` is the anonymous + * audience, which no signed-up persona can hold at all. Filtering them here means + * an app that mistakenly declares one still gets a truthful run instead of a + * persona whose position assignment can never take effect. + */ +export function declaredPositionNames(config: any): string[] { + const anchors = AUDIENCE_ANCHOR_POSITIONS as readonly string[]; + const seen = new Set(); + const names: string[] = []; + for (const declared of (config?.positions ?? []) as any[]) { + const name = typeof declared === 'string' ? declared : declared?.name; + if (typeof name !== 'string' || name.length === 0) continue; + if (anchors.includes(name) || seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} export type RlsStatus = /** PROVEN: the probe could not read the row and could not mutate it. */ @@ -101,6 +180,54 @@ export interface RlsProbeDescriptor { degraded?: string; } +export interface RlsSummary { + objects: number; + consistent: number; + holes: number; + memberVisible: number; + probeBlocked: number; + skipped: number; + /** Objects on which the by-id-write class was actually exercised. */ + proven: number; + /** Objects the run did not exercise (`memberVisible + probeBlocked + skipped`). */ + unproven: number; +} + +/** One persona's full pass over the app's objects. */ +export interface RlsPositionRun { + /** The declared position this persona holds — and holds alone. */ + position: string; + probe: RlsProbeDescriptor; + results: RlsResult[]; + unproven: Array<{ object: string; status: RlsStatus; detail?: string }>; + summary: RlsSummary; +} + +/** + * What the position fan-out actually covered — reported so a skip can never read + * as a pass (#7685's rule, applied to the fan-out itself, #7978). + */ +export interface RlsPositionCoverage { + /** Positions the app DECLARES (`declaredPositionNames`) — the intended reach. */ + declared: string[]; + /** Positions a persona actually probed with. */ + ran: string[]; + /** + * Declared positions with no verdict, and why. A provisioning failure lands + * here rather than vanishing: the run proved strictly less than its numbers + * suggest, and callers surface it (`objectstack verify` counts it as a hard + * failure, exactly as it counts a degraded base persona). + */ + notRun: Array<{ position: string; reason: string }>; + /** + * Set ONLY when the app declares no positions at all. "No position personas to + * run" is a distinct statement from "the position personas found nothing" — + * without it, an app with zero positions would silently read like an app whose + * position-authored narrowing had been proven. + */ + note?: string; +} + export interface RlsReport { app: string; /** Which persona drove the by-id probes. */ @@ -112,18 +239,33 @@ export interface RlsReport { * filter a consumer has to remember to apply. */ unproven: Array<{ object: string; status: RlsStatus; detail?: string }>; - summary: { - objects: number; - consistent: number; - holes: number; - memberVisible: number; - probeBlocked: number; - skipped: number; - /** Objects on which the by-id-write class was actually exercised. */ - proven: number; - /** Objects the run did not exercise (`memberVisible + probeBlocked + skipped`). */ - unproven: number; - }; + /** + * The BASE (verifier-authored) persona's pass. Position personas each carry + * their own summary in `positionRuns`; `totals` is the whole run. + */ + summary: RlsSummary; + /** [#7978] One entry per declared position a persona was provisioned for. */ + positionRuns: RlsPositionRun[]; + /** [#7978] Intended vs achieved position reach. */ + positionCoverage: RlsPositionCoverage; + /** + * [#7978] Every persona's verdicts summed — base + each position run. The unit + * is one (object × persona) PROBE, not one object, so `totals.objects` exceeds + * the app's object count whenever the fan-out ran. This is what a caller reads + * to decide whether the run found holes: a hole a position persona found is + * exactly as real as one the base persona found. + */ + totals: RlsSummary; +} + +/** A provisioned persona holding exactly one declared position. */ +export interface RlsPositionPersonaInput { + /** The declared position machine name this persona holds. */ + position: string; + /** Bearer token for the persona. */ + token: string; + /** Human label (an email) for the report. */ + label: string; } export interface RlsProofOptions { @@ -134,6 +276,20 @@ export interface RlsProofOptions { * precisely the state this runner must not report as a pass. */ probe?: RlsProbeDescriptor; + /** + * [#7978] Personas holding one declared position each + * (`provisionRlsPositionPersona`). Provisioning is the CALLER's job — it needs + * a live stack and writes RBAC rows — while the reach they were SUPPOSED to + * cover is derived here from the config, so a caller that provisions fewer + * personas than the app declares shows up as `positionCoverage.notRun` rather + * than as a quietly narrower run. + */ + positionPersonas?: RlsPositionPersonaInput[]; + /** + * Positions whose persona could not be provisioned, with the reason. Reported, + * never swallowed — see {@link RlsPositionCoverage.notRun}. + */ + positionFailures?: Array<{ position: string; error: string }>; } /** The identity + token of an object-granted probe persona. */ @@ -341,6 +497,92 @@ export async function provisionRlsProbePersona( }; } +/** The identity + token of a persona holding exactly one declared position. */ +export interface RlsPositionPersona { + position: string; + token: string; + email: string; + userId: string; +} + +/** + * [#7978] Sign up a persona and give it ONE declared position — nothing else. + * + * Deliberately the opposite construction from {@link provisionRlsProbePersona}: + * that one authors its own capability so the PLATFORM's by-id-write gate is + * reachable; this one authors NOTHING. Its whole capability is whatever the app + * binds to the position (`sys_position_permission_set` → the app's own permission + * sets, narrowing and all), which is what makes it a probe of the APP's authored + * policy rather than of a policy the verifier invented. It also reproduces + * #7665's persona exactly: a bare `contributor` on the showcase is the shape that + * defect wore. + * + * The assignment is one `sys_user_position` row — the platform's source of truth + * for "who holds which position" (ADR-0057 D4 / ADR-0090 D3), keyed by the + * position's MACHINE NAME, which is how `ctx.positions` is keyed downstream. + * Written under a system context through ObjectQL for the same reason the base + * persona's grants are: provisioning is test SETUP, and routing it through the + * data door would make the proof depend on whether this deployment lets an admin + * POST RBAC rows — a second thing that can fail for reasons unrelated to RLS. + * + * ⚠️ The row is deliberately UNANCHORED (`business_unit_id: null`): a BU anchor + * would add depth-scoped visibility on top of the position and the verdict would + * no longer be attributable to the position-gated policy alone. + * + * Throws when the stack has no ObjectQL engine or the user cannot be resolved. + * Callers must NOT swallow that: report the position as not-run + * (`RlsPositionCoverage.notRun`) so the missing reach is visible. + */ +export async function provisionRlsPositionPersona( + stack: VerifyStack, + position: string, + opts: { email?: string; password?: string } = {}, +): Promise { + const email = opts.email ?? rlsPositionProbeEmail(position); + const password = opts.password ?? RLS_POSITION_PROBE_PASSWORD; + + await stack.signUp(email, password); + + const ql = await stack.kernel.getServiceAsync('objectql'); + if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { + throw new Error( + `verify: cannot provision the RLS position persona for '${position}' — no ObjectQL engine on ` + + 'this stack. Without the position assignment the app\'s position-gated policies are not ' + + 'applicable to the persona, so the app-authored narrowing is unreachable (#7978).', + ); + } + const sysCtx = { context: { isSystem: true } }; + + const users = rowsOf(await ql.find('sys_user', { where: { email }, limit: 1 }, sysCtx)); + const userId = users[0]?.id; + if (!userId) { + throw new Error( + `verify: cannot provision the RLS position persona for '${position}' — no sys_user row for ${email}.`, + ); + } + + await ql.insert( + 'sys_user_position', + { + id: genId('up'), + user_id: userId, + position, + business_unit_id: null, + organization_id: null, + granted_by: null, + reason: `Ephemeral persona minted by \`objectstack verify --rls\`: holds '${position}' and nothing else, so the app's own position-gated RLS narrowing is exercised (#7978).`, + }, + sysCtx, + ); + + // Re-sign-in after the assignment exists, for the same reason the base persona + // does: a session minted before the row is exactly the shape that makes a + // provisioning bug look like an enforcement result. + const token = await stack.signIn(email, password); + + return { position, token, email, userId: String(userId) }; +} + /** The id of an existing row of `object`, read as admin, or null. */ async function firstExistingId(stack: VerifyStack, adminToken: string, object: string): Promise { const res = await stack.apiAs(adminToken, 'GET', `/data/${object}?$top=1`); @@ -355,27 +597,52 @@ async function firstExistingId(stack: VerifyStack, adminToken: string, object: s return id ? String(id) : null; } -export async function runRlsProofs( +/** + * A probe target: the row every persona runs the invariant against, plus the + * plain-text field they mutate. + * + * [#7978] Establishing these ONCE is what keeps the position fan-out affordable: + * the admin creates (or adopts) one row per object for the whole run, and each + * persona then costs 4 HTTP calls per object instead of re-deriving and + * re-creating a record of its own. It is also more honest — every persona is + * judged against the same ground truth rather than a row of its own that some + * app-level validation may have shaped differently. + */ +interface ProbeTarget { + object: string; + id: string; + /** Plain-text field the personas mutate. */ + field: string; + origin: NonNullable; + /** Detail suffix recording an adopted target. */ + via: string; +} + +/** Either an object that never got a target (recorded once, replayed per persona) or one that did. */ +type ProbeCase = { object: string; skipped: RlsResult } | { object: string; target: ProbeTarget }; + +async function establishProbeTargets( stack: VerifyStack, adminToken: string, - memberToken: string, config: any, - opts: RlsProofOptions = {}, -): Promise { +): Promise { const cases = deriveCrudCases(config); - const results: RlsResult[] = []; + const out: ProbeCase[] = []; // Admin-created (or adopted) ids, threaded so a detail's required relation // points at a real master (topological order created it first) — lets the // #1994 invariant reach relationship-dense objects, not just the leaves. const createdIds = new Map(); for (const c of cases) { - if (c.blocked) { results.push({ object: c.object, status: 'skipped', detail: c.blocked }); continue; } + const skip = (detail: string): void => { + out.push({ object: c.object, skipped: { object: c.object, status: 'skipped', detail } }); + }; + if (c.blocked) { skip(c.blocked); continue; } // A plain-text field to mutate (avoid email/url/phone — their format checks // would reject the probe for a benign reason, masking the RLS signal). const probe = (c.asserts ?? []).find((a) => PROBE_TYPES.has(a.type)); - if (!probe) { results.push({ object: c.object, status: 'skipped', detail: 'no plain-text probe field' }); continue; } + if (!probe) { skip('no plain-text probe field'); continue; } let resolved = fillRelationalRefs(c, createdIds); if (resolved.missing) { @@ -392,12 +659,12 @@ export async function runRlsProofs( } if (adopted) resolved = fillRelationalRefs(c, createdIds); } - if (resolved.missing) { results.push({ object: c.object, status: 'skipped', detail: resolved.missing }); continue; } + if (resolved.missing) { skip(resolved.missing); continue; } // Admin (owner) creates the record — or, when the app's own validation // rejects the derived body, adopt an existing row so the object is still // probed instead of silently dropping out of the run. - let target: RlsResult['target'] = 'created'; + let target: NonNullable = 'created'; let id: string | null = null; let createDetail = ''; const created = await stack.apiAs(adminToken, 'POST', `/data/${c.object}`, resolved.body); @@ -414,11 +681,37 @@ export async function runRlsProofs( if (existing) { id = existing; target = 'adopted'; } } if (!id) { - results.push({ object: c.object, status: 'skipped', detail: `${createDetail}; no existing ${c.object} row to adopt` }); + skip(`${createDetail}; no existing ${c.object} row to adopt`); continue; } createdIds.set(c.object, id); const via = target === 'adopted' ? ` [target adopted from existing rows — ${createDetail}]` : ''; + out.push({ object: c.object, target: { object: c.object, id, field: probe.field, origin: target, via } }); + } + + return out; +} + +/** + * Run the #1994 invariant over every established target as ONE persona. + * + * `mutation` is that persona's marker — see {@link positionMutation}. Two + * personas probing the same row must write different markers, or the second + * would read the first's successful write as its own (a fabricated hole) and its + * own refused write as a change (a fabricated pass). + */ +async function probeAsPersona( + stack: VerifyStack, + adminToken: string, + personaToken: string, + probeCases: ProbeCase[], + mutation: string, +): Promise { + const results: RlsResult[] = []; + + for (const probeCase of probeCases) { + if ('skipped' in probeCase) { results.push({ ...probeCase.skipped }); continue; } + const { object, id, field, origin, via } = probeCase.target; // ── Reachability, MEASURED (#7685) ──────────────────────────────────────── // Does the object-level gate let this persona through at all? A 403 on the @@ -426,14 +719,14 @@ export async function runRlsProofs( // consulted, so nothing below could ever observe the by-id-write class on // this object — and recording that as `rls-consistent` (which is what this // runner did for 11 of 13 "consistent" showcase objects) is a false pass. - const list = await stack.apiAs(memberToken, 'GET', `/data/${c.object}?$top=1`); + const list = await stack.apiAs(personaToken, 'GET', `/data/${object}?$top=1`); if (list.status === 403) { results.push({ - object: c.object, + object, status: 'probe-blocked', - target, + target: origin, detail: - `the probe persona holds no object-level READ grant on ${c.object} (LIST 403), so the ` + + `the probe persona holds no object-level READ grant on ${object} (LIST 403), so the ` + 'object gate answers before record scope — the by-id-write class was NOT exercised here. ' + `Not a pass.${via}`, }); @@ -441,7 +734,7 @@ export async function runRlsProofs( } // Probe: can they SEE it? - const bRead = await stack.apiAs(memberToken, 'GET', `/data/${c.object}/${id}`); + const bRead = await stack.apiAs(personaToken, 'GET', `/data/${object}/${id}`); let canRead = false; if (bRead.status === 200) { const rec = ((await bRead.json()) as any)?.record; @@ -449,46 +742,50 @@ export async function runRlsProofs( } // Probe: try to MUTATE it by id. - const bWrite = await stack.apiAs(memberToken, 'PATCH', `/data/${c.object}/${id}`, { [probe.field]: MUTATION }); + const bWrite = await stack.apiAs(personaToken, 'PATCH', `/data/${object}/${id}`, { [field]: mutation }); // Ground truth: re-read as admin — did the row actually change? - const after = await stack.apiAs(adminToken, 'GET', `/data/${c.object}/${id}`); - const afterVal = (((await after.json()) as any)?.record ?? {})[probe.field]; - const changed = afterVal === MUTATION; + const after = await stack.apiAs(adminToken, 'GET', `/data/${object}/${id}`); + const afterVal = (((await after.json()) as any)?.record ?? {})[field]; + const changed = afterVal === mutation; if (canRead) { results.push({ - object: c.object, + object, status: 'member-visible', - target, + target: origin, detail: 'the probe can read this object — not a cross-owner scenario, so the by-id-write class is ' + `not exercised here (no record-scope narrowing reaches this persona, or read is granted)${via}`, }); } else if (changed) { results.push({ - object: c.object, + object, status: 'rls-hole', - target, + target: origin, detail: `the probe cannot read it (GET ${bRead.status}) yet MUTATED it by id (PATCH ${bWrite.status}) — by-id write bypassed RLS (#1994 class)${via}`, }); } else { results.push({ - object: c.object, + object, status: 'rls-consistent', - target, + target: origin, detail: `the probe cannot read (GET ${bRead.status}) and could not mutate (PATCH ${bWrite.status}, row unchanged)${via}`, }); } } - const count = (s: RlsStatus) => results.filter((r) => r.status === s).length; + return results; +} + +function summarize(results: RlsResult[]): RlsSummary { + const count = (s: RlsStatus): number => results.filter((r) => r.status === s).length; const consistent = count('rls-consistent'); const holes = count('rls-hole'); const memberVisible = count('member-visible'); const probeBlocked = count('probe-blocked'); const skipped = count('skipped'); - const summary = { + return { objects: results.length, consistent, holes, @@ -498,29 +795,117 @@ export async function runRlsProofs( proven: consistent + holes, unproven: memberVisible + probeBlocked + skipped, }; - const unproven = results +} + +function notProven(results: RlsResult[]): Array<{ object: string; status: RlsStatus; detail?: string }> { + return results .filter((r) => r.status === 'member-visible' || r.status === 'probe-blocked' || r.status === 'skipped') .map((r) => ({ object: r.object, status: r.status, detail: r.detail })); +} + +function sumSummaries(all: RlsSummary[]): RlsSummary { + return all.reduce( + (acc, s) => ({ + objects: acc.objects + s.objects, + consistent: acc.consistent + s.consistent, + holes: acc.holes + s.holes, + memberVisible: acc.memberVisible + s.memberVisible, + probeBlocked: acc.probeBlocked + s.probeBlocked, + skipped: acc.skipped + s.skipped, + proven: acc.proven + s.proven, + unproven: acc.unproven + s.unproven, + }), + { objects: 0, consistent: 0, holes: 0, memberVisible: 0, probeBlocked: 0, skipped: 0, proven: 0, unproven: 0 }, + ); +} + +export async function runRlsProofs( + stack: VerifyStack, + adminToken: string, + memberToken: string, + config: any, + opts: RlsProofOptions = {}, +): Promise { + const probeCases = await establishProbeTargets(stack, adminToken, config); + + // The base (verifier-authored) persona — the platform half, unchanged. + const results = await probeAsPersona(stack, adminToken, memberToken, probeCases, MUTATION); + const summary = summarize(results); + + // [#7978] One pass per declared position the caller could provision. Each is a + // separate run with its own summary: a position bound to a VAMA-carrying set + // reads everything and proves nothing, and rolling that into one number would + // claim reach the fan-out does not have. + const positionRuns: RlsPositionRun[] = []; + const personas = opts.positionPersonas ?? []; + for (let i = 0; i < personas.length; i += 1) { + const persona = personas[i]; + const personaResults = await probeAsPersona( + stack, adminToken, persona.token, probeCases, positionMutation(i), + ); + positionRuns.push({ + position: persona.position, + probe: { label: persona.label }, + results: personaResults, + unproven: notProven(personaResults), + summary: summarize(personaResults), + }); + } + + // Intended reach is DERIVED from the app, so a caller that provisioned fewer + // personas than the app declares reports a gap instead of a narrower run. + const declared = declaredPositionNames(config); + const ran = positionRuns.map((r) => r.position); + const failures = opts.positionFailures ?? []; + const notRun = [...new Set([...declared, ...failures.map((f) => f.position)])] + .filter((position) => !ran.includes(position)) + .map((position) => ({ + position, + reason: + failures.find((f) => f.position === position)?.error ?? + 'no persona was provisioned for this declared position — the app-authored narrowing it gates was NOT exercised', + })); + + const positionCoverage: RlsPositionCoverage = { + declared, + ran, + notRun, + ...(declared.length === 0 + ? { note: 'this app declares no positions — there are no position personas to run, so nothing here was proven about position-gated narrowing' } + : {}), + }; return { app: config?.manifest?.id ?? 'app', probe: opts.probe ?? { label: 'unspecified persona' }, results, - unproven, + unproven: notProven(results), summary, + positionRuns, + positionCoverage, + totals: sumSummaries([summary, ...positionRuns.map((r) => r.summary)]), }; } +function statusMark(status: RlsStatus): string { + return status === 'rls-hole' ? '✗✗' + : status === 'rls-consistent' ? '✓' + : status === 'member-visible' ? '·' + : status === 'probe-blocked' ? '!' + : '–'; +} + +function summaryLine(s: RlsSummary): string { + return ( + `${s.proven} PROVEN (${s.consistent} consistent, ${s.holes} HOLES) · ` + + `${s.unproven} NOT PROVEN (${s.memberVisible} member-visible, ${s.probeBlocked} probe-blocked, ${s.skipped} skipped)` + ); +} + export function formatRlsReport(report: RlsReport): string { const lines: string[] = [`\n=== objectstack verify (RLS / #1994) — ${report.app} ===`]; for (const r of report.results) { - const mark = - r.status === 'rls-hole' ? '✗✗' - : r.status === 'rls-consistent' ? '✓' - : r.status === 'member-visible' ? '·' - : r.status === 'probe-blocked' ? '!' - : '–'; - lines.push(` ${mark} ${r.object} [${r.status}] ${r.detail ?? ''}`); + lines.push(` ${statusMark(r.status)} ${r.object} [${r.status}] ${r.detail ?? ''}`); } const s = report.summary; const p = report.probe; @@ -532,10 +917,7 @@ export function formatRlsReport(report: RlsReport): string { lines.push(' Every verdict below proves LESS than it reads: without object-level grants the'); lines.push(' object gate answers before record scope, so the by-id-write class is unreachable.'); } - lines.push( - ` ── ${s.proven} PROVEN (${s.consistent} consistent, ${s.holes} HOLES) · ` + - `${s.unproven} NOT PROVEN (${s.memberVisible} member-visible, ${s.probeBlocked} probe-blocked, ${s.skipped} skipped)`, - ); + lines.push(` ── ${summaryLine(s)}`); // A skip must never read as a pass (#7685). The old summary line reported // "0 HOLES" over a run that had actually looked at 15 of 23 objects. if (s.unproven > 0) { @@ -549,5 +931,37 @@ export function formatRlsReport(report: RlsReport): string { ' established nothing. Check that the probe persona was provisioned.', ); } + + // ── [#7978] Position personas ────────────────────────────────────────────── + // Per position, never rolled into one number: a position bound to a + // VAMA-carrying set reads every row and proves nothing, so a combined line + // would claim N× the reach the fan-out actually has. + const cov = report.positionCoverage; + lines.push( + `\n ── position personas (#7978) — ${cov.ran.length} of ${cov.declared.length} declared position(s) probed`, + ); + if (cov.note) lines.push(` · ${cov.note}`); + for (const run of report.positionRuns) { + const rs = run.summary; + lines.push(` ▸ ${run.position} (${run.probe.label}) ${summaryLine(rs)}`); + const proven = run.results.filter((r) => r.status === 'rls-consistent').map((r) => r.object); + if (proven.length > 0) lines.push(` ✓ proven: ${proven.join(', ')}`); + for (const r of run.results.filter((x) => x.status === 'rls-hole')) { + lines.push(` ✗✗ ${r.object} [rls-hole] ${r.detail ?? ''}`); + } + if (rs.proven === 0) { + lines.push( + ` ⚠ this persona proved NOTHING — every object was member-visible, probe-blocked or skipped.`, + ); + } + } + for (const missing of cov.notRun) { + lines.push(` ⛔ position '${missing.position}' was NOT probed — ${missing.reason}`); + } + + const t = report.totals; + if (report.positionRuns.length > 0 || cov.notRun.length > 0) { + lines.push(` ══ all personas: ${summaryLine(t)} [unit: one object × persona probe]`); + } return lines.join('\n'); } From 7ec767908ffec434842da02cb0a2e40124c80a9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:15:06 +0000 Subject: [PATCH 2/3] docs(changeset): verify --rls position personas (#7978) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V --- .changeset/verify-rls-position-personas.md | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .changeset/verify-rls-position-personas.md diff --git a/.changeset/verify-rls-position-personas.md b/.changeset/verify-rls-position-personas.md new file mode 100644 index 0000000000..15afef6b3f --- /dev/null +++ b/.changeset/verify-rls-position-personas.md @@ -0,0 +1,71 @@ +--- +"@objectstack/verify": minor +"@objectstack/cli": minor +--- + +feat(verify): `--rls` runs one probe persona per DECLARED POSITION, so app-authored narrowing is exercised (#7978) + +`objectstack verify --rls` proves one invariant — **you cannot mutate what you +cannot see** (#1994). Since #7685 its probe persona authors its own capability +(object read+edit, owner-scoped `select` only), which makes the *platform's* +by-id write gate reachable. That persona holds **no positions** by construction, +and a policy carrying `positions: [...]` is never applicable to a caller who does +not hold one — so an app's own position-gated narrowing was never exercised, only +the platform gate underneath it. That is exactly the authoring shape the real +#7665 defect wore: an ordinary `contributor` against +`positions: ['contributor']` rules. + +## What changed + +- **One persona per position the app DECLARES.** The set is derived from + `config.positions` (`declaredPositionNames`), never a list kept in the + verifier — a position added to an app is covered without touching this + package. Each persona holds that position and nothing else, so its whole + capability is what the app itself binds to it (`provisionRlsPositionPersona` + writes one `sys_user_position` row; the built-in `everyone` / `guest` anchors + are excluded, since no app declares them). +- **Probe targets are established once and shared** by every persona, so a + position costs 4 HTTP calls per object rather than re-deriving and re-creating + a record per persona. Each persona mutates with a **distinct short marker**, so + "did the row change" stays attributable — and short, because a probe field's + `maxLength` would truncate a long marker into a false negative. +- **Coverage is reported per position, never rolled into one number.** + `RlsReport` gains `positionRuns[]` (one summary per position), `totals` (every + persona's verdicts summed; the unit is one *object × persona* probe) and + `positionCoverage` (`declared` vs `ran`, plus `notRun` for a declared position + whose persona could not be provisioned, and a `note` when the app declares no + positions at all — "nothing to run" must not read like "nothing to find"). + `summary` / `results` / `unproven` still describe the base persona exactly as + before. +- **`verify` counts a position persona's holes.** The exit contract reads + `totals.holes`, and an unprovisionable declared position is a hard failure for + the same reason a degraded base persona is: the run covered less than its + numbers read. + +## Measured, before → after + +| | showcase | crm | +|:--|:--|:--| +| before | 23 probes: 20 proven (20 consistent, **0 holes**), 3 unproven (0 probe-blocked, 3 skipped) — exit 0 | 6 probes: 6 proven, 0 holes, 0 unproven — exit 0 | +| after | 230 probes (base + 9 positions): 35 proven (33 consistent, **2 HOLES**), 195 unproven (54 member-visible, 111 probe-blocked, 30 skipped) — exit 1 | 24 probes (base + 3 positions): 6 proven, 0 holes, 18 unproven (18 probe-blocked) — exit 0 | + +Cost: showcase 22s → 50s, crm 10s → 12s (`dogfood-verify` budget is 20 min). + +**The two showcase holes are real and are NOT fixed here** — filed as #8059. A +`contributor` reads `GET 404` on a `showcase_invoice` and still PATCHes it by id; +the app's check-only `update` policy suppresses #7665's write-scope derivation, +and the post-image `check` that should have caught it is dropped for +position-scoped callers. Tuning the probe to keep the run green is the one thing +this verifier must never do, so `verify --rls` on the showcase now exits 1 until +#8059 lands. + +The new personas are falsifiable, not decorative: ablating the #7665 write-scope +derivation flips the `contributor` persona's `showcase_task` from +`rls-consistent` to `rls-hole` (and the base persona's 16, unchanged from #7685's +measurement). + +## No behaviour change outside the verifier + +Tooling only: no runtime, spec or enforcement path is touched. `runRlsProofs`' +existing call shape still works, and consumers that only read the report gain +fields rather than losing any. From 655587de327e9f8504f0334ba5161eaf8f18500a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:22:43 +0000 Subject: [PATCH 3/3] chore(gate): classify provisionRlsPositionPersona as NOT_A_STAND_IN (#7978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:verify-stand-in` discovers every exported `packages/verify` function whose first parameter is annotated with an interface the package declares and publishes, and fails until each is classified. `provisionRlsPositionPersona(stack: VerifyStack)` is the same class as its sibling `provisionRlsProbePersona`: it takes the handle `bootStack` returned and MINTS a persona through it — sign-up via that stack's auth route, one `sys_user_position` row through `stack.kernel`'s ObjectQL service. It checks nothing, so the parameter type is not the compile-time half of any conformance, and reaching the kernel is what a minimal structural surface could never carry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V --- scripts/check-verify-stand-in-erasure.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/check-verify-stand-in-erasure.mjs b/scripts/check-verify-stand-in-erasure.mjs index d5877bd85a..294442c4ca 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -213,6 +213,14 @@ const NOT_A_STAND_IN = { provisionRlsProbePersona: 'same `VerifyStack` handle as runCrudVerification — it MINTS the RBAC rows for the #7685 ' + 'object-granted RLS probe persona through that stack, so there is no second implementer.', + provisionRlsPositionPersona: + 'same `VerifyStack` handle as provisionRlsProbePersona, and the same direction of dependency: ' + + 'it MINTS the #7978 per-position persona — sign-up through that stack\'s real auth route, then ' + + 'one `sys_user_position` row written through `stack.kernel`\'s ObjectQL service. Reaching the ' + + 'kernel is the tell: a minimal structural surface an out-of-tree implementer could satisfy ' + + 'would not carry a live ObjectKernel. It also CHECKS nothing — it is provisioning, so there is ' + + 'no conformance whose compile-time half the parameter type could be, and an assertion at a call ' + + 'site would delete no check.', formatReport: 'formats a `VerifyReport` this package produced; presentation, not conformance.', formatRlsReport: 'formats an `RlsReport` this package produced; presentation, not conformance.', fillRelationalRefs: