Testing
Comprehensive testing guidelines covering unit testing, E2E testing, test organization, and test-driven development workflows.
Test Execution Commands
pnpm run test # Run all tests (unit + E2E)
pnpm run test:unit # Run all unit tests
pnpm run build && pnpm run test:e2e # Always build before E2E tests
Important: Always run tests before committing changes.
Arguments for the E2E suite need -- when they come from the root, because the root
script is a Turborepo task: pnpm run test:e2e -- tests/landing.spec.ts forwards the
filter, while the same command without -- makes Turborepo read tests/landing.spec.ts
as a task name and abort. From apps/web, call the runner directly and drop the --:
pnpm exec tsx ./tests/run-e2e.ts <args>. Whichever form you use, read the summary
line — a filter that matches nothing reports "No tests found", and a filter that never
reached Playwright means you just ran something other than what you meant to.
Test-Driven Development Workflow
When addressing bugs or implementing new features, follow the Red-Green-Refactor cycle:
- Red – Write a test that reproduces the issue or validates the new behavior. The test should fail.
- Green – Implement the minimal code to make the test pass.
- Refactor – Clean up the solution while keeping all tests green.
Mutation proofs
A green suite proves nothing about a specific test until that test has been seen red for the right reason. When the code under test already exists (a fix being re-verified, a regression test against already-correct code), red-first isn't natural — substitute a mutation proof:
- Back up the file with
cp file file.bak— nevergit checkout -- <file>to undo a mutation on a file that also carries uncommitted work, since it discards everything. - Revert or neuter exactly the behavior the test pins.
- Run the specific test and confirm it fails with the expected message — not just "a" failure.
- Restore the backup and verify it landed (diff or checksum against the original), then confirm green.
False-green smells to check whenever a test passes suspiciously easily: the expected value coincides with the old buggy behavior (the test passes whether the bug is fixed or not); the code path is stubbed above the change under test; an error assertion satisfied by a different error than the one under test firing first; a collection prop every fixture passes empty, leaving the branch that renders it unexercised — an early return above that branch deletes the feature with the whole suite green.
For concurrency tests, never use a timer heuristic (Promise.race vs a delay) to prove code blocked — it false-passes under load. Use a deterministic signal, like polling pg_locks for a waiter on the exact lock identity.
Gate reconciliation
Record the exact test and file counts before starting. After the change, compute the expected totals from every it()/describe block added or removed, then run test:unit twice: both runs must match the arithmetic and each other. Any mismatch is a signal — a dropped test file, a duplicated suite, or a flaky test — never noise to shrug off.
Unit Testing Guidelines
Unit tests use Vitest and test individual functions, components, and business logic.
Core Principles
- Test the exposed API, its inputs and outputs rather than implementation details
- Focus on application behavior and accessibility
- Avoid testing implementation details like CSS classes or specific HTML tags
- Prefer queries based on roles or other accessibility attributes
- Do not test Zod schemas
Test Organization
- Group tests with a single
describeblock per subject (e.g., per public function or component) - Use the name of the subject as the parameter for
describe - Avoid catch-all labels like "additional tests"
- Use descriptive names for both
describeanditblocks to make code folding and navigation easier
Example:
describe('updatePassword', () => {
it('fails when passwords are equal', async () => {
// test implementation
})
it('updates the user password', async () => {
// test implementation
})
})
Assertion Best Practices
- Prefer expressive matchers such as
toContain,toContainEqual, orcontainSubsetinstead of manual array scans with.some - When validating failure cases, assert on the specific error (name or class) rather than only checking
result.success === false
Example:
// ✅ Good: Check specific error
expect(result.success).toEqual(false)
expect(isInputError(result.errors[0])).toBe(true)
expect(result.errors[0].message).toBe('Your new password must be different')
// ❌ Bad: Only check success flag
expect(result.success).toBe(false)
Database Testing
- Never delete records in unit tests
- Instead, insert records with random identifiers and query by those identifiers
- The database is not cleaned between tests, allowing tests to run in parallel
Example:
it('updates the user password', async () => {
const userId = crypto.randomUUID()
await db()
.insertInto('users')
.values({
id: userId,
email: `${userId}@test.ca`,
encryptedPassword: Buffer.from('current-password')
})
.execute()
// Test with userId
const result = await updatePassword({ userId, newPassword: 'new-password' })
expect(result.success).toBe(true)
})
Parallel safety
The suite runs many test files concurrently against one shared database. Three patterns flake under that concurrency:
- Never assert on an unscoped aggregate (
count(*)without a filter tied to rows this test created) over tables other test files also write to — scope every count to the entity under test. - Random needles for search/substring tests need real entropy: use
crypto.randomUUID(), not a few hex characters that can collide with other fixtures. zod's.uuid()also validates RFC4122 version/variant bits, so hand-typed ids like'11111111-1111-1111-1111-111111111111'fail validation — generate fixture ids. - Shared lazily-initialized infrastructure (a schema, a singleton) must be bootstrapped once in global setup (
app/test/global-setup.ts), not on first use per test file — concurrent first uses race the initialization.
Example:
it('creates a session for the user', async () => {
const userId = crypto.randomUUID()
await db()
.insertInto('users')
.values({
id: userId,
email: `${userId}@test.example.com`,
emailAuthHash: crypto.randomBytes(32),
})
.execute()
const session = await createUserSession({
userId,
ipAddress: null,
userAgent: null,
})
expect(session.userId).toBe(userId)
})
Env portability
Unit tests run under whatever apps/web/.env.test the machine happens to have: CI pins placeholder values in ci.yml, while a worktree's copy may carry real endpoints and tokens (setup falls back to copying .env when the main checkout has no apps/web/.env.test). Two rules keep the suite green everywhere:
- A test that asserts an env-derived value (a request URL, an auth token, a host) must pin the env itself:
vi.stubEnv('SOME_KEY', 'value')inbeforeEachandvi.unstubAllEnvs()inafterEach.env()re-parsesprocess.envon every call, so stubs take effect immediately. Never write a literal that only matches CI's env values. - When MSW fails with
[MSW] Cannot bypass a request when using the "error" strategy, the request matched no registered handler. Compare the request URL MSW logs against the handler pattern and the effective env before suspecting MSW or Node fetch internals — the error reads like an interceptor bug but is almost always a URL mismatch.
Pipeline tests against a scratch database
Some tests must run a whole pipeline against a pristine database rather than the shared one — the dev-seed coverage manifest (app/db/dev-seed/coverage.test.ts) and the empty-DB pre-flight (app/db/dev-seed/preflight.test.ts) are the examples. The shared app_test database is unusable for these: every other test file writes into it so it is never empty (the seed's empty-database pre-flight would abort), and its name does not end in _development so the dev-seed guard rejects it.
The pattern (see app/db/dev-seed/scratch-database.ts):
- Provision a throwaway database per run with
scratchDatabaseName(suffix), which derives the name from the ambientDATABASE_URL's database plus a_developmentsuffix. Because it is derived from the ambient name, concurrent runs in different worktrees never drop each other's scratch, and the_developmentending satisfies the dev-seed guard honestly. Create and drop it inbeforeAll/afterAllover a maintenance connection to/postgres; the role needs theCREATEDBprivilege (the helper surfaces a clear error if it is missing). - Run the real artifact by shelling out, not by importing seed modules — the coverage test does
execFile('pnpm', ['run', 'db:migrate:production'])then'db:seed:dev'with an overriddenDATABASE_URL, so it exercises the canonical ordered pipeline (assert → prod seeds → dev seed) and can never drift from real ordering. - Assert on a dedicated
Kysely<DB>opened on the scratch URL (connectToDatabase), never the shareddb()singleton, which points atapp_test. - Budget a long timeout — the pipeline runs every migration plus the full seed, so the pipeline
beforeAlluses a multi-minute timeout, well above the default.
The coverage manifest has one blind spot worth naming: it fails a listed surface whose demo state is missing, but it cannot detect a surface nobody listed. A new in-person exam has four surfaces — the venue capture card, the lab result read, the customer-facing care interpreted panel, and the raw observations — and the care interpreted panel is the one most often forgotten, because building the capture card and the lab view feels like finishing the exam. Its absence leaves every gate green (it is unlisted, so nothing fails) yet the customer sees no result. When you add an exam, ship its care interpreted panel and its care/results/interpreted (<panel>) manifest entry alongside the others, and confirm the customer view in the browser — the manifest will not remind you.
React Router Testing
- Never mock react-router in tests
- Instead, use
createRoutesStubto test components that depend on routing
Example:
import { createRoutesStub } from 'react-router'
it('renders a basic preview', () => {
const Stub = createRoutesStub([
{
path: '/blog',
Component: () => <PostPreview post={post} />,
},
])
render(<Stub initialEntries={['/blog']} />)
const link = screen.getByRole('link', { name: /hello world/i })
expect(link.getAttribute('href')).toBe('/blog/hello-world')
})
For detailed examples of createRoutesStub usage, see references/examples.md.
Additional Guidelines
- Don't export internal helpers purely for test coverage
- Use the
// @vitest-environment jsdomcomment at the top of UI test files - Clean up after each test with
afterEach(() => { cleanup() })
E2E Testing Guidelines
Playwright drives the real app in a browser: the production build served with
NODE_ENV=test, a real worker, real MailDev, a real database. The suite covers every
route in app/routes.ts and a coverage gate keeps it that way. Everything below lives
in apps/web/tests/.
How a run works
pnpm run test:e2e runs tests/run-e2e.ts, which forwards its arguments to
playwright test and then runs the coverage gate. A bare spec name is not a
filter — -- landing is silently ignored and the whole suite runs; filter with
a path (tests/landing.spec.ts) or --grep. Playwright's globalSetup
(tests/global-setup.ts) prepares the world before any spec runs, and webServer
boots test:server, test:maildev and test:worker together.
- The suite has its own database.
tests/seed/database-url.tsderives it by appending_e2eto whatever database.env.test'sDATABASE_URLnames —app_test_e2e, orapp_wt_<lane>_test_e2ein a worktree. Unit tests keep the plain_testdatabase, so the two suites can never trample each other.playwright.config.tspasses the derived URL toglobalSetupand towebServer, where it wins over.env.testbecause dotenv does not override a variable that is already set. - The seed is layered.
tests/seed/seed-e2e.tscreates the database if it is missing, migrates it, and — only when it is empty — shells out to the realdb:seed:devpipeline for the base world (venues, staff, protocol CMS, customer journeys). Then it runs every flow module intests/seed/flows/and writestests/.auth/fixtures.json. The database is never dropped for you and never truncated: it is deliberately long-lived and dirty, which is what forces seeds and specs to be convergent. To rebuild it, drop it (dropdb <name>_e2e) and run again. - Sessions are minted, not typed. Seed flows call
context.mintStorageState(userId, persona), which commits a real session cookie intotests/.auth/state.<persona>.json. Specs adopt a persona instead of signing in. - Every request is observed. When
E2E_ROUTE_LOGis set, a few lines of Express middleware inserver/app.tsappend each request path totests/.artifacts/route-log(prefetches excluded, so aprefetch="viewport"link never fakes coverage). The gate reads that log afterwards. - Two projects.
chromiumrunstests/*.spec.tson Desktop Chrome;mobilerunstests/*.mobile.spec.tson a Pixel 7. Each ignores the other's pattern, so nothing runs twice.
tests/.auth/ and tests/.artifacts/ are gitignored: fixtures and sessions are build
output, never something to commit or hand-edit.
The environment the app under test reads lives in two places that must agree:
apps/web/.env.test locally and the e2e job's env block in
.github/workflows/ci.yml. Anything new a run depends on goes in both, in the same
change — .env.test alone is green on your machine and red in CI.
Running it
pnpm run build && pnpm run test:e2e # the full gated run
pnpm run test:e2e -- tests/landing.spec.ts # root: arguments need --
cd apps/web && pnpm exec tsx ./tests/run-e2e.ts --project=mobile
cd apps/web && pnpm exec tsx ./tests/run-e2e.ts --workers=2
cd apps/web && pnpm exec tsx ./tests/coverage/gate.ts --report # re-read the last log
dropdb --if-exists app_test_e2e # rebuild the world
- Always build first. The server serves
build/, so an unbuilt change is invisible and you debug a spec against yesterday's app. - Locally Playwright reuses a server already listening on the port instead of starting its own — another reason a stale build bites.
- The gate enforces only on an unfiltered run. Any spec filter,
--project,--grepor--sharddrops it to report-only, because a partial run cannot know which routes the rest of the suite would have reached.--workersand--repeat-eachare not filters, so they keep the gate enforcing. --repeat-eachcopies run concurrently. A mutating spec proves itself with--repeat-each=3 --workers=1.
The route coverage gate
tests/coverage/inventory.ts imports app/routes.ts and derives one route id per
index()/route() entry from its file path (layouts do not count — they are covered
whenever a child is). tests/coverage/gate.ts replays the route log through
matchRoutes and fails the run on any of five conditions:
- an uncovered route listed in neither
exclusions.tsnorpending.ts; - an exclusion for a route that no longer exists;
- a pending entry for a route that no longer exists;
- an excluded or pending route the suite did reach — the ratchet: once covered, the entry must go;
- a route listed in both files.
pending.ts is empty and must stay that way. A route belongs in exclusions.ts only
with a written rationale that cites the unit test owning the behavior end to end or
proves the route is unreachable — "no spec yet" is not a rationale. Adding a route
means adding a spec that reaches it, in the same change.
Writing a spec
- One
test()per file, and the filename is the behavior sentence in kebab-case:venue-visits-reschedule-a-visit-to-a-free-slot.spec.ts. Any number of assertions inside; the limit is ontest()calls. - Never touch the database and never read
process.env. Everything a spec needs comes fromfixtures()intests/helpers.ts(typed againstfixtures.json) and from Playwright'sbaseURL. The MailDev port is the one sanctioned environment value, and it arrives through fixtures too. - Pick a persona:
test.use({ storageState: storageStatePath('e2e-care-results') }). Signed out istest.use({ storageState: { cookies: [], origins: [] } }). A second actor in the same spec comes frombrowser.newContext({ storageState }). Sign in through the UI only where authentication is the subject — the magic-link journey intests/sign-up.ts, which polls MailDev's web API for the real email. - Select the way a user perceives the page:
getByRole,getByLabel,getByPlaceholderwith the real pt-BR copy. No test ids. Absence isawait expect(locator).toHaveCount(0), never a negated visibility check. - Assert display strings that came from fixtures, so the seed and the spec cannot
drift apart. Importing an app
.common.tsmodule for copy or constants is encouraged (DASS21_QUESTIONS,VISIT_STATE_LABELS); importing a.server.tsmodule is not — it would drag the database and env into the test process. - Prove persistence by round trip: mutate, re-navigate, assert the durable state.
- Do not assert flash toasts. On prefetch-heavy shells a viewport prefetch consumes
the one-shot
_app_flashcookie before the page you are watching reads it. AsserttoHaveURLplus the re-navigated state instead. - Never assert an exact count or completeness of a shared list. Sibling flows and future seeds add rows to the same venue, protocol or customer list. Scope to the row you created and assert on that.
- A paginated list hides the rows a growing seed pushed off page one. Lab lists
paginate at ten, and every new seed flow adds rows to them, so a spec that opens a list
to find its own row is one seeded row from red. Ask for a page wide enough to hold
everything —
?per-page=100(the loader camel-cases search params, soperPageis the same parameter) — and identify the row by something intrinsic to the record,filter({ has: page.locator('[href="/lab/results/<id>"]') }), rather than by text plus.first(), which quietly matches a sibling flow's row instead (tests/lab-result-details-lists-observations-and-duplicates-the-result.spec.ts). filter({ has: ... })re-roots the inner locator at each candidate, so a locator that reads correctly can match nothing once it is nested in a helper. Build the inner locator relative to the candidate, and check the count you expect.- Renaming or removing user-facing copy means grepping all of
tests/for the old string. A lane's own subset run will not catch cross-file drift; CI will.
Retry safety is the bar
CI retries three times. A spec is correct only if it passes when re-run against the state an attempt that died at any line left behind — not merely when re-run after a clean pass. That is why cleanup-at-the-end is never the answer: the attempt that mattered never reached the cleanup.
Two patterns satisfy the bar:
- A convergent prologue: start by driving the world into the state the spec needs (delete the leftover copy, close the open visit, clear the filter) instead of assuming a pristine one.
- Attempt-unique identity: name the thing you create per attempt, or draw from a
seeded pool with
attemptSlot()intests/helpers.ts, which indexes byrepeatEachIndexandretry. A pool must hold at least as many entries as CI can attempt (1 + 3 retries = 4 per repeat), or a spec that mutates then fails exhausts it and the next attempt fails for the wrong reason.
Making an operation one-way — an answer that cannot be rewritten, a record that submits once — retroactively breaks every spec that performs it, however well written: the attempt that died leaves a resource no later attempt can drive, so all three retries fail for the wrong reason. A change that removes repeatability from a flow carries the duty to move every spec that drives it onto a per-attempt pool in the same change.
Proving it: --repeat-each=3 only proves the happy path is repeatable — it
cleans up after itself every time. To prove death-safety, simulate the death inside a
single Playwright invocation: put a SIMULATED-DEATH test above the real one that
performs the mutation and stops, or abort mid-spec on the first attempt
(expect(test.info().retry).not.toBe(0)), then run with --workers=1 and watch the
real spec pass on the retry. Pair it with a negative control — the same run without the
convergent prologue must fail — or you have proven nothing.
Racy by design: what to wait for
- Hydration. Never navigate or click immediately after
page.goto()on a route with a hydratingclientLoader: the server HTML is interactive before the client router takes over, and the first click is swallowed. Wait forwindow.__reactRouterDataRouter.state.initialized === truefirst —openVisitsBoardintests/venue-visits.tsis the pattern. Reproduce a CI-only race locally with CDPEmulation.setCPUThrottlingRateat 8–20×. A failure that is intermittent across runs but deterministic within an attempt is a machine-speed race, not bad state. - Debounced inputs. A debounced search field navigates only after its delay, and
fill()returns immediately — assertions written right after it can pass against the previous DOM (a false green that survives deleting the feature), and the nextfill()cancels the still-pending submit outright. After each fill, assert the URL carries that step's term, percent-encoded —await expect(page).toHaveURL(new RegExp('q=' + encodeURIComponent(term)))— before asserting content;tests/lab-services-searches-the-catalog.spec.tsis the pattern. - Eventual consistency. Pages built on
cachedClientLoaderplus polling are eventually consistent by design; the first paint after a mutation legitimately shows stale data. UsereloadUntil(page, assertion)fromtests/helpers.ts, which reloads until the server agrees — and thereby also proves persistence. This is the opposite of flake-masking: wrapping a flow that should be synchronous intoPassto make it green is the anti-pattern. - Prefer waiting on the action itself.
waitForAction(page, '/path', interact)waits for the POST the click fires. A reload-until loop around a synchronous flow can hide a real product race for months.
Time is not yours to assume
- The seeded world is anchored to one rolling date computed in SQL
(
context.dates), and display strings are formatted in SQL and exported through fixtures. NoDate.now()in seeds, no absolute dates in specs. context.datesis a UTC date. Venue and lab days run inAmerica/Sao_Paulo. Compute any day boundary in SQL against the venue's own timezone.- Fresh-database class of failure. CI builds the E2E database from scratch, so a
seeded row backdated relative to
now()can predate a configuration row created seconds earlier, and "the setting active at time X" correctly resolves to nothing. The fix is to rewrite the seeded history onto a one-second ladder ending yesterday (tests/seed/flows/venuePanel.ts), never a wait and never a weakened assertion. Reproduce by dropping the_e2edatabase and running immediately. - Wall-clock independence. A predicate like "a slot still available today" goes empty once a whole visit no longer fits before closing time — specs that pass all afternoon fail deterministically after 22:00. Derive the seeded window from the data it must serve: close the venue one slot plus the longest offered visit past midnight, computed in SQL from the venue's own protocols, and fail loudly if there is none. One residual hazard is known and unclosed from test files: the venue day rolls over at São Paulo midnight (03:00 UTC) while the seeded visit timelines reach about a hundred minutes into the past. Between midnight and roughly 01:40 local those events belong to yesterday, so every "today" list — the operations panel's "Concluídos hoje", for one — is correctly empty and the specs that read them fail. A local run in that window is not a regression; wait it out or run something else.
Seeding a new journey
Add tests/seed/flows/<flow>.ts exporting namespaces (the identifier prefixes it
reserves) and seed(context) returning its fixtures. The orchestrator merges the return
value under the module's stem, so flows/care.ts becomes fixtures().care.
- Converge, never assume. Find-or-create on a stable natural key, and update the
found row into the state you want — a
doNothing()conflict clause does not converge a row a spec mutated on a previous run. - Go through real business functions with real contexts for anything with domain invariants; raw Kysely writes are for skeleton rows (users, venues, roles) only.
- Own your namespace. Guards abort the seed on a fixture-key collision, on two flows
reserving overlapping prefixes, and on a flow exporting a value that lands inside
another flow's namespace. They police seed fixtures only — a spec can still squat a
namespace at runtime (the magic-link spec creates users under
e2e-auth), so keep that discipline by hand. - Filter what you gather. A seed query that bundles "all protocols" sweeps in sibling
flows' namespaced rows, and the next convergent pass exports a foreign name straight
into the trespass guard. Any bundle-everything query filters
where name not like '%e2e-%'— a contains match, not a prefix one — and sheds rows it absorbed before the filter existed. - Dedicate entities to mutating scenarios. Never let a spec that writes share the dev-seed personas with the specs that read.
LLM-backed jobs
There is no LLM stub. The suite's real worker executes real jobs, and a job that calls
generateText reaches the real provider SDKs — which cannot work in CI, where every
provider key is the placeholder test-key. Two established patterns cover every case:
- Fabricate the output in the seed. Write the rows the job would have written
(an analysis run, an extraction row with its reading) exactly as
dev-seed/results.tsfabricates analysis steps, and let the spec assert the UI over them. This is how success paths are exercised. - Drive the enqueue, expect the failure path. A spec may exercise the real
upload-to-job flow, but the job's provider call will fail in every environment (bogus
keys in CI; locally, test uploads land in
LocalFileStoragewhile job downloads go through the real S3 client). That failure is deterministic today — and only today: if storage or keys are ever unified, an LLM stub (the fake-Stripe endpoint pattern) must ship before any such spec, or it goes non-deterministic.
Never assert exact model output anywhere, and never write a spec whose premise requires a real model to behave a particular way.
The mobile project
*.mobile.spec.ts files run on a Pixel 7. Write one when a journey is genuinely
different on a phone — a collapsed sidebar, a bottom dock, an icon-only control, a long
article — not to duplicate desktop coverage. Assert what proves the layout works:
navigation is reachable, content is readable, primary actions are tappable
(click({ trial: true }) runs Playwright's full actionability check without firing the
action), and expectFitsTheViewport(page) from tests/helpers.ts catches a page that
scrolls sideways. Never assert pixel values. The native shell itself belongs to Maestro
(apps/mobile/e2e/); this project covers the mobile web layout the shell renders.
When CI fails and your machine does not
- Failure artifacts come from
apps/web/test-results/(screenshots and first-retry traces) andapps/web/tests/.artifacts/(the route log). There is no HTML report: the suite runs thelistreporter. - A pull request showing "No checks reported" is not a CI outage — it conflicts with
main, GitHub cannot build the merge commit, and it silently skips the workflows. Rebase. - Measure before theorizing.
gh run download <runId>fetches the failing retries' Playwright traces; their.networkentries carry per-request timings. Compare them with a locally traced run of the same spec (--trace on): a data request that costs ~100ms locally routinely costs 2–4s on the two-core CI runner, so a post-action assertion bounded by the default 5s expect window sits on a cliff whenever a page's round trips stack up. The venue panel is the suite's heaviest page and its specs assert through a widenedexpect.configure({ timeout: 15_000 })for exactly this reason — reuse that pattern for any new spec on a comparably heavy page. - Two cheap experiments before blaming your diff. Rerun the failed job on the
unchanged commit (
gh run rerun <runId> --failed): a pass proves there is no deterministic regression in the diff. And read the failing spec's duration off recent green main runs (gh run view <id> --log | grep <spec>): a spec whose green-run duration has been climbing toward its budget (say 5.5s → 9.8s → 28.6s against 30s) was already on a cliff, and any branch's run merely tips it. When that trend exists, the fix is the page's cost — count statements from the Postgres log and remove the duplicated work — not a bigger budget; halving the venue panel's round trips cut the whole suite from ~11 to ~6.5 minutes. - When the question is state rather than time, get ground truth with a throwaway
diagnostic branch: a draft PR whose only addition is a spec named to sort just before
the failing family, dumping the relevant tables with raw SQL
console.logs. Read the dump in the CI log, close the PR unmerged, delete the branch. - A failed first attempt can leave the seeded day half-mutated, so its retries and every later spec in the family inherit corrupted state. Diagnose the first failure in file order — the loudest downstream failure is usually cascade, not cause.
For a worked spec and a worked seed flow, see references/examples.md.
Reference Examples
For concrete code examples demonstrating these patterns, see references/examples.md.