What a witness is
The format is Witness (capitalised); a single artefact in that format is a witness (lower-case). A witness is a sworn statement about required behaviour. Every line is a labelled fact: Subject:, Before:, When:, Then:, and so on, drawn from a fixed vocabulary of thirteen handles.
Witnesses translate deterministically into cases for any test runner — Vitest, Jest, pytest, RSpec, JUnit, Playwright, Cypress, and so on. The format is upstream of every runner, never a substitute for one. A witness is reviewed line by line; it is not run.
File and lifecycle
- Path
.context/specs/{area}/{slug}.md— area mirrors apps/packages (web,api,db,shared,e2e).- Append-only
- New tasks add witnesses; old witnesses stay. The file becomes the comprehension catalogue for the area.
- Anchors
- Each witness's slugified H1 is its anchor for cross-references.
- Splitting
- Split a file when it exceeds ~500 lines.
The 13 handles
Every witness is built from these handles. Each one names a slot in the classical anatomy of a test; together they are the completeness floor.
- 01Subject
- The unit of behaviour under verification — component, route, or function. One per witness.Subject: the search bar component on the home page
- 02Before
- Fixture state preceding the trigger. Translates to beforeEach.Before: the input bar is empty; no results panel is visible
- 03When
- The action or input that triggers the behaviour. The act phase.When: someone types "ki" and waits 300 ms
- 04Then
- The expected outcome — the high-level assertion.Then: results panel shows ≥1 row whose title contains "ki"
- 05Visible
- How a reviewer would see the outcome — the oracle. Distinguishes "what happened" from "how we know".Visible: at least one result row appears beneath the search bar
- 06Always
- Invariants the system maintains across this scenario.Always: input value equals what was typed
- 07Untouched
- State the scenario must not modify — the frame condition.Untouched: URL, navigation panel, user session
- 08Kinds-of
- Equivalence classes the scenario covers.matching: typed text matches ≥1 doc
- 09Edge
- Boundary values between adjacent kinds.1-vs-2-chars: 1 → no request; 2 → request fires
- 10Rules
- Decision logic, expressed as a markdown table. Used when the behaviour is a function of multiple inputs.| typed-len | matches | result |
- 11Goes-from
- State transitions, numbered or arrow-form. May be (none — stateless).idle → searching: when len ≥ 2
- 12Wrong
- Error paths and their outcomes.network-fail: banner "Could not search; try again"
- 13For-instance
- Parameterised examples, a markdown table. Each row is one runnable case.| typed | result-count | first-row-contains |
A fourteenth control handle, Independent, declares whether the witness depends on order; default yes with a one-clause reason (e.g. “yes — each example clears input first.”).
How a witness translates to a test
A witness is the upstream source. The mapping below is mechanical — the same handles drive Vitest, Jest, pytest, Playwright, Cypress, RSpec, JUnit, or whatever runner your stack already uses, without rewriting intent. Vitest and Playwright are shown here as two concrete examples; the shape of the translation is the same for any runner with setup hooks, assertions, and parameterised cases.
| Handle | Vitest | Playwright |
|---|---|---|
| Before | beforeEach | test.beforeEach |
| When | arrange-act-assert · act phase | user interaction |
| Then / Visible | expect(...) | await expect(locator)… |
| Rules | it.each | test.describe.parallel |
| For-instance | it.each | data-driven test() per row |
| Goes-from | one test per transition | one test per transition |
| Wrong | one test per error path | one test per error path |
Scenario · UI behaviour (the search bar)
A first-time reader meets every handle here. Skim the labels down the left margin: each one carries one fact about the search bar's behaviour, and together they fully specify “what does typing in the search bar do?”
# Search results visible after typing
Subject: the search bar component on the home page
Independent: yes — each example clears input first.
Before: input bar is empty; no results panel is visible.
When: someone types "ki" and waits 300 ms.
Then: results panel shows ≥1 row whose title contains "ki".
Visible: at least one result row appears beneath the search bar;
no visible row is missing "ki" in its title.
Always: input value equals what was typed.
Untouched: URL, navigation panel, user session.
Kinds-of:
matching: typed text matches ≥1 doc
no-match: typed text matches 0 docs
empty: typed nothing
too-short: 1 character (below min)
at-min: 2 characters (min len)
at-max: 99 characters (max len)
too-long: 100 characters (over max)
Edge:
1-vs-2-chars: 1 → no request; 2 → request fires.
99-vs-100-chars: 99 → accepted; 100 → rejected.
Rules:
| typed-len | matches | result |
|-----------|---------|-------------------------|
| <2 | n/a | panel hidden |
| 2..99 | ≥1 | matches shown |
| 2..99 | 0 | empty-state shown |
| ≥100 | n/a | input rejects extra char|
Goes-from:
idle → searching: when len ≥ 2.
searching → showing-results: on response.
searching → error: on request error.
Wrong:
network-fail: banner "Could not search; try again"; previous results stay.
timeout-5s: cancel request; show timeout message.
For-instance:
| typed | result-count | first-row-contains |
|-----------|--------------|--------------------|
| "ki" | ≥1 | "ki" |
| "xyznever"| 0 | — |
| " " | 0 | — |Scenario · Worker route (GET /me)
Independent: for a stateless route reads differently from a UI's: every request is a fresh fixture by construction, so the line names the fixture discipline rather than a reset step. Goes-from: may legitimately be (none — stateless).
# GET /me returns the authenticated user
Subject: GET /me on apps/api
Independent: yes — each example uses a fresh fixture.
Before: a user exists; the user has a valid bearer token.
When: someone makes GET /me with Authorization: Bearer <token>.
Then: response is 200 with body { id, email, plan } for that user.
Visible: status is 200; user id in body matches the bearer's user id.
Always: the response never returns the id of any other user.
Untouched: the db row for the user; the session table.
Kinds-of:
valid: active user + valid token
expired: valid user + expired token
missing: no Authorization header
malformed: header doesn't start with "Bearer "
deleted: bearer for since-deleted user
Edge:
1ms-before-expiry: 200.
1ms-after-expiry: 401.
Rules:
| token state | user state | response |
|-------------|------------|-----------------------|
| valid | active | 200 + profile |
| valid | deleted | 404 + "user not found"|
| expired | * | 401 + "token expired" |
| missing | * | 401 + "missing token" |
| malformed | * | 401 + "invalid token" |
Goes-from: (none — stateless)
Wrong:
expired-token: 401, body "token expired".
missing-header: 401, body "missing token".
deleted-user: 404, body "user not found".
For-instance:
| token | user | status | body.id |
|---------|---------|--------|---------|
| valid | active | 200 | user.id |
| expired | active | 401 | — |
| none | active | 401 | — |
| valid | deleted | 404 | — |Scenario · Decision-table-led (permission gate)
When the centre of gravity is a decision — does this caller have permission to do this thing? — Rules: carries the whole behaviour and Then: collapses to one line. Below: a textbook role-based authorisation gate on a project-update endpoint. The prose handles say what subject, under what conditions, and what frame should not be touched; Rules: does the rest.
# PATCH /projects/:id permits owner or admin; rejects everyone else
Subject: PATCH /projects/:id on apps/api
Independent: yes — each example uses a fresh fixture.
Before: a project exists with one owner and zero or more members,
each carrying a role on the project.
When: the bearer of the Authorization header attempts
PATCH /projects/:id with a partial-update body.
Then: the request succeeds iff the bearer is the owner
or has role admin on the project.
Visible: success → 200 with the updated project;
failure → 403 with body "forbidden".
Always: project fields change only on success; unchanged on failure.
Untouched: every other project; the user's role on every other project.
Rules:
| auth state | ownership | role on project | response |
|------------|-----------|-----------------|----------|
| absent | * | * | 401 |
| valid | owner | * | 200 |
| valid | not-owner | admin | 200 |
| valid | not-owner | member | 403 |
| valid | not-owner | none | 403 |
Wrong:
no-auth: 401, body "missing token".
member-attempt: 403, body "forbidden".
stranger: 403, body "forbidden".Scenario · Parameterised case (pure function)
When the centre of gravity is examples rather than logic, For-instance: carries the witness. Rules: says what the function does in the abstract; For-instance: pins down what it does on specific inputs. Below: an email normaliser of the kind you find in any auth or invite flow.
# normalizeEmail: lowercase, trim, strip plus-tags
Subject: normalizeEmail in packages/shared/src/auth
Independent: yes — pure function, no shared state.
Before: a raw email string from a signup, login, or invite form.
When: normalizeEmail(raw) is called.
Then: returns a canonical email — lowercased, outer whitespace trimmed,
and any "+tag" segment in the local part removed.
Visible: returned string equals the expected string for each For-instance row.
Always: the function is total — never throws on string input.
Untouched: the input string (the function is pure).
Rules:
| input character class | output character class |
|-----------------------------|------------------------|
| leading/trailing whitespace | removed |
| uppercase letters | lowercased |
| "+tag" suffix in local part | removed |
| the "@" and domain | preserved |
| every other character | preserved |
For-instance:
| raw | parsed |
|---------------------------|-----------------------|
| " Alice@Example.com " | "alice@example.com" |
| "user+promo@gmail.com" | "user@gmail.com" |
| "BOB@WORK.IO" | "bob@work.io" |
| "carol+a+b+c@mail.dev" | "carol@mail.dev" |
| "" | "" |
| "no-at-sign" | "no-at-sign" |
Wrong: (none — total function;
null/undefined inputs are a type error caught at compile time)Authoring conventions
- 01Happy path at top (
Before·When·Then·Visible). Errors belowWrong:. - 02One witness per behaviour. If a witness's implementation will touch more than three non-test source files, split it before red.
- 03Anchor IDs are the slugified H1 of each witness.
- 04Keep
Then:to one line where you can; letRules:andFor-instance:do the heavy lifting when the behaviour is a function or a table.