Imagineers · Specification

Witness

A verification spec language.

A sworn-statement format for specifying behaviour. Thirteen labelled handles translate deterministically into cases for any test runner — Vitest, Playwright, Jest, pytest, RSpec, JUnit — upstream of all of them, never a substitute. Witnesses are reviewed line by line; they are not run.

Stablev1Updated Apr 26, 2026Runner-agnostic
§1

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.

§2

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.
§3

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.”).

§4

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.

HandleVitestPlaywright
BeforebeforeEachtest.beforeEach
Whenarrange-act-assert · act phaseuser interaction
Then / Visibleexpect(...)await expect(locator)…
Rulesit.eachtest.describe.parallel
For-instanceit.eachdata-driven test() per row
Goes-fromone test per transitionone test per transition
Wrongone test per error pathone test per error path
§5

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?”

Legendhandlesub-key"string"`code`→ ≥ ≤(none — …)
witness · search-bar.md
# 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            | —                  |
§6

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

witness · get-me.md
# 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    | —       |
§7

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.

witness · project-update-gate.md
# 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".
§8

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.

witness · normalize-email.md
# 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)
§9

Authoring conventions

  • 01Happy path at top (Before · When · Then · Visible). Errors below Wrong:.
  • 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; let Rules: and For-instance: do the heavy lifting when the behaviour is a function or a table.
End of specificationWitness · v1