Skip to content

Commit f93bf12

Browse files
feat(verify): the unique-scope derived proof family, runnable as os verify --unique-scopes (#15953 slice 3) (#23069)
Part of #15953 Clause-②: yes ## What this is Slice 3 of #15953: the D halves of the classification table's rows 6-7 (comment 6115219467). It adds the unique-scope derived proof family to `@objectstack/verify`, runs it from `os verify --unique-scopes` (opt-in), and documents it. Rows 6-7's K residues stay in hotcrm, and their R residues (driver-sql DDL, ADR-0120 D3) are not in this PR. #15953 stays open for its remaining rows. **Property, for any app.** Every field declared `unique` collides as its declared scope says (`UniqueScopeSchema`, ADR-0120 D1 and D3): - **Within one organization**, a second holder of the value is refused, whatever the scope. On a `single`-posture boot that organization is the install's only one: the untenanted install, with no wall, where every write lands. - **Across two organizations**, a `true` / `'organization'` value is admitted. A `'global'` value, or any value of an object with no organization column (`tenancy.enabled: false`, which degrades the scope to the column alone), is refused. ## The family (`packages/verify/src/unique-scope.ts`) - **Population.** Every field of every declared object whose `unique`, after the spec's own `FieldSchema.safeParse`, is declared (`isUniqueDeclared`). This is the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'`. The family never re-derives that default by hand. - A unique `indexes[]` entry is NOT in the population. It is a different surface, with its own meaning of bare `true` (refused there since protocol 18), and the field-level-versus-`indexes[]` spelling is already an author-time rule (R10). - **Published sources each expectation is read from.** - The scope: `FieldSchema.unique` and `isUniqueDeclared` / `isGlobalUnique`. - Its meaning: the `UniqueScopeSchema` docblock and ADR-0120 D1 / D3 / S11. - The organization column: `isTenancyDisabled` (ADR-0066). - A wall: `postureEnforcesWall` over the stack's `tenancy()` posture. - What the caller cannot set: `RUNTIME_OWNED_FIELD_TYPES` plus `readonly` / `system`. - The refusal's identity: `DuplicateRecordError`'s documented split, `DUPLICATE_RECORD` in process and `UNIQUE_VIOLATION` on the wire. - **Doors.** Each probe writes through one of two doors: - **REST** as a signed-in caller: `POST /data/OBJECT`, and `PATCH /data/OBJECT/ID` for the update probe. - **The engine's own write as the system principal**: `hooks.run` with `{ system: true }`. - **Probes.** Per door, `within × insert` and `within × update`; on a walled boot also `across × insert`. - A first holder is written and read back. - A second record is offered the value **as the first one stores it**, so a normalizing hook (hotcrm upper-cases SKUs) does not hide the collision. - **Boots and legs.** - `within` runs on every boot. - `across` runs only on a walled boot (`os verify --multi-tenant`, `bootStack({ multiTenant: true })`). There the family provisions a second organization the way a user makes one: a sign-up that creates its own (`POST /auth/organization/create`). - The admin is the first organization's caller when the boot binds it to one; otherwise a second provisioned sign-up is. - The system door names the 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 with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (measured). - On a `single` boot, `across` is reported **not run** on `report.boot`, with the reason, never silently skipped. - **Attribution.** A refusal proves a collision only when two things hold: - it is the platform's unique refusal at that door, naming the probed object; - it is this field's: it names the field, or it names none and a **twin** (the same record with only the probed value changed) is admitted. - The twin exists because of a measured fact. A `true` / `'organization'` constraint is the COALESCE expression index, and SQLite reports it by index name, so the envelope carries no `field` (`uniqueViolationColumn`'s contract). A `'global'` index, or an object with no organization column, names the field. - An update needs no twin: it changes nothing else. - Any other refusal (an app's own dedupe, a rule, a permission, a constraint on another field) is `unattributed`. - **Exclusions, with reasons, counted.** - A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the **system door only**, which keeps an explicit value (measured: REST answers `droppedFields: readonly` for an autonumber; the system door stores it). - A derived value (`formula`, `summary`) is `excluded`. - These are `not-derivable`: a closed or tiny value space (`boolean`, `select`, …), a multi-valued reference, a target the app does not declare, an object the CRUD derivation cannot synthesize. - A one-to-one reference is probed within; its across leg is named, not run, because the value is one organization's record. - An object declaring its own `tenancy.tenantField` has its across leg named, not run. - **Fixtures.** Reused `deriveCrudCases` / `fillRelationalRefs`. The family-local `fixtureBody` follows slice 2's cut-down shape (required fields, dates spaced, unique values distinct); slice 2's copy is module-private in `delete-behavior.ts`, outside this claim. Fixtures go through the probe's own door and principal, with per-writer base records. Run on a fresh stack of its own. `os verify --unique-scopes`: - boots its own fresh stack (with `--multi-tenant`, the walled one); - adds `uniqueScopes.summary.hardFailures` (contradicted plus failed) to the exit status; - emits `uniqueScopes` under `--json`. Without the flag, nothing changes. ## Evidence All readings at this PR's head unless stated otherwise. The fixture and hotcrm runs used `packages/verify/dist` built from the merged tree `5fae9e5a13`; the only later commit is the two `NOT_A_STAND_IN` rows. **Fixture app** (`packages/verify/src/unique-scope.test.ts`, `Tests 12 passed (12)`): - **Single boot:** 9 fields. - Probed: 6 per organization, 2 global. - 8 proven over 28 writes, all held. - 1 not derivable (a unique `boolean`). - `across` reported not run. - **Walled boot** (`multiTenant: 'posture-only'`, real organizations and per-caller stamping, see below): 8 proven over 39 writes, all held. Across, per-organization values are admitted, and a `'global'` value or one with no organization column is refused. **Negative controls**, one per leg, no engine change: - **within**: a field declared unique on a table that already holds a duplicate when the declaration arrives. - driver-sql cannot create the index and logs that the constraint "is NOT enforced until the data is deduplicated" (ADR-0120 D4). - Every second holder is admitted, and the family reports `contradicted` on 4 of 4 writes (REST and system, insert and update), hardFailures 1. - **across**: a field declared `unique: true` beside a declared `indexes: [{ fields: ['name'], unique: 'global' }]`, the R10 double declaration. - The second organization is refused naming the field. - The family reports `contradicted` on 2 of 2 across writes, with within held. **Attribution control:** - An app hook's own dedupe (`DUPLICATE_VALUE`, the hotcrm shape) is `unattributed` on 4 of 4 writes. - A field-less unique refusal that the twin meets too (a hook stamps one `code` on every record) is `unattributed` with `twin: refused`. - Neither is ever credited. **Ablation of the family's own comparison** (`judge`), through `scripts/ablation-replace.mjs` (wrap mode) inside a scratch script with `trap restore EXIT INT TERM` on absolute paths, at `ed93727ce2`. No build is involved: the test imports the module by a relative path. - **A.** `if (probe.expected === 'admitted') return { outcome: 'held' };` was replaced by an unconditional `held` plus the marker `ABLATION_15953_S3_A`. - On disk: anchor 1 to 0, marker 0 to 1, blob `fee9f33f` to `e477ff09`, marker 1 during the run. - RED: `Tests 1 failed | 11 passed (12)`, exactly the within negative control. - Restored: blob `fee9f33f` equals HEAD and `git diff HEAD` is empty. - **B.** The same on `if (probe.expected === 'refused') …` (marker `ABLATION_15953_S3_B`). - On disk: anchor 1 to 0, blob `fee9f33f` to `7b2dcfad`, marker 1 during the run. - RED: `Tests 1 failed | 11 passed (12)`, exactly the across negative control. - Restored to HEAD's blob, empty diff. - Predicted direction for both: red. Observed: red. **hotcrm, the first consumer.** Read-only clone at `1d7148bf`, fetched first. `main` is at `0f84c9c7`, and `git diff 1d7148bf origin/main -- src` adds or removes no `unique` / `autonumber` line (the same grep hits those files at HEAD). The clone's `git status` stayed empty throughout. `os verify` itself cannot boot hotcrm on this tree (protocol `^17.7.0` versus 18), so the family ran directly, as in slices 1-2. - **Artifact.** Built by hotcrm's own CLI 17.7.0 into the scratchpad. - **Snapshot.** This tree's `os migrate meta --from 17 --out`, with only `engines.protocol` moved to `^18`. - **Population.** 13 fields, all per-organization: - `crm_account.name`, `crm_contact.email`, `crm_product.sku`; - 10 autonumbers, nine of them `'organization'` by the parse default, plus `crm_case.case_number`, which declares `true` by hand. | run | engine / boot | legs | proven | contradicted | inconclusive | writes held / total | |---|---|---|--:|--:|--:|--:| | A | hotcrm's pinned 17.7.0 `bootStack` over its own artifact, REST door only (17.7.0 has no system door) | within (across not run: hotcrm declares no `@objectstack/organizations`) | 2 | 0 | 11 | 4 / 6 | | B | this tree, snapshot plus the artifact's 44 hook bodies grafted by name, single | within | 9 | 0 | 4 | 23 / 32 | | B | the same, walled (`multiTenant: true`, the open `@objectstack/organizations`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`) | within + across | 9 | 0 | 4 | 35 / 48 | | C | this tree, snapshot with hooks inert, single | within | 11 | 0 | 2 | 28 / 32 | | C | the same, walled | within + across | 11 | 0 | 2 | 42 / 48 | **Against what the retired pins asserted (D halves only):** - **Row 6, `test/account-name-tenant-scope.test.ts`, `crm_account.name`: PROVEN on every run.** - "Still rejects a duplicate account name WITHIN one organization": refused through REST and system, insert and update, on the walled boot and on the single boot. - "On an UNTENANTED install too": the single boot. - "Accepts the same account name in two different organizations": admitted through REST and system on the walled boot. - Run A shows the within half holding on hotcrm's own pinned engine. - **Row 7, `test/case-number-tenant-scope.test.ts`, `crm_case.case_number`: PROVEN on runs B and C.** - Within: refused through the system door, insert and update, on both boots. - Across: admitted in a second organization on the walled boot. - It is an autonumber, so REST is excluded (that door drops the value), and Run A, which is REST only, names it inconclusive with that reason. - 0 contradictions with what either file asserted. **Extra evidence, not a table row: `test/contact-email-tenant-scope.test.ts`.** This file's subject is hotcrm's `contact_integrity` hook (an app dedupe, a K property), with the `(organization, email)` index behind it. - Hooks live: within is `unattributed`, because the hook answers its own `DUPLICATE_VALUE` (the attribution control holding on a real app). On the single boot the system insert reaches the index and is credited: the hook scopes a system write by the row's own organization stamp, and that write carries none. Across, the value is admitted through both doors ("lets ANOTHER organization know the same person"). - Hooks inert: proven. **Inconclusive on hotcrm, with the app's own reasons:** - `crm_campaign_member.member_number` and `crm_event_attendee.attendee_number`: a generic fixture names no lead or contact party. - `crm_contract.contract_number`: `contract_validation` ties the term to the dates (hooks live only). - `crm_contact.email`: as above. ## Verification At head `9c89d3cc89`: this branch with `origin/main` `0cbfb7c32c` merged, so 9 files and 1637 changed lines (`+1630 / -7`) against merge base `0cbfb7c32`. Readings below are at that head unless another commit is named. **Suites.** - **`@objectstack/verify`:** `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2` reads `Test Files 34 passed (34)` / `Tests 270 passed (270)`, lock verdict `command-exit 0`. The family file alone reads `Tests 12 passed (12)`. - **Typechecks:** `pnpm --filter @objectstack/verify typecheck` and `pnpm --filter @objectstack/cli typecheck` exit 0, and `check:test-typecheck: OK` holds for both (at `5fae9e5a13`; no file of either program changed since). `tsc --listFilesOnly -p tsconfig.test.json` shows `unique-scope.test.ts` in verify's program (2 hits, with its module) and `verify-unique-scopes.test.ts` in the CLI's (1 hit; control `verify-delete-behaviors.test.ts`, 1 hit). - **CLI unit tier:** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` reads `Test Files 283 passed (283)` / `Tests 4270 passed (4270)` (at `5fae9e5a13`; the second merge changed no `packages/cli` file). - **CLI integration tier:** `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2` over the six `verify-*` files, this PR's new one included, reads `Test Files 6 passed (6)` / `Tests 18 passed (18)`. The rest of the integration tier is declared to CI. - **Core `repo` project:** `pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2` reads `Test Files 6 passed (6)` / `Tests 83 passed (83)`. The `#22661` second-object read pin is among them, green with no `CALLERS` row. - **`pnpm check:verify-stand-in`:** - Before the rows: exit 1, `2 problem(s)` (`runUniqueScopeProofs` and `formatUniqueScopeReport` unclassified). - After: exit 0, `OK check-verify-stand-in-erasure: 2 stand-in check(s) guarded, 13 candidate(s) explicitly exempt, 10 call site(s) reached, 0 asserted driver arguments`. - `--self-test` OK. **Gates.** - **Derivation:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, gives 115 commands (64 `pnpm`, 51 direct `node`). - **Results:** every one exits 0, with its exit code captured before any pipe. `--ran` reads `Run reconciliation — 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.` - The derivation was current when the run began. - The reconcile pass printed `STALE TREE`, because `origin/main` moved 4 commits during the 40-minute run (to `e448d3c27f`). The stale family files are `.github/workflows/lint.yml`, `scripts/check-system-context-census.mjs` and `scripts/sdui-manifest.record.json`, none on this PR's paths. - An earlier battery at `5e776ba287` recorded 3 `PREREQUISITE NOT MET` (exit 3: `check:dual-build-cjs-loads`, spec `check:skill-examples`, `check:i18n-coverage`, each needing packages outside the built closures). After a full `turbo run build --filter=!@objectstack/docs` (73 tasks), this battery measured all three, exit 0. - Every command and its printed verdict line is in the report comment on #15953. **Lint, by proven narrowing** at `9c89d3cc89`: `eslint --no-inline-config --format json` over the 6 touched source files. 1. **Population, read from eslint's own config:** `--print-config` resolves for 6 of 6. 2. **Counts, read from the JSON:** 6 files, 0 errors, 0 warnings. 3. **Invariance:** `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules, its own comment at `:327-328`), so this diff cannot move any untouched file's verdict. The two `.md` / `.mdx` files are outside eslint's population. ## Acceptance notes Noted, not filed (none is a reproducible defect through a public door, a contract violation or an authoring trap; carrier: none): - `bootStack` over an UNPARSED object literal registers the raw field. An `autonumber` that authors no `unique` therefore gets no index there, and a system-door duplicate is admitted (measured: raw literal 2 of 2 admitted; the same object through `defineStack` stores `'organization'` and refuses the second). - Every config `os verify` / `os validate` / `os build` load is `defineStack`-built (`STACK_PROVENANCE_MISSING` otherwise), so no public door reaches it. - The family reads the parse's scope and would report such a programmatic boot contradicted. - This PR's positive fixture boots `defineStack` output for that reason. - The ADR-0120 D3 NULL-organization bucket is unreachable through either door on today's runtime: a `single` install derives its one organization for a system write, and REST stamps the caller's. So "untenanted install" is the single-posture boot, plus objects with no organization column. The NULL-row DDL fact stays row 7's R residue (driver-sql). - `os verify --multi-tenant` with the open `@objectstack/organizations` refuses to boot until a membership policy is declared (`WalledMembershipPolicyError`; `OS_AUTH_MEMBERSHIP_POLICY`). This is by that package's design, and it applies to `--rls --multi-tenant` alike. The README states it beside the flag. - The CLI-level control in `packages/cli/test/verify-unique-scopes.test.ts` is a door that faults on a duplicate (exit 1), not a contradiction. Through `os verify`, every config is `defineStack`-built on a fresh database, so no metadata makes a same-organization duplicate land. The across leg needs `--multi-tenant`, whose runtime package no framework package may declare, so CI's test graph cannot boot it. The two contradiction controls are in `packages/verify`. - The walled-boot unit suites use `multiTenant: 'posture-only'`: a walled posture with real organizations (`/auth/organization/create`) and per-caller stamping, measured. The family's subject is which rows collide, which the index decides from each row's organization; read isolation is not under test. The real-package walled boot is exercised by the hotcrm runs above. ## Deviations - PM mechanism assumption 2 was measured and partly falsified. The per-organization refusal carries no `field` (SQLite names the expression index), so "proved only on that code naming the probed field" would prove no per-organization field at all. Attribution therefore also accepts a field-less unique refusal when the twin is admitted, and pins it with the attribution control above. - The conditional `NOT_A_STAND_IN` surface was used (2 rows), because `check:verify-stand-in` asked. The `#22661` pin needed no `CALLERS` row: the family reads a reference's target from the CRUD derivation and never calls `referenceTargetOf` (the `repo` project is green without one). - `origin/main` (`df7a137923`, 4 commits, none on this surface) was merged before the PR, per AGENTS.md section 10. The build was refreshed, and every suite and gate here was run on the merged tree. - Commits carry the model-free trailer pair per AGENTS.md, and this body ends with its session-URL footer rather than the harness's own attribution form. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a29bec commit f93bf12

9 files changed

Lines changed: 1630 additions & 7 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953)
7+
8+
Clause-②: yes
9+
10+
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.
11+
12+
- **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.
13+
- **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`.
14+
15+
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`.
16+
17+
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.
18+
19+
**`@objectstack/verify` — new exports.**
20+
21+
- `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.
22+
- `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`.
23+
- Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`.
24+
25+
```ts
26+
import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify';
27+
28+
const stack = await bootStack(myApp, { multiTenant: true });
29+
const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp);
30+
console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran
31+
expect(report.summary.hardFailures).toBe(0);
32+
await stack.stop();
33+
```
34+
35+
**`@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.

‎content/docs/deployment/cli.mdx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1924,6 +1924,7 @@ os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS
19241924
os verify --rls --multi-tenant --json # Org-scoped boot, structured report
19251925
os verify --reference-shapes # Also prove reference fields refuse non-id values
19261926
os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does
1927+
os verify --unique-scopes --multi-tenant # Also prove each declared unique collides where its scope says
19271928
```
19281929
19291930
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
19661967
`excluded` rather than compared, because an engine rule decides their
19671968
outcome, not the declaration. It is opt-in, and a `tree` field's delete is
19681969
not honoured today, so an app that declares one would fail.
1970+
With `--unique-scopes`, another fresh boot proves that every field declared
1971+
`unique` collides as its scope says: per field, a first record holds a value
1972+
and a second one is offered it, by insert and by update, through the REST
1973+
door and the system door. Within one organization the second holder must be
1974+
refused with the platform's unique refusal, attributed to the field (it
1975+
names the field, or it names none and the same record with only that value
1976+
changed is admitted). With `--multi-tenant` the run also provisions a second
1977+
organization (a sign-up that creates its own) and offers it the value: a
1978+
`true` or `'organization'` field must admit it, and a `'global'` field, or
1979+
any field of an object with no organization column, must refuse it. Without
1980+
`--multi-tenant` there is one organization, so that leg is reported **not
1981+
run** with the reason rather than skipped. The scope is read as the spec's
1982+
parse of the field states it (an `autonumber` that authors no `unique`
1983+
states `'organization'`); a field the caller cannot set — `readonly`,
1984+
`system`, a runtime-issued `autonumber` — is probed through the system door
1985+
only. A second holder met wrongly fails the run; a refusal that is not the
1986+
field's own constraint (an app hook's dedupe, a rule, a permission) is
1987+
reported inconclusive, never proven.
19691988
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
19701989
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
19711990
`group`) asks for the same boot.
@@ -1994,7 +2013,9 @@ document carries depends on where the run ended:
19942013
package of the artifact), or the schema issues when the stack does not parse.
19952014
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
19962015
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`),
1997-
`deleteBehaviors` (with `--delete-behaviors`) and `hardFailures`, the count
2016+
`deleteBehaviors` (with `--delete-behaviors`), `uniqueScopes` (with
2017+
`--unique-scopes`; its `boot` names the legs the run could prove) and
2018+
`hardFailures`, the count
19982019
the exit status is read from.
19992020
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
20002021
when the failure carries them.

‎packages/cli/src/commands/verify.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import {
1919
formatReferenceShapeReport,
2020
runDeleteBehaviorProofs,
2121
formatDeleteBehaviorReport,
22+
runUniqueScopeProofs,
23+
formatUniqueScopeReport,
2224
type VerifyReport,
2325
type RlsReport,
2426
type ReferenceShapeReport,
2527
type DeleteBehaviorReport,
28+
type UniqueScopeReport,
2629
type RlsProbeDescriptor,
2730
type RlsPositionPersonaInput,
2831
} from '@objectstack/verify';
@@ -91,19 +94,25 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b
9194
* - delete behaviour (--delete-behaviors): every `deleteBehavior` a
9295
* reference field declares is what a by-id delete of the referenced
9396
* record does, through the REST door and the system door
97+
* - unique scope (--unique-scopes, ADR-0120 D1): every field declared
98+
* `unique` refuses a second holder within one organization, through
99+
* the REST door and the system door, and — on an org-scoped boot
100+
* (--multi-tenant) — admits or refuses one in another organization
101+
* as its declared scope says
94102
*
95103
* Exits non-zero on either stage's failures so it drops straight into CI.
96104
*/
97105
export default class Verify extends Command {
98106
static override description =
99-
'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)';
107+
'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)';
100108

101109
static override examples = [
102110
'<%= config.bin %> verify',
103111
'<%= config.bin %> verify --app ./objectstack.config.ts --rls',
104112
'<%= config.bin %> verify --rls --multi-tenant --json',
105113
'<%= config.bin %> verify --reference-shapes',
106114
'<%= config.bin %> verify --delete-behaviors',
115+
'<%= config.bin %> verify --unique-scopes --multi-tenant',
107116
];
108117

109118
static override flags = {
@@ -123,6 +132,10 @@ export default class Verify extends Command {
123132
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',
124133
default: false,
125134
}),
135+
'unique-scopes': Flags.boolean({
136+
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)",
137+
default: false,
138+
}),
126139
'multi-tenant': Flags.boolean({
127140
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)',
128141
default: false,
@@ -202,6 +215,7 @@ export default class Verify extends Command {
202215
rls: boolean;
203216
'reference-shapes': boolean;
204217
'delete-behaviors': boolean;
218+
'unique-scopes': boolean;
205219
'multi-tenant': boolean;
206220
json: boolean;
207221
}): Promise<void> {
@@ -331,6 +345,20 @@ export default class Verify extends Command {
331345
}
332346
}
333347

348+
// The unique-scope proofs run on a fresh stack of their own as well: they
349+
// write duplicates on purpose, and on an org-scoped boot they sign up a
350+
// second organization, neither of which another phase should meet.
351+
let uniqueScopes: UniqueScopeReport | undefined;
352+
if (flags['unique-scopes']) {
353+
const uniqueStack = await bootStack(config, { multiTenant, hostRoot });
354+
try {
355+
const adminToken = await uniqueStack.signIn();
356+
uniqueScopes = await runUniqueScopeProofs(uniqueStack, adminToken, config);
357+
} finally {
358+
await uniqueStack.stop();
359+
}
360+
}
361+
334362
// Failure contract: a "real" runtime break the app's author must see.
335363
// A degraded RLS probe counts: the run reported verdicts it could not have
336364
// established, which is worse than no verifier at all.
@@ -355,18 +383,23 @@ export default class Verify extends Command {
355383
// A declared deleteBehavior a delete contradicted, or a door that
356384
// faulted on one. Inconclusive, excluded and not-derivable fields are
357385
// reported, not counted.
358-
(deleteBehaviors?.summary.hardFailures ?? 0);
386+
(deleteBehaviors?.summary.hardFailures ?? 0) +
387+
// A second holder a declared unique scope met wrongly, or a door that
388+
// faulted on one. A leg this boot cannot run is reported on the report's
389+
// `boot`, not counted.
390+
(uniqueScopes?.summary.hardFailures ?? 0);
359391

360392
if (flags.json) {
361393
// `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the
362394
// report has to leave through the real stream — and awaiting the write
363395
// drains a report larger than one pipe buffer before `this.exit` below.
364-
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, hardFailures });
396+
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, hardFailures });
365397
} else {
366398
this.log(formatReport(crud));
367399
if (rls) this.log(formatRlsReport(rls));
368400
if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes));
369401
if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors));
402+
if (uniqueScopes) this.log(formatUniqueScopeReport(uniqueScopes));
370403
this.log('');
371404
this.log(
372405
hardFailures > 0

0 commit comments

Comments
 (0)