From 24d8c527885b707deb760096e71d349fe39a65ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:46:46 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(verify):=20the=20reference-value=20sha?= =?UTF-8?q?pe=20family=20=E2=80=94=20every=20client-writable=20reference?= =?UTF-8?q?=20field=20refuses=20a=20value=20its=20declared=20stored=20shap?= =?UTF-8?q?e=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A derived proof family beside the CRUD and RLS ones. From any app's metadata it takes every field whose type is in REFERENCE_VALUE_TYPES, reads what the declaration promises from the spec's valueSchemaFor(field, 'stored'), and asks the engine through the REST door and the system door whether a boolean, a number and an embedded record object are refused, naming the field. Attribution is the control; a lax posture is the negative control. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/index.ts | 29 +- packages/verify/src/reference-shape.test.ts | 209 +++++++++ packages/verify/src/reference-shape.ts | 464 ++++++++++++++++++++ 3 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 packages/verify/src/reference-shape.test.ts create mode 100644 packages/verify/src/reference-shape.ts diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index f4e41088f18..92f96b8cc5e 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -3,9 +3,10 @@ // @objectstack/verify — public API. // // Boot any ObjectStack app in-process and verify it through the real HTTP -// stack. Two proof families, both app-agnostic (derived from your metadata): -// - data fidelity : runCrudVerification — author → write → read → assert -// - authorization : runRlsProofs — "you can't write what you can't read" +// stack. Three proof families, all app-agnostic (derived from your metadata): +// - data fidelity : runCrudVerification — author → write → read → assert +// - authorization : runRlsProofs — "you can't write what you can't read" +// - value shape : runReferenceShapeProofs — "a reference refuses a value that is not a record id" export { bootStack, bootStackOnce } from './harness.js'; export type { VerifyStack, BootOptions } from './harness.js'; @@ -57,6 +58,28 @@ export type { RlsProofOptions, } from './rls.js'; +// ADR-0104 D1 — the reference-value shape family: every client-writable +// reference field refuses a value its declared stored shape refuses, asked of +// the engine through the REST door and the system door. +export { + deriveReferenceShapeCases, + referenceShapeProbeValue, + runReferenceShapeProofs, + formatReferenceShapeReport, + REFERENCE_SHAPE_PROBE_ID, +} from './reference-shape.js'; +export type { + ReferenceShapeCase, + ReferenceShapeDoor, + ReferenceShapeOperation, + ReferenceShapeProbeKind, + ReferenceShapeProbeResult, + ReferenceShapeFieldStatus, + ReferenceShapeFieldResult, + ReferenceShapeReport, + ReferenceShapeProofOptions, +} from './reference-shape.js'; + // ADR-0060 — reusable conformance-ledger helper (static complement to the // runtime harness): classify every declarable property, fail closed on drift. export { checkLedger } from './conformance.js'; diff --git a/packages/verify/src/reference-shape.test.ts b/packages/verify/src/reference-shape.test.ts new file mode 100644 index 00000000000..47264b6c412 --- /dev/null +++ b/packages/verify/src/reference-shape.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The reference-value shape family (ADR-0104 D1) — what it derives, what it +// proves on a real stack, and the two controls that keep its green honest: +// +// • the NEGATIVE control: the same metadata on a stack whose posture admits +// wrong-shaped references (`OS_ALLOW_LAX_VALUE_SHAPES=1`, ADR-0104's +// declared escape hatch) must be reported with holes, and must count as +// hard failures; +// • the ATTRIBUTION control: an engine that refuses every write without +// naming the probed field must not read as proven. +// +// The positive half boots the real stack: a fresh datastore is born attested +// (ADR-0104, 2026-07-30 addendum), so the engine's shape gate is strict there, +// and every probe has to come back refused WITH the field named. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { valueSchemaFor } from '@objectstack/spec/data'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { + deriveReferenceShapeCases, + referenceShapeProbeValue, + runReferenceShapeProofs, + formatReferenceShapeReport, + REFERENCE_SHAPE_PROBE_ID, + type ReferenceShapeReport, +} from './reference-shape.js'; + +const LAX = 'OS_ALLOW_LAX_VALUE_SHAPES'; + +/** Every single-valued reference type the engine stores as one record id, plus one excluded field. */ +const app = { + manifest: { id: 'com.example.reference-shape', namespace: 'refshape', version: '0.0.1', type: 'app', name: 'Reference Shape Fixture' }, + objects: [ + { name: 'rs_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }, + { + name: 'rs_contact', + label: 'Contact', + fields: { + name: { type: 'text', label: 'Name', required: true }, + account: { type: 'lookup', reference: 'rs_account', label: 'Account' }, + parent: { type: 'master_detail', reference: 'rs_account', label: 'Parent', required: true }, + assignee: { type: 'user', label: 'Assignee' }, + stamped_account: { type: 'lookup', reference: 'rs_account', label: 'Stamped Account', readonly: true }, + }, + }, + ], +}; + +const PROBED = ['account', 'parent', 'assignee'] as const; +/** 2 doors × 2 operations × 3 probe kinds. */ +const PROBES_PER_FIELD = 12; + +const resultFor = (report: ReferenceShapeReport, field: string) => { + const r = report.results.find((x) => x.object === 'rs_contact' && x.field === field); + if (!r) throw new Error(`no result for rs_contact.${field}`); + return r; +}; + +describe('deriveReferenceShapeCases — what the family derives from the metadata', () => { + it('covers every declared reference field, and names the ones outside the property', () => { + const config = { + objects: [ + ...app.objects, + { + name: 'rs_board', + fields: { + accounts: { type: 'lookup', reference: 'rs_account', multiple: true }, + parent_board: { type: 'tree', reference: 'rs_board' }, + owner_ref: { type: 'lookup', reference: 'rs_account', system: true }, + title: { type: 'text' }, + }, + }, + ], + }; + const cases = deriveReferenceShapeCases(config); + const byKey = new Map(cases.map((c) => [`${c.object}.${c.field}`, c])); + + expect([...byKey.keys()].sort()).toEqual([ + 'rs_board.accounts', 'rs_board.owner_ref', 'rs_board.parent_board', + 'rs_contact.account', 'rs_contact.assignee', 'rs_contact.parent', 'rs_contact.stamped_account', + ]); + expect(byKey.get('rs_contact.stamped_account')).toMatchObject({ excluded: expect.stringMatching(/^readonly/), probes: [] }); + expect(byKey.get('rs_board.owner_ref')).toMatchObject({ excluded: expect.stringMatching(/^system/), probes: [] }); + expect(byKey.get('rs_contact.assignee')).toMatchObject({ type: 'user', target: 'sys_user', multiple: false }); + expect(byKey.get('rs_board.accounts')).toMatchObject({ multiple: true, target: 'rs_account' }); + for (const c of cases.filter((x) => !x.excluded)) { + expect(c.probes, `${c.object}.${c.field}`).toEqual(['boolean', 'number', 'embedded-record']); + expect(c.droppedProbes).toBeUndefined(); + } + }); + + it('writes each probe in the declared cardinality, and the spec contract refuses every one', () => { + for (const multiple of [false, true]) { + const stored = valueSchemaFor({ type: 'lookup', multiple }, 'stored'); + for (const kind of ['boolean', 'number', 'embedded-record'] as const) { + const value = referenceShapeProbeValue(kind, multiple, REFERENCE_SHAPE_PROBE_ID); + expect(Array.isArray(value), `${kind} multiple=${multiple}`).toBe(multiple); + expect(stored.safeParse(value).success, `${kind} multiple=${multiple}`).toBe(false); + } + // The control on the contract read: a record id in the declared + // cardinality is a value the same schema accepts. + const id = multiple ? [REFERENCE_SHAPE_PROBE_ID] : REFERENCE_SHAPE_PROBE_ID; + expect(stored.safeParse(id).success).toBe(true); + } + }); +}); + +describe('runReferenceShapeProofs — on a real stack', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(app as never); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('proves every client-writable reference field refuses each wrong shape, on both doors, naming the field', async () => { + const report = await runReferenceShapeProofs(stack, token, app); + + for (const field of PROBED) { + const r = resultFor(report, field); + expect(r.status, `${field}:\n${formatReferenceShapeReport(report)}`).toBe('proven'); + expect(r.probes).toHaveLength(PROBES_PER_FIELD); + // The engine's shape gate answered, not a later one: the code it located + // the refusal with is the value-shape verdict's. + expect(new Set(r.probes.map((p) => `${p.outcome}:${p.code}`))).toEqual(new Set(['refused:invalid_type'])); + // Both doors and both operations really ran. + expect(new Set(r.probes.map((p) => `${p.door}/${p.operation}`))).toEqual( + new Set(['rest/insert', 'rest/update', 'system/insert', 'system/update']), + ); + } + expect(resultFor(report, 'stamped_account')).toMatchObject({ status: 'excluded', probes: [] }); + expect(report.summary).toMatchObject({ + fields: 4, proven: 3, holes: 0, failed: 0, inconclusive: 0, excluded: 1, + probes: 3 * PROBES_PER_FIELD, refused: 3 * PROBES_PER_FIELD, admitted: 0, unattributed: 0, notRun: 0, + hardFailures: 0, + }); + }, 120_000); + + it('NEGATIVE CONTROL: the same metadata on a posture that admits wrong shapes is reported with holes', async () => { + const prior = process.env[LAX]; + process.env[LAX] = '1'; + let report: ReferenceShapeReport; + try { + report = await runReferenceShapeProofs(stack, token, app); + } finally { + if (prior === undefined) delete process.env[LAX]; + else process.env[LAX] = prior; + } + + for (const field of PROBED) { + const r = resultFor(report, field); + expect(r.status, `${field}:\n${formatReferenceShapeReport(report)}`).toBe('holes'); + // The system door runs no reference-existence check, so with the shape + // gate relaxed a wrong-shaped value lands there. + const systemInsertBoolean = r.probes.find((p) => p.door === 'system' && p.operation === 'insert' && p.probe === 'boolean'); + expect(systemInsertBoolean?.outcome).toBe('admitted'); + } + expect(report.summary.holes).toBe(3); + expect(report.summary.hardFailures).toBe(3); + expect(formatReferenceShapeReport(report)).toMatch(/✗ rs_contact\.account \s+ADMITTED/); + }, 120_000); +}); + +describe('runReferenceShapeProofs — attribution is the control', () => { + /** Whether the double has already answered the base record's create. */ + let probing = false; + + /** + * A stack double whose every probe is refused, but for a field the probe did + * not write. The base record is created (so every probe runs), and nothing + * else is answered. An engine that refuses EVERYTHING would satisfy each + * probe if a bare refusal counted; it must read as inconclusive instead. + */ + function refuseNamingAnotherField(): VerifyStack { + const refusal = { code: 'VALIDATION_FAILED', fields: [{ field: 'name', code: 'required' }] }; + return { + async apiAs(_token: string, method: string, path: string) { + if (method === 'POST' && path === '/data/rs_account') return Response.json({ id: 'acc_1' }, { status: 201 }); + if (method === 'POST' && path === '/data/rs_contact' && !probing) { + probing = true; + return Response.json({ id: 'con_1' }, { status: 201 }); + } + return Response.json({ error: 'Name is required', ...refusal }, { status: 400 }); + }, + hooks: { + async run() { + throw Object.assign(new Error('Name is required'), refusal); + }, + }, + } as unknown as VerifyStack; + } + it('a refusal that does not name the probed field is unattributed, never proven', async () => { + probing = false; + const report = await runReferenceShapeProofs(refuseNamingAnotherField(), 'token', app); + for (const field of PROBED) { + const r = resultFor(report, field); + expect(r.status).toBe('inconclusive'); + expect(r.probes.every((p) => p.outcome === 'unattributed' && p.code === 'VALIDATION_FAILED')).toBe(true); + } + expect(report.summary).toMatchObject({ proven: 0, refused: 0, hardFailures: 0, inconclusive: 3 }); + }); +}); diff --git a/packages/verify/src/reference-shape.ts b/packages/verify/src/reference-shape.ts new file mode 100644 index 00000000000..aaafd8b7d87 --- /dev/null +++ b/packages/verify/src/reference-shape.ts @@ -0,0 +1,464 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The reference-value shape family (ADR-0104 D1): every client-writable +// reference field refuses a value its declared stored shape refuses. +// +// ## What the family asks, and of whom +// +// The PROMISE is the declaration's, read from the one contract that owns it: +// `valueSchemaFor(field, 'stored')` in `@objectstack/spec/data` says a +// single-valued `lookup` / `master_detail` / `user` / `tree` stores a record-id +// string, and a `multiple: true` one stores an array of them. The ANSWER is the +// engine's, asked through the real write doors: the REST data routes as the +// signed-in caller (`POST /data/:object`, `PATCH /data/:object/:id`), and the +// engine's own write as the system principal (the handle's `hooks.run` with +// `{ system: true }`, the door an integration, a system job or a `runAs: +// 'system'` flow takes). +// +// ⛔ Nothing here decides whether a value is acceptable. The spec decides what +// the declaration promises; the engine decides what happens; this file only +// compares the two. A family that re-derived the engine's verdict would be the +// hand-built stand-in an app used to carry for exactly this check, and would +// need a second test to keep the copy honest. +// +// ## The probes, and why these three +// +// One wrong-shaped value per kind an author (human or AI) is known to write +// into a reference column: a boolean (an absent link written as `false`), a +// number, and an embedded record object (the EXPANDED read shape written back, +// the mistake the spec's own `ReferenceIdValueSchema` message names). A +// multi-valued field gets each as an array element, because a bare scalar is +// wrapped into an array upstream of the validator — that wrapping is the +// engine's documented normalization, not a shape question. +// +// Every probe is checked against the spec contract at derivation time: a probe +// the stored schema ACCEPTS cannot test a refusal, so it is dropped and named +// rather than reported as a hole. +// +// ## Attribution is the control +// +// A refusal counts only when the engine NAMES the probed field (`fields[]` on +// the `VALIDATION_FAILED` answer). A refusal that names something else — an app +// validation rule, a hook's throw, a required field the derived record could +// not satisfy — is `unattributed`, never `refused`: an engine that refused +// everything would otherwise satisfy every probe. The insert probes ride on the +// record `deriveCrudCases` synthesizes, so the rest of the write is one the app +// can accept; when that base record could not be created, the update probes do +// not run and say why. + +import { REFERENCE_VALUE_TYPES, isMultiValueField, referenceTargetOf, valueSchemaFor } from '@objectstack/spec/data'; +import { declaredCollection } from './artifact-collections.js'; +import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import type { VerifyStack } from './harness.js'; + +/** The kinds of wrong-shaped value written into a reference field. */ +export type ReferenceShapeProbeKind = 'boolean' | 'number' | 'embedded-record'; + +/** The write door a probe goes through. */ +export type ReferenceShapeDoor = 'rest' | 'system'; + +/** The write operation a probe makes. */ +export type ReferenceShapeOperation = 'insert' | 'update'; + +const PROBE_KINDS: readonly ReferenceShapeProbeKind[] = ['boolean', 'number', 'embedded-record']; +const DOORS: readonly ReferenceShapeDoor[] = ['rest', 'system']; +const OPERATIONS: readonly ReferenceShapeOperation[] = ['insert', 'update']; + +/** + * The id an embedded-record probe carries when the field's target has no + * record of its own on the stack (a `user` field, or a target the CRUD + * derivation could not create). Shaped like a record id, so the probe differs + * from a valid value in its SHAPE only. + */ +export const REFERENCE_SHAPE_PROBE_ID = 'verify_reference_shape_probe'; + +/** One reference field the family covers, or names as outside the property. */ +export interface ReferenceShapeCase { + object: string; + field: string; + /** The declared field type (a member of `REFERENCE_VALUE_TYPES`). */ + type: string; + /** Whether the declaration stores an array (`isMultiValueField`). */ + multiple: boolean; + /** The object the field points at (`referenceTargetOf`), when it names one. */ + target?: string; + /** Why the property does not apply to this field; when set, `probes` is empty. */ + excluded?: string; + /** The probes this field gets. */ + probes: ReferenceShapeProbeKind[]; + /** Probes the spec's stored-shape contract accepts for this field, so they cannot test a refusal. */ + droppedProbes?: Array<{ kind: ReferenceShapeProbeKind; reason: string }>; +} + +/** The value one probe writes, with `id` standing in the embedded record. */ +export function referenceShapeProbeValue(kind: ReferenceShapeProbeKind, multiple: boolean, id: string): unknown { + const value = kind === 'boolean' ? false : kind === 'number' ? 42 : { id }; + return multiple ? [value] : value; +} + +/** + * Derive one case per declared reference field of every object the app + * declares. Fields the caller does not write — declared `readonly` (the write + * path drops a caller's value) or `system` (the engine owns it) — are returned + * `excluded`, with the reason, so the report says why they were not probed. + */ +export function deriveReferenceShapeCases(config: any): ReferenceShapeCase[] { + const cases: ReferenceShapeCase[] = []; + for (const obj of declaredCollection(config, 'objects')) { + if (!obj?.name) continue; + const fields: Record = obj.fields ?? {}; + for (const [field, def] of Object.entries(fields)) { + const type = typeof def?.type === 'string' ? def.type : ''; + if (!REFERENCE_VALUE_TYPES.has(type)) continue; + const multiple = isMultiValueField({ type, multiple: def?.multiple === true }); + const target = referenceTargetOf(def); + const base = { object: obj.name as string, field, type, multiple, ...(target ? { target } : {}) }; + if (field === 'id' || def?.system === true) { + cases.push({ ...base, excluded: 'system (the engine owns this field)', probes: [] }); + continue; + } + if (def?.readonly === true) { + cases.push({ ...base, excluded: 'readonly (the write path drops a caller\'s value)', probes: [] }); + continue; + } + const stored = valueSchemaFor({ type, multiple: def?.multiple === true }, 'stored'); + const probes: ReferenceShapeProbeKind[] = []; + const droppedProbes: Array<{ kind: ReferenceShapeProbeKind; reason: string }> = []; + for (const kind of PROBE_KINDS) { + if (stored.safeParse(referenceShapeProbeValue(kind, multiple, REFERENCE_SHAPE_PROBE_ID)).success) { + droppedProbes.push({ kind, reason: 'the spec\'s stored-shape contract accepts this value' }); + } else { + probes.push(kind); + } + } + cases.push({ ...base, probes, ...(droppedProbes.length ? { droppedProbes } : {}) }); + } + } + return cases; +} + +/** What the engine answered one probe. */ +export interface ReferenceShapeProbeResult { + door: ReferenceShapeDoor; + operation: ReferenceShapeOperation; + probe: ReferenceShapeProbeKind; + /** + * - `refused` — the engine refused the write and named this field; + * - `admitted` — the write landed (the property does NOT hold here); + * - `unattributed` — refused, but the engine named something else; + * - `failed` — the door faulted (a 5xx, or a throw carrying no refusal code); + * - `not-run` — the probe could not be written (see `detail`). + */ + outcome: 'refused' | 'admitted' | 'unattributed' | 'failed' | 'not-run'; + /** The HTTP status (REST door). */ + status?: number; + /** The code the engine named the field with on a refusal; otherwise the answer's own code. */ + code?: string; + /** The value the engine answered for the field, when it admitted the write. */ + stored?: unknown; + detail?: string; +} + +/** + * - `proven` — every probe was refused, naming the field; + * - `holes` — at least one probe was admitted (a hard failure); + * - `failed` — a door faulted on a probe and none was admitted (a hard failure); + * - `inconclusive` — some probe was unattributed or could not run, none admitted or faulted; + * - `excluded` — the property does not apply (see `reason`). + */ +export type ReferenceShapeFieldStatus = 'proven' | 'holes' | 'failed' | 'inconclusive' | 'excluded'; + +export interface ReferenceShapeFieldResult { + object: string; + field: string; + type: string; + multiple: boolean; + status: ReferenceShapeFieldStatus; + reason?: string; + probes: ReferenceShapeProbeResult[]; + droppedProbes?: Array<{ kind: ReferenceShapeProbeKind; reason: string }>; +} + +export interface ReferenceShapeReport { + app: string; + results: ReferenceShapeFieldResult[]; + summary: { + fields: number; + proven: number; + holes: number; + failed: number; + inconclusive: number; + excluded: number; + probes: number; + refused: number; + admitted: number; + unattributed: number; + notRun: number; + /** `holes + failed` — the count a CI gate reads. */ + hardFailures: number; + }; +} + +export interface ReferenceShapeProofOptions { + /** The write doors to probe. Default: both, `rest` and `system`. */ + doors?: readonly ReferenceShapeDoor[]; +} + +/** The base record each object's probes ride on, built the way the CRUD family builds it. */ +interface BaseRecord { + body?: Record; + id?: string; + /** Why the insert probes cannot run (no derivable body). */ + noBody?: string; + /** Why the update probes cannot run (no created record). */ + noRecord?: string; +} + +const clip = (s: unknown, n = 200): string => String(s ?? '').slice(0, n); + +/** + * A variant of the base body whose `unique: true` values differ from every + * other variant's, so an admitted probe insert is not then refused by the + * uniqueness index for colliding with the base record — a refusal that would + * read as `unattributed` and hide the hole. + */ +function distinctBody(body: Record, objectDef: any, n: number): Record { + const out: Record = { ...body }; + const fields: Record = objectDef?.fields ?? {}; + for (const [name, value] of Object.entries(body)) { + if (fields[name]?.unique !== true) continue; + if (typeof value === 'number') out[name] = value + n; + else if (typeof value === 'string') { + const at = value.indexOf('@'); + out[name] = at > 0 ? `${value.slice(0, at)}-${n}${value.slice(at)}` : `${value}-${n}`; + } + } + return out; +} + +async function buildBaseRecords( + stack: VerifyStack, + token: string, + crudCases: CrudCase[], + createdIds: Map, +): Promise> { + const bases = new Map(); + for (const c of crudCases) { + if (c.blocked) { + bases.set(c.object, { noBody: c.blocked, noRecord: c.blocked }); + continue; + } + const { body, missing } = fillRelationalRefs(c, createdIds); + if (missing) { + bases.set(c.object, { noBody: missing, noRecord: missing }); + continue; + } + let res: Response; + try { + res = await stack.apiAs(token, 'POST', `/data/${c.object}`, body); + } catch (e) { + bases.set(c.object, { body, noRecord: `base record create threw: ${clip((e as Error)?.message ?? e)}` }); + continue; + } + const text = await res.text(); + if (res.status >= 300) { + bases.set(c.object, { body, noRecord: `base record refused (${res.status}): ${clip(text)}` }); + continue; + } + let id: unknown; + try { + const parsed = JSON.parse(text) as { id?: unknown; record?: { id?: unknown } }; + id = parsed?.id ?? parsed?.record?.id; + } catch { + /* answered below */ + } + if (id == null) { + bases.set(c.object, { body, noRecord: 'base record create returned no id' }); + continue; + } + createdIds.set(c.object, String(id)); + bases.set(c.object, { body, id: String(id) }); + } + return bases; +} + +/** Read the REST door's answer to one probe. */ +async function readRestAnswer(res: Response, field: string): Promise> { + const status = res.status; + const text = await res.text(); + let body: any; + try { + body = JSON.parse(text); + } catch { + body = undefined; + } + if (status >= 200 && status < 300) return { outcome: 'admitted', status, stored: body?.record?.[field] }; + if (status >= 500) return { outcome: 'failed', status, code: body?.code, detail: clip(body?.error ?? text) }; + const named = Array.isArray(body?.fields) ? body.fields.find((f: any) => f?.field === field) : undefined; + if (named) return { outcome: 'refused', status, code: named.code }; + return { outcome: 'unattributed', status, code: body?.code, detail: clip(body?.error ?? text) }; +} + +/** Read the system door's answer (a resolved row, or the engine's own error). */ +async function readSystemAnswer( + write: () => Promise, + field: string, +): Promise> { + try { + const row = (await write()) as Record | undefined; + return { outcome: 'admitted', stored: row?.[field] }; + } catch (e) { + const err = e as { code?: unknown; fields?: unknown; message?: unknown }; + const named = Array.isArray(err?.fields) ? (err.fields as any[]).find((f) => f?.field === field) : undefined; + if (named) return { outcome: 'refused', code: named.code }; + if (typeof err?.code === 'string') return { outcome: 'unattributed', code: err.code, detail: clip(err.message) }; + return { outcome: 'failed', detail: clip(err?.message ?? e) }; + } +} + +function fieldStatus(probes: ReferenceShapeProbeResult[]): { status: ReferenceShapeFieldStatus; reason?: string } { + if (probes.some((p) => p.outcome === 'admitted')) return { status: 'holes' }; + if (probes.some((p) => p.outcome === 'failed')) return { status: 'failed' }; + const open = probes.filter((p) => p.outcome === 'unattributed' || p.outcome === 'not-run'); + if (open.length > 0 || probes.length === 0) { + return { + status: 'inconclusive', + reason: probes.length === 0 + ? 'no probe could be written' + : `${open.length} of ${probes.length} probe(s) not attributed to this field or not run: ${clip(open[0]?.detail ?? open[0]?.code, 160)}`, + }; + } + return { status: 'proven' }; +} + +/** + * Run the reference-value shape family against a live stack, writing as the + * authenticated `token` on the REST door and as the system principal on the + * engine door. Returns a structured report; never throws on a per-field + * failure (collects them). + * + * Run it on its own fresh stack: its probe inserts land rows when a door admits + * them, and the base records it creates would collide with another family's + * on a unique field. + */ +export async function runReferenceShapeProofs( + stack: VerifyStack, + token: string, + config: any, + options: ReferenceShapeProofOptions = {}, +): Promise { + const doors = options.doors ?? DOORS; + const cases = deriveReferenceShapeCases(config); + const objectDefs = new Map(); + for (const o of declaredCollection(config, 'objects')) if (o?.name) objectDefs.set(o.name, o); + + const createdIds = new Map(); + const probed = new Set(cases.filter((c) => !c.excluded).map((c) => c.object)); + const bases = probed.size > 0 + ? await buildBaseRecords(stack, token, deriveCrudCases(config), createdIds) + : new Map(); + + let variant = 0; + const results: ReferenceShapeFieldResult[] = []; + for (const c of cases) { + const head = { object: c.object, field: c.field, type: c.type, multiple: c.multiple }; + if (c.excluded) { + results.push({ ...head, status: 'excluded', reason: c.excluded, probes: [] }); + continue; + } + const base = bases.get(c.object) ?? { noBody: 'object not derived', noRecord: 'object not derived' }; + const id = (c.target && createdIds.get(c.target)) || REFERENCE_SHAPE_PROBE_ID; + const probes: ReferenceShapeProbeResult[] = []; + for (const door of doors) { + for (const operation of OPERATIONS) { + for (const probe of c.probes) { + const value = referenceShapeProbeValue(probe, c.multiple, id); + const at = { door, operation, probe } as const; + if (operation === 'insert' && !base.body) { + probes.push({ ...at, outcome: 'not-run', detail: base.noBody }); + continue; + } + if (operation === 'update' && !base.id) { + probes.push({ ...at, outcome: 'not-run', detail: base.noRecord }); + continue; + } + const input = operation === 'insert' + ? { ...distinctBody(base.body!, objectDefs.get(c.object), ++variant), [c.field]: value } + : { [c.field]: value }; + let answer: Omit; + if (door === 'rest') { + try { + const res = operation === 'insert' + ? await stack.apiAs(token, 'POST', `/data/${c.object}`, input) + : await stack.apiAs(token, 'PATCH', `/data/${c.object}/${base.id}`, input); + answer = await readRestAnswer(res, c.field); + } catch (e) { + answer = { outcome: 'failed', detail: clip((e as Error)?.message ?? e) }; + } + } else { + answer = await readSystemAnswer( + () => operation === 'insert' + ? stack.hooks.run(c.object, 'insert', input, { system: true }) + : stack.hooks.run(c.object, 'update', { ...input, id: base.id }, { system: true }), + c.field, + ); + } + probes.push({ ...at, ...answer }); + } + } + } + results.push({ + ...head, + ...fieldStatus(probes), + probes, + ...(c.droppedProbes ? { droppedProbes: c.droppedProbes } : {}), + }); + } + + const all = results.flatMap((r) => r.probes); + const count = (s: ReferenceShapeFieldStatus) => results.filter((r) => r.status === s).length; + const summary = { + fields: results.length, + proven: count('proven'), + holes: count('holes'), + failed: count('failed'), + inconclusive: count('inconclusive'), + excluded: count('excluded'), + probes: all.length, + refused: all.filter((p) => p.outcome === 'refused').length, + admitted: all.filter((p) => p.outcome === 'admitted').length, + unattributed: all.filter((p) => p.outcome === 'unattributed').length, + notRun: all.filter((p) => p.outcome === 'not-run').length, + hardFailures: count('holes') + count('failed'), + }; + return { app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', results, summary }; +} + +/** Pretty one-line-per-field summary for logs. */ +export function formatReferenceShapeReport(report: ReferenceShapeReport): string { + const lines: string[] = [`\n=== objectstack verify — reference-value shapes (ADR-0104) — ${report.app} ===`]; + for (const r of report.results) { + const name = `${r.object}.${r.field} <${r.type}${r.multiple ? ', multiple' : ''}>`; + if (r.status === 'proven') { + const codes = [...new Set(r.probes.map((p) => p.code).filter(Boolean))].join(', '); + lines.push(` ✓ ${name} ${r.probes.length} wrong-shape write(s) refused (${codes})`); + } else if (r.status === 'holes' || r.status === 'failed') { + const bad = r.probes.filter((p) => p.outcome === (r.status === 'holes' ? 'admitted' : 'failed')); + lines.push(` ✗ ${name} ${r.status === 'holes' ? 'ADMITTED' : 'FAULTED ON'} ${bad.length} of ${r.probes.length} wrong-shape write(s):`); + for (const p of bad) { + const what = p.outcome === 'admitted' ? `stored ${JSON.stringify(p.stored)}` : (p.detail ?? ''); + lines.push(` ${p.door} ${p.operation} ${p.probe}${p.status ? ` → ${p.status}` : ''}: ${clip(what, 160)}`); + } + } else if (r.status === 'inconclusive') { + lines.push(` ~ ${name} inconclusive: ${r.reason ?? ''}`); + } else { + lines.push(` – ${name} excluded: ${r.reason ?? ''}`); + } + } + const s = report.summary; + lines.push( + ` ── ${s.proven} proven, ${s.holes} with holes, ${s.failed} FAILED, ${s.inconclusive} inconclusive, ${s.excluded} excluded ` + + `(${s.probes} probes: ${s.refused} refused, ${s.admitted} admitted, ${s.unattributed} unattributed, ${s.notRun} not run)`, + ); + return lines.join('\n'); +} From f6a5088a0eaeb775efa22b952b582957c32352d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:47:37 +0000 Subject: [PATCH 2/7] feat(cli): os verify --reference-shapes runs the reference-value shape family Opt-in like --rls, on a fresh stack of its own; a field that admits a wrong-shaped value, or a door that faults on one, counts in hardFailures and the exit status. The --json document gains referenceShapes when the flag is given and is unchanged without it. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/cli/src/commands/verify.ts | 39 +++++- .../cli/test/verify-reference-shapes.test.ts | 128 ++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/cli/test/verify-reference-shapes.test.ts diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index ae2cbb51803..e663f04a1aa 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -15,8 +15,11 @@ import { provisionRlsPositionPersona, declaredPositionNames, rlsProbeSecurity, + runReferenceShapeProofs, + formatReferenceShapeReport, type VerifyReport, type RlsReport, + type ReferenceShapeReport, type RlsProbeDescriptor, type RlsPositionPersonaInput, } from '@objectstack/verify'; @@ -79,17 +82,21 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b * asserting runtime behavior the static gates can't see: * - data fidelity: author → write → read → assert, per object/field type * - authorization (--rls): "you can't write what you can't read" (#1994 class) + * - reference-value shape (--reference-shapes, ADR-0104 D1): every + * client-writable reference field refuses a value that is not a + * record id, through the REST door and the system door * * Exits non-zero on either stage's failures so it drops straight into CI. */ export default class Verify extends Command { static override description = - 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)'; + 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant and the reference-value shape proofs on request)'; static override examples = [ '<%= config.bin %> verify', '<%= config.bin %> verify --app ./objectstack.config.ts --rls', '<%= config.bin %> verify --rls --multi-tenant --json', + '<%= config.bin %> verify --reference-shapes', ]; static override flags = { @@ -101,6 +108,10 @@ export default class Verify extends Command { description: 'Also run the cross-owner RLS invariant (a fresh member must not write what it cannot read)', default: false, }), + 'reference-shapes': Flags.boolean({ + description: 'Also prove every client-writable reference field refuses a value that is not a record id (a boolean, a number, an embedded record), through the REST door and the system door (ADR-0104 value shapes)', + default: false, + }), 'multi-tenant': Flags.boolean({ description: 'Boot org-scoped (register the @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)', default: false, @@ -178,6 +189,7 @@ export default class Verify extends Command { private async runVerification(flags: { app?: string; rls: boolean; + 'reference-shapes': boolean; 'multi-tenant': boolean; json: boolean; }): Promise { @@ -278,6 +290,21 @@ export default class Verify extends Command { } } + // The reference-value shape proofs run on a fresh stack of their own, for + // the reason the RLS proofs do: a door that admits a probe lands a row, + // and the base records the family creates would collide with the other + // phases' on a unique field. + let referenceShapes: ReferenceShapeReport | undefined; + if (flags['reference-shapes']) { + const shapeStack = await bootStack(config, { multiTenant, hostRoot }); + try { + const adminToken = await shapeStack.signIn(); + referenceShapes = await runReferenceShapeProofs(shapeStack, adminToken, config); + } finally { + await shapeStack.stop(); + } + } + // 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. @@ -293,16 +320,22 @@ export default class Verify extends Command { crud.summary.fidelityGaps + (rls?.totals.holes ?? 0) + (rls?.probe.degraded ? 1 : 0) + - (rls?.positionCoverage.notRun.length ?? 0); + (rls?.positionCoverage.notRun.length ?? 0) + + // A reference field that ADMITTED a wrong-shaped value, or a door that + // faulted on one. Inconclusive fields (the probe could not be attributed + // or written) are reported, not counted, exactly as CRUD's + // `needs-fixture` is. + (referenceShapes?.summary.hardFailures ?? 0); if (flags.json) { // `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the // report has to leave through the real stream — and awaiting the write // drains a report larger than one pipe buffer before `this.exit` below. - await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, hardFailures }); + await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, hardFailures }); } else { this.log(formatReport(crud)); if (rls) this.log(formatRlsReport(rls)); + if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes)); this.log(''); this.log( hardFailures > 0 diff --git a/packages/cli/test/verify-reference-shapes.test.ts b/packages/cli/test/verify-reference-shapes.test.ts new file mode 100644 index 00000000000..06865f5bc03 --- /dev/null +++ b/packages/cli/test/verify-reference-shapes.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — `os verify --reference-shapes` runs the reference-value shape family + * (ADR-0104 D1) and counts what it finds in the exit status. + * + * ## What is pinned + * + * - without the flag the run is what it was: no `referenceShapes` key in the + * `--json` document, and the exit status reads CRUD (and RLS) only; + * - with the flag, on a stack whose engine refuses wrong-shaped references + * (a fresh datastore is born strict), every client-writable reference + * field is `proven`, `hardFailures` stays 0 and the run exits 0; + * - with the flag, on the same metadata under the posture that admits + * wrong shapes (`OS_ALLOW_LAX_VALUE_SHAPES=1`, ADR-0104's declared escape + * hatch), each field is reported with holes, `hardFailures` counts them and + * the run exits 1 — the negative control, through the CLI. + * + * Spawned rather than run in-process: the exit status is the contract a CI + * gate reads, and only a real process has one. + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; + +const STACK = { + manifest: { + id: 'com.example.verify-reference-shapes', + namespace: 'vrs', + version: '1.0.0', + name: 'Verify Reference Shapes', + type: 'app', + engines: { protocol: '^18' }, + }, + objects: [ + { + name: 'vrs_account', + label: 'Account', + pluralLabel: 'Accounts', + fields: { name: { type: 'text', label: 'Name', required: true } }, + }, + { + name: 'vrs_contact', + label: 'Contact', + pluralLabel: 'Contacts', + fields: { + name: { type: 'text', label: 'Name', required: true }, + account: { type: 'lookup', reference: 'vrs_account', label: 'Account' }, + }, + }, + ], +}; + +interface Run { + status: number | null; + stdout: string; + stderr: string; +} + +interface Doc { + hardFailures?: number; + referenceShapes?: { + results: Array<{ object: string; field: string; status: string }>; + summary: { proven: number; holes: number; hardFailures: number }; + }; +} + +function runCli(dir: string, args: string[], env: Record = {}): Run { + // Through tsx, so the child runs this checkout's `src/`. + const r = spawnSync(TSX, [CLI, ...args], { + cwd: dir, + encoding: 'utf8', + env: childEnv({ NO_COLOR: '1', OS_ALLOW_LAX_VALUE_SHAPES: undefined, ...env }), + maxBuffer: 64 * 1024 * 1024, + }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +const docOf = (run: Run): Doc => JSON.parse(run.stdout) as Doc; + +describe('os verify --reference-shapes', () => { + let dir: string; + let plain: Run; + let strict: Run; + let lax: Run; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-verify-reference-shapes-')); + writeFileSync(join(dir, 'objectstack.config.mjs'), defineStackSource(STACK)); + linkSpec(dir); + plain = runCli(dir, ['verify', '--json']); + strict = runCli(dir, ['verify', '--reference-shapes', '--json']); + lax = runCli(dir, ['verify', '--reference-shapes', '--json'], { OS_ALLOW_LAX_VALUE_SHAPES: '1' }); + }, 480_000); + + afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('leaves the run unchanged without the flag', () => { + expect(plain.status, `${plain.stdout}\n${plain.stderr}`).toBe(0); + const doc = docOf(plain); + expect('referenceShapes' in doc).toBe(false); + expect(doc.hardFailures).toBe(0); + }); + + it('proves the reference field on a strict stack, and exits 0', () => { + expect(strict.status, `${strict.stdout}\n${strict.stderr}`).toBe(0); + const doc = docOf(strict); + expect(doc.referenceShapes?.results).toEqual([ + expect.objectContaining({ object: 'vrs_contact', field: 'account', status: 'proven' }), + ]); + expect(doc.referenceShapes?.summary).toMatchObject({ proven: 1, holes: 0, hardFailures: 0 }); + expect(doc.hardFailures).toBe(0); + }); + + it('NEGATIVE CONTROL: reports the hole on a posture that admits wrong shapes, counts it, and exits 1', () => { + expect(lax.status, `${lax.stdout}\n${lax.stderr}`).toBe(1); + const doc = docOf(lax); + expect(doc.referenceShapes?.summary).toMatchObject({ proven: 0, holes: 1, hardFailures: 1 }); + expect(doc.hardFailures).toBe(1); + }); +}); From a2f1234ab901c490e987f9aef418264058f1016d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:50:35 +0000 Subject: [PATCH 3/7] test(cli): the reference-shapes fixture declares its OWD, so the author-time stage passes Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/cli/test/verify-reference-shapes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/test/verify-reference-shapes.test.ts b/packages/cli/test/verify-reference-shapes.test.ts index 06865f5bc03..6580f251023 100644 --- a/packages/cli/test/verify-reference-shapes.test.ts +++ b/packages/cli/test/verify-reference-shapes.test.ts @@ -42,12 +42,14 @@ const STACK = { name: 'vrs_account', label: 'Account', pluralLabel: 'Accounts', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } }, }, { name: 'vrs_contact', label: 'Contact', pluralLabel: 'Contacts', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true }, account: { type: 'lookup', reference: 'vrs_account', label: 'Account' }, From 777263207d59e922dfa529f7faee6bec49cff2d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:59:05 +0000 Subject: [PATCH 4/7] chore(changeset): verify minor + cli minor for the reference-value shape family; the negative control boots its own stack A lax-posture admission revokes the stack's ADR-0104 attestation, so the negative control no longer shares a stack with the strict reading. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../15953-verify-reference-shape-family.md | 30 +++++++++++++++++++ packages/verify/src/reference-shape.test.ts | 18 +++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/15953-verify-reference-shape-family.md diff --git a/.changeset/15953-verify-reference-shape-family.md b/.changeset/15953-verify-reference-shape-family.md new file mode 100644 index 00000000000..83f6d0c926b --- /dev/null +++ b/.changeset/15953-verify-reference-shape-family.md @@ -0,0 +1,30 @@ +--- +'@objectstack/verify': minor +'@objectstack/cli': minor +--- + +feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) + +Clause-②: yes + +A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. + +A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. + +**`@objectstack/verify` — new exports.** + +- `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. +- `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. +- Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. + +```ts +import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; + +const stack = await bootStack(myApp); +const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); +console.log(formatReferenceShapeReport(report)); +expect(report.summary.hardFailures).toBe(0); +await stack.stop(); +``` + +**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. diff --git a/packages/verify/src/reference-shape.test.ts b/packages/verify/src/reference-shape.test.ts index 47264b6c412..cafaf42d0fb 100644 --- a/packages/verify/src/reference-shape.test.ts +++ b/packages/verify/src/reference-shape.test.ts @@ -142,6 +142,24 @@ describe('runReferenceShapeProofs — on a real stack', () => { hardFailures: 0, }); }, 120_000); +}); + +describe('runReferenceShapeProofs — the negative control, on a stack of its own', () => { + // Its own boot, deliberately: a wrong-shaped value the lax posture admits + // REVOKES the stack's ADR-0104 attestation (the engine closes the gate on + // evidence it has just contradicted), so any strict reading taken on the + // same stack afterwards would be a reading of a warn-first deployment. + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + stack = await bootStack(app as never); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); it('NEGATIVE CONTROL: the same metadata on a posture that admits wrong shapes is reported with holes', async () => { const prior = process.env[LAX]; From 0598a8d13da7190fd931fe8bff236d7b7786b4f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:33:50 +0000 Subject: [PATCH 5/7] test(core): classify the reference-value shape family in the second-object read census `packages/verify/src/reference-shape.ts` calls the reference-target arbiter only to pick, from records the family itself created, the id its embedded-record probe carries; it writes probes to the source object through the REST door and the engine's system write and reads no record of the target, so it is `not-a-served-read`. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../src/security/second-object-read-exposure.pin.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index cd6fb0ba145..35ae538b347 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -221,6 +221,14 @@ const CALLERS: Record = { kind: 'not-a-served-read', reason: 'validation-rule analysis and evaluation on the write path; the verdict is pass/fail and no target field is served', }, + 'packages/verify/src/reference-shape.ts': { + kind: 'not-a-served-read', + reason: + "the verification harness's reference-value shape family: the target only picks, from the ids of records the " + + 'family itself created through the REST door, the id its embedded-record probe carries. It writes probe values ' + + "to the SOURCE object (the REST data routes, which judge that object's exposure, and the engine's system write) " + + 'and reads no record of the target', + }, }; const SCAN_TIMEOUT_MS = 60_000; From c0a33471cac6604a06b82384c54c9c04521fe157 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:35:07 +0000 Subject: [PATCH 6/7] chore(scripts): classify the reference-value shape family's two exports as not stand-ins `runReferenceShapeProofs` takes the `VerifyStack` handle `bootStack` returns and `formatReferenceShapeReport` formats a report this package produced, the same footing as `runCrudVerification` / `formatReport`. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- scripts/check-verify-stand-in-erasure.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check-verify-stand-in-erasure.mjs b/scripts/check-verify-stand-in-erasure.mjs index 8c582bf48ae..3a669f4cd36 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -221,6 +221,9 @@ const NOT_A_STAND_IN = { '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.', + runReferenceShapeProofs: 'same `VerifyStack` handle as runCrudVerification.', + formatReferenceShapeReport: + 'formats a `ReferenceShapeReport` this package produced; presentation, not conformance.', fillRelationalRefs: 'takes one `CrudCase` derived by `deriveCrudCases` — a data record this package built, with no ' + 'external implementer.', From f8a821d054b102a3cb7885e0f54f2b08ddce8b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 00:36:04 +0000 Subject: [PATCH 7/7] docs(verify,cli): name the reference-value shape family and os verify --reference-shapes, and why it is opt-in The verify README gains the family (bullet, CLI line, programmatic snippet, statuses, API) and the os verify section of the CLI reference names the flag and the referenceShapes JSON key. Opt-in because the engine still admits wrong-shaped elements on a multi-valued lookup/user field (#22908); the changeset says so too. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../15953-verify-reference-shape-family.md | 2 +- content/docs/deployment/cli.mdx | 14 +++++-- packages/verify/README.md | 42 +++++++++++++++++-- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.changeset/15953-verify-reference-shape-family.md b/.changeset/15953-verify-reference-shape-family.md index 83f6d0c926b..e9b40351ade 100644 --- a/.changeset/15953-verify-reference-shape-family.md +++ b/.changeset/15953-verify-reference-shape-family.md @@ -27,4 +27,4 @@ expect(report.summary.hardFailures).toBe(0); await stack.stop(); ``` -**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. +**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index cde2d62e62c..ee505112d7e 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1922,6 +1922,7 @@ only when the first passes. os verify # Author-time rules, then CRUD round-trip fidelity os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant os verify --rls --multi-tenant --json # Org-scoped boot, structured report +os verify --reference-shapes # Also prove reference fields refuse non-id values ``` 1. **Author-time rules.** The rule registry `os validate` runs, over the stack @@ -1943,7 +1944,14 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep fidelity failure fails the run. With `--rls`, a second fresh boot proves the cross-owner invariant — a member must not write what it cannot read — for a probe persona and for one persona per declared position, and a hole fails - the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies + the run. With `--reference-shapes`, another fresh boot proves that every + client-writable reference field (`lookup`, `master_detail`, `user`, `tree`) + refuses a value that is not a record id — a boolean, a number, an embedded + record object — on insert and update, through the REST door and the system + door; a field that admits one fails the run. It is opt-in because the engine + still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` + field, so an app that declares one would fail until the engine refuses them. + `--multi-tenant` boots org-scoped so tenant-isolation RLS policies apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or `group`) asks for the same boot. @@ -1970,8 +1978,8 @@ document carries depends on where the run ended: `path`, `message`, `hint`, plus `package` for a finding raised inside one package of the artifact), or the schema issues when the stack does not parse. - **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`, - `crud`, `rls` (with `--rls`) and `hardFailures`, the count the exit status is - read from. + `crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`) + and `hardFailures`, the count the exit status is read from. - **Could not run:** `{ "error": "" }`, plus `code` and `httpStatus` when the failure carries them. diff --git a/packages/verify/README.md b/packages/verify/README.md index 553d1aee0f4..2abcb41f0e2 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -1,7 +1,7 @@ # @objectstack/verify Boot any ObjectStack app **in-process** and verify it through the **real HTTP -stack** — no mocks, no ports, no sockets. Two app-agnostic proof families, both +stack** — no mocks, no ports, no sockets. Three app-agnostic proof families, all derived from your own metadata: - **Data fidelity** — author one record per object covering every field type, @@ -9,6 +9,11 @@ derived from your own metadata: with type fidelity. - **Authorization** — the cross-owner RLS invariant: *a user who cannot READ a record must not be able to WRITE it.* +- **Reference-value shape** (opt-in) — *every client-writable reference field + refuses a value that is not a record id* (ADR-0104): a boolean, a number or an + embedded record object is written into each `lookup` / `master_detail` / + `user` / `tree` field, on insert and update, through the REST door and the + system door, and the engine must refuse it naming the field. And, on the same booted stack, an **in-process handle** so an app's own tests can run a hook, a validation rule, a flow, an action, a seed or a read against @@ -44,16 +49,28 @@ objectstack verify # explicit config + the RLS invariant + multi-tenant isolation objectstack verify --app ./objectstack.config.ts --rls --multi-tenant + +# also prove every reference field refuses a value that is not a record id +objectstack verify --reference-shapes ``` +`--reference-shapes` is opt-in, on a fresh boot of its own: the engine still +admits wrong-shaped ELEMENTS on a `multiple: true` `lookup` / `user` field, so +on an app that declares one the family reports holes and fails the run until +the engine refuses them. + Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, -`fidelity-gaps`, `rls-hole`) so it drops straight into a CI gate. Inconclusive +`fidelity-gaps`, `rls-hole`, and with `--reference-shapes` a field with +`holes` or `failed`) so it drops straight into a CI gate. Inconclusive verdicts (`needs-fixture`, `skipped`, `member-visible`) are warnings and exit 0. ## Programmatic (embed in your own test runner) ```ts -import { bootStack, runCrudVerification, runRlsProofs, formatReport } from '@objectstack/verify'; +import { + bootStack, runCrudVerification, runRlsProofs, formatReport, + runReferenceShapeProofs, formatReferenceShapeReport, +} from '@objectstack/verify'; import myApp from './objectstack.config.js'; const stack = await bootStack(myApp); @@ -70,6 +87,13 @@ const rls = await runRlsProofs(stack, adminToken, memberToken, myApp); expect(rls.summary.holes).toBe(0); await stack.stop(); + +// Reference-value shape, on a fresh stack: a door that admits a probe lands a row +const shapeStack = await bootStack(myApp); +const shapes = await runReferenceShapeProofs(shapeStack, await shapeStack.signIn(), myApp); +console.log(formatReferenceShapeReport(shapes)); +expect(shapes.summary.hardFailures).toBe(0); +await shapeStack.stop(); ``` ## The in-process handle (drive the real engine from a test) @@ -254,6 +278,16 @@ await expect(shipped.hooks.run('crm_lead', 'insert', { last_name: 'Ada' }, { sys | `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) | +**Reference-value shape** (`runReferenceShapeProofs`), per field: + +| status | meaning | +| --- | --- | +| `proven` | every wrong-shaped write was refused, the refusal naming the field | +| `holes` | at least one wrong-shaped write was admitted **(failure)** | +| `failed` | a door faulted on a probe **(failure)** | +| `inconclusive` | a refusal named something else, or a probe could not be written (the reason is reported) | +| `excluded` | the field is `readonly` or `system`; the caller does not write it | + `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. @@ -290,6 +324,8 @@ run" must never read like "nothing to find". - `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object. - `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary. - `runRlsProofs(stack, adminToken, memberToken, config)` → `RlsReport`; `formatRlsReport(report)`. +- `deriveReferenceShapeCases(config)` → one case per reference field; `runReferenceShapeProofs(stack, token, config, { doors? })` + → `ReferenceShapeReport` (`summary.hardFailures` is what a gate reads); `formatReferenceShapeReport(report)`. `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` for owner-scoped fixtures), `multiTenant` (also what decides the posture