prd.json schema, the three test harnesses, the access contract vocabulary, CLI commands, and generated artifacts. The narrative documentation — what the PRD is for, the trust model, and how to review one — lives in PRD Verification. Requires app framework 2.10.0+.
File layout
prd.json schema
Feature entries
test.namemust equal the entry (or child) id; the build fails on an entry with no test file.- Children nest one level; the build rejects a grandchild. A child that needs children is a top-level feature.
- Roll-up is strict: any failing child fails its feature, even when the hero passed.
- Child kinds: an e2e child is a distinct visual state the hero never passes through (own small recording); a unit child is internal logic with a combinatorial input space (a ✓/✗ table).
Access entries
prd.json carries a top-level accessControl block, one entry per backend procedure:
whoCanDoThis and howAccessIsVerified are generated from access by prd:render — never hand-author them.
The access contract
access is a machine-checked decision table. The build cross-checks it against the procedure’s guard (both directions: a declared scope the code doesn’t enforce fails, and vice versa) and derives the scenario clauses the entry’s test must cover.
allow is "always", "never", or a boolean expression over declared attributes (caller.isAdmin, caller.level > 10). Anything richer belongs in kind: "custom" with conditions[].
Derived clauses (each requires a scenario of that kind):
validate:prd:access (a build phase) names exactly which clause is missing per entry, and fails any procedure with no access entry or any entry with no access block.
Concerns
Review flags are computed from the verified contract, never authored: an endpoint where any signed-in user can modify rows they don’t own, or read every row, gets a warning that the permissive access should be verified. Concerns are a review signal (the “need attention” count in the PRD view), not a build failure.E2E harness (@synthetiq/app-framework/prd/harness)
Structural rules the harness enforces:
stepandverifyare siblings, never nested — the harness throws on nesting.- A step performs one user action and must end on a visibility assertion (
toBeVisible), not a bare wait — the recording holds on whatever is on screen whenfnends. - Use
pressSequentially(text, { delay })rather thanfillso typing is visible on camera. - An uncaught page error or a fully blank render fails the run regardless of assertions.
console.erroroutput is captured as advisory.
Personas
actAs accepts { scopes: [...] } for an exact permission set (a hypothetical role is minted), the presets "Admin" (all scopes) / "Member" (no scopes), or { id, scopes? } to act as a seeded user so seeded rows belong to the caller. Call actAs more than once to switch identity on the same recorded page; the player shows who is acting, and their permissions, at each step.
Seeding
Both harnesses take the same shape:{ Model: [rows…] }, created in declaration order. Rows specify only the fields under test; required scalar columns are auto-filled from the Prisma schema with deterministic values. The harness never fabricates relations/foreign keys, optional columns, @default columns, or @updatedAt — set relationships yourself. Every run resets the database first; seeds never leak between tests.
fixture(defaults) (from /prd/api or /prd/harness) returns (overrides?) => row for reusable per-model shapes shared across e2e and api specs; pass a factory () => ({...}) when rows need fresh objects or dates.
Service stubs
Every service call crosses one stubbed boundary; nothing real is ever called. Resolution order for a tool’s response:- In-test
prd.stub({ "service.tool": response })override. - A fixture file
prd/fixtures/services/<service>.<tool>.json. - A generated sample from the tool’s declared output schema.
$byParams matches responses to a call’s parameters declaratively (JSON-safe, so it works in e2e):
prd/fixtures/app-settings.json (the configured world for every test); a test whose point is missing configuration overrides with prd.appSettings.
Workflows in tests
When the app callsworkflows.submitJob, the harness intercepts the submission and executes the job in-process with the real workflow engine: real SQL steps against the test database, real imports and persists, every service step through the same stubs. Execution is synchronous; a failing workflow step fails the test with that step’s error. Workflow-originated service calls are tagged with origin: "workflow". An explicit prd.stub({ "workflows.submitJob": … }) keeps submission stubbed for the rare test about submission itself.
API harness (@synthetiq/app-framework/prd/api)
kind is one of "auth" | "scope" | "owner" | "relationship" | "tenant" | "attribute" | "custom". custom renders under Business rules; the rest under Access checks.
Scenario handle:
The scenario invariant: one seed, then every state change goes through a real endpoint call (captured in the transcript), with read-only verification of the outcome. Each scenario runs in isolation with its own fresh seed. A scenario may chain calls when order is meaningful (create to a limit, then over it), but it owns its setup. Service-call assertions are available headlessly via
expectServiceCalled / expectServiceNotCalled from the same module.
Unit harness (@synthetiq/app-framework/prd/unit)
check(label, fn) is one row of the entry’s ✓/✗ table; the label is plain-language user-facing copy bound 1:1 to the assertion. Checks are collect-and-continue: every row runs and reports, then the entry fails if any row failed.
Build gates on every unit test:
- Import gate — the test must import the app’s real implementation; imports that never leave
prd/fail the entry (fixtures don’t count, nor do raw Prisma queries with the test’s own filters). Logic inline in a component or procedure gets extracted into a pure exported function the app itself calls. - AST derivation gate — every assertion’s value must derive from an imported app call, directly or through intermediate values computed in the file. Locally restated arithmetic fails; a same-value conditional (
x ? 100 : 100) fails; dynamically importing app code to sidestep the analysis fails. - Effect-style reads (Prisma /
getCapturedServiceCalls()) are allowed only when the test invokes imported app code — observe a real call’s effect, never seed-and-read. - Permutations must differentiate — every check asserting the same literal draws a warning; cover zero/empty, a middle value, each boundary, the cap.
- Runtime call recording — each call into the implementation is captured (function, arguments, return) and shown grouped by check in the PRD view. A test whose checks make no app calls fails at runtime; a passing check whose recorded outputs don’t contain its asserted value is flagged for review.
prd/api scenario.
CLI and modes
synthetiq-app build runs the PRD phases automatically: prose regeneration, the suite (a failing entry fails the build; an app with no PRD entries passes instantly; the Playwright browser auto-installs), then the access gate. PRD_FAST=1 selects the fast gate (pass/fail only: no video, overlay, or slow-mo). Recording pace is tunable via PRD_STEP_PAUSE_MS (default 1500) and PRD_RESULT_PAUSE_MS (default 2200).
Artifacts
Per entry, a record-mode run writes:<id>.webm (light theme), <id>.dark.webm (dark theme), a <id>.json manifest (step timeline with timestamps, setup and end-state data snapshots, captured service calls, app-code calls for unit entries, console output), and <id>.trace.zip, an interactive Playwright trace (gitignored). The desktop PRD view renders entirely from prd.json plus these manifests and recordings.
The build-time enforcement behind all of the above is summarized in the build validations reference; the guarantees they add up to are in The trust model.
