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

# Services and workflows

> How service calls are stubbed, and how workflows execute in tests

This page describes how the harness handles the service boundary and workflow execution. For reading the captured calls as a reviewer, see [What gets captured](/docs/platform-docs/app-framework/prd/capture-layers).

Apps do much of their important work through services: LLM calls, Slack messages, warehouse queries, emails. This work is normally the hardest to test, because a test can't safely send a real message. The PRD harness is designed so these flows run, and get verified, anyway.

## A single stubbed service boundary

Every service call an app makes crosses a single boundary defined by the services framework, where each service declares its tools with typed request and response shapes. PRD tests stub that boundary completely: the app's code runs unmodified right up to the moment a call would leave the machine, and the harness answers instead.

This gives three guarantees:

* **Nothing real is ever called.** There is no path around the boundary, so no test can message a Slack channel, spend LLM tokens, or write to a warehouse. This is structural, not best-effort HTTP mocking.
* **Mutations are safely testable.** Because the send can't actually happen, the flow that sends can actually run. The PRD records what would have been sent, verbatim.
* **Stubs are faithful.** Default stub responses are generated from each tool's declared output schema, so the app receives realistically shaped data without per-test setup. When the story needs specific content, the test overrides it.

```typescript theme={null}
// A specific, story-coherent response for this test
await prd.stub({ "claude-api.generate_text": { text: "You shipped the refactor — buy milk next!" } });
```

When one service is called several times with different inputs, such as generating a distinct summary per user, `$byParams` matches responses to the call's parameters declaratively:

```typescript theme={null}
await prd.stub({ "claude-api.create_message": { $byParams: [
  { contains: "OpenAI connector", response: { content: [{ type: "text", text: "Joe's night: the connector shipped." }] } },
  { contains: "migration plan",   response: { content: [{ type: "text", text: "Rae's night: the migration plan is ready." }] } },
] } });
```

## Asserting what was sent, and what wasn't

Captured calls are assertable in the test, with plain-language labels that land in the timeline:

```typescript theme={null}
await prd.step("Complete the task", async () => { /* click the toggle */ });
await prd.expectServiceCall(
  "A Slack message is sent for the completion",
  "slack.sendMessage",
  (args) => args.channel === "C123" && String(args.text).includes("✅"),
);
```

The positive assertion has a blind spot: it matches *any* captured call, so it can't catch a violating extra call. For scoped behavior (per-user, per-tenant, per-setting), tests pair it with a matcher-negative that checks every captured call:

```typescript theme={null}
// Rae's data must never reach the LLM, no matter how many calls fired
await prd.expectServiceNotCalled(
  "The AI is never asked about another user's tasks",
  "claude-api.create_message",
  (args) => JSON.stringify(args.messages ?? "").includes("Raes secret migration task"),
);
```

This mirrors the seeding rule for visibility boundaries: the test asserts that the in-scope data produced a call, and that the out-of-scope marker appears in none of them. Together the pair verifies scoping through the entire pipeline, not just on the screen.

App-level configuration follows the same pattern. `prd/fixtures/app-settings.json` declares the configured world once (the Slack channel is set) and every test runs against it. The one test whose point is missing configuration overrides it with `prd.appSettings({ slackChannelId: "" })` and asserts the call doesn't fire.

## Workflows execute in the test

A feature backed by a workflow (trigger, pipeline, persisted result, display) is not verified by inspecting the workflow's definition. A test that reads the definition only proves the definition says what you meant, not that running it does what you meant. Instead, when the app submits a workflow job, 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, and every service step routed through the same stubs. Execution is synchronous, and a failing workflow step fails the test with that step's error.

The hero test drives the whole chain and asserts at each link:

```typescript theme={null}
await prd.step("Generate a summary", async () => {
  /* click Generate — the workflow runs before this resolves */
});

// The LLM prompt was built from this user's real data, proving the aggregation
await prd.expectServiceCall(
  "The summary prompt is built from this user's real tasks",
  "claude-api.generate_text",
  (args) => String(args.prompt).includes("Ship the refactor"),
);

// The workflow persisted its result, and the UI renders it
await prd.verify("The dashboard shows the generated summary", async () => {
  await expect(page.getByText("You shipped the refactor — buy milk next!")).toBeVisible();
});
```

A workflow with both a manual trigger and a schedule is **two stories**, because they're different journeys: the user-triggered run, scoped to the caller (with the matcher-negative proving other users' data stayed out), and the scheduled run, which the test executes exactly as the scheduler would with `prd.runWorkflow`. In the scheduled story, all users are processed, each seeded owner gets one properly scoped service call, and each user finds their own result without having triggered anything.

`prd.runWorkflow` also means nothing has to be added to the product to make something testable: the harness can trigger anything the system can, so the app never grows an admin "run for everyone" button just so a test can reach the scheduled path.
