Skip to content

Commit 311bcc6

Browse files
os-zhuangclaude
andauthored
feat(dogfood): metadata-driven verifier (objectstack verify) — auto-derive runtime proofs (#2027)
The dogfood gate pins the framework's own example apps; it never sees a third party's metadata. But the platform's value is that an AI authors arbitrary metadata and it works at runtime — for a SPECIFIC consumer app the framework CI can't reach. This adds the seed of `objectstack verify`: derive a runtime contract from an app's metadata ALONE (no hand-written tests), then run it against the real in-process stack. v0 derives per-object CRUD round-trip — synthesize a valid create body from each object's field types, POST it, GET it back, assert type fidelity. - src/derive.ts — metadata → CRUD cases. Synthesizes values per field type; reports objects blocked by un-synthesizable required fields (e.g. a required lookup) rather than silently skipping. - src/verify.ts — runs the derived cases over real HTTP, returns a structured report. Classifies outcomes: verified / fidelity-gaps (type leaks) / needs-fixture (the auto-record tripped the app's own validation rules) / create-failed|read-failed (real runtime failures) / skipped. - harness.ts — wire LocalCryptoProvider (as `objectstack dev` does) so apps with a Field.secret are exercisable end-to-end. - test/auto-verify.dogfood.test.ts — runs it on CRM + showcase; gates on "no object fails to create/read". On showcase it auto-found f_progress's type-fidelity gap too — which the hand-written field-zoo matrix had missed. - test/verify-external.dogfood.test.ts — OS_VERIFY_ARTIFACT=<app>/dist/objectstack.json points it at ANY app's built artifact (the consumer entry point; skipped in CI). Proven against hotcrm (15-object third-party app, separate repo, zero dogfood dependency): booted in-process, seeded 162 records, auto-verified — found the platform rating type-leak in crm_case/crm_lead AND a hotcrm-specific write divergence (crm_product.sku normalized to uppercase) that no framework test could reach. Private package — no changeset. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1707fe5 commit 311bcc6

5 files changed

Lines changed: 379 additions & 1 deletion

File tree

packages/dogfood/src/derive.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Metadata-driven proof derivation.
4+
//
5+
// The platform's apps are 100% declarative metadata, so a baseline runtime
6+
// contract can be DERIVED from the metadata itself — no hand-written tests. This
7+
// is the seed of `objectstack verify`: point it at any app (a framework example
8+
// OR a third-party app like hotcrm) and it auto-generates "author this object →
9+
// write it → read it back → assert type fidelity" for every object, then runs
10+
// it against the real in-process stack.
11+
//
12+
// v0 derives per-object CRUD round-trip cases. RLS cross-owner denial (the
13+
// #1994 invariant) is v1 — it needs the multi-user harness + sharing service.
14+
15+
/* eslint-disable @typescript-eslint/no-explicit-any */
16+
17+
const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']);
18+
const RELATIONAL = new Set(['lookup', 'master_detail', 'master-detail', 'masterdetail', 'tree']);
19+
const STRUCTURED = new Set(['composite', 'repeater', 'record', 'location', 'address']);
20+
const MEDIA = new Set(['image', 'file', 'avatar', 'video', 'audio', 'signature', 'qrcode']);
21+
const SYSTEM_NAMES = new Set([
22+
'id', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owner',
23+
'space', 'instance_state', 'record_id', 'is_deleted',
24+
]);
25+
26+
export type AssertKind = 'equal' | 'set' | 'none';
27+
export interface DerivedAssert {
28+
field: string;
29+
type: string;
30+
value: unknown;
31+
kind: AssertKind;
32+
}
33+
export interface CrudCase {
34+
object: string;
35+
blocked?: string; // why this object can't be auto-CRUD'd (e.g. required lookup)
36+
body?: Record<string, unknown>;
37+
asserts?: DerivedAssert[];
38+
skippedFields?: Array<{ name: string; type: string; reason: string }>;
39+
}
40+
41+
function clampNum(f: any, fallback: number): number {
42+
const { min, max, step } = f;
43+
let v = fallback;
44+
if (typeof min === 'number' && v < min) v = min;
45+
if (typeof max === 'number' && v > max) v = max;
46+
if (typeof step === 'number' && typeof min === 'number') {
47+
v = min + step * Math.round((v - min) / step);
48+
}
49+
return v;
50+
}
51+
52+
/** Synthesize a valid value for a field type, or null if not synthesizable. */
53+
function synth(type: string, f: any): { value: unknown; kind: AssertKind } | null {
54+
switch (type) {
55+
case 'text': case 'textarea': case 'string':
56+
case 'markdown': case 'html': case 'richtext': case 'code':
57+
return { value: 'verify-sample', kind: 'equal' };
58+
case 'email': return { value: 'verify@example.com', kind: 'equal' };
59+
case 'url': return { value: 'https://example.com', kind: 'equal' };
60+
case 'phone': return { value: '+14155550100', kind: 'equal' };
61+
case 'color': return { value: '#3366CC', kind: 'equal' };
62+
case 'number': return { value: clampNum(f, 7), kind: 'equal' };
63+
case 'currency': return { value: clampNum(f, 100), kind: 'equal' };
64+
case 'percent': return { value: clampNum(f, 50), kind: 'equal' };
65+
case 'rating': return { value: clampNum(f, Math.min(3, f.max ?? 5)), kind: 'equal' };
66+
case 'slider': case 'progress': return { value: clampNum(f, 25), kind: 'equal' };
67+
case 'boolean': case 'toggle': return { value: true, kind: 'equal' };
68+
case 'date': return { value: '2024-03-15', kind: 'equal' };
69+
case 'datetime': return { value: '2024-03-15T08:30:00.000Z', kind: 'equal' };
70+
case 'time': return { value: '14:30:00', kind: 'equal' };
71+
case 'json': return { value: { sample: true }, kind: 'equal' };
72+
case 'select': case 'radio': {
73+
const opt = f.options?.[0]?.value;
74+
return opt != null ? { value: opt, kind: 'equal' } : null;
75+
}
76+
case 'multiselect': case 'checkboxes': {
77+
const opt = f.options?.[0]?.value;
78+
return opt != null ? { value: [opt], kind: 'set' } : null;
79+
}
80+
case 'tags': return { value: ['alpha', 'beta'], kind: 'set' };
81+
// Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted).
82+
case 'password': case 'secret': return { value: 'Sample-Secret-123', kind: 'none' };
83+
default: return null;
84+
}
85+
}
86+
87+
/**
88+
* Derive one CRUD round-trip case per authorable object in the config.
89+
* An object whose REQUIRED fields can't be synthesized (e.g. a required lookup
90+
* needing a target record) is reported `blocked` rather than silently skipped.
91+
*/
92+
export function deriveCrudCases(config: any): CrudCase[] {
93+
const cases: CrudCase[] = [];
94+
for (const obj of config?.objects ?? []) {
95+
const fields: Record<string, any> = obj?.fields ?? {};
96+
const body: Record<string, unknown> = {};
97+
const asserts: DerivedAssert[] = [];
98+
const skippedFields: Array<{ name: string; type: string; reason: string }> = [];
99+
let blocked: string | undefined;
100+
101+
for (const [name, f] of Object.entries(fields)) {
102+
const type = String((f as any)?.type ?? '').toLowerCase();
103+
if (SYSTEM_NAMES.has(name) || (f as any)?.system || (f as any)?.readonly) continue;
104+
if (COMPUTED.has(type)) continue;
105+
106+
if (RELATIONAL.has(type) || STRUCTURED.has(type) || MEDIA.has(type)) {
107+
if ((f as any)?.required) { blocked = `required ${type} field "${name}" (needs target/structured value)`; break; }
108+
skippedFields.push({ name, type, reason: 'unsynthesizable-optional' });
109+
continue;
110+
}
111+
112+
const s = synth(type, f);
113+
if (!s) {
114+
if ((f as any)?.required) { blocked = `required field "${name}" of type "${type}" is not synthesizable`; break; }
115+
skippedFields.push({ name, type, reason: 'no-synth' });
116+
continue;
117+
}
118+
body[name] = s.value;
119+
if (s.kind !== 'none') asserts.push({ field: name, type, value: s.value, kind: s.kind });
120+
}
121+
122+
// Every object needs a name-ish required text; synth already covers `name`.
123+
if (blocked) cases.push({ object: obj.name, blocked });
124+
else cases.push({ object: obj.name, body, asserts, skippedFields });
125+
}
126+
return cases;
127+
}

packages/dogfood/src/harness.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
2222
import { createRestApiPlugin } from '@objectstack/rest';
2323
import { AuthPlugin } from '@objectstack/plugin-auth';
2424
import { SecurityPlugin } from '@objectstack/plugin-security';
25-
import { SettingsServicePlugin } from '@objectstack/service-settings';
25+
import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings';
2626
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
2727

2828
/** A Hono app exposes `.request(path, init)` returning a standard `Response`. */
@@ -94,6 +94,18 @@ export async function bootDogfoodStack(
9494
// Fire the ready lifecycle: seed data, dev-admin bootstrap, route registration.
9595
await kernel.bootstrap();
9696

97+
// Secret fields (Field.secret) refuse to persist without a crypto provider —
98+
// mirror `objectstack dev`, which wires LocalCryptoProvider in development so
99+
// an app with an encrypted field is exercisable end-to-end.
100+
try {
101+
const engine = await kernel.getServiceAsync<{ setCryptoProvider?: (p: unknown) => void }>('objectql');
102+
if (engine && typeof engine.setCryptoProvider === 'function') {
103+
engine.setCryptoProvider(new LocalCryptoProvider());
104+
}
105+
} catch {
106+
/* no engine / no crypto support — secret fields will fail closed, as in prod */
107+
}
108+
97109
const httpServer = await kernel.getServiceAsync<{ getRawApp(): InjectableApp; close?(): Promise<void> }>(
98110
'http-server',
99111
);

packages/dogfood/src/verify.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The verify runner — executes derived proofs against a live dogfood stack.
4+
//
5+
// This is what a consumer (a framework example OR a third-party app like hotcrm)
6+
// would run: boot my app in-process, auto-derive a runtime contract from my
7+
// metadata, exercise it through the real HTTP surface, and tell me where the
8+
// declared behavior doesn't actually hold at runtime.
9+
10+
/* eslint-disable @typescript-eslint/no-explicit-any */
11+
12+
import type { DogfoodStack } from './harness.js';
13+
import { deriveCrudCases, type CrudCase } from './derive.js';
14+
15+
export interface ObjectVerifyResult {
16+
object: string;
17+
status: 'verified' | 'fidelity-gaps' | 'create-failed' | 'read-failed' | 'skipped' | 'needs-fixture';
18+
checked?: number;
19+
reason?: string;
20+
code?: number;
21+
detail?: string;
22+
mismatches?: Array<{ field: string; type: string; wrote: unknown; read: unknown }>;
23+
}
24+
25+
export interface VerifyReport {
26+
app: string;
27+
results: ObjectVerifyResult[];
28+
summary: {
29+
objects: number;
30+
verified: number;
31+
fidelityGaps: number;
32+
createFailed: number;
33+
needsFixture: number;
34+
readFailed: number;
35+
skipped: number;
36+
mismatchTotal: number;
37+
};
38+
}
39+
40+
function setEqual(a: unknown, b: unknown[]): boolean {
41+
if (!Array.isArray(a)) return false;
42+
return JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
43+
}
44+
function deepEqual(a: unknown, b: unknown): boolean {
45+
return JSON.stringify(a) === JSON.stringify(b);
46+
}
47+
48+
/**
49+
* Run auto-derived CRUD round-trip proofs for every object in `config`, as the
50+
* authenticated `token`, against the live `stack`. Returns a structured report;
51+
* never throws on a per-object failure (collects them).
52+
*/
53+
export async function runCrudVerification(
54+
stack: DogfoodStack,
55+
token: string,
56+
config: any,
57+
): Promise<VerifyReport> {
58+
const cases = deriveCrudCases(config);
59+
const results: ObjectVerifyResult[] = [];
60+
61+
for (const c of cases as CrudCase[]) {
62+
if (c.blocked) {
63+
results.push({ object: c.object, status: 'skipped', reason: c.blocked });
64+
continue;
65+
}
66+
let created: Response;
67+
try {
68+
created = await stack.apiAs(token, 'POST', `/data/${c.object}`, c.body);
69+
} catch (e: any) {
70+
results.push({ object: c.object, status: 'create-failed', detail: String(e?.message ?? e).slice(0, 200) });
71+
continue;
72+
}
73+
if (created.status >= 300) {
74+
const text = (await created.text()).slice(0, 280);
75+
// A 400 validation failure means our auto-derived record didn't satisfy the
76+
// app's own validation rules (record-level format/json-schema/CEL) — that's
77+
// a fixture gap, not a platform finding. A 5xx (or crypto/driver error) is a
78+
// real runtime failure the app's author needs to see.
79+
const isValidation = created.status === 400 && /VALIDATION_FAILED|Validation failed/i.test(text);
80+
results.push({
81+
object: c.object,
82+
status: isValidation ? 'needs-fixture' : 'create-failed',
83+
code: created.status,
84+
detail: text,
85+
});
86+
continue;
87+
}
88+
const cj = (await created.json()) as any;
89+
const id = cj?.id ?? cj?.record?.id;
90+
if (!id) {
91+
results.push({ object: c.object, status: 'create-failed', detail: 'no id returned' });
92+
continue;
93+
}
94+
95+
const got = await stack.apiAs(token, 'GET', `/data/${c.object}/${id}`);
96+
if (got.status !== 200) {
97+
results.push({ object: c.object, status: 'read-failed', code: got.status });
98+
continue;
99+
}
100+
const rec = ((await got.json()) as any)?.record ?? {};
101+
102+
const mismatches: ObjectVerifyResult['mismatches'] = [];
103+
for (const a of c.asserts ?? []) {
104+
const actual = rec[a.field];
105+
const ok = a.kind === 'set' ? setEqual(actual, a.value as unknown[]) : deepEqual(actual, a.value);
106+
if (!ok) mismatches.push({ field: a.field, type: a.type, wrote: a.value, read: actual });
107+
}
108+
results.push({
109+
object: c.object,
110+
status: mismatches.length ? 'fidelity-gaps' : 'verified',
111+
checked: (c.asserts ?? []).length,
112+
...(mismatches.length ? { mismatches } : {}),
113+
});
114+
}
115+
116+
const summary = {
117+
objects: results.length,
118+
verified: results.filter((r) => r.status === 'verified').length,
119+
fidelityGaps: results.filter((r) => r.status === 'fidelity-gaps').length,
120+
createFailed: results.filter((r) => r.status === 'create-failed').length,
121+
needsFixture: results.filter((r) => r.status === 'needs-fixture').length,
122+
readFailed: results.filter((r) => r.status === 'read-failed').length,
123+
skipped: results.filter((r) => r.status === 'skipped').length,
124+
mismatchTotal: results.reduce((n, r) => n + (r.mismatches?.length ?? 0), 0),
125+
};
126+
return { app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', results, summary };
127+
}
128+
129+
/** Pretty one-line-per-object summary for logs. */
130+
export function formatReport(report: VerifyReport): string {
131+
const lines: string[] = [`\n=== objectstack verify — ${report.app} ===`];
132+
for (const r of report.results) {
133+
if (r.status === 'verified') lines.push(` ✓ ${r.object} (${r.checked} fields)`);
134+
else if (r.status === 'fidelity-gaps') {
135+
lines.push(` ⚠ ${r.object} ${r.mismatches!.length} fidelity gap(s):`);
136+
for (const m of r.mismatches!) lines.push(` ${m.field} <${m.type}>: wrote ${JSON.stringify(m.wrote)} → read ${JSON.stringify(m.read)}`);
137+
}
138+
else if (r.status === 'skipped') lines.push(` – ${r.object} skipped: ${r.reason}`);
139+
else if (r.status === 'needs-fixture') lines.push(` ~ ${r.object} needs-fixture (app validation rejected the auto-record): ${(r.detail ?? '').slice(0,120)}`);
140+
else lines.push(` ✗ ${r.object} ${r.status}${r.code ? ` (${r.code})` : ''}: ${r.detail ?? ''}`);
141+
}
142+
const s = report.summary;
143+
lines.push(` ── ${s.verified} verified, ${s.fidelityGaps} gaps, ${s.createFailed + s.readFailed} FAILED, ${s.needsFixture} needs-fixture, ${s.skipped} skipped (${s.mismatchTotal} mismatches)`);
144+
return lines.join('\n');
145+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// objectstack verify — metadata-driven runtime verification, proven against the
4+
// framework's own example apps. From each app's metadata ALONE it auto-derives a
5+
// CRUD round-trip contract (no hand-written cases) and runs it over real HTTP.
6+
// The same runner points at any third-party app's built artifact via
7+
// `test/verify-external.dogfood.test.ts`.
8+
9+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
10+
import crmStack from '@objectstack/example-crm';
11+
import showcaseStack from '@objectstack/example-showcase';
12+
import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js';
13+
import { runCrudVerification, formatReport, type VerifyReport } from '../src/verify.js';
14+
15+
const APPS: Array<[string, unknown]> = [
16+
['crm', crmStack],
17+
['showcase', showcaseStack],
18+
];
19+
20+
for (const [name, config] of APPS) {
21+
describe(`objectstack verify: ${name} (auto-derived CRUD round-trip)`, () => {
22+
let stack: DogfoodStack;
23+
let report: VerifyReport;
24+
25+
beforeAll(async () => {
26+
stack = await bootDogfoodStack(config as never);
27+
const token = await stack.signIn();
28+
report = await runCrudVerification(stack, token, config);
29+
// eslint-disable-next-line no-console
30+
console.error(formatReport(report));
31+
}, 60_000);
32+
33+
afterAll(async () => {
34+
await stack?.stop();
35+
});
36+
37+
it('derives a runtime contract from metadata and boots', () => {
38+
expect(report.summary.objects).toBeGreaterThan(0);
39+
});
40+
41+
it('verifies objects end-to-end over real HTTP', () => {
42+
expect(report.summary.verified).toBeGreaterThan(0);
43+
});
44+
45+
it('has no object that fails to create or read (the hard runtime invariant)', () => {
46+
// create/read failures = the app's metadata produces a record the runtime
47+
// refuses or can't read back — a real platform/integration finding (vs
48+
// `needs-fixture`, the auto-record tripping the app's own validation rules,
49+
// and `fidelity-gaps`, type leaks tracked separately).
50+
const hard = report.results.filter((r) => r.status === 'create-failed' || r.status === 'read-failed');
51+
expect(hard, JSON.stringify(hard, null, 2)).toHaveLength(0);
52+
});
53+
});
54+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Point the verifier at ANY app's built artifact — the embryonic
4+
// `objectstack verify <app>`. This is the consumer-facing use: a third-party app
5+
// (e.g. hotcrm) runs it against its OWN metadata to learn where its declared
6+
// behavior doesn't hold at runtime.
7+
//
8+
// Gated on OS_VERIFY_ARTIFACT so it never runs in framework CI (the external app
9+
// isn't in the workspace). Run locally:
10+
// OS_VERIFY_ARTIFACT=/abs/path/to/app/dist/objectstack.json \
11+
// pnpm --filter @objectstack/dogfood exec vitest run test/verify-external.dogfood.test.ts
12+
13+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14+
import { readFileSync, existsSync } from 'node:fs';
15+
import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js';
16+
import { runCrudVerification, formatReport, type VerifyReport } from '../src/verify.js';
17+
18+
const ARTIFACT = process.env.OS_VERIFY_ARTIFACT;
19+
20+
describe.skipIf(!ARTIFACT || !existsSync(ARTIFACT))('objectstack verify: external app artifact', () => {
21+
let stack: DogfoodStack;
22+
let report: VerifyReport;
23+
24+
beforeAll(async () => {
25+
const config = JSON.parse(readFileSync(ARTIFACT as string, 'utf8'));
26+
stack = await bootDogfoodStack(config);
27+
const token = await stack.signIn();
28+
report = await runCrudVerification(stack, token, config);
29+
// eslint-disable-next-line no-console
30+
console.error(formatReport(report));
31+
}, 120_000);
32+
33+
afterAll(async () => {
34+
await stack?.stop();
35+
});
36+
37+
it('boots the external app and auto-derives a runtime contract', () => {
38+
expect(report.summary.objects).toBeGreaterThan(0);
39+
});
40+
});

0 commit comments

Comments
 (0)