Skip to content

Commit fd2e1a2

Browse files
authored
feat(verify): extract dogfood engine into public @objectstack/verify + CLI (#2041)
Public @objectstack/verify library + objectstack verify CLI; dogfood slimmed to consume it (golden tests kept); changeset fixed updated; CI verify step over example apps. Validated on framework examples, the external hotcrm app, and the 9-app template corpus. ADR-0054 updated to record the proof engine's extraction.
1 parent ea6154e commit fd2e1a2

25 files changed

Lines changed: 519 additions & 250 deletions

.changeset/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@
8080
"@objectstack/account",
8181
"create-objectstack",
8282
"objectstack-vscode",
83-
"@objectstack/connector-openapi"
83+
"@objectstack/connector-openapi",
84+
"@objectstack/verify"
8485
]
8586
],
8687
"linked": [],

.changeset/verify-package.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@objectstack/verify": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
Add `@objectstack/verify` — boot any ObjectStack app in-process and verify it through the real HTTP stack: auto-derived CRUD round-trip fidelity (`runCrudVerification`) plus the cross-owner RLS invariant (`runRlsProofs`, "you can't write what you can't read"). Also adds an `objectstack verify` CLI command that runs these proofs against an app config and exits non-zero on real failures.
7+
8+
Extracted from the internal dogfood regression gate so third-party and template authors can run the same runtime proofs against their own apps. The private `@objectstack/dogfood` package now consumes this library for its golden regression tests.

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ jobs:
161161
- name: Boot example apps and exercise real user flows
162162
run: pnpm turbo run test --filter=@objectstack/dogfood
163163

164+
# Replaces the former auto-verify dogfood tests: runs the published
165+
# `objectstack verify` engine over each example app through the CLI —
166+
# auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant.
167+
# Exits non-zero on a real runtime failure, so it gates like the tests did.
168+
- name: Verify example apps via the `objectstack verify` CLI
169+
run: |
170+
pnpm turbo run build --filter=@objectstack/cli
171+
for app in examples/app-crm examples/app-showcase; do
172+
echo "::group::objectstack verify $app --rls"
173+
OS_LOG_LEVEL=error node packages/cli/bin/run.js verify --app "$app/objectstack.config.ts" --rls
174+
echo "::endgroup::"
175+
done
176+
164177
build-core:
165178
name: Build Core
166179
needs: filter

docs/adr/0054-runtime-proof-for-authorable-surface.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Status**: Accepted (2026-06-18)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove gate), [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime), [ADR-0053](./0053-date-and-datetime-semantics.md) (the domain of the motivating regression)
6-
**Consumers**: `@objectstack/spec` (liveness ledger `packages/spec/liveness/<type>.json`), the Spec Liveness Check CI gate (#1919), `@objectstack/dogfood` (the runtime gate, [#2020](https://github.com/objectstack-ai/framework/pull/2020)), spec authors, platform contributors.
6+
**Consumers**: `@objectstack/spec` (liveness ledger `packages/spec/liveness/<type>.json`), the Spec Liveness Check CI gate (#1919), `@objectstack/dogfood` (the runtime gate, [#2020](https://github.com/objectstack-ai/framework/pull/2020)), `@objectstack/verify` (the published proof engine + CLI, [#2041](https://github.com/objectstack-ai/framework/pull/2041)), spec authors, platform contributors.
77
**Surfaced by**: PR [#2018](https://github.com/objectstack-ai/framework/pull/2018) — "organization timezone drives analytics date bucketing" was **green on every static gate** (build, ~900 unit tests, spec-liveness, CodeQL) yet broken end-to-end across three integration seams; and the field-type capability-matrix dogfood ([#2022](https://github.com/objectstack-ai/framework/pull/2022)), which on its first run found `rating`/`slider`/`toggle` reading back wrong-typed.
88

99
---
@@ -156,3 +156,45 @@ the proof corpus stays CI-cheap as it grows.
156156
server-reachable behavior. Pure objectui/React render correctness belongs in
157157
objectui's own suite; a property whose only failure mode is client render is
158158
out of scope for this gate.
159+
160+
161+
---
162+
163+
## Update (2026-06-19) — the proof engine is now `@objectstack/verify`
164+
165+
The proof *mechanism* this ADR assigns to `@objectstack/dogfood` has since been
166+
extracted into a published, app-agnostic package: **`@objectstack/verify`**
167+
([#2041](https://github.com/objectstack-ai/framework/pull/2041)) — `bootStack`
168+
(the real in-process stack via Hono request-injection), `deriveCrudCases`
169+
(a runtime contract auto-derived from any app's metadata), `runCrudVerification`
170+
(write → read → assert type fidelity), and `runRlsProofs` (the #1994
171+
"can't-write-what-you-can't-read" invariant) — plus an `objectstack verify` CLI.
172+
173+
This sharpens the decision without changing it:
174+
175+
- **The gate vs. the engine.** `@objectstack/dogfood` remains the *gate* — the
176+
framework's own **hand-written golden proofs** (e.g. the #2018 tz-bucketing
177+
test, which `derive` can never auto-generate) — and now runs *on* the
178+
`@objectstack/verify` engine instead of carrying it. A `proof` (§1) is still a
179+
dogfood test; what changed is that the harness underneath it is reusable.
180+
- **Phase 1 is now a reusable matrix.** The field-type matrix (#2022) is the
181+
published `deriveCrudCases` + `runCrudVerification`; the #1994 RLS seed is
182+
`runRlsProofs`. They are no longer internal to dogfood.
183+
- **Phase 3 has a concrete vehicle.** `deriveCrudCases` — metadata → synthesized
184+
record → asserted round-trip — *is* the seed of the deferred generative pass;
185+
Phase 3 grows it rather than starting from zero.
186+
- **Third parties get the same gate.** Because it is published, a third-party or
187+
template author runs the identical proofs against their own app
188+
(`objectstack verify --rls`), extending *prove-it-runs* beyond the framework's
189+
curated examples — the AI-authoring audience this ADR is written for. Validated
190+
against the external `hotcrm` app and the 9-app template corpus (#2041).
191+
192+
**Honest scope of the *auto-derived* path.** `@objectstack/verify`'s auto-derive
193+
asserts only **scalar field round-trip fidelity** and the **by-id RLS invariant**,
194+
and **skips** objects whose required fields it can't synthesize (lookups /
195+
master-detail) and field classes it can't assert (computed/formula, flow nodes,
196+
analytics bucketing, UI). A green `objectstack verify` therefore proves those two
197+
dimensions over the auto-reachable subset — it does **not** subsume the
198+
hand-written golden proofs, which is exactly why §4's gate keeps them. Closing
199+
that gap (related-record topological synthesis; computed/flow/analytics
200+
assertions) is the substance of Phases 2–3.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"@objectstack/service-storage": "workspace:*",
8989
"@objectstack/spec": "workspace:*",
9090
"@objectstack/types": "workspace:*",
91+
"@objectstack/verify": "workspace:*",
9192
"@oclif/core": "^4.11.4",
9293
"bundle-require": "^5.1.0",
9394
"chalk": "^5.6.2",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import { readEnvWithDeprecation } from '@objectstack/types';
6+
import {
7+
bootStack,
8+
runCrudVerification,
9+
formatReport,
10+
runRlsProofs,
11+
formatRlsReport,
12+
type VerifyReport,
13+
type RlsReport,
14+
} from '@objectstack/verify';
15+
import { loadConfig } from '../utils/config.js';
16+
17+
/**
18+
* `objectstack verify` — boot the app in-process and exercise it through the
19+
* real HTTP stack, asserting runtime behavior the static gates can't see:
20+
* - data fidelity: author → write → read → assert, per object/field type
21+
* - authorization (--rls): "you can't write what you can't read" (#1994 class)
22+
*
23+
* Exits non-zero on real failures so it drops straight into CI.
24+
*/
25+
export default class Verify extends Command {
26+
static override description =
27+
'Boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)';
28+
29+
static override examples = [
30+
'<%= config.bin %> verify',
31+
'<%= config.bin %> verify --app ./objectstack.config.ts --rls',
32+
'<%= config.bin %> verify --rls --multi-tenant --json',
33+
];
34+
35+
static override flags = {
36+
app: Flags.string({
37+
char: 'a',
38+
description: 'Path to the app config (defaults to ./objectstack.config.{ts,js,mjs})',
39+
}),
40+
rls: Flags.boolean({
41+
description: 'Also run the cross-owner RLS invariant (a fresh member must not write what it cannot read)',
42+
default: false,
43+
}),
44+
'multi-tenant': Flags.boolean({
45+
description: 'Boot org-scoped (register plugin-org-scoping) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)',
46+
default: false,
47+
}),
48+
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
49+
};
50+
51+
async run(): Promise<void> {
52+
const { flags } = await this.parse(Verify);
53+
54+
const { config, absolutePath } = await loadConfig(flags.app);
55+
56+
const multiTenant =
57+
flags['multi-tenant'] ||
58+
String(readEnvWithDeprecation('OS_MULTI_ORG_ENABLED', 'OS_MULTI_TENANT') ?? 'false').toLowerCase() !==
59+
'false';
60+
61+
// Data fidelity runs on its own pristine stack.
62+
let crud: VerifyReport;
63+
{
64+
const stack = await bootStack(config, { multiTenant });
65+
try {
66+
const adminToken = await stack.signIn();
67+
crud = await runCrudVerification(stack, adminToken, config);
68+
} finally {
69+
await stack.stop();
70+
}
71+
}
72+
73+
// The RLS proofs run on a SEPARATE, fresh stack. Reusing the fidelity stack
74+
// would let the RLS phase's admin-creates collide with the rows the fidelity
75+
// phase already wrote on unique-constrained fields (e.g. a unique `sku` or
76+
// `account_number`) — a 409 that silently skips the object instead of
77+
// proving its authorization.
78+
let rls: RlsReport | undefined;
79+
if (flags.rls) {
80+
const rlsStack = await bootStack(config, { multiTenant });
81+
try {
82+
const adminToken = await rlsStack.signIn();
83+
const memberToken = await rlsStack.signUp('verify-member@objectstack.test');
84+
rls = await runRlsProofs(rlsStack, adminToken, memberToken, config);
85+
} finally {
86+
await rlsStack.stop();
87+
}
88+
}
89+
90+
// Failure contract: a "real" runtime break the app's author must see.
91+
const hardFailures =
92+
crud.summary.createFailed +
93+
crud.summary.readFailed +
94+
crud.summary.fidelityGaps +
95+
(rls?.summary.holes ?? 0);
96+
97+
if (flags.json) {
98+
this.log(JSON.stringify({ app: crud.app, config: absolutePath, multiTenant, crud, rls, hardFailures }, null, 2));
99+
} else {
100+
this.log(formatReport(crud));
101+
if (rls) this.log(formatRlsReport(rls));
102+
this.log('');
103+
this.log(
104+
hardFailures > 0
105+
? chalk.red(`✗ verify FAILED — ${hardFailures} runtime failure(s)`)
106+
: chalk.green('✓ verify passed — no runtime failures'),
107+
);
108+
}
109+
110+
// Force process exit: the in-process stack leaves handles open (http server,
111+
// sqlite-wasm, better-auth timers) that keep the event loop alive after
112+
// stop(), so a bare return would hang. exit() also encodes the CI contract.
113+
this.exit(hardFailures > 0 ? 1 : 0);
114+
}
115+
}

packages/dogfood/README.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,21 @@ sockets, CI-stable). Tests act as a browser client would: sign in, hit
2323

2424
## Layout
2525

26-
- `src/harness.ts``bootDogfoodStack(config)``{ kernel, api, raw, signIn, apiAs, stop }`.
26+
The in-process harness + auto-derived verifiers (`bootStack`, `runCrudVerification`,
27+
`runRlsProofs`) now live in the published **[`@objectstack/verify`](../verify)**
28+
package — point it at any app. This package is the framework's own consumer: it
29+
holds the **hand-written golden tests** that pin specific historical regressions
30+
the generic verifier cannot auto-derive.
31+
32+
- depends on `@objectstack/verify` for `bootStack(config)``{ kernel, api, raw, signIn, signUp, apiAs, stop }`.
2733
- `test/*.dogfood.test.ts` — golden flows. Each should assert on **observable
2834
output** (a number, a bucket label, a row count), not just "no error".
2935

3036
## Adding a golden test
3137

3238
1. Pick a real user flow that a static test can't cover (it spans engine +
3339
service + HTTP, or depends on seeded/written data).
34-
2. `bootDogfoodStack(<appConfig>)`, `signIn()`, drive it via `api()/apiAs()`.
40+
2. `bootStack(<appConfig>)`, `signIn()`, drive it via `api()/apiAs()`.
3541
3. Assert on the concrete result.
3642
4. **Prove it catches the bug**: temporarily revert the relevant fix and confirm
3743
the test goes red. A green-on-the-bug test is not a gate.
@@ -67,7 +73,7 @@ The binding policy — every authorable+live primitive must carry a runtime proo
6773

6874
The capability matrix above proves *data* round-trips. The authorization
6975
dimension proves a record the caller must not touch stays untouched. The
70-
app-agnostic invariant (`src/rls.ts`, `runRlsProofs`):
76+
app-agnostic invariant (`runRlsProofs`, from `@objectstack/verify`):
7177

7278
> **A user who cannot READ a record must not be able to WRITE it.**
7379
@@ -87,9 +93,10 @@ changed. Verdicts: `rls-consistent` (can't read **and** can't write — good),
8793
`rls-hole` (can't read **yet** wrote — the #1994 bug), `member-visible`
8894
(member *can* read it — inconclusive, not a cross-owner scenario).
8995

90-
`auto-verify-rls.dogfood.test.ts` runs this over the example apps, but they boot
91-
**single-tenant**, where every object comes back `member-visible` — so the
92-
by-id-write path is never actually exercised. Two ways to create real isolation:
96+
`objectstack verify <app> --rls` runs this over any app in CI, but the example
97+
apps boot **single-tenant**, where every object comes back `member-visible` — so
98+
the by-id-write path is never actually exercised. Two ways to create real
99+
isolation, both pinned as golden tests here:
93100

94101
### 1. Owner-scoped fixture — `test/rls-fixture.dogfood.test.ts` (hard gate)
95102

@@ -98,11 +105,11 @@ permission set carries `RLS.ownerPolicy('rls_note', 'created_by')`. The predicat
98105
is `created_by = current_user.id` — keyed on the column the engine stamps on
99106
every record and referencing `current_user.id`, **not**
100107
`current_user.organization_id`, so it survives single-tenant policy stripping. A
101-
fresh member genuinely can't read the admin's note. `bootDogfoodStack` takes a
108+
fresh member genuinely can't read the admin's note. `bootStack` takes a
102109
`security:` override so the fixture's permission set is the member's fallback:
103110

104111
```ts
105-
bootDogfoodStack(rlsFixtureStack, { security: rlsFixtureSecurity(ownerScopedMemberSet) })
112+
bootStack(rlsFixtureStack, { security: rlsFixtureSecurity(ownerScopedMemberSet) })
106113
```
107114

108115
- **Green gate** (owner policy on `all` ops) → `rls-consistent`. Safe *only*
@@ -139,7 +146,7 @@ Faithful fix — boot multi-tenant so `@objectstack/plugin-org-scoping` register
139146
before `SecurityPlugin` and the `organization_id` policies apply:
140147

141148
```ts
142-
bootDogfoodStack(crmStack, { multiTenant: true })
149+
bootStack(crmStack, { multiTenant: true })
143150
```
144151

145152
The dev admin is bound to the seeded default org; a fresh `signUp` member is not,

packages/dogfood/package.json

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,18 @@
33
"version": "0.0.1",
44
"private": true,
55
"license": "Apache-2.0",
6-
"description": "Dogfood regression gate — boots real example apps in-process and exercises them through the real HTTP/service stack, catching runtime regressions that static checks (build, unit tests, spec liveness) miss.",
6+
"description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.",
77
"type": "module",
88
"scripts": {
99
"test": "vitest run"
1010
},
1111
"dependencies": {
12-
"@objectstack/core": "workspace:*",
13-
"@objectstack/runtime": "workspace:*",
12+
"@objectstack/verify": "workspace:*",
1413
"@objectstack/objectql": "workspace:*",
1514
"@objectstack/spec": "workspace:*",
16-
"@objectstack/driver-sqlite-wasm": "workspace:*",
17-
"@objectstack/plugin-hono-server": "workspace:*",
18-
"@objectstack/rest": "workspace:*",
19-
"@objectstack/plugin-auth": "workspace:*",
2015
"@objectstack/plugin-security": "workspace:*",
21-
"@objectstack/service-settings": "workspace:*",
22-
"@objectstack/service-analytics": "workspace:*",
2316
"@objectstack/example-crm": "workspace:*",
24-
"@objectstack/example-showcase": "workspace:*",
25-
"@objectstack/plugin-sharing": "workspace:*",
26-
"@objectstack/plugin-org-scoping": "workspace:*"
17+
"@objectstack/example-showcase": "workspace:*"
2718
},
2819
"devDependencies": {
2920
"@types/node": "^25.9.3",

packages/dogfood/test/analytics-timezone.dogfood.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1212
import crmStack from '@objectstack/example-crm';
13-
import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
1414

1515
// 03:00 UTC on 2024-03-01 is still 2024-02-29 (19:00) in America/Los_Angeles
1616
// (PST = UTC-8, before DST). So a *day* bucket labels this instant 2024-03-01
@@ -29,11 +29,11 @@ const leadByDay = {
2929
};
3030

3131
describe('dogfood: org timezone drives analytics date bucketing (#1982/#2018)', () => {
32-
let stack: DogfoodStack;
32+
let stack: VerifyStack;
3333
let token: string;
3434

3535
beforeAll(async () => {
36-
stack = await bootDogfoodStack(crmStack);
36+
stack = await bootStack(crmStack);
3737

3838
// Deterministic fixture: N leads pinned to the tz-boundary instant, inserted
3939
// as system so the write path's defaults/validation don't fight the setup.

packages/dogfood/test/auto-verify-rls.dogfood.test.ts

Lines changed: 0 additions & 57 deletions
This file was deleted.

0 commit comments

Comments
 (0)