> ## Documentation Index
> Fetch the complete documentation index at: https://synthetiq.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# PRD reference

> prd.json schema, harness APIs, access contracts, CLI commands, and artifacts

The complete reference for the PRD verification system: the `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](/docs/platform-docs/app-framework/prd/overview). Requires app framework **2.10.0+**.

## File layout

| Path              | Contents                                                                                                          |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| `prd.json`        | The requirements document: `sections` (features) + `accessControl` (the access matrix)                            |
| `prd/tests/`      | Playwright e2e specs for feature entries, one per entry, recorded                                                 |
| `prd/unit/`       | Vitest calculation specs (`check`-style, real implementation)                                                     |
| `prd/api/`        | Vitest access specs (headless, scenario-style)                                                                    |
| `prd/fixtures/`   | Shared seed fixtures; `services/<service>.<tool>.json` stub responses; `app-settings.json`                        |
| `prd/recordings/` | Generated `<id>.webm` (light) and `<id>.dark.webm` recordings, `<id>.json` run manifests, `<id>.trace.zip` traces |

## prd.json schema

### Feature entries

```jsonc theme={null}
{
  "sections": [{
    "id": "tasks", "title": "Managing tasks",
    "entries": [{
      "id": "tasks.member-can-add",
      "title": "A member can add a task",
      "behavior": "A signed-in member types a task and adds it; it appears in their list.",
      "acceptanceCriteria": ["The new task appears in the member's list immediately"],
      "test": { "type": "e2e", "file": "prd/tests/tasks.member-can-add.spec.ts", "name": "tasks.member-can-add" },
      "children": [{
        "id": "tasks.member-can-add.empty-state",   // prefixed by the parent id
        "title": "…", "behavior": "…",
        "test": { "type": "e2e" | "unit", "file": "…", "name": "…" },
        "visualByRequest": true                      // optional: a human asked to SEE this case; regeneration won't demote it to a unit
      }]
    }]
  }]
}
```

Binding rules, all build-enforced:

* `test.name` must 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:

```jsonc theme={null}
{
  "accessControl": {
    "categories": [{
      "id": "…", "title": "…", "description": "…",
      "entries": [{
        "id": "todos.list-only-own",
        "procedure": "todos.list",                   // dotted tRPC path; ONE entry per procedure
        "action": "…",
        "access": { /* the contract — see below */ },
        "test": { "type": "api", "file": "prd/api/todos.list-only-own.spec.ts", "name": "todos.list-only-own" }
      }]
    }]
  }
}
```

`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.

```jsonc theme={null}
"access": {
  "authenticated": true,              // must the caller be signed in?
  "scopes": ["manageTeam"],           // must match the procedure's guard exactly
  "dataScope": {
    "kind": "owner",                  // public | owner | relationship | tenant | custom
    "rowSubject": "Todo.userId",      // owner/relationship: the column that owns a row
    "rowOrg": "Invoice.orgId",        // tenant: the org column
    "relation": {                     // relationship: how a row ties back to the caller
      "recursive": { "model": "Employee", "subjectKey": "userId", "parentKey": "managerId" },
      "path": [{ "model": "Project", "on": "Todo.projectId = Project.id", "caller": "ownerId" }],
      "transitive": true,
      "label": "reports"              // display-only
    },
    "rules": [                        // unmatched rows are deny-by-default
      { "match": "self",   "allow": "always" },
      { "match": "others", "allow": "caller.isAdmin" }
    ]
  },
  "attributes": {                     // attributes referenced by allow expressions
    "caller.isAdmin": { "model": "UserProfile", "key": "userId", "column": "isAdmin", "label": "admin" }
  },
  "conditions": [{ "id": "…", "describe": "…" }]  // bespoke rules the vocabulary can't express
}
```

| `dataScope.kind` | Rows returned                          | Valid `rules.match`             | Requires                  |
| ---------------- | -------------------------------------- | ------------------------------- | ------------------------- |
| `public`         | all                                    | (none)                          | —                         |
| `owner`          | the caller's own                       | `self`, `others`                | `rowSubject`              |
| `relationship`   | rows tied to the caller via `relation` | `direct`, `indirect`, `related` | `rowSubject` + `relation` |
| `tenant`         | the caller's org's                     | `sameOrg`, `otherOrg`           | `rowOrg`                  |
| `custom`         | bespoke                                | (any)                           | `conditions[]`            |

`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`):

| Contract declares         | Required clause                                                                                                       |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `authenticated: true`     | `auth` — the anonymous caller is denied                                                                               |
| non-empty `scopes`        | `scope` — a signed-in caller without the scope is denied                                                              |
| `kind: "owner"`           | `owner` — the owner's call works, another user's doesn't; an attribute `allow` on `others` adds an `attribute` clause |
| `kind: "relationship"`    | `relationship` — direct visible, indirect per `transitive`, outside-the-chain excluded                                |
| `kind: "tenant"`          | `tenant` — own org visible, other org denied                                                                          |
| each `conditions[]` entry | `custom` — the boundary rejects it (shown under Business rules)                                                       |

`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`)

```ts theme={null}
import { definePrdTest, expect } from "@synthetiq/app-framework/prd/harness";
definePrdTest("<entry-id>", async ({ prd }) => { /* … */ });
```

| API                                                               | Behavior                                                                                             |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `prd.step(label, fn)`                                             | One user action, ending on its visible result. A beat in the recording and a timeline row.           |
| `prd.verify(label, fn)`                                           | One assertion; ✓/✗ in the timeline; rethrows on failure. The label is the acceptance criterion.      |
| `prd.highlight(...locators)`                                      | Spotlights the asserted element(s) during a verify.                                                  |
| `prd.caption(text)`                                               | Set (or clear) an on-screen caption without an action.                                               |
| `prd.seed({ Model: [rows…] })`                                    | Seed the database; see [Seeding](#seeding).                                                          |
| `prd.actAs(persona)`                                              | Signed-in page as a persona; see [Personas](#personas). Returns the shared recorded page.            |
| `prd.stub({ "service.tool": response })`                          | Override a service stub; supports `$byParams`. See [Service stubs](#service-stubs).                  |
| `prd.appSettings({ key: value })`                                 | Override app settings for this test; `""` clears a setting.                                          |
| `prd.expectServiceCall(label, "service.tool", argsMatcher?)`      | Assert a captured call matches. ANY-match: cannot catch a violating extra call.                      |
| `prd.expectServiceNotCalled(label, "service.tool", argsMatcher?)` | Assert no captured call matches; with a matcher, checks every captured call.                         |
| `prd.runWorkflow(definition)`                                     | Execute a workflow exactly as the scheduler would (unscoped). Import the definition from app source. |

Structural rules the harness enforces:

* `step` and `verify` are **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 when `fn` ends.
* Use `pressSequentially(text, { delay })` rather than `fill` so typing is visible on camera.
* An uncaught page error or a fully blank render fails the run regardless of assertions. `console.error` output 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:

1. In-test `prd.stub({ "service.tool": response })` override.
2. A fixture file `prd/fixtures/services/<service>.<tool>.json`.
3. 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):

```ts theme={null}
await prd.stub({ "claude-api.create_message": { $byParams: [
  { contains: "OpenAI connector", response: { content: [{ type: "text", text: "…" }] } },
  { $default: true, response: { content: [{ type: "text", text: "…" }] } },
] } });
```

App settings hydrate from `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 calls `workflows.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`)

```ts theme={null}
import { definePrdApiTest, expect } from "@synthetiq/app-framework/prd/api";

definePrdApiTest("<entry-id>", (endpoint) => {
  endpoint.scenario(label, { kind }, async ({ seed, as, db, verify, expectRejected }) => { /* … */ });
});
```

`kind` is one of `"auth" | "scope" | "owner" | "relationship" | "tenant" | "attribute" | "custom"`. `custom` renders under Business rules; the rest under Access checks.

Scenario handle:

| API                                          | Behavior                                                                                                      |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `seed({ Model: [rows…] })`                   | This scenario's fixtures. Seals after the first `call()`.                                                     |
| `as(persona)`                                | Bind a client: `{ id, scopes?, name?, email?, orgId? }`, `"Admin"`, `"Member"`, or `"anonymous"` (no token).  |
| `client.call(procedure, input?, { label? })` | Calls the real procedure as that persona; returns data or throws. The label reads as prose in the transcript. |
| `db`                                         | **Read-only** Prisma client for verification; writes throw.                                                   |
| `verify(label, fn)`                          | Assert and record a plain-language step.                                                                      |
| `expectRejected(fn, label?)`                 | Assert the call is rejected.                                                                                  |

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`)

```ts theme={null}
import { definePrdUnitTest, expect } from "@synthetiq/app-framework/prd/unit";
import { productivityScore } from "../../src/lib/score"; // the app's REAL implementation

definePrdUnitTest("<entry-id>", async ({ check }) => {
  await check("Five or more completions caps the score at 100", async () => {
    expect(productivityScore(9)).toBe(100);
  });
});
```

`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.

Access or scoping verified through the database is not a unit entry — write it as a `prd/api` scenario.

## CLI and modes

| Command                             | Behavior                                                                                                                                                                                                                                                                             |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `synthetiq-app prd:test [ids…]`     | Record-mode run: boots the app's server against a throwaway test database, all services stubbed, one fresh database per entry; writes pass/fail back into `prd.json` and recordings/manifests to `prd/recordings/`. Requesting a parent expands to its children for a fresh roll-up. |
| `synthetiq-app prd:test --stale`    | Re-run only entries whose recordings are missing or older than their spec/build inputs.                                                                                                                                                                                              |
| `synthetiq-app prd:status`          | Compute per-entry staleness without running.                                                                                                                                                                                                                                         |
| `synthetiq-app prd:render`          | Regenerate the `whoCanDoThis` / `howAccessIsVerified` prose from access contracts.                                                                                                                                                                                                   |
| `synthetiq-app validate:prd:access` | The access gate: matrix coverage, contract↔guard cross-check, clause checklist.                                                                                                                                                                                                      |

`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](/docs/platform-docs/app-framework/reference/build-validations); the guarantees they add up to are in [The trust model](/docs/platform-docs/app-framework/prd/trust-model).
