diff --git a/.changeset/15953-verify-unique-scope-family.md b/.changeset/15953-verify-unique-scope-family.md new file mode 100644 index 00000000000..b1c6a6f48bd --- /dev/null +++ b/.changeset/15953-verify-unique-scope-family.md @@ -0,0 +1,35 @@ +--- +'@objectstack/verify': minor +'@objectstack/cli': minor +--- + +feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) + +Clause-②: yes + +A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. + +- **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. +- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. + +A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + +A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. + +**`@objectstack/verify` — new exports.** + +- `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. +- `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. +- Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. + +```ts +import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; + +const stack = await bootStack(myApp, { multiTenant: true }); +const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); +console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran +expect(report.summary.hardFailures).toBe(0); +await stack.stop(); +``` + +**`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` 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 9cba1e0cc59..3d97153b4cc 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1924,6 +1924,7 @@ os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS os verify --rls --multi-tenant --json # Org-scoped boot, structured report os verify --reference-shapes # Also prove reference fields refuse non-id values os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does +os verify --unique-scopes --multi-tenant # Also prove each declared unique collides where its scope says ``` 1. **Author-time rules.** The rule registry `os validate` runs, over the stack @@ -1966,6 +1967,24 @@ os verify --delete-behaviors # Also prove each declared delete `excluded` rather than compared, because an engine rule decides their outcome, not the declaration. It is opt-in, and a `tree` field's delete is not honoured today, so an app that declares one would fail. + With `--unique-scopes`, another fresh boot proves that every field declared + `unique` collides as its scope says: per field, a first record holds a value + and a second one is offered it, by insert and by update, through the REST + door and the system door. Within one organization the second holder must be + refused with the platform's unique refusal, attributed to the field (it + names the field, or it names none and the same record with only that value + changed is admitted). With `--multi-tenant` the run also provisions a second + organization (a sign-up that creates its own) and offers it the value: a + `true` or `'organization'` field must admit it, and a `'global'` field, or + any field of an object with no organization column, must refuse it. Without + `--multi-tenant` there is one organization, so that leg is reported **not + run** with the reason rather than skipped. The scope is read as the spec's + parse of the field states it (an `autonumber` that authors no `unique` + states `'organization'`); a field the caller cannot set — `readonly`, + `system`, a runtime-issued `autonumber` — is probed through the system door + only. A second holder met wrongly fails the run; a refusal that is not the + field's own constraint (an app hook's dedupe, a rule, a permission) is + reported inconclusive, never proven. `--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. @@ -1994,7 +2013,9 @@ document carries depends on where the run ended: 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`), `referenceShapes` (with `--reference-shapes`), - `deleteBehaviors` (with `--delete-behaviors`) and `hardFailures`, the count + `deleteBehaviors` (with `--delete-behaviors`), `uniqueScopes` (with + `--unique-scopes`; its `boot` names the legs the run could prove) 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 f26769703cf..95ab269a525 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -19,10 +19,13 @@ import { formatReferenceShapeReport, runDeleteBehaviorProofs, formatDeleteBehaviorReport, + runUniqueScopeProofs, + formatUniqueScopeReport, type VerifyReport, type RlsReport, type ReferenceShapeReport, type DeleteBehaviorReport, + type UniqueScopeReport, type RlsProbeDescriptor, type RlsPositionPersonaInput, } from '@objectstack/verify'; @@ -91,12 +94,17 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b * - delete behaviour (--delete-behaviors): every `deleteBehavior` a * reference field declares is what a by-id delete of the referenced * record does, through the REST door and the system door + * - unique scope (--unique-scopes, ADR-0120 D1): every field declared + * `unique` refuses a second holder within one organization, through + * the REST door and the system door, and — on an org-scoped boot + * (--multi-tenant) — admits or refuses one in another organization + * as its declared scope says * * 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, plus the cross-owner RLS invariant, the reference-value shape proofs and the declared delete-behaviour proofs on request)'; + '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, the reference-value shape proofs, the declared delete-behaviour proofs and the declared unique-scope proofs on request)'; static override examples = [ '<%= config.bin %> verify', @@ -104,6 +112,7 @@ export default class Verify extends Command { '<%= config.bin %> verify --rls --multi-tenant --json', '<%= config.bin %> verify --reference-shapes', '<%= config.bin %> verify --delete-behaviors', + '<%= config.bin %> verify --unique-scopes --multi-tenant', ]; static override flags = { @@ -123,6 +132,10 @@ export default class Verify extends Command { description: 'Also prove every deleteBehavior a reference field declares (cascade, set_null, restrict) is what a delete of the referenced record does, through the REST door and the system door', default: false, }), + 'unique-scopes': Flags.boolean({ + description: "Also prove every field declared unique collides as its scope says (true / 'organization': within one organization only; 'global': across the installation), through the REST door and the system door; the cross-organization half needs --multi-tenant and is reported not run without it (ADR-0120 unique scopes)", + 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, @@ -202,6 +215,7 @@ export default class Verify extends Command { rls: boolean; 'reference-shapes': boolean; 'delete-behaviors': boolean; + 'unique-scopes': boolean; 'multi-tenant': boolean; json: boolean; }): Promise { @@ -331,6 +345,20 @@ export default class Verify extends Command { } } + // The unique-scope proofs run on a fresh stack of their own as well: they + // write duplicates on purpose, and on an org-scoped boot they sign up a + // second organization, neither of which another phase should meet. + let uniqueScopes: UniqueScopeReport | undefined; + if (flags['unique-scopes']) { + const uniqueStack = await bootStack(config, { multiTenant, hostRoot }); + try { + const adminToken = await uniqueStack.signIn(); + uniqueScopes = await runUniqueScopeProofs(uniqueStack, adminToken, config); + } finally { + await uniqueStack.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. @@ -355,18 +383,23 @@ export default class Verify extends Command { // A declared deleteBehavior a delete contradicted, or a door that // faulted on one. Inconclusive, excluded and not-derivable fields are // reported, not counted. - (deleteBehaviors?.summary.hardFailures ?? 0); + (deleteBehaviors?.summary.hardFailures ?? 0) + + // A second holder a declared unique scope met wrongly, or a door that + // faulted on one. A leg this boot cannot run is reported on the report's + // `boot`, not counted. + (uniqueScopes?.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, referenceShapes, deleteBehaviors, hardFailures }); + await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, hardFailures }); } else { this.log(formatReport(crud)); if (rls) this.log(formatRlsReport(rls)); if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes)); if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors)); + if (uniqueScopes) this.log(formatUniqueScopeReport(uniqueScopes)); this.log(''); this.log( hardFailures > 0 diff --git a/packages/cli/test/verify-unique-scopes.test.ts b/packages/cli/test/verify-unique-scopes.test.ts new file mode 100644 index 00000000000..83f2cfb5190 --- /dev/null +++ b/packages/cli/test/verify-unique-scopes.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — `os verify --unique-scopes` runs the unique-scope family and counts + * what it finds in the exit status. + * + * ## What is pinned + * + * - without the flag the run is what it was: no `uniqueScopes` key in the + * `--json` document, and the exit status reads CRUD only; + * - with the flag, each declared scope (`true`, `'organization'`, + * `'global'`, and the `'organization'` the spec states for an autonumber + * that authors none) is `proven` within the install's one organization, + * the cross-organization leg is reported NOT RUN on this boot with the + * flag that runs it, `hardFailures` stays 0 and the run exits 0; + * - with the flag, on the same metadata plus a hook that answers a duplicate + * `code` with an uncoded throw instead of letting the constraint refuse it, + * the field is reported `failed`, `hardFailures` counts it and the run + * exits 1. + * + * The third case is a FAULT, not a contradiction, and that is the most this + * door can show: every config `os verify` loads is `defineStack`-built and + * boots a fresh database, so no metadata makes a same-organization duplicate + * land, and the cross-organization leg needs `--multi-tenant`, whose runtime + * package no framework package may declare. The contradiction controls live + * beside the family, in `packages/verify/src/unique-scope.test.ts`. + * + * 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 { mkdirSync, 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, defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; + +const MANIFEST = { + id: 'com.example.verify-unique-scopes', + namespace: 'vus', + version: '1.0.0', + name: 'Verify Unique Scopes', + type: 'app', + engines: { protocol: '^18' }, +}; + +const OBJECTS = [ + { + name: 'vus_account', + label: 'Account', + pluralLabel: 'Accounts', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name', required: true, unique: true }, + code: { type: 'text', label: 'Code', unique: 'organization' }, + ext_id: { type: 'text', label: 'External id', unique: 'global' }, + number: { type: 'autonumber', label: 'Number' }, + }, + }, +]; + +/** The same app, plus a hook that crashes on a duplicate `code` before the constraint can refuse it. */ +const CONTROL_SOURCE = `{ + manifest: ${JSON.stringify({ ...MANIFEST, id: 'com.example.verify-unique-scopes-control' })}, + objects: ${JSON.stringify(OBJECTS)}, + hooks: [ + { + name: 'vus_account_crashes_on_a_duplicate_code', + object: 'vus_account', + events: ['beforeInsert'], + handler: async (ctx) => { + if (typeof ctx.input.code !== 'string') return; + const held = await ctx.api.object('vus_account').find({ where: { code: ctx.input.code } }); + if (held.length > 0) throw new Error('lookup table corrupted'); + }, + }, + ], +}`; + +interface Run { + status: number | null; + stdout: string; + stderr: string; +} + +interface Doc { + hardFailures?: number; + uniqueScopes?: { + boot: { posture?: string; legs: { within: string; across: string }; acrossNotRun?: string }; + results: Array<{ object: string; field: string; declared?: unknown; status: string; doors: string[] }>; + summary: { proven: number; failed: number; contradicted: number; hardFailures: number }; + }; +} + +function runCli(dir: string, args: string[]): 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' }), + 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 --unique-scopes', () => { + let root: string; + let plain: Run; + let proven: Run; + let control: Run; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-verify-unique-scopes-')); + const app = join(root, 'app'); + const controlApp = join(root, 'control'); + for (const [dir, source] of [ + [app, defineStackSource({ manifest: MANIFEST, objects: OBJECTS })], + [controlApp, defineStackSourceFromLiteral(CONTROL_SOURCE)], + ] as const) { + mkdirSync(dir); + writeFileSync(join(dir, 'objectstack.config.mjs'), source); + linkSpec(dir); + } + plain = runCli(app, ['verify', '--json']); + proven = runCli(app, ['verify', '--unique-scopes', '--json']); + control = runCli(controlApp, ['verify', '--unique-scopes', '--json']); + }, 480_000); + + afterAll(() => { + if (root) rmSync(root, { 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('uniqueScopes' in doc).toBe(false); + expect(doc.hardFailures).toBe(0); + }); + + it('proves each declared scope within the one organization, names the leg it cannot run, and exits 0', () => { + expect(proven.status, `${proven.stdout}\n${proven.stderr}`).toBe(0); + const doc = docOf(proven); + expect(doc.uniqueScopes?.boot).toMatchObject({ posture: 'single', legs: { within: 'run', across: 'not-run' } }); + expect(doc.uniqueScopes?.boot.acrossNotRun).toMatch(/--multi-tenant/); + expect(doc.uniqueScopes?.results).toEqual([ + expect.objectContaining({ object: 'vus_account', field: 'name', declared: true, status: 'proven', doors: ['rest', 'system'] }), + expect.objectContaining({ object: 'vus_account', field: 'code', declared: 'organization', status: 'proven' }), + expect.objectContaining({ object: 'vus_account', field: 'ext_id', declared: 'global', status: 'proven' }), + expect.objectContaining({ object: 'vus_account', field: 'number', declared: 'organization', status: 'proven', doors: ['system'] }), + ]); + expect(doc.uniqueScopes?.summary).toMatchObject({ proven: 4, contradicted: 0, failed: 0, hardFailures: 0 }); + expect(doc.hardFailures).toBe(0); + }); + + it('counts a door that faults on a duplicate, and exits 1', () => { + expect(control.status, `${control.stdout}\n${control.stderr}`).toBe(1); + const doc = docOf(control); + expect(doc.uniqueScopes?.results).toEqual([ + expect.objectContaining({ field: 'name', status: 'proven' }), + expect.objectContaining({ field: 'code', status: 'failed' }), + expect.objectContaining({ field: 'ext_id', status: 'proven' }), + expect.objectContaining({ field: 'number', status: 'proven' }), + ]); + expect(doc.uniqueScopes?.summary).toMatchObject({ proven: 3, failed: 1, hardFailures: 1 }); + expect(doc.hardFailures).toBe(1); + }); +}); diff --git a/packages/verify/README.md b/packages/verify/README.md index d43bd1fe493..53f80bc0d9d 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. Four app-agnostic proof families, all +stack** — no mocks, no ports, no sockets. Five app-agnostic proof families, all derived from your own metadata: - **Data fidelity** — author one record per object covering every field type, @@ -21,6 +21,14 @@ derived from your own metadata: holder, `set_null` must keep it with the link cleared, and `restrict` must refuse the delete (`DELETE_RESTRICTED` naming the holder's object); a second holder of another target record must be left alone either way. +- **Declared unique scope** (opt-in) — *every field declared `unique` collides + as its scope says* (ADR-0120): per field, a first record holds a value and a + second one is offered it, by insert and by update, through the REST door and + the system door. Within one organization the second holder must be refused + (`UNIQUE_VIOLATION` / `DUPLICATE_RECORD`, attributed to the field). On an + org-scoped boot a second organization is provisioned too, and there a + `true` / `'organization'` value must be admitted while a `'global'` one, or + one on an object with no organization column, must be refused. 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 @@ -62,6 +70,9 @@ objectstack verify --reference-shapes # also prove every declared deleteBehavior is what a delete does objectstack verify --delete-behaviors + +# also prove every declared unique collides where its scope says (both legs) +objectstack verify --unique-scopes --multi-tenant ``` `--reference-shapes` is opt-in, on a fresh boot of its own: the engine still @@ -76,9 +87,25 @@ A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so on an app that declares one the family reports a contradiction and fails the run. +`--unique-scopes` is opt-in, on a fresh boot of its own (it writes duplicates on +purpose). The scope is read as the spec's parse of the field states it: an +`autonumber` that authors no `unique` states `'organization'`. A field the +caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is +probed through the system door only, which keeps an explicit value. Without +`--multi-tenant` the boot has one organization and no wall, so the +cross-organization leg is reported **not run**, with the reason, on the +report's `boot` — run it org-scoped to prove that `true` / `'organization'` +lets two organizations hold one value (the walled boot needs a declared +membership policy, `OS_AUTH_MEMBERSHIP_POLICY`). A refusal proves the +collision only when it is the platform's unique refusal and it is this field's: +it names the field, or it names none (a per-organization constraint is an +expression index, which the driver reports by index name) and the same record +with only the probed value changed is admitted. + Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, `fidelity-gaps`, `rls-hole`, with `--reference-shapes` a field with `holes` or -`failed`, and with `--delete-behaviors` a field `contradicted` or `failed`) so +`failed`, and with `--delete-behaviors` or `--unique-scopes` a field +`contradicted` or `failed`) so it drops straight into a CI gate. Inconclusive verdicts (`needs-fixture`, `skipped`, `member-visible`) are warnings and exit 0. @@ -89,6 +116,7 @@ import { bootStack, runCrudVerification, runRlsProofs, formatReport, runReferenceShapeProofs, formatReferenceShapeReport, runDeleteBehaviorProofs, formatDeleteBehaviorReport, + runUniqueScopeProofs, formatUniqueScopeReport, } from '@objectstack/verify'; import myApp from './objectstack.config.js'; @@ -120,6 +148,14 @@ const deletes = await runDeleteBehaviorProofs(deleteStack, await deleteStack.sig console.log(formatDeleteBehaviorReport(deletes)); expect(deletes.summary.hardFailures).toBe(0); await deleteStack.stop(); + +// Declared unique scope, on a fresh stack: it writes duplicates on purpose. +// Boot it with `{ multiTenant: true }` to run the cross-organization leg too. +const uniqueStack = await bootStack(myApp); +const uniques = await runUniqueScopeProofs(uniqueStack, await uniqueStack.signIn(), myApp); +console.log(formatUniqueScopeReport(uniques)); // `uniques.boot` says which legs ran +expect(uniques.summary.hardFailures).toBe(0); +await uniqueStack.stop(); ``` ## The in-process handle (drive the real engine from a test) @@ -326,6 +362,20 @@ and per door (by-id deletes only: a predicate or multi-row delete is not asked): | `excluded` | the declaration states no outcome of its own: a `set_null` on a required reference (it cannot be cleared, so an engine rule decides), or a field the spec's parse gives no `deleteBehavior` (a bare `master_detail`, a `user`) | | `not-derivable` | no fixture can be built from the app's metadata: the target is not an object the app declares (`sys_user`), the field is `readonly` / `system`, or the CRUD derivation cannot synthesize the holder or the target | +**Declared unique scope** (`runUniqueScopeProofs`), per field declared `unique` +(the field-level declaration; a unique `indexes[]` entry is a different surface +and is not in the population). Probes: `within` × door × insert and update, and +on a walled boot `across` × door × insert: + +| status | meaning | +| --- | --- | +| `proven` | every second holder met what the declared scope says: refused within one organization; across, admitted for `true` / `'organization'`, refused for `'global'` or with no organization column | +| `contradicted` | a second holder was admitted where the scope refuses it, or refused where it admits it (the constraint is installation-wide, and its refusal tells one organization another holds the value) **(failure)** | +| `failed` | a door faulted **(failure)** | +| `inconclusive` | a refusal was not this field's unique constraint (an app's own dedupe, a rule, a permission, a constraint on another field), or a fixture could not be written (the reason is reported) | +| `excluded` | a derived value (`formula`, `summary`) is not stored, or the declaration does not parse | +| `not-derivable` | no fresh value can be built: a closed or tiny value space (`boolean`, `select`, …), a multi-valued reference, a target the app does not declare, or an object the CRUD derivation cannot synthesize | + `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. @@ -367,6 +417,10 @@ run" must never read like "nothing to find". - `deriveDeleteBehaviorCases(config)` → one case per reference field, with its declared behaviour or the reason it is not compared; `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport` (`summary.hardFailures` is what a gate reads); `formatDeleteBehaviorReport(report)`. +- `deriveUniqueScopeCases(config)` → one case per field declared `unique`, with its parsed scope, its doors and what a + second organization meets, or the reason it is not compared; `runUniqueScopeProofs(stack, token, config, { doors? })` + → `UniqueScopeReport` (`boot` names the legs this boot ran; `summary.hardFailures` is what a gate reads); + `formatUniqueScopeReport(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 7012630ffa6..5ceca92d529 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -3,11 +3,12 @@ // @objectstack/verify — public API. // // Boot any ObjectStack app in-process and verify it through the real HTTP -// stack. Four proof families, all app-agnostic (derived from your metadata): +// stack. Five 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" // - delete behaviour: runDeleteBehaviorProofs — "a declared deleteBehavior is what a delete does" +// - unique scope : runUniqueScopeProofs — "a declared unique collides where its scope says" export { bootStack, bootStackOnce } from './harness.js'; export type { VerifyStack, BootOptions } from './harness.js'; @@ -101,6 +102,29 @@ export type { DeleteBehaviorProofOptions, } from './delete-behavior.js'; +// ADR-0120 D1 — the unique-scope family: every field a stack declares `unique` +// collides as its declared scope says (within one organization; across two +// only for `'global'` or an object with no organization column), asked of the +// engine through the REST door and the system door. +export { + deriveUniqueScopeCases, + runUniqueScopeProofs, + formatUniqueScopeReport, +} from './unique-scope.js'; +export type { + DeclaredUniqueScope, + UniqueScopeCase, + UniqueScopeDoor, + UniqueScopeLeg, + UniqueScopeOperation, + UniqueScopeAnswer, + UniqueScopeProbeResult, + UniqueScopeFieldStatus, + UniqueScopeFieldResult, + UniqueScopeReport, + UniqueScopeProofOptions, +} from './unique-scope.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/unique-scope.test.ts b/packages/verify/src/unique-scope.test.ts new file mode 100644 index 00000000000..25cc7a632b2 --- /dev/null +++ b/packages/verify/src/unique-scope.test.ts @@ -0,0 +1,424 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The unique-scope family — what it reads from the metadata, what it proves on +// a real stack, and the controls that keep its green honest: +// +// • two NEGATIVE controls, one per leg. Within: a field declared unique on a +// table that already holds a duplicate when the declaration arrives, so the +// driver cannot create the index (ADR-0120 D4: the constraint is not +// enforced until the rows are deduplicated) — every second holder is +// admitted, and must be reported contradicted. Across: a field declared +// per-organization beside a declared `'global'` index on the same column +// (the R10 double declaration), so another organization is refused the +// value — the declared per-organization scope must be reported contradicted; +// • the ATTRIBUTION control: a refusal that is not this field's unique +// constraint (an app hook's own dedupe; a constraint on another field the +// twin also meets) must never read as a proven collision. +// +// The across leg needs two organizations, so it runs on a walled boot. The +// real multi-organization runtime is host-supplied and no framework package +// may declare it, so these suites boot `multiTenant: 'posture-only'`: a walled +// posture with real organizations (`/auth/organization/create`) and real +// per-caller stamping. The family's subject is which rows collide, which the +// index decides from each row's organization; isolation of reads is not under +// test here. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { defineStack } from '@objectstack/spec'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { + deriveUniqueScopeCases, + runUniqueScopeProofs, + formatUniqueScopeReport, + type UniqueScopeReport, +} from './unique-scope.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; +const RUN_TIMEOUT = 180_000; + +/** + * One field per scope and per door shape the family probes, and one per shape + * it names instead. Booted as `defineStack` output, which is what an app's + * artifact is: the parse is what puts the autonumber's `'organization'` on the + * field the runtime registers. + */ +const source = { + manifest: { id: 'com.example.unique-scope', namespace: 'uqs', version: '0.0.1', type: 'app', name: 'Unique Scope Fixture' }, + objects: [ + { + name: 'uqs_account', + label: 'Account', + fields: { + name: { type: 'text', label: 'Name', required: true, unique: true }, + code: { type: 'text', label: 'Code', unique: 'organization' }, + ext_id: { type: 'text', label: 'External id', unique: 'global' }, + email: { type: 'email', label: 'Email', unique: true }, + // Nothing authored: the spec's parse states `'organization'` for an autonumber. + number: { type: 'autonumber', label: 'Number' }, + stamped: { type: 'text', label: 'Stamped', readonly: true, unique: 'global' }, + // Not in the population: no unique scope after the parse. + sequence: { type: 'autonumber', label: 'Sequence', unique: false }, + note: { type: 'text', label: 'Note' }, + active: { type: 'boolean', label: 'Active', unique: true }, + }, + }, + { + name: 'uqs_badge', + label: 'Badge', + fields: { + title: { type: 'text', label: 'Title' }, + holder: { type: 'lookup', reference: 'uqs_account', label: 'Holder', unique: true }, + }, + }, + { + name: 'uqs_flat', + label: 'Flat', + tenancy: { enabled: false }, + fields: { name: { type: 'text', label: 'Name', unique: true } }, + }, + ], +}; + +const app = defineStack(source as never) as unknown as typeof source; + +const PROBED_WITHIN = [ + ['uqs_account', 'name', ['rest', 'system']], + ['uqs_account', 'code', ['rest', 'system']], + ['uqs_account', 'ext_id', ['rest', 'system']], + ['uqs_account', 'email', ['rest', 'system']], + ['uqs_account', 'number', ['system']], + ['uqs_account', 'stamped', ['system']], + ['uqs_badge', 'holder', ['rest', 'system']], + ['uqs_flat', 'name', ['rest', 'system']], +] as const; + +const resultFor = (report: UniqueScopeReport, object: string, field: string) => { + const r = report.results.find((x) => x.object === object && x.field === field); + if (!r) throw new Error(`no result for ${object}.${field}`); + return r; +}; + +const shape = (report: UniqueScopeReport, object: string, field: string) => + resultFor(report, object, field).probes.map((p) => `${p.leg} ${p.door} ${p.operation} ${p.expected} ${p.outcome}`); + +describe('deriveUniqueScopeCases — what the family reads from the metadata', () => { + it("reads each field's scope through the spec's parse, and names what it cannot compare", () => { + const config = { + objects: [ + ...source.objects, + { name: 'uqs_bad', fields: { name: { type: 'text', unique: 'tenant' } } }, + { name: 'uqs_calc', fields: { total: { type: 'formula', expression: '1 + 1', unique: true } } }, + ], + }; + const byKey = new Map(deriveUniqueScopeCases(config).map((c) => [`${c.object}.${c.field}`, c])); + + // Every field the parse declares unique, and nothing else (`sequence` opted out, `note`/`title` declare none). + expect([...byKey.keys()].sort()).toEqual([ + 'uqs_account.active', 'uqs_account.code', 'uqs_account.email', 'uqs_account.ext_id', 'uqs_account.name', + 'uqs_account.number', 'uqs_account.stamped', 'uqs_bad.name', 'uqs_badge.holder', 'uqs_calc.total', 'uqs_flat.name', + ]); + expect(byKey.get('uqs_account.name')).toMatchObject({ declared: true, scope: 'organization', across: 'admitted', doors: ['rest', 'system'] }); + expect(byKey.get('uqs_account.code')).toMatchObject({ declared: 'organization', scope: 'organization', across: 'admitted' }); + expect(byKey.get('uqs_account.ext_id')).toMatchObject({ declared: 'global', scope: 'global', across: 'refused' }); + // The autonumber's scope is the parse's, and the REST door is named, not probed. + expect(byKey.get('uqs_account.number')).toMatchObject({ + declared: 'organization', + doors: ['system'], + restExcluded: expect.stringMatching(/^runtime-issued \(autonumber\)/), + }); + expect(byKey.get('uqs_account.stamped')).toMatchObject({ declared: 'global', doors: ['system'], restExcluded: expect.stringMatching(/^readonly/) }); + // No organization column: a per-organization spelling is installation-wide. + expect(byKey.get('uqs_flat.name')).toMatchObject({ declared: true, organizationColumn: false, across: 'refused' }); + // A one-to-one reference is probed within; across, the value is one organization's record. + expect(byKey.get('uqs_badge.holder')).toMatchObject({ declared: true, acrossExcluded: expect.stringMatching(/^a reference value is a record of one organization/) }); + expect(byKey.get('uqs_badge.holder')!.notDerivable).toBeUndefined(); + // Named instead of probed. + expect(byKey.get('uqs_account.active')).toMatchObject({ notDerivable: expect.stringMatching(/^a boolean value space/) }); + expect(byKey.get('uqs_calc.total')).toMatchObject({ excluded: expect.stringMatching(/^a formula value is derived on read/) }); + expect(byKey.get('uqs_bad.name')).toMatchObject({ excluded: expect.stringMatching(/^the declaration does not parse under the spec's FieldSchema \(unique: Invalid unique scope 'tenant'/) }); + expect(byKey.get('uqs_bad.name')!.declared).toBeUndefined(); + }); +}); + +describe('runUniqueScopeProofs — a single-posture boot (the untenanted install)', () => { + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + stack = await bootStack(app as never); + report = await runUniqueScopeProofs(stack, await stack.signIn(), app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('reports the across leg not run, with the reason, rather than skipping it', () => { + expect(report.boot).toMatchObject({ posture: 'single', walled: false, legs: { within: 'run', across: 'not-run' } }); + expect(report.boot.acrossNotRun).toMatch(/no organization wall, one organization/); + expect(formatUniqueScopeReport(report)).toContain('across leg NOT RUN'); + }); + + it('proves every probed declaration within the one organization, through its doors, by insert and by update', () => { + for (const [object, field, doors] of PROBED_WITHIN) { + const r = resultFor(report, object, field); + expect(r.status, `${object}.${field}: ${JSON.stringify(r.probes)}`).toBe('proven'); + expect(shape(report, object, field)).toEqual( + doors.flatMap((d) => [`within ${d} insert refused held`, `within ${d} update refused held`]), + ); + } + }); + + it('reads the refusal it credits: the unique code at each door, the field named only for a bare-column index', () => { + const at = (object: string, field: string, door: string, operation: string) => + resultFor(report, object, field).probes.find((p) => p.door === door && p.operation === operation)!; + // A per-organization index is an expression index: no field, so the twin attributes it. + expect(at('uqs_account', 'name', 'rest', 'insert')).toMatchObject({ + answer: { kind: 'refused', code: 'UNIQUE_VIOLATION', status: 409, object: 'uqs_account' }, + twin: 'written', + }); + expect(at('uqs_account', 'name', 'rest', 'insert').answer!.field).toBeUndefined(); + expect(at('uqs_account', 'name', 'system', 'insert')).toMatchObject({ answer: { kind: 'refused', code: 'DUPLICATE_RECORD', status: 409 }, twin: 'written' }); + // A global index, or no organization column: the column is named, no twin needed. + expect(at('uqs_account', 'ext_id', 'rest', 'insert')).toMatchObject({ answer: { code: 'UNIQUE_VIOLATION', field: 'ext_id' } }); + expect(at('uqs_account', 'ext_id', 'rest', 'insert').twin).toBeUndefined(); + expect(at('uqs_flat', 'name', 'system', 'insert')).toMatchObject({ answer: { code: 'DUPLICATE_RECORD', field: 'name' } }); + // An update changes only the probed value, so it needs no twin. + expect(at('uqs_account', 'code', 'rest', 'update')).toMatchObject({ answer: { kind: 'refused', code: 'UNIQUE_VIOLATION' } }); + expect(at('uqs_account', 'code', 'rest', 'update').twin).toBeUndefined(); + }); + + it('counts the shapes it names instead of probing, and reports no hard failure', () => { + expect(resultFor(report, 'uqs_account', 'active')).toMatchObject({ status: 'not-derivable', probes: [] }); + expect(report.summary).toEqual({ + fields: 9, + probed: { organization: 6, global: 2 }, + proven: 8, + contradicted: 0, + failed: 0, + inconclusive: 0, + excluded: 0, + notDerivable: 1, + probes: 28, + held: 28, + contradictions: 0, + unattributed: 0, + faults: 0, + notRun: 0, + hardFailures: 0, + }); + expect(formatUniqueScopeReport(report)).toContain('8 proven, 0 CONTRADICTED, 0 FAILED'); + }); + + it('names a field whose only door the run does not ask, instead of counting it', async () => { + const account = source.objects[0]!; + const systemOnly = { ...source, objects: [{ ...account, fields: { number: account.fields.number, stamped: account.fields.stamped } }] }; + const restOnly = await runUniqueScopeProofs(stack, await stack.signIn(), systemOnly, { doors: ['rest'] }); + for (const field of ['number', 'stamped']) { + expect(resultFor(restOnly, 'uqs_account', field)).toMatchObject({ + status: 'inconclusive', + probes: [], + reason: expect.stringMatching(/^probed through the system door only \(.*\), which this run does not ask$/), + }); + } + expect(restOnly.summary).toMatchObject({ proven: 0, inconclusive: 2, probes: 0, hardFailures: 0 }); + }); +}); + +describe('runUniqueScopeProofs — a walled boot (two organizations)', () => { + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + stack = await bootStack(app as never, { multiTenant: 'posture-only' }); + report = await runUniqueScopeProofs(stack, await stack.signIn(), app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('runs both legs, into two organizations it provisioned', () => { + expect(report.boot).toMatchObject({ posture: 'isolated', walled: true, legs: { within: 'run', across: 'run' } }); + expect(report.boot.acrossNotRun).toBeUndefined(); + }); + + it('admits a per-organization value in another organization, and refuses a global one or one with no organization column', () => { + expect(shape(report, 'uqs_account', 'name')).toEqual([ + 'within rest insert refused held', 'within rest update refused held', + 'within system insert refused held', 'within system update refused held', + 'across rest insert admitted held', 'across system insert admitted held', + ]); + expect(shape(report, 'uqs_account', 'ext_id').slice(4)).toEqual(['across rest insert refused held', 'across system insert refused held']); + expect(shape(report, 'uqs_account', 'number').slice(2)).toEqual(['across system insert admitted held']); + // No organization column: the system door names no organization, so the across leg is the REST door's. + expect(shape(report, 'uqs_flat', 'name').slice(4)).toEqual(['across rest insert refused held']); + // A reference's across leg is named, not run. + expect(shape(report, 'uqs_badge', 'holder')).toHaveLength(4); + for (const [object, field] of PROBED_WITHIN) { + expect(resultFor(report, object, field).status, `${object}.${field}`).toBe('proven'); + } + expect(report.summary).toMatchObject({ proven: 8, contradicted: 0, failed: 0, inconclusive: 0, probes: 39, held: 39, hardFailures: 0 }); + }); +}); + +describe('runUniqueScopeProofs — NEGATIVE CONTROL, within: a declaration its database cannot enforce', () => { + const controlApp = (unique: boolean) => ({ + manifest: { id: 'com.example.unique-scope-within', namespace: 'uqw', version: '0.0.1', type: 'app', name: 'Within Control' }, + objects: [{ name: 'uqw_item', label: 'Item', fields: { name: { type: 'text', label: 'Name', ...(unique ? { unique: true } : {}) } } }], + }); + let dir: string; + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + // The table already holds a duplicate when the declaration arrives: the + // driver cannot create the index, and says the constraint is not enforced. + dir = mkdtempSync(join(tmpdir(), 'verify-unique-scope-')); + const databaseFile = join(dir, 'db.sqlite'); + const before = await bootStack(controlApp(false) as never, { databaseFile }); + await before.seed('uqw_item', [{ name: 'held twice' }, { name: 'held twice' }]); + await before.stop(); + stack = await bootStack(controlApp(true) as never, { databaseFile }); + report = await runUniqueScopeProofs(stack, await stack.signIn(), controlApp(true)); + }, 2 * BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('reports every admitted second holder contradicted, through both doors, by insert and by update', () => { + const r = resultFor(report, 'uqw_item', 'name'); + expect(r.status, JSON.stringify(r.probes)).toBe('contradicted'); + expect(shape(report, 'uqw_item', 'name')).toEqual([ + 'within rest insert refused contradicted', 'within rest update refused contradicted', + 'within system insert refused contradicted', 'within system update refused contradicted', + ]); + for (const p of r.probes) expect(p.detail).toMatch(/^declared unique: true, and a second record holding the value in the same organization was admitted/); + expect(report.summary).toMatchObject({ contradicted: 1, contradictions: 4, hardFailures: 1 }); + expect(formatUniqueScopeReport(report)).toContain('✗ uqw_item.name unique: true CONTRADICTED on 4 of 4 write(s):'); + }); +}); + +describe('runUniqueScopeProofs — NEGATIVE CONTROL, across: a per-organization field a global index overrides', () => { + const controlApp = { + manifest: { id: 'com.example.unique-scope-across', namespace: 'uqx', version: '0.0.1', type: 'app', name: 'Across Control' }, + objects: [ + { + name: 'uqx_item', + label: 'Item', + fields: { name: { type: 'text', label: 'Name', unique: true } }, + indexes: [{ fields: ['name'], unique: 'global' }], + }, + ], + }; + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + stack = await bootStack(controlApp as never, { multiTenant: 'posture-only' }); + report = await runUniqueScopeProofs(stack, await stack.signIn(), controlApp); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('reports the declared per-organization scope contradicted across, and held within', () => { + const r = resultFor(report, 'uqx_item', 'name'); + expect(r.status, JSON.stringify(r.probes)).toBe('contradicted'); + expect(shape(report, 'uqx_item', 'name')).toEqual([ + 'within rest insert refused held', 'within rest update refused held', + 'within system insert refused held', 'within system update refused held', + 'across rest insert admitted contradicted', 'across system insert admitted contradicted', + ]); + for (const p of r.probes.filter((x) => x.leg === 'across')) { + expect(p.answer).toMatchObject({ kind: 'refused', field: 'name' }); + expect(p.detail).toMatch(/^declared unique: true, and a record in another organization was refused the value/); + } + expect(report.summary).toMatchObject({ contradicted: 1, contradictions: 2, hardFailures: 1 }); + }); +}); + +/** + * The attribution control. `uqa_contact`'s hooks refuse a repeated email with + * the app's own code before the engine writes (an app's dedupe, the shape + * hotcrm's contact hook has); `uqa_tagged`'s hook stamps one `code` on every + * record, so every record after the first meets `code`'s constraint, and so + * does the twin of any probe of `label`. + */ +const attributionApp = { + manifest: { id: 'com.example.unique-scope-attribution', namespace: 'uqa', version: '0.0.1', type: 'app', name: 'Attribution Control' }, + objects: [ + { name: 'uqa_contact', label: 'Contact', fields: { email: { type: 'email', label: 'Email', unique: true } } }, + { + name: 'uqa_tagged', + label: 'Tagged', + fields: { label: { type: 'text', label: 'Label', unique: true }, code: { type: 'text', label: 'Code', unique: true } }, + }, + ], + hooks: [ + { + name: 'uqa_contact_dedupes_email', + object: 'uqa_contact', + events: ['beforeInsert', 'beforeUpdate'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: async (ctx: { input: { email?: unknown; id?: unknown }; api: any }) => { + const email = ctx.input.email; + if (typeof email !== 'string') return; + const existing = await ctx.api.object('uqa_contact').find({ where: { email } }); + if (existing.some((row: { id?: unknown }) => row.id !== ctx.input.id)) { + throw Object.assign(new Error('Another contact already has this email'), { code: 'DUPLICATE_VALUE', status: 409 }); + } + }, + }, + { + name: 'uqa_tagged_stamps_one_code', + object: 'uqa_tagged', + events: ['beforeInsert'], + handler: async (ctx: { input: { code?: unknown } }) => { + ctx.input.code = 'one code for every record'; + }, + }, + ], +}; + +describe('runUniqueScopeProofs — ATTRIBUTION CONTROL', () => { + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + stack = await bootStack(attributionApp as never); + report = await runUniqueScopeProofs(stack, await stack.signIn(), attributionApp); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it("never credits an app's own dedupe refusal to the declared constraint", () => { + const r = resultFor(report, 'uqa_contact', 'email'); + expect(r.status, JSON.stringify(r.probes)).toBe('inconclusive'); + expect(r.probes.map((p) => p.outcome)).toEqual(['unattributed', 'unattributed', 'unattributed', 'unattributed']); + for (const p of r.probes) expect(p.detail).toMatch(/^refused for another reason: /); + // In process the app's own code arrives verbatim. + for (const p of r.probes.filter((x) => x.door === 'system')) expect(p.answer).toMatchObject({ kind: 'refused', code: 'DUPLICATE_VALUE' }); + }); + + it('never credits a field-less unique refusal the twin meets too', () => { + const r = resultFor(report, 'uqa_tagged', 'label'); + expect(r.status, JSON.stringify(r.probes)).toBe('inconclusive'); + const first = r.probes[0]!; + expect(first).toMatchObject({ leg: 'within', door: 'rest', operation: 'insert', outcome: 'unattributed', twin: 'refused' }); + expect(first.detail).toMatch(/^the unique refusal names no field, and the same record with only label changed was refused too/); + expect(r.probes.some((p) => p.outcome === 'held')).toBe(false); + expect(report.summary).toMatchObject({ proven: 0, contradicted: 0, hardFailures: 0 }); + }); +}); diff --git a/packages/verify/src/unique-scope.ts b/packages/verify/src/unique-scope.ts new file mode 100644 index 00000000000..fee9f33f4a0 --- /dev/null +++ b/packages/verify/src/unique-scope.ts @@ -0,0 +1,858 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The unique-scope family: every field a stack declares `unique` collides as +// its declared scope says. +// +// ## What the family asks, and of whom +// +// The PROMISE is the declaration's, read from the field as the spec's own +// parse produces it (`FieldSchema` in `@objectstack/spec/data`), which is the +// value a built artifact carries: an authored scope is kept verbatim, and an +// `autonumber` that authors no `unique` parses to `'organization'`. What each +// scope means is `UniqueScopeSchema`'s published meaning (ADR-0120 D1, D3): +// +// - `true` / `'organization'` — one holder per organization: a second +// record holding the value in the SAME organization is refused, and a +// record in ANOTHER organization may hold it too; +// - `'global'` — one holder across the whole installation: refused in both; +// - on an object with no organization column (`isTenancyDisabled`, ADR-0066) +// both per-organization spellings degrade to the column alone, so the +// value is installation-wide there too. +// +// The ANSWER is the engine's, asked through two write doors: the REST data +// route as a 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 import, a migration, a seed +// replay or a `runAs: 'system'` flow writes through). +// +// ## The two legs, and which boot runs them +// +// - `within` — the second holder is written into the organization the first +// was: on a `single`-posture boot that is the install's one organization +// (the untenanted install: no wall, every write lands in it), on a walled +// boot one organization of the wall. Expected: refused, always. +// - `across` — the second holder is written into ANOTHER organization. +// Only a walled boot (`os verify --multi-tenant`) has two; on a `single` +// boot the leg is reported NOT RUN, with the reason, never skipped +// silently. Expected: admitted for a per-organization scope, refused for +// `'global'` and for an object with no organization column. +// +// On a walled boot the family provisions the second organization the way a +// user makes one: a fresh sign-up that creates its workspace +// (`POST /auth/organization/create`). The system door names each organization +// on the row (`organization_id`), which is how a system writer must address +// one under a wall (an org-less system write is refused there). +// +// ⛔ Nothing here resolves a scope. A field whose scope is decided by something +// other than its declaration is EXCLUDED or NOT DERIVABLE and named, never +// given an expectation copied from the engine. +// +// ## Attribution is the control +// +// A refusal proves a collision only when it is the platform's unique refusal +// (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door, +// naming the probed object) AND it is this field's: either the refusal names +// the probed field, or it names none and a TWIN — the same record with only the +// probed value changed — is admitted. The twin is needed because a +// per-organization constraint is an expression index, and the driver names the +// index, not the column, so the refusal carries no `field` (that is +// `uniqueViolationColumn`'s contract: an index name is never reported as a +// column). Any other refusal — a hook's own dedupe, a validation rule, a +// permission, a constraint on another field — is `unattributed`: an engine +// that refused every write would otherwise prove every scope. +// +// Run it on a fresh stack of its own: it writes duplicates by design, and the +// records it creates would collide with another family's on a unique field. + +import { + FieldSchema, + REFERENCE_VALUE_TYPES, + RUNTIME_OWNED_FIELD_TYPES, + isGlobalUnique, + isMultiValueField, + isTenancyDisabled, + isUniqueDeclared, +} from '@objectstack/spec/data'; +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { declaredCollection } from './artifact-collections.js'; +import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import type { VerifyStack } from './harness.js'; + +/** A scope a declaration can state (`UniqueScopeSchema`, minus `false`). */ +export type DeclaredUniqueScope = true | 'organization' | 'global'; + +/** The door a probe writes through. */ +export type UniqueScopeDoor = 'rest' | 'system'; + +/** Whether the second holder is written into the same organization or another one. */ +export type UniqueScopeLeg = 'within' | 'across'; + +/** The write that offers the second holder: a new record, or an existing one changed to the value. */ +export type UniqueScopeOperation = 'insert' | 'update'; + +const DOORS: readonly UniqueScopeDoor[] = ['rest', 'system']; + +/** The platform's unique refusal, per door (`DuplicateRecordError`; ADR-0112). */ +const UNIQUE_REFUSAL: Record = { rest: 'UNIQUE_VIOLATION', system: 'DUPLICATE_RECORD' }; + +/** Derived on read, never stored: no duplicate of one can be written. */ +const DERIVED_TYPES = new Set(['formula', 'summary', 'rollup']); + +/** The declared types whose values this family can vary, so every probe gets a fresh one. */ +const VARIABLE_TYPES = new Set([ + 'text', 'textarea', 'string', 'markdown', 'html', 'richtext', 'code', 'email', 'url', 'phone', + 'number', 'currency', 'percent', 'date', 'datetime', +]); + +/** One field the app declares unique, and what the family can read from its declaration. */ +export interface UniqueScopeCase { + object: string; + field: string; + type: string; + /** The scope the declaration states, as the spec's parse of the field carries it. */ + declared?: DeclaredUniqueScope; + /** `'organization'` for `true` and `'organization'` (synonyms), `'global'` for `'global'`. */ + scope?: 'organization' | 'global'; + /** Whether the object carries an organization column (`false` = `tenancy.enabled: false`). */ + organizationColumn: boolean; + /** What a second holder in ANOTHER organization meets. */ + across?: 'admitted' | 'refused'; + /** The doors the field is probed through. */ + doors: UniqueScopeDoor[]; + /** Why the REST door is not one of them. */ + restExcluded?: string; + /** Why the across leg is not run for this field. */ + acrossExcluded?: string; + /** Why the declaration states nothing this family can compare against. */ + excluded?: string; + /** Why no record holding a fresh value of the field can be built from the app's metadata. */ + notDerivable?: string; +} + +/** A field's scalar sample, or `undefined` with the reason when there is none. */ +interface ValueSource { + /** The CRUD derivation's sample for the field's type, varied per probe. */ + base?: string | number; + /** For a reference field: the object a fresh value is a new record of. */ + target?: string; + reason?: string; +} + +function valueSource(type: string, field: string, def: any, objectName: string, crud: CrudCase | undefined): ValueSource { + if (REFERENCE_VALUE_TYPES.has(type)) { + if (isMultiValueField({ type, multiple: def?.multiple === true })) { + return { reason: 'a multi-valued reference: its value is a list, not one value two records can share' }; + } + const ref = crud?.relationalRefs?.find((r) => r.field === field); + return ref + ? { target: ref.target } + : { reason: 'its target is not an object this app declares, so no record to reference can be built from its metadata' }; + } + const synthAs = RUNTIME_OWNED_FIELD_TYPES.has(type) ? 'text' : type; + if (!VARIABLE_TYPES.has(synthAs)) { + return { reason: `a ${type} value space is too small or too closed to give every probe a fresh value` }; + } + // The CRUD derivation's own sample, asked of a one-field object: it skips a + // readonly, system or runtime-issued field, and an autonumber stores text. + const probe = { objects: [{ name: objectName, fields: { [field]: { ...def, type: synthAs, readonly: false, system: false, required: false } } }] }; + const sample = deriveCrudCases(probe)[0]?.body?.[field]; + return typeof sample === 'string' || typeof sample === 'number' + ? { base: sample } + : { reason: `the CRUD derivation synthesizes no ${type} value` }; +} + +/** + * Derive one case per field the app declares unique, over every object it + * declares. The population is the field-level declaration (`FieldSchema.unique` + * after the spec's parse); a unique `indexes[]` entry is a different surface + * with its own meaning of bare `true` and is not in it. A field the family + * cannot compare is still returned, `excluded` or `notDerivable`, with the + * reason, so the report counts it. + */ +export function deriveUniqueScopeCases(config: any): UniqueScopeCase[] { + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const cases: UniqueScopeCase[] = []; + for (const obj of declaredCollection(config, 'objects')) { + if (!obj?.name) continue; + const organizationColumn = !isTenancyDisabled(obj); + for (const [field, def] of Object.entries(obj.fields ?? {})) { + const type = typeof def?.type === 'string' ? def.type : ''; + const parsed = FieldSchema.safeParse(def); + if (!parsed.success) { + if (def?.unique === undefined || def?.unique === false) continue; + const issue = parsed.error.issues[0]; + cases.push({ + object: obj.name, field, type, organizationColumn, doors: [], + excluded: `the declaration does not parse under the spec's FieldSchema (${issue?.path.join('.') || 'field'}: ${clip(issue?.message, 120)})`, + }); + continue; + } + const declared = parsed.data.unique; + if (!isUniqueDeclared(declared)) continue; + const scope = isGlobalUnique(declared) ? 'global' : 'organization'; + const c: UniqueScopeCase = { + object: obj.name, + field, + type, + declared: declared as DeclaredUniqueScope, + scope, + organizationColumn, + across: scope === 'global' || !organizationColumn ? 'refused' : 'admitted', + doors: [...DOORS], + }; + if (field === 'id' || def?.system === true || def?.readonly === true || RUNTIME_OWNED_FIELD_TYPES.has(type)) { + c.doors = ['system']; + c.restExcluded = + `${RUNTIME_OWNED_FIELD_TYPES.has(type) ? `runtime-issued (${type})` : def?.readonly === true ? 'readonly' : 'system'}: ` + + "the REST door drops a caller's value on this field, so no caller can offer a duplicate there"; + } + if (typeof obj.tenancy?.tenantField === 'string' && obj.tenancy.tenantField !== 'organization_id') { + c.acrossExcluded = `the object names its own tenant column (${obj.tenancy.tenantField}), and the family addresses an organization by organization_id`; + } + const source = DERIVED_TYPES.has(type) ? undefined : valueSource(type, field, def, obj.name, crud.get(obj.name)); + if (!source) { + c.excluded = `a ${type} value is derived on read, not stored, so no second record can hold it`; + } else if (source.reason) { + c.notDerivable = source.reason; + } else if (crud.get(obj.name)?.blocked) { + c.notDerivable = `the object cannot be synthesized: ${crud.get(obj.name)!.blocked}`; + } else if (source.target && crud.get(source.target)?.blocked) { + c.notDerivable = `the referenced object cannot be synthesized: ${crud.get(source.target)!.blocked}`; + } else if (source.target && !c.acrossExcluded) { + c.acrossExcluded = 'a reference value is a record of one organization, so another organization cannot offer the same one'; + } + cases.push(c); + } + } + return cases; +} + +/** What one write answered. */ +export interface UniqueScopeAnswer { + /** `written` — the door answered success; `refused` — it refused with a code; `faulted` — a 5xx, or a throw with no code. */ + kind: 'written' | 'refused' | 'faulted'; + /** The HTTP status (REST door), or the refusal's own `status`. */ + status?: number; + code?: string; + /** The refusal's `object` and `field` (the unique refusal names `field` only when the driver named a column). */ + object?: string; + field?: string; + detail?: string; +} + +/** What one probe found. */ +export interface UniqueScopeProbeResult { + leg: UniqueScopeLeg; + door: UniqueScopeDoor; + operation: UniqueScopeOperation; + /** What the declaration says the second holder meets. */ + expected: 'refused' | 'admitted'; + /** + * - `held` — the write met what the declaration says; + * - `contradicted` — it met something else (a hard failure; see `detail`); + * - `unattributed` — refused, but not by this field's unique constraint; + * - `failed` — the door faulted; + * - `not-run` — a fixture could not be written, or the write path changed what it was handed. + */ + outcome: 'held' | 'contradicted' | 'unattributed' | 'failed' | 'not-run'; + /** The second holder's write. */ + answer?: UniqueScopeAnswer; + /** The attribution control: the same record with only the probed value changed, when a refusal named no field. */ + twin?: 'written' | 'refused' | 'not-run'; + detail?: string; +} + +/** + * - `proven` — every probe held; + * - `contradicted` — some probe met something other than the declaration (a hard failure); + * - `failed` — a door faulted and none contradicted (a hard failure); + * - `inconclusive` — some probe was unattributed or could not run, none contradicted or faulted; + * - `excluded` — the declaration states nothing comparable (see `reason`); + * - `not-derivable` — no record holding a fresh value can be built from the app's metadata (see `reason`). + */ +export type UniqueScopeFieldStatus = 'proven' | 'contradicted' | 'failed' | 'inconclusive' | 'excluded' | 'not-derivable'; + +export interface UniqueScopeFieldResult { + object: string; + field: string; + type: string; + declared?: DeclaredUniqueScope; + scope?: 'organization' | 'global'; + organizationColumn: boolean; + across?: 'admitted' | 'refused'; + doors: UniqueScopeDoor[]; + restExcluded?: string; + acrossExcluded?: string; + status: UniqueScopeFieldStatus; + reason?: string; + probes: UniqueScopeProbeResult[]; +} + +export interface UniqueScopeReport { + app: string; + /** The boot the family ran on, and the legs it could run there. */ + boot: { + posture?: string; + walled: boolean; + legs: { within: 'run'; across: 'run' | 'not-run' }; + /** Why the across leg did not run, when it did not. */ + acrossNotRun?: string; + }; + results: UniqueScopeFieldResult[]; + summary: { + fields: number; + /** Fields probed (neither excluded nor not-derivable), per declared scope. */ + probed: { organization: number; global: number }; + proven: number; + contradicted: number; + failed: number; + inconclusive: number; + excluded: number; + notDerivable: number; + probes: number; + held: number; + contradictions: number; + unattributed: number; + faults: number; + notRun: number; + /** `contradicted + failed` — the count a CI gate reads. */ + hardFailures: number; + }; +} + +export interface UniqueScopeProofOptions { + /** The write doors to probe. Default: both, `rest` and `system`. */ + doors?: readonly UniqueScopeDoor[]; +} + +const clip = (s: unknown, n = 200): string => String(s ?? '').slice(0, n); + +const DAY_MS = 86_400_000; + +/** A `date` / `datetime` value moved `days` later, in the same spelling. */ +function laterBy(value: string, days: number): string { + const at = Date.parse(value); + if (Number.isNaN(at) || days === 0) return value; + const moved = new Date(at + days * DAY_MS).toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; +} + +/** The `k`-th fresh value of a field whose sample is `base`. */ +function vary(base: string | number, k: number): string | number { + if (typeof base === 'number') return base + k; + if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); + const at = base.indexOf('@'); + if (at > 0) return `${base.slice(0, at)}-u${k}${base.slice(at)}`; + if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/u${k}`; + if (/^\+\d+$/.test(base)) return `${base}${k}`; + return `${base}-u${k}`; +} + +/** Does a stored value equal the value written (a date read back in another spelling counts)? */ +function holds(stored: unknown, value: unknown): boolean { + if (stored === value) return true; + if (stored == null || value == null) return false; + if (typeof value === 'number') return Number(stored) === value; + if (typeof value === 'string' && typeof stored === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) { + return Date.parse(stored) === Date.parse(value); + } + return String(stored) === String(value); +} + +/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ +function uniqueFields(objectDef: any): Set { + const out = new Set(); + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.unique !== undefined && def?.unique !== false) out.add(name); + } + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); + } + return out; +} + +/** + * The smallest record of `objectDef` the CRUD derivation can offer: its values + * for the fields the object REQUIRES (required relations already filled), so + * every optional field takes the app's own default. Each `date` / `datetime` + * is one day after the one declared before it, and each unique-constrained + * value is made distinct by `n`, so a fixture collides with nothing but the + * value a probe writes on purpose. + */ +function fixtureBody(body: Record, objectDef: any, n: number): Record { + const unique = uniqueFields(objectDef); + const out: Record = {}; + let dates = 0; + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.required !== true || !(name in body)) continue; + let value = body[name]; + if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); + if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); + out[name] = value; + } + return out; +} + +/** A writer: one door, as one principal, into one organization. */ +interface Writer { + label: string; + door: UniqueScopeDoor; + /** The organization a system writer names on the row; a REST writer's is its session's. */ + stamp?: string; + insert(object: string, row: Record): Promise; + update(object: string, id: string, patch: Record): Promise; +} + +async function readRest(res: Response): 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) { + const id = body?.id ?? body?.record?.id; + return { kind: 'written', status, ...(id != null ? { id: String(id) } : {}) }; + } + if (status >= 500 || typeof body?.code !== 'string') { + return { kind: 'faulted', status, ...(body?.code ? { code: body.code } : {}), detail: clip(body?.error ?? text) }; + } + return { + kind: 'refused', + status, + code: body.code, + ...(typeof body.object === 'string' ? { object: body.object } : {}), + ...(typeof body.field === 'string' ? { field: body.field } : {}), + detail: clip(body.error ?? text), + }; +} + +async function readSystem(run: () => Promise): Promise { + try { + const row = (await run()) as { id?: unknown } | undefined; + return { kind: 'written', ...(row?.id != null ? { id: String(row.id) } : {}) }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; object?: unknown; field?: unknown; message?: unknown }; + if (typeof err?.code !== 'string') return { kind: 'faulted', detail: clip(err?.message ?? e) }; + return { + kind: 'refused', + code: err.code, + ...(typeof err.status === 'number' ? { status: err.status } : {}), + ...(typeof err.object === 'string' ? { object: err.object } : {}), + ...(typeof err.field === 'string' ? { field: err.field } : {}), + detail: clip(err.message), + }; + } +} + +function restWriter(stack: VerifyStack, token: string, label: string): Writer { + const call = async (method: string, path: string, body: unknown) => { + try { + return await readRest(await stack.apiAs(token, method, path, body)); + } catch (e) { + return { kind: 'faulted' as const, detail: clip((e as Error)?.message ?? e) }; + } + }; + return { + label, + door: 'rest', + insert: (object, row) => call('POST', `/data/${object}`, row), + update: (object, id, patch) => call('PATCH', `/data/${object}/${id}`, patch), + }; +} + +function systemWriter(stack: VerifyStack, label: string, stamp: string | undefined, organizationColumn: (object: string) => boolean): Writer { + const stamped = (object: string, row: Record) => + stamp && organizationColumn(object) ? { ...row, organization_id: stamp } : row; + return { + label, + door: 'system', + ...(stamp ? { stamp } : {}), + insert: (object, row) => readSystem(() => stack.hooks.run(object, 'insert', stamped(object, row), { system: true })), + update: (object, id, patch) => readSystem(() => stack.hooks.run(object, 'update', { ...patch, id }, { system: true })), + }; +} + +/** The observation one probe's verdict is read from. */ +interface UniqueScopeObservation { + answer: UniqueScopeAnswer; + twin?: 'written' | 'refused' | 'not-run'; + /** The first holder still holds the value after the second write. */ + holderHolds: boolean; + /** When the second write was admitted: the second record holds the value. */ + secondHolds?: boolean; + /** When the second write was admitted: both records carry the same organization. */ + sameOrganization?: boolean; +} + +/** + * Compare one second-holder write against its declaration. The whole verdict + * of the family is this function: it reads the declared scope's meaning, the + * door's answer and the rows the engine left, and nothing else. + */ +function judge( + c: UniqueScopeCase, + probe: { leg: UniqueScopeLeg; door: UniqueScopeDoor; operation: UniqueScopeOperation; expected: 'refused' | 'admitted' }, + o: UniqueScopeObservation, +): Pick { + const { answer } = o; + const declared = `declared unique: ${JSON.stringify(c.declared)}`; + if (answer.kind === 'faulted') return { outcome: 'failed', detail: `the door faulted: ${answer.detail ?? ''}` }; + if (answer.kind === 'refused') { + const unique = answer.code === UNIQUE_REFUSAL[probe.door] && (answer.object === undefined || answer.object === c.object); + if (!unique) { + return { outcome: 'unattributed', detail: `refused for another reason: ${answer.code}${answer.status ? ` (${answer.status})` : ''} ${answer.detail ?? ''}` }; + } + if (answer.field !== undefined && answer.field !== c.field) { + return { outcome: 'unattributed', detail: `refused by the unique constraint on another field (${answer.field})` }; + } + // A refusal naming no field is this field's when the only change that + // turns it into an admitted write is the probed value: an update changes + // nothing else, and an insert's twin changes nothing else. + if (answer.field === undefined && probe.operation === 'insert' && o.twin !== 'written') { + return { + outcome: 'unattributed', + detail: `the unique refusal names no field, and the same record with only ${c.field} changed was ${o.twin === 'refused' ? 'refused too' : 'not written'}`, + }; + } + if (probe.expected === 'refused') return { outcome: 'held' }; + return { + outcome: 'contradicted', + detail: + `${declared}, and a record in another organization was refused the value: the constraint is ` + + "installation-wide, and its refusal tells that organization the value is held elsewhere", + }; + } + if (o.secondHolds === false) return { outcome: 'not-run', detail: `the write path changed the ${c.field} value it was handed` }; + if (!o.holderHolds) return { outcome: 'unattributed', detail: `the first holder no longer holds the value: the write path moved it` }; + if (c.organizationColumn && probe.leg === 'within' && o.sameOrganization === false) { + return { outcome: 'not-run', detail: 'the write path placed the second record in another organization' }; + } + if (c.organizationColumn && probe.leg === 'across' && o.sameOrganization === true) { + return { outcome: 'not-run', detail: 'both records landed in one organization' }; + } + if (probe.expected === 'admitted') return { outcome: 'held' }; + const where = probe.leg === 'within' ? 'in the same organization' : 'in another organization'; + return { outcome: 'contradicted', detail: `${declared}, and a second record holding the value ${where} was admitted (${probe.operation})` }; +} + +function fieldStatus(probes: UniqueScopeProbeResult[]): { status: UniqueScopeFieldStatus; reason?: string } { + if (probes.some((p) => p.outcome === 'contradicted')) return { status: 'contradicted' }; + 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 run' + : `${open.length} of ${probes.length} probe(s) unattributed or not run: ${clip(open[0]?.detail, 160)}`, + }; + } + return { status: 'proven' }; +} + +/** The organization an execution context carries, if any. */ +async function organizationOf(stack: VerifyStack, token: string): Promise { + try { + const tenantId = (await stack.contextFor(token))?.tenantId; + return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; + } catch { + return undefined; + } +} + +/** A fresh sign-up that creates its own organization, the way a user makes a workspace. */ +async function provisionOrganization(stack: VerifyStack, tag: string): Promise<{ token: string; organization: string }> { + const token = await stack.signUp(`verify-unique-scope-${tag}@objectstack.test`); + const slug = `verify-unique-scope-${tag}`; + const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { name: `Verify unique scope ${tag}`, slug }); + if (res.status >= 300) throw new Error(`POST /auth/organization/create answered ${res.status}: ${clip(await res.text())}`); + const organization = await organizationOf(stack, token); + if (!organization) throw new Error('the new organization is not the sign-up\'s active organization'); + return { token, organization }; +} + +/** + * Run the unique-scope family against a live stack: the `within` leg through + * every door in `options.doors` (the REST door as the signed-in `token`, the + * engine's write as the system principal), and on a walled boot the `across` + * leg into a second organization the family provisions. Returns a structured + * report; never throws on a per-field failure (collects them). + */ +export async function runUniqueScopeProofs( + stack: VerifyStack, + token: string, + config: any, + options: UniqueScopeProofOptions = {}, +): Promise { + const doors = options.doors ?? DOORS; + const cases = deriveUniqueScopeCases(config); + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const objectDefs = new Map(); + for (const o of declaredCollection(config, 'objects')) if (o?.name) objectDefs.set(o.name, o); + const organizationColumn = (object: string) => !isTenancyDisabled(objectDefs.get(object)); + + // ── The boot, and the writers each leg writes through ────────────────────── + let posture: TenancyPosture | undefined; + try { + posture = stack.tenancy()?.posture as TenancyPosture | undefined; + } catch { + posture = undefined; + } + const walled = posture !== undefined && postureEnforcesWall(posture); + const writers = new Map(); + let acrossNotRun: string | undefined; + if (!walled) { + writers.set('rest:a', restWriter(stack, token, 'REST as the signed-in caller')); + writers.set('system:a', systemWriter(stack, 'system', undefined, organizationColumn)); + acrossNotRun = + `this boot's tenancy posture is '${posture ?? 'unknown'}': no organization wall, one organization, so there ` + + 'is no second organization to write into (boot org-scoped, `os verify --multi-tenant`, to run it)'; + } else { + let a: { token: string; organization: string } | undefined; + const own = await organizationOf(stack, token); + try { + a = own ? { token, organization: own } : await provisionOrganization(stack, 'a'); + const b = await provisionOrganization(stack, 'b'); + if (b.organization === a.organization) throw new Error('the second organization is the first one'); + writers.set('rest:b', restWriter(stack, b.token, `REST as a member of ${b.organization}`)); + writers.set('system:b', systemWriter(stack, `system into ${b.organization}`, b.organization, organizationColumn)); + } catch (e) { + acrossNotRun = `the second organization could not be provisioned: ${clip((e as Error)?.message ?? e)}`; + } + const first = a ?? { token, organization: own }; + writers.set('rest:a', restWriter(stack, first.token, `REST as a member of ${first.organization ?? 'no organization'}`)); + writers.set('system:a', systemWriter(stack, `system into ${first.organization ?? 'no organization'}`, first.organization, organizationColumn)); + } + + // ── Fixtures, per writer: base records for required relations, made once ── + const bases = new Map>(); + const createdIds = new Map>(); + let variant = 0; + + async function create( + w: Writer, + object: string, + overrides: Record, + n: number = ++variant, + ): Promise<{ id?: string; reason?: string; answer?: UniqueScopeAnswer }> { + const c = crud.get(object); + if (!c || c.blocked) return { reason: c?.blocked ?? `object ${object} not derived` }; + for (const ref of c.relationalRefs ?? []) { + if (!ref.required || ref.target === object) continue; + const dep = await base(w, ref.target); + if (!dep.id) return { reason: `required relation ${ref.field} → ${ref.target}: ${dep.reason}` }; + } + const ids = createdIds.get(w.label) ?? new Map(); + createdIds.set(w.label, ids); + const { body, missing } = fillRelationalRefs(c, ids); + if (missing) return { reason: missing }; + const answer = await w.insert(object, { ...fixtureBody(body, objectDefs.get(object), n), ...overrides }); + if (answer.kind !== 'written') return { answer, reason: `${w.label}: create ${object} ${answer.kind} (${answer.code ?? answer.status ?? ''}): ${answer.detail ?? ''}` }; + return answer.id ? { id: answer.id, answer } : { answer, reason: `${w.label}: create ${object} returned no id` }; + } + + async function base(w: Writer, object: string): Promise<{ id?: string; reason?: string }> { + const mine = bases.get(w.label) ?? new Map(); + bases.set(w.label, mine); + const known = mine.get(object); + if (known) return known; + mine.set(object, { reason: `base ${object} record is being created` }); + const made = await create(w, object, {}); + if (made.id) { + const ids = createdIds.get(w.label) ?? new Map(); + ids.set(object, made.id); + createdIds.set(w.label, ids); + } + mine.set(object, made); + return made; + } + + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]; + + // ── Probes ───────────────────────────────────────────────────────────────── + const results: UniqueScopeFieldResult[] = []; + for (const c of cases) { + const head = { + object: c.object, + field: c.field, + type: c.type, + ...(c.declared !== undefined ? { declared: c.declared, scope: c.scope, across: c.across } : {}), + organizationColumn: c.organizationColumn, + doors: c.doors, + ...(c.restExcluded ? { restExcluded: c.restExcluded } : {}), + ...(c.acrossExcluded ? { acrossExcluded: c.acrossExcluded } : {}), + }; + if (c.excluded) { + results.push({ ...head, status: 'excluded', reason: c.excluded, probes: [] }); + continue; + } + if (c.notDerivable) { + results.push({ ...head, status: 'not-derivable', reason: c.notDerivable, probes: [] }); + continue; + } + if (!c.doors.some((d) => doors.includes(d))) { + const why = c.restExcluded ? ` (${c.restExcluded})` : ''; + results.push({ ...head, status: 'inconclusive', reason: `probed through the ${c.doors.join(' and ')} door only${why}, which this run does not ask`, probes: [] }); + continue; + } + const source = valueSource(c.type, c.field, objectDefs.get(c.object)?.fields?.[c.field], c.object, crud.get(c.object)); + // Probe values draw on the fixtures' counter, so no probe value equals a fixture's. + const fresh = async (w: Writer): Promise<{ value?: unknown; reason?: string }> => { + if (source.base !== undefined) return { value: vary(source.base, ++variant) }; + const t = await create(w, source.target!, {}); + return t.id ? { value: t.id } : { reason: `a record to reference: ${t.reason}` }; + }; + const probes: UniqueScopeProbeResult[] = []; + + /** + * Write the first holder of a fresh value through `w`, and read back the + * value it holds. The second holder is offered THAT value: a write path + * that normalizes (an upper-casing hook) changes what is stored, and two + * records holding one value is a statement about stored values. + */ + const holder = async (w: Writer): Promise<{ id?: string; value?: unknown; reason?: string }> => { + const v = await fresh(w); + if (v.reason) return { reason: v.reason }; + const h = await create(w, c.object, { [c.field]: v.value }); + if (!h.id) return { reason: `the first holder: ${h.reason}` }; + const stored = (await row(c.object, h.id))?.[c.field]; + if (stored == null) return { reason: `the first holder as written holds no ${c.field}` }; + return { id: h.id, value: stored }; + }; + + const offerInsert = async (leg: UniqueScopeLeg, first: Writer, second: Writer): Promise => { + const expected = leg === 'within' ? 'refused' : c.across!; + const base = { leg, door: second.door, operation: 'insert' as const, expected }; + const h = await holder(first); + if (!h.id) return void probes.push({ ...base, outcome: 'not-run', detail: h.reason }); + const n = ++variant; + const d = await create(second, c.object, { [c.field]: h.value }, n); + if (!d.answer) return void probes.push({ ...base, outcome: 'not-run', detail: `the second holder: ${d.reason}` }); + let twin: UniqueScopeObservation['twin']; + if (d.answer.kind === 'refused' && d.answer.field === undefined) { + const v = await fresh(second); + const t = v.reason ? undefined : await create(second, c.object, { [c.field]: v.value }, n); + twin = !t?.answer ? 'not-run' : t.answer.kind === 'written' ? 'written' : 'refused'; + } + const holderRow = await row(c.object, h.id); + const secondRow = d.id ? await row(c.object, d.id) : undefined; + const observation: UniqueScopeObservation = { + answer: d.answer, + ...(twin ? { twin } : {}), + holderHolds: holds(holderRow?.[c.field], h.value), + ...(d.id ? { + secondHolds: holds(secondRow?.[c.field], h.value), + sameOrganization: (holderRow?.organization_id ?? null) === (secondRow?.organization_id ?? null), + } : {}), + }; + probes.push({ ...base, answer: d.answer, ...(twin ? { twin } : {}), ...judge(c, base, observation) }); + }; + + const offerUpdate = async (w: Writer): Promise => { + const base = { leg: 'within' as const, door: w.door, operation: 'update' as const, expected: 'refused' as const }; + const h = await holder(w); + if (!h.id) return void probes.push({ ...base, outcome: 'not-run', detail: h.reason }); + const other = await fresh(w); + const e = other.reason ? { reason: other.reason } : await create(w, c.object, { [c.field]: other.value }); + if (!e.id) return void probes.push({ ...base, outcome: 'not-run', detail: `the record to change: ${e.reason}` }); + const answer = await w.update(c.object, e.id, { [c.field]: h.value }); + const holderRow = await row(c.object, h.id); + const changed = await row(c.object, e.id); + const observation: UniqueScopeObservation = { + answer, + holderHolds: holds(holderRow?.[c.field], h.value), + ...(answer.kind === 'written' ? { + secondHolds: holds(changed?.[c.field], h.value), + sameOrganization: (holderRow?.organization_id ?? null) === (changed?.organization_id ?? null), + } : {}), + }; + probes.push({ ...base, answer, ...judge(c, base, observation) }); + }; + + for (const door of doors) { + if (!c.doors.includes(door)) continue; + const w = writers.get(`${door}:a`)!; + await offerInsert('within', w, w); + await offerUpdate(w); + } + if (!acrossNotRun && !c.acrossExcluded) { + for (const door of doors) { + if (!c.doors.includes(door)) continue; + // A system write to an object with no organization column names no + // organization, so its across write is the within write again. + if (door === 'system' && !c.organizationColumn) continue; + await offerInsert('across', writers.get(`${door}:a`)!, writers.get(`${door}:b`)!); + } + } + results.push({ ...head, ...fieldStatus(probes), probes }); + } + + const all = results.flatMap((r) => r.probes); + const count = (s: UniqueScopeFieldStatus) => results.filter((r) => r.status === s).length; + const probed = (scope: 'organization' | 'global') => + results.filter((r) => r.scope === scope && r.status !== 'excluded' && r.status !== 'not-derivable').length; + return { + app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', + boot: { + ...(posture ? { posture } : {}), + walled, + legs: { within: 'run', across: acrossNotRun ? 'not-run' : 'run' }, + ...(acrossNotRun ? { acrossNotRun } : {}), + }, + results, + summary: { + fields: results.length, + probed: { organization: probed('organization'), global: probed('global') }, + proven: count('proven'), + contradicted: count('contradicted'), + failed: count('failed'), + inconclusive: count('inconclusive'), + excluded: count('excluded'), + notDerivable: count('not-derivable'), + probes: all.length, + held: all.filter((p) => p.outcome === 'held').length, + contradictions: all.filter((p) => p.outcome === 'contradicted').length, + unattributed: all.filter((p) => p.outcome === 'unattributed').length, + faults: all.filter((p) => p.outcome === 'failed').length, + notRun: all.filter((p) => p.outcome === 'not-run').length, + hardFailures: count('contradicted') + count('failed'), + }, + }; +} + +/** Pretty one-line-per-field summary for logs. */ +export function formatUniqueScopeReport(report: UniqueScopeReport): string { + const lines: string[] = [`\n=== objectstack verify — declared unique scope — ${report.app} ===`]; + const b = report.boot; + lines.push( + ` boot: ${b.posture ?? 'unknown'} posture${b.walled ? ', walled' : ''}; within leg run; across leg ` + + (b.legs.across === 'run' ? 'run' : `NOT RUN — ${b.acrossNotRun ?? ''}`), + ); + for (const r of report.results) { + const name = `${r.object}.${r.field} <${r.type}${r.organizationColumn ? '' : ', no organization column'}>`; + const declared = r.declared !== undefined ? ` unique: ${JSON.stringify(r.declared)}` : ''; + const legs = (p: UniqueScopeProbeResult) => `${p.leg} ${p.door} ${p.operation}`; + if (r.status === 'proven') { + lines.push(` ✓ ${name}${declared} held on ${r.probes.length} write(s) (${r.probes.map(legs).join(', ')})`); + } else if (r.status === 'contradicted' || r.status === 'failed') { + const bad = r.probes.filter((p) => p.outcome === (r.status === 'contradicted' ? 'contradicted' : 'failed')); + lines.push(` ✗ ${name}${declared} ${r.status === 'contradicted' ? 'CONTRADICTED' : 'FAULTED'} on ${bad.length} of ${r.probes.length} write(s):`); + for (const p of bad) lines.push(` ${legs(p)}: ${clip(p.detail, 200)}`); + } else if (r.status === 'inconclusive') { + lines.push(` ~ ${name}${declared} inconclusive: ${r.reason ?? ''}`); + } else { + lines.push(` – ${name}${declared} ${r.status}: ${r.reason ?? ''}`); + } + } + const s = report.summary; + lines.push( + ` ── ${s.proven} proven, ${s.contradicted} CONTRADICTED, ${s.failed} FAILED, ${s.inconclusive} inconclusive, ` + + `${s.excluded} excluded, ${s.notDerivable} not derivable ` + + `(probed: ${s.probed.organization} per organization, ${s.probed.global} global; ` + + `${s.probes} writes: ${s.held} held, ${s.contradictions} contradicted, ${s.unattributed} unattributed, ${s.faults} faulted, ${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 4542dc35534..aa664273167 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -227,6 +227,9 @@ const NOT_A_STAND_IN = { runDeleteBehaviorProofs: 'same `VerifyStack` handle as runCrudVerification.', formatDeleteBehaviorReport: 'formats a `DeleteBehaviorReport` this package produced; presentation, not conformance.', + runUniqueScopeProofs: 'same `VerifyStack` handle as runCrudVerification.', + formatUniqueScopeReport: + 'formats a `UniqueScopeReport` this package produced; presentation, not conformance.', fillRelationalRefs: 'takes one `CrudCase` derived by `deriveCrudCases` — a data record this package built, with no ' + 'external implementer.',