Requirements as Code: making a contract fail the build
Every consulting engagement produces a document that says what the software will do, and a codebase that does what it does. These start out aligned, because the document was written first and the code hadn’t disappointed anyone yet. Then the code changes, the document doesn’t, and after a while the document describes a system that no longer exists. Everyone knows this. Nobody re-reads the Statement of Work to check.
I have been in situations where the SOW is pretty worked up - a team can spend a lot of time digging into detail.
I have also been on jobs where the SOW doesn’t exist or is so light on detail as to be meaningless - a high level nothingburger. This might work well for sales but is a nightmare to deliver.
Now we may say that a customer who signs off a nothingburger SOW deserves whats coming, but that seems an uncharitable act.
In an attempt to get developers / project owners and customers on the same page I have started looking at ways of creating “Living Documentation” that live in the codebase rather than lost in a ppt or confluence page.
It takes me back over a decade to when I was introduced to symfony by Ciaran McNulty on one of his courses at Sensiolabs - he started talking about DDD and BDD and waxed glorious about Behat. (I was probably his weakest student on that course tbh)
The seed took, but only half of it. BDD as I’ve practised it in the years since was the developer’s half: feature files in the repo, scenarios driving a real browser, documentation that was “living” in the sense that it executed. What never quite materialised was the half Ciaran was actually selling — the scenarios as the shared artefact, the thing the customer reads, agrees to, and holds you to. My feature files lived and breathed; the SOW stayed a Word document in someone’s inbox. Which is how I ended up with the situation in the first paragraph: twenty years of tooling for keeping code honest, and the one document that actually gets signed had never met any of it.
This post is about the fix: making the requirement — not the document — the unit of traceability, so that drift between contract, criteria, and code fails the build.
Lineage
The idea is not mine. Stéphane Erard has been writing about type-first
requirements at serard.dev, and building the idea out as
the ts-requirements ecosystem in TypeScript:
requirements as typed artifacts, decorators binding tests to acceptance
criteria, a compliance scanner, generated documentation and dashboards. His
framing that stuck with me
is that a requirement is a durable promise, its acceptance criteria are
project-specific measures, and the link between them should be deliberately
loose — a style choice, not a weld.
I run PHP and Symfony, my acceptance layer is Gherkin, and my promises live in a Word document a client signs. So this is an adaptation, not a port, and the departures are deliberate:
- The typed layer is a thin registry, not the specification. In Stéphane’s system the typed artifact is the spec — the criteria live in decorators. In mine, a requirement class carries an id, a title, and a pointer to the document section, and almost nothing else. The WHY stays in the human document, upstream of the code.
- Gherkin is the criteria language, because a client can read a Gherkin scenario and agree to it, and Behat can execute the very same file. The criteria the client signs and the criteria the browser runs are one artifact.
- No code generation. Behat’s snippet generation and a red build do the scaffolding work; I didn’t need a generator half.
The mechanism
A requirement is a small PHP class whose only job is to be reflected over:
#[Requirement('REQ-006', title: 'Remote approval workflow', section: '2.3.1')]
#[Criterion('Documents routed for remote approval reach the entitled external subscribers')]
#[Criterion('Approval outcomes flow back and drive the publish state machine')]
final class RemoteApprovalWorkflow implements RequirementDefinition {}
Its acceptance criteria are Gherkin scenarios linked by nothing more than a tag:
@REQ-006
Feature: Remote approval workflow
Scenario: A subscriber approves a routed document
...
Plain PHPUnit tests can join the evidence with #[Verifies('REQ-006')], and
implementation code can carry an informational #[Satisfies] marker. Then a
console command — requirements:compliance — cross-references four sources:
the requirement classes (via reflection), the feature files (via the same
Gherkin parser Behat uses), JUnit results from both Behat and PHPUnit, and the
markers. It emits a compliance matrix and exits non-zero if a tag points at a
requirement that doesn’t exist, if two classes claim the same id, or if an
automated requirement has no coverage at all.
The exit code is the whole point. A requirement with no acceptance criteria is not a note in a backlog; it is a broken build. Declaring a requirement and then covering it works exactly like test-driven development one level up: the gate goes red, the red is your to-do list, and green is not a matter of opinion.
Two policy valves keep the gate honest rather than merely loud. A requirement
verified only by PHPUnit — no client-readable Gherkin — passes with a warning,
which makes the backlog of unwritten scenarios permanently visible without
blocking work. And a requirement can be declared planned: true: registered,
counted, exempt from the uncovered check. That one matters on greenfield
projects, where you want the whole contract registered on day one without the
gate being red for months. Red has to mean drift. The moment red means
“not built yet, as expected”, nobody looks at it again.
Closing the loop with the document
The piece that started all this: the SOW appendix is no longer pasted. A pandoc
Lua filter splices the generated fragments into the document at build time —
the feature files verbatim, grouped by contract section, followed by the
compliance matrix with per-scenario results. If the fragments are missing or
stale, the document build fails. The .docx a client reads now contains, as
its acceptance appendix, the literal files the test suite executed and the
results of the last run. There is no second copy left to drift.
A small dashboard renders the same matrix at a route in the application, which turns out to be the artifact non-technical stakeholders actually look at: requirements down the side, green ticks and file-line references against each, one honest warning count at the top.
What it caught
The retrofit itself was the first test. Wiring an existing application into this system surfaced, in one week: the drifted appendix that motivated it; a genuine user-facing bug in a multi-step form’s back-navigation that the existing tests had been silently failing to reach; a dozen orphaned test classes that were quietly covering contract requirements no one had ever declared; and two test files that had gone stale against a UI rework — which the gate now refuses to let anyone forget, because their requirement carries a visible warning until they’re repaired.
There is also a less obvious payoff, which Stéphane’s more recent writing circles around: this structure changes what it is safe to delegate to an LLM. The fundamental problem with agentic coding assistants is verification asymmetry — output is cheap, checking is expensive, and an agent will cheerfully report success. A compliance gate the agent cannot redefine converts “the assistant says it’s done” into “the matrix says it’s green”, which is a different epistemic category. My working rule after a week of building this way: everything downstream of agreed acceptance criteria is safely delegable; nothing upstream of them is. The human’s irreplaceable job is agreeing what the Gherkin should say. The machine can hold everyone to it afterwards.
The package
The whole mechanism extracted cleanly into a Symfony bundle —
gavinerickson/requirements-as-code,
MIT, pre-release. Everything that was hardcoded became configuration: tag
prefix, id format, scan roots, gate policies, dashboard. The package’s own test
suite dogfoods its attributes — it declares its requirements as RAC-* classes
and a self-audit test fails if any of them loses #[Verifies] coverage, a
discipline stolen directly from the ts-requirements repo, which bans plain
test declarations entirely in favour of its own decorators.
The next project starts from the document side on day one: register the
contract as planned requirements at signature, draft the scenarios with the
client, and build to green. Whether that survives contact with a real
engagement is a future post.