diff --git a/.changeset/15953-verify-reference-shape-family.md b/.changeset/15953-verify-reference-shape-family.md new file mode 100644 index 00000000000..e9b40351ade --- /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. 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/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..6580f251023 --- /dev/null +++ b/packages/cli/test/verify-reference-shapes.test.ts @@ -0,0 +1,130 @@ +// 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', + 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' }, + }, + }, + ], +}; + +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); + }); +}); 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; 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 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..cafaf42d0fb --- /dev/null +++ b/packages/verify/src/reference-shape.test.ts @@ -0,0 +1,227 @@ +// 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); +}); + +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]; + 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'); +} 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.',