Skip to content

Commit eab7236

Browse files
feat(verify): the reference-value shape family, runnable as os verify --reference-shapes (#15953 slice 1) (#22907)
Part of #15953 Clause-②: yes Slice 1 of the card (hotcrm epic step 5c): the classification table, plus ONE derived proof family end to end. #15953 remains open for the remaining D and R rows. ## The classification table Posted on the card before any implementation: comment 6115219467 (#15953 (comment)). It has 14 rows: the 13 hotcrm files the audit's platform-semantics sentence names (`docs/audits/2026-09-hotcrm-handwritten-test-split.md:187-192`, files read off its Appendix A), plus the ADR-0104 reference-shape check from hotcrm's `test/helpers/hook-harness.ts`. Each row is classed D / R / K, with a destination, a reason, and the residue of any file whose describes split. hotcrm deleted that helper and its differential test in hotcrm `9451b6de` (2026-10-08), so that row was read at `9451b6de^`. ## The family this slice implements: reference-value shape (ADR-0104 D1) Row 14, which the claim preferred. The property is "every client-writable reference field refuses a value its declared stored shape refuses". It holds for any app, so it is derived, and the table classes it D. - **Derivation** (`deriveReferenceShapeCases`). It takes every field of the app's objects whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reading the objects through the same collection reader as the CRUD family, so a multi-package artifact is covered. Fields declared `readonly` or `system` are returned `excluded` with the reason. The promise is read from the spec contract `valueSchemaFor(field, 'stored')`. Three probes: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each probe as an array element. A probe that the stored schema accepts is dropped and named, so it is never reported as a hole. - **The engine is asked; nothing is re-derived.** Each probe is written on insert and on update through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal (the handle's `hooks.run` with `{ system: true }`). - **Attribution is the control.** A refusal counts only when the engine names the probed field in `fields[]`. A refusal that names anything else is `unattributed`, so an engine that refuses every write cannot read as proven. - **Field statuses:** `proven`, `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (the reason is reported), `excluded`. `hardFailures` = holes + failed. - **New exports** on `@objectstack/verify`: `runReferenceShapeProofs`, `deriveReferenceShapeCases`, `referenceShapeProbeValue`, `formatReferenceShapeReport`, `REFERENCE_SHAPE_PROBE_ID`, and nine types. Changeset: `@objectstack/verify` minor. ## `os verify --reference-shapes` What I added to `packages/cli/src/commands/verify.ts`: - a boolean flag `--reference-shapes` (default false), opt-in like `--rls`; - the family runs on a fresh boot of its own (a door that admits a probe lands a row, and the base records would collide with another phase on a unique field); - `hardFailures` adds `referenceShapes.summary.hardFailures`, so the exit status reads it; - the `--json` document carries `referenceShapes` when the flag is given; - the text face prints `formatReferenceShapeReport`. Without the flag, the run and its document are unchanged. Changeset: `@objectstack/cli` minor (a new flag and a new optional document key). **Why opt-in rather than default:** the family finds a live engine defect on `multiple: true` reference fields (see the finding below). `examples/app-showcase` declares one (`showcase_project.team_members`, `Field.user` with `multiple: true`). `Dogfood Verify CLI` runs `os verify --rls` on the showcase in CI, so a default-on family would turn that job red the moment this lands. The engine gap is #22908, whose direction is to make the family default in the PR that lands that repair. The changeset, the verify README and the `os verify` section of `content/docs/deployment/cli.mdx` say why it is opt-in. ## Negative control and ablation - **Negative control (permanent tests).** In `packages/verify/src/reference-shape.test.ts`, the same metadata under `OS_ALLOW_LAX_VALUE_SHAPES=1` (ADR-0104's declared escape hatch) is reported with holes on all three probed fields, with `hardFailures` 3. It runs on a stack of its own, because a lax admission revokes the stack's ADR-0104 attestation. Through the CLI, in `packages/cli/test/verify-reference-shapes.test.ts`, the same posture makes the run exit 1 with `hardFailures` 1. An attribution control (a stack double that refuses every write while naming another field) reads `inconclusive`, never `proven`. - **Ablation (one-off, at `777263207d`).** In `packages/objectql/src/validation/record-validator.ts`, the reference/structured-JSON refusal condition (`valueShapeStrictEffective(valueStrict)`) was ANDed with an always-false marker term, via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `c30d7977f3bb` to `ae3ed2be190c`). Then `pnpm --filter @objectstack/objectql build`, and `scripts/ablation-dist-preflight.mjs` found the marker present in 4 dist files. Result: `reference-shape.test.ts` went RED (1 failed, 4 passed): the positive test reported `rs_contact.account` ADMITTED on 8 of 12 writes. - **Restore.** `git checkout HEAD -- ABS_PATH`, proven by the blob matching HEAD and an empty `git diff HEAD`. Then a rebuild, and the preflight with `--absent` reported the marker absent from all 14 dist files and a clean tree. Result: GREEN (5 passed). The script carried an EXIT/INT/TERM trap. No permanent ablation file was left behind. ## First consumer's evidence: hotcrm Read-only clone of objectstack-ai/hotcrm `main` `1d7148b`, outside the worktree. Nothing was written to hotcrm. - **`os verify` itself is not achievable against hotcrm on this tree.** hotcrm pins `@objectstack/*` 17.7.0, and this tree is protocol 18. `os verify --reference-shapes --json` on hotcrm's config stops at the author-time stage, which refuses 118 `expression-invalid` findings (the protocol-18 retirement of the brace-template dialect in flow value slots), so the runtime stage never runs. `bootStack` on this tree refuses the package with `ProtocolIncompatibleError` (it targets `^17.7.0`). - **What I ran instead** (a scratch script, not shipped): the family from this branch's build, over hotcrm's shipped artifact (`objectstack.config.ts`, both packages), in two readings. - **Reading 1, hotcrm's pinned engine** (its installed `@objectstack/verify` 17.7.0 `bootStack`, REST door only, because 17.7.0 has no system overload on `hooks.run`): 56 reference fields (51 lookup, 5 master_detail, none multi-valued). 18 proven, 25 inconclusive, 13 excluded (`owner_id`, `system`), 0 holes. Probes: 258, of which 147 refused, 0 admitted, 21 unattributed, 90 not run. - **Reading 2, this tree's engine, both doors**, over the snapshot `os migrate meta --from 17 --out` writes from the same config, with only the declared protocol range moved to `^18`: 23 proven, 20 inconclusive, 13 excluded, 0 holes. Probes: 516, of which 366 refused, 0 admitted, 0 unattributed, 150 not run. - **Differential against the retired pin.** The pin's verdict function (`referenceValueShapeError` at hotcrm `9451b6de^`) was applied to the same fields and probe values. Every probe the pin says is refused, and that a door could run, was refused with the field named: 93 probe kinds in reading 1 and 114 in reading 2, with 0 contradictions in both. The 13 fields the pin skipped (`system`) are the 13 the family excludes. The remaining probes are inconclusive or not run because the CRUD derivation's synthesized base record does not satisfy some of hotcrm's own rules (date-ordering validations, a required relation whose target could not be created, a hook refusing a task). That is reported per field, never counted as proven. ## Finding (not fixed here: no engine change in this slice) On `9fdb12752c`, a `multiple: true` `lookup` or `user` field admits wrong-shape elements. The spec contract says `valueSchemaFor({ type: 'lookup', multiple: true }, 'stored')` is an array of record-id strings, and the family reports holes: - **REST** (signed-in admin): `POST` / `PATCH` with `[{ id }]` answer 201 / 200 and store the embedded object. `[false]` and `[42]` are refused, but as `reference_not_found` (the existence probe), not as a shape error. - **System door:** `[false]`, `[42]` and `[{ id }]` are all admitted and stored verbatim. The cause, read in the source: the record validator's multi-value branch returns early for `lookup` / `user` / `file` / `image` elements ("reference integrity is handled elsewhere"). The engine's reference-existence check skips object elements and skips system writes entirely. Recorded in the report for the PM to file. ## Patch round (head `f8a821d054`) CI on `777263207d` was red in two places, both caused by this PR's new exports. Neither was in the locally derived gate list. - **`Test Core (3/6)`**, `@objectstack/core#test:repo`, `second-object-read-exposure.pin.test.ts`: `packages/verify/src/reference-shape.ts` calls the reference-target arbiter and was unclassified. Measured: the family uses the answer only to pick an id for its embedded-record probe, and the id comes from a record the family itself created through the REST door. It writes probe values to the SOURCE object (through the REST data routes, which judge that object's exposure, and through the engine's system write) and reads no record of the target. So the pin's `CALLERS` table gains one `not-a-served-read` row; the pin's logic and existing rows are unchanged. `pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2 src/security/second-object-read-exposure.pin.test.ts`: RED at `777263207d` (1 failed, 3 passed), GREEN at `0598a8d13d` (4 passed). The whole core `repo` project at the new head: 6 files, 83 tests passed. None of its 6 files names `packages/verify`. - **`Lint & Repo Gates`**, step "@objectstack/verify stand-in erasure guard" (`pnpm check:verify-stand-in`): `runReferenceShapeProofs` and `formatReferenceShapeReport` were unclassified. `scripts/check-verify-stand-in-erasure.mjs` gains two `NOT_A_STAND_IN` rows, the gate's own prescribed remedy, on the footing of `runCrudVerification` and `formatReport`: one takes the `VerifyStack` that `bootStack` returns, the other formats a report this package produced. The gate is now OK: "2 stand-in check(s) guarded, 9 candidate(s) explicitly exempt, 10 call site(s) reached, 0 asserted driver arguments". This file was outside the claimed surface; it is declared in the report. The CI steps after step 14 of that job were skipped on `777263207d`, so they first run on this head. - **Docs:** the verify README names the family (intro bullet, CLI line, programmatic snippet, the status table, API), and the `os verify` section of `content/docs/deployment/cli.mdx` names the flag, the `referenceShapes` key, and why the flag is opt-in. ## Tests and gates (code at `777263207d`; gates and lint at `f8a821d054`) - `pnpm --filter @objectstack/verify test`: 32 files, 250 tests passed. - `pnpm --filter @objectstack/verify typecheck`: exit 0; test layer 0 files, 0 errors. - `pnpm --filter @objectstack/cli typecheck`: exit 0. The new test file is in the `tsconfig.test.json` program (`--listFilesOnly`, 1 hit). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 283 files, 4269 tests passed. - `--project integration` on the new spawn test: 3 passed. The new file is integration tier, so it was run locally; the rest of the integration tier is declared to CI. - Gates at `f8a821d054`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 117 commands; the docs page and the scripts file added 52 families to the 65 derived at `777263207d`. 114 exit 0. Three exit 3 and are NOT MEASURED, not red, because each reads built output of packages outside this closure: `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n-coverage` and `pnpm --filter @objectstack/spec run check:skill-examples` (the client SDK has no dist). `pnpm check:verify-stand-in` and `pnpm check:pm-dispatch-gates` exit 0. `--ran` reconciliation: 117 derived, 114 run, 3 NOT-MEASURED, 0 UNRUN. - Lint at `f8a821d054`, a proven narrowing: `eslint --no-inline-config --format json` over the 7 touched source files reports 7 files, 0 errors, 0 warnings. All 7 are in the config's population (`--print-config` resolves for each). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict of any untouched file. ## Acceptance notes - Remaining rows for the next slices, all recorded on the card. D: deleteBehavior (rows 1-3), unique scope (6-7), parent-derived write reach (12), the static detail-section rule (13), and the D residues of rows 9 and 11. R: rows 8 and 10, the R residues of rows 3, 4, 5, 7 and 12, and row 9, which is already pinned upstream by the spec temporal-conformance matrix and the driver suites. - `checkReadCoercion` and `checkDateBucketParity` were classified against row 9: neither covers a datetime FILTER window. - `--reference-shapes` stays opt-in in this PR. The engine gap it would fail on is #22908, which records making the family default in the PR that lands that repair. - Gate-derivation gap, reported in the PR's report: deriving gates for `packages/verify/src` paths names neither `pnpm check:verify-stand-in` nor `@objectstack/core`'s `repo` project. Both turned red here. - Not merged with `origin/main`: the later commits touch nothing this PR derives from, according to the derivation header at `f8a821d054`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7dbb89d commit eab7236

10 files changed

Lines changed: 974 additions & 12 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953)
7+
8+
Clause-②: yes
9+
10+
A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal.
11+
12+
A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`.
13+
14+
**`@objectstack/verify` — new exports.**
15+
16+
- `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row.
17+
- `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`.
18+
- Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`.
19+
20+
```ts
21+
import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify';
22+
23+
const stack = await bootStack(myApp);
24+
const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp);
25+
console.log(formatReferenceShapeReport(report));
26+
expect(report.summary.hardFailures).toBe(0);
27+
await stack.stop();
28+
```
29+
30+
**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,7 @@ only when the first passes.
19221922
os verify # Author-time rules, then CRUD round-trip fidelity
19231923
os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant
19241924
os verify --rls --multi-tenant --json # Org-scoped boot, structured report
1925+
os verify --reference-shapes # Also prove reference fields refuse non-id values
19251926
```
19261927
19271928
1. **Author-time rules.** The rule registry `os validate` runs, over the stack
@@ -1943,7 +1944,14 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep
19431944
fidelity failure fails the run. With `--rls`, a second fresh boot proves the
19441945
cross-owner invariant — a member must not write what it cannot read — for a
19451946
probe persona and for one persona per declared position, and a hole fails
1946-
the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies
1947+
the run. With `--reference-shapes`, another fresh boot proves that every
1948+
client-writable reference field (`lookup`, `master_detail`, `user`, `tree`)
1949+
refuses a value that is not a record id — a boolean, a number, an embedded
1950+
record object — on insert and update, through the REST door and the system
1951+
door; a field that admits one fails the run. It is opt-in because the engine
1952+
still admits wrong-shaped elements on a `multiple: true` `lookup` or `user`
1953+
field, so an app that declares one would fail until the engine refuses them.
1954+
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
19471955
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
19481956
`group`) asks for the same boot.
19491957
@@ -1970,8 +1978,8 @@ document carries depends on where the run ended:
19701978
`path`, `message`, `hint`, plus `package` for a finding raised inside one
19711979
package of the artifact), or the schema issues when the stack does not parse.
19721980
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
1973-
`crud`, `rls` (with `--rls`) and `hardFailures`, the count the exit status is
1974-
read from.
1981+
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`)
1982+
and `hardFailures`, the count the exit status is read from.
19751983
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
19761984
when the failure carries them.
19771985

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import {
1515
provisionRlsPositionPersona,
1616
declaredPositionNames,
1717
rlsProbeSecurity,
18+
runReferenceShapeProofs,
19+
formatReferenceShapeReport,
1820
type VerifyReport,
1921
type RlsReport,
22+
type ReferenceShapeReport,
2023
type RlsProbeDescriptor,
2124
type RlsPositionPersonaInput,
2225
} from '@objectstack/verify';
@@ -79,17 +82,21 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b
7982
* asserting runtime behavior the static gates can't see:
8083
* - data fidelity: author → write → read → assert, per object/field type
8184
* - authorization (--rls): "you can't write what you can't read" (#1994 class)
85+
* - reference-value shape (--reference-shapes, ADR-0104 D1): every
86+
* client-writable reference field refuses a value that is not a
87+
* record id, through the REST door and the system door
8288
*
8389
* Exits non-zero on either stage's failures so it drops straight into CI.
8490
*/
8591
export default class Verify extends Command {
8692
static override description =
87-
'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)';
93+
'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant and the reference-value shape proofs on request)';
8894

8995
static override examples = [
9096
'<%= config.bin %> verify',
9197
'<%= config.bin %> verify --app ./objectstack.config.ts --rls',
9298
'<%= config.bin %> verify --rls --multi-tenant --json',
99+
'<%= config.bin %> verify --reference-shapes',
93100
];
94101

95102
static override flags = {
@@ -101,6 +108,10 @@ export default class Verify extends Command {
101108
description: 'Also run the cross-owner RLS invariant (a fresh member must not write what it cannot read)',
102109
default: false,
103110
}),
111+
'reference-shapes': Flags.boolean({
112+
description: 'Also prove every client-writable reference field refuses a value that is not a record id (a boolean, a number, an embedded record), through the REST door and the system door (ADR-0104 value shapes)',
113+
default: false,
114+
}),
104115
'multi-tenant': Flags.boolean({
105116
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)',
106117
default: false,
@@ -178,6 +189,7 @@ export default class Verify extends Command {
178189
private async runVerification(flags: {
179190
app?: string;
180191
rls: boolean;
192+
'reference-shapes': boolean;
181193
'multi-tenant': boolean;
182194
json: boolean;
183195
}): Promise<void> {
@@ -278,6 +290,21 @@ export default class Verify extends Command {
278290
}
279291
}
280292

293+
// The reference-value shape proofs run on a fresh stack of their own, for
294+
// the reason the RLS proofs do: a door that admits a probe lands a row,
295+
// and the base records the family creates would collide with the other
296+
// phases' on a unique field.
297+
let referenceShapes: ReferenceShapeReport | undefined;
298+
if (flags['reference-shapes']) {
299+
const shapeStack = await bootStack(config, { multiTenant, hostRoot });
300+
try {
301+
const adminToken = await shapeStack.signIn();
302+
referenceShapes = await runReferenceShapeProofs(shapeStack, adminToken, config);
303+
} finally {
304+
await shapeStack.stop();
305+
}
306+
}
307+
281308
// Failure contract: a "real" runtime break the app's author must see.
282309
// A degraded RLS probe counts: the run reported verdicts it could not have
283310
// established, which is worse than no verifier at all.
@@ -293,16 +320,22 @@ export default class Verify extends Command {
293320
crud.summary.fidelityGaps +
294321
(rls?.totals.holes ?? 0) +
295322
(rls?.probe.degraded ? 1 : 0) +
296-
(rls?.positionCoverage.notRun.length ?? 0);
323+
(rls?.positionCoverage.notRun.length ?? 0) +
324+
// A reference field that ADMITTED a wrong-shaped value, or a door that
325+
// faulted on one. Inconclusive fields (the probe could not be attributed
326+
// or written) are reported, not counted, exactly as CRUD's
327+
// `needs-fixture` is.
328+
(referenceShapes?.summary.hardFailures ?? 0);
297329

298330
if (flags.json) {
299331
// `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the
300332
// report has to leave through the real stream — and awaiting the write
301333
// drains a report larger than one pipe buffer before `this.exit` below.
302-
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, hardFailures });
334+
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, hardFailures });
303335
} else {
304336
this.log(formatReport(crud));
305337
if (rls) this.log(formatRlsReport(rls));
338+
if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes));
306339
this.log('');
307340
this.log(
308341
hardFailures > 0
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN — `os verify --reference-shapes` runs the reference-value shape family
5+
* (ADR-0104 D1) and counts what it finds in the exit status.
6+
*
7+
* ## What is pinned
8+
*
9+
* - without the flag the run is what it was: no `referenceShapes` key in the
10+
* `--json` document, and the exit status reads CRUD (and RLS) only;
11+
* - with the flag, on a stack whose engine refuses wrong-shaped references
12+
* (a fresh datastore is born strict), every client-writable reference
13+
* field is `proven`, `hardFailures` stays 0 and the run exits 0;
14+
* - with the flag, on the same metadata under the posture that admits
15+
* wrong shapes (`OS_ALLOW_LAX_VALUE_SHAPES=1`, ADR-0104's declared escape
16+
* hatch), each field is reported with holes, `hardFailures` counts them and
17+
* the run exits 1 — the negative control, through the CLI.
18+
*
19+
* Spawned rather than run in-process: the exit status is the contract a CI
20+
* gate reads, and only a real process has one.
21+
*/
22+
23+
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
24+
import { spawnSync } from 'node:child_process';
25+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
26+
import { tmpdir } from 'node:os';
27+
import { join } from 'node:path';
28+
import { CLI, TSX, childEnv } from './helpers/serve-process.js';
29+
import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js';
30+
31+
const STACK = {
32+
manifest: {
33+
id: 'com.example.verify-reference-shapes',
34+
namespace: 'vrs',
35+
version: '1.0.0',
36+
name: 'Verify Reference Shapes',
37+
type: 'app',
38+
engines: { protocol: '^18' },
39+
},
40+
objects: [
41+
{
42+
name: 'vrs_account',
43+
label: 'Account',
44+
pluralLabel: 'Accounts',
45+
sharingModel: 'private',
46+
fields: { name: { type: 'text', label: 'Name', required: true } },
47+
},
48+
{
49+
name: 'vrs_contact',
50+
label: 'Contact',
51+
pluralLabel: 'Contacts',
52+
sharingModel: 'private',
53+
fields: {
54+
name: { type: 'text', label: 'Name', required: true },
55+
account: { type: 'lookup', reference: 'vrs_account', label: 'Account' },
56+
},
57+
},
58+
],
59+
};
60+
61+
interface Run {
62+
status: number | null;
63+
stdout: string;
64+
stderr: string;
65+
}
66+
67+
interface Doc {
68+
hardFailures?: number;
69+
referenceShapes?: {
70+
results: Array<{ object: string; field: string; status: string }>;
71+
summary: { proven: number; holes: number; hardFailures: number };
72+
};
73+
}
74+
75+
function runCli(dir: string, args: string[], env: Record<string, string> = {}): Run {
76+
// Through tsx, so the child runs this checkout's `src/`.
77+
const r = spawnSync(TSX, [CLI, ...args], {
78+
cwd: dir,
79+
encoding: 'utf8',
80+
env: childEnv({ NO_COLOR: '1', OS_ALLOW_LAX_VALUE_SHAPES: undefined, ...env }),
81+
maxBuffer: 64 * 1024 * 1024,
82+
});
83+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
84+
}
85+
86+
const docOf = (run: Run): Doc => JSON.parse(run.stdout) as Doc;
87+
88+
describe('os verify --reference-shapes', () => {
89+
let dir: string;
90+
let plain: Run;
91+
let strict: Run;
92+
let lax: Run;
93+
94+
beforeAll(() => {
95+
dir = mkdtempSync(join(tmpdir(), 'os-verify-reference-shapes-'));
96+
writeFileSync(join(dir, 'objectstack.config.mjs'), defineStackSource(STACK));
97+
linkSpec(dir);
98+
plain = runCli(dir, ['verify', '--json']);
99+
strict = runCli(dir, ['verify', '--reference-shapes', '--json']);
100+
lax = runCli(dir, ['verify', '--reference-shapes', '--json'], { OS_ALLOW_LAX_VALUE_SHAPES: '1' });
101+
}, 480_000);
102+
103+
afterAll(() => {
104+
if (dir) rmSync(dir, { recursive: true, force: true });
105+
});
106+
107+
it('leaves the run unchanged without the flag', () => {
108+
expect(plain.status, `${plain.stdout}\n${plain.stderr}`).toBe(0);
109+
const doc = docOf(plain);
110+
expect('referenceShapes' in doc).toBe(false);
111+
expect(doc.hardFailures).toBe(0);
112+
});
113+
114+
it('proves the reference field on a strict stack, and exits 0', () => {
115+
expect(strict.status, `${strict.stdout}\n${strict.stderr}`).toBe(0);
116+
const doc = docOf(strict);
117+
expect(doc.referenceShapes?.results).toEqual([
118+
expect.objectContaining({ object: 'vrs_contact', field: 'account', status: 'proven' }),
119+
]);
120+
expect(doc.referenceShapes?.summary).toMatchObject({ proven: 1, holes: 0, hardFailures: 0 });
121+
expect(doc.hardFailures).toBe(0);
122+
});
123+
124+
it('NEGATIVE CONTROL: reports the hole on a posture that admits wrong shapes, counts it, and exits 1', () => {
125+
expect(lax.status, `${lax.stdout}\n${lax.stderr}`).toBe(1);
126+
const doc = docOf(lax);
127+
expect(doc.referenceShapes?.summary).toMatchObject({ proven: 0, holes: 1, hardFailures: 1 });
128+
expect(doc.hardFailures).toBe(1);
129+
});
130+
});

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ const CALLERS: Record<string, Classification> = {
221221
kind: 'not-a-served-read',
222222
reason: 'validation-rule analysis and evaluation on the write path; the verdict is pass/fail and no target field is served',
223223
},
224+
'packages/verify/src/reference-shape.ts': {
225+
kind: 'not-a-served-read',
226+
reason:
227+
"the verification harness's reference-value shape family: the target only picks, from the ids of records the " +
228+
'family itself created through the REST door, the id its embedded-record probe carries. It writes probe values ' +
229+
"to the SOURCE object (the REST data routes, which judge that object's exposure, and the engine's system write) " +
230+
'and reads no record of the target',
231+
},
224232
};
225233

226234
const SCAN_TIMEOUT_MS = 60_000;

0 commit comments

Comments
 (0)