Requirements as Code: BRD and FRD in YAML With a Validator That Fails the Build
Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.
Key takeaways
- A requirement in a Word document is prose. A requirement in YAML with an id, an owner, a priority, and acceptance criteria is a record, and records can be counted, diffed, joined, and validated.
- The identifier is the whole point. REQ-014 assigned once and never reused is what lets a script prove that the scenario, the ticket, the test result, and the sign-off all refer to the same thing.
- Do not write YAML by hand and do not let AI write it unchecked. Draft in prose with a model, convert to YAML with a second pass, then let the schema validator reject anything malformed before it reaches review.
- The BRD and the FRD stop being two documents that drift and become two renderings of one file, generated on every push.
- The test that a functional requirement is well written is mechanical: if you cannot write a Gherkin Then step from it without asking a question, it is not finished.
Requirements as code means the authoritative version of every requirement is a structured record in git, with a permanent id, rather than a paragraph in a document. The BRD and FRD that stakeholders read are rendered from that file on every push. The payoff is mechanical: a script can validate the requirements, diff them, and join them to test results by id, which prose can never support.
Here is the failure this solves. A specification has forty requirements. Fourteen are in tables, twelve are in bullet lists, and fourteen are buried in explanatory paragraphs because the author was describing a process rather than listing rules. The test analyst covers the tables and the bullets. The paragraphs get missed, which is why a third of UAT defects are not defects at all but requirements that were in the document and not in the suite.
The problem is not the author’s discipline. It is that prose has no structure a machine can check. This article is stage two and three of the requirements to UAT pipeline: giving every requirement a shape. If you want the finished BRD and FRD templates in document form, they are in The BA Deliverables Template Pack.
What does a requirement look like as a record?
Two files. business.yaml holds what the organisation needs. functional.yaml holds the system behaviour that satisfies it. The conceptual split is covered properly in BRD vs FRD; here is what it looks like as data.
# requirements/business.yaml
- id: REQ-014
title: Reject payments above the customer daily limit
statement: >
The platform must reject a payment instruction when it would take the
originating customer above their agreed daily limit for that currency.
rationale: >
Credit risk exposure and the operational cost of recalling a settled
payment. Current manual control catches breaches after settlement.
owner: Head of Payments Operations
stakeholders: [Credit Risk, Operations, Customer Service]
priority: must # must | should | could | wont
status: approved # draft | in-review | approved | superseded
source: elicitation/2026-09-15-pacs008-workshop.md#decision-4
acceptance:
- A payment that would breach the limit is rejected, not queued.
- The customer is told the reason within 5 seconds of submission.
- Every breach attempt is retrievable for audit for 7 years.
nfr:
- id: NFR-003
type: performance
statement: Limit check adds no more than 200ms at p95.
# requirements/functional.yaml
- id: FR-031
refines: REQ-014
title: Daily limit evaluation on payment submission
statement: >
On receipt of a payment instruction, the Processor sums the customer's
accepted and settled payments for the current business day in the
instruction currency, adds the instructed amount, and compares the total
to the customer's configured daily limit for that currency.
rules:
- id: FR-031-R1
rule: If total exceeds the limit, reject with ISO 20022 reason code AM04.
- id: FR-031-R2
rule: If total equals the limit exactly, accept. The limit is inclusive.
- id: FR-031-R3
rule: Business day is 00:00:00 to 23:59:59 Europe/Paris, not a rolling 24 hours.
- id: FR-031-R4
rule: >
Payments in a currency with no configured limit are accepted and an
entry is written to the unlimited-currency audit log.
- id: FR-031-R5
rule: Pending payments not yet accepted are excluded from the sum.
data:
- field: dailyLimitAmount
source: customer_limits.daily_amount
type: decimal(18,2)
- field: instructedAmount
source: pacs.008 InstdAmt
type: decimal(18,2)
open_questions: []
Look at FR-031-R2. Whether the limit is inclusive or exclusive is exactly the sort of thing prose leaves implicit, and it is exactly the sort of thing that produces a defect at 49,999.99 in UAT. Structured rules force the question at authoring time, which is where you want it.
Why identifiers are the whole mechanism
REQ-014 is not a label. It is a foreign key.
That id appears in the functional requirement that refines it, the use case that realises it, the test condition derived from it, the @REQ-014 tag on every Gherkin scenario, the Jira issue that implements it, the Xray test that executes it, and the row in the sign-off pack. Because a single string ties all of them together, the traceability matrix is a join rather than a maintained artifact.
Three rules keep ids trustworthy:
- Assign once, never reuse. If REQ-014 is dropped, its status becomes
supersededand the number is retired. Reusing a retired id corrupts every historical test result. - Never renumber. The instinct to tidy up gaps in the sequence is the single most destructive thing you can do to a traceability chain.
- Only a human assigns them. A model drafting a requirement must leave the id blank or propose
REQ-NEW-1. Let a model pick ids and you will get duplicates within a week.
The schema, and what it rejects
The validator is the part that changes behaviour, because it turns a review checklist into a build failure. JSON Schema works fine against YAML.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": {
"type": "object",
"required": ["id", "title", "statement", "owner", "priority", "status", "source", "acceptance"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^REQ-[0-9]{3}$" },
"title": { "type": "string", "minLength": 10, "maxLength": 90 },
"statement": { "type": "string", "minLength": 30 },
"rationale": { "type": "string" },
"owner": { "type": "string", "minLength": 3 },
"stakeholders": { "type": "array", "items": { "type": "string" } },
"priority": { "enum": ["must", "should", "could", "wont"] },
"status": { "enum": ["draft", "in-review", "approved", "superseded"] },
"source": { "type": "string", "minLength": 5 },
"acceptance": { "type": "array", "minItems": 1, "items": { "type": "string" } },
"nfr": { "type": "array" }
}
}
}
Then the linting a schema cannot express. This is where the real value sits, because these are the four failures that produce untestable specifications:
# scripts/validate.py (the lint pass)
import re, sys, yaml, json, jsonschema
WEASEL = re.compile(
r"\b(appropriate|as needed|etc\.?|fast|flexible|friendly|generally|"
r"if possible|intuitive|large|minimal|normal|optimal|quickly|reasonable|"
r"relevant|robust|seamless|significant|simple|sufficient|suitable|"
r"user-friendly|where applicable)\b", re.I)
errors = []
business = yaml.safe_load(open("requirements/business.yaml", encoding="utf-8"))
schema = json.load(open("schemas/business.schema.json", encoding="utf-8"))
jsonschema.validate(business, schema)
seen = set()
for r in business:
# 1. duplicate ids
if r["id"] in seen:
errors.append(f'{r["id"]}: duplicate id')
seen.add(r["id"])
# 2. unmeasurable language in a statement or an acceptance criterion
for field in ["statement", *r.get("acceptance", [])]:
for hit in WEASEL.findall(field):
errors.append(f'{r["id"]}: unmeasurable word "{hit}" in "{field[:60]}..."')
# 3. a must-have with no rationale is a preference in disguise
if r["priority"] == "must" and not r.get("rationale"):
errors.append(f'{r["id"]}: priority must with no rationale')
# 4. an approved requirement whose source file no longer exists
src = r["source"].split("#")[0]
if not src.startswith("http") and not __import__("os").path.exists(src):
errors.append(f'{r["id"]}: source not found: {src}')
for e in errors:
print("FAIL", e)
sys.exit(1 if errors else 0)
The weasel word list is thirty lines of regex and it is the most useful thing in the file. “The system must handle large payments appropriately” fails the build, which is a considerably more effective intervention than a reviewer writing “please clarify” in a comment that is resolved without being addressed.
A second script enforces the cross-file rules: every refines points at an existing REQ, every must requirement has at least one FR, every FR has at least one rule, and no FR has a non-empty open_questions list while its status is approved. That last one is small and prevents a genuinely common failure, which is a specification approved with three unanswered questions still embedded in it.
Drafting with AI without letting it invent
The productive loop is three passes, and the model never touches the id.
Pass one, prose. Give the model the context pack, the transcript extraction, and the house style, and ask for the requirement in prose. Models write better requirement prose than most analysts because they are relentless about including the rationale and the negative case.
Pass two, conversion. A separate prompt that only converts.
Convert the approved requirement prose below into a record matching
schemas/business.schema.json.
Rules:
- Leave `id` as the literal string REQ-NEW. I assign ids, you never do.
- `acceptance` must contain only criteria with a measurable value.
If the prose says "quickly", output ACCEPTANCE NEEDS A VALUE instead
of choosing one.
- `source` is the file I gave you, verbatim. Do not invent a source.
- Do not add a field that is not in the schema.
- Output YAML only, no commentary.
Pass three, the adversarial read. Point the model at the finished record and ask what a hostile reviewer would say. Run the blind spot review properly at the set level later, but a quick single-record pass catches the obvious.
Read FR-031 as a developer who will implement it and a tester who will
test it. List, separately:
- values a developer must choose because the requirement does not say
- boundary conditions with no stated behaviour
- states the requirement does not cover
- anything two people could read differently
Do not suggest improvements. Only list what is missing.
That prompt is where FR-031-R2 came from. Nobody writes “the limit is inclusive” on the first draft. A model asked what a developer must choose says it every time.
Rendering the documents people actually read
Stakeholders never see YAML. They see a rendered document, regenerated on every push so it cannot drift.
# scripts/render.py (core)
import yaml
from pathlib import Path
business = yaml.safe_load(open("requirements/business.yaml", encoding="utf-8"))
functional = yaml.safe_load(open("requirements/functional.yaml", encoding="utf-8"))
by_req = {}
for f in functional:
by_req.setdefault(f["refines"], []).append(f)
out = ["# Business Requirements", ""]
for r in business:
if r["status"] == "superseded":
continue
out += [
f'## {r["id"]} {r["title"]}', "",
f'**Priority** {r["priority"]} | **Owner** {r["owner"]} | **Status** {r["status"]}', "",
r["statement"].strip(), "",
f'**Why** {r.get("rationale", "").strip()}', "",
"**Acceptance criteria**", "",
*[f"- {a}" for a in r["acceptance"]], "",
"**Realised by**", "",
*[f'- {f["id"]} {f["title"]}' for f in by_req.get(r["id"], [])] or ["- NOT YET REFINED"],
"",
]
Path("build/BRD.md").write_text("\n".join(out), encoding="utf-8")
Two details worth copying. Superseded requirements are excluded from the rendered document but never deleted from the file, so history survives without cluttering the read. And a business requirement with no functional requirement renders as NOT YET REFINED in bold in the middle of the BRD, which is far more effective at getting it refined than a gap in a coverage report nobody opens.
Push the rendered Markdown into Confluence and the same content is where the organisation already reads. That publish step, with scoped tokens, is the next article.
The mechanical test for a finished requirement
Apply this before anything moves to the next stage.
| Check | Fails when | Example failure |
|---|---|---|
| Gherkin test | You cannot write a Then step without asking a question | ”must handle errors gracefully” |
| Value test | An adjective stands where a number belongs | ”responds quickly” |
| Boundary test | A threshold exists with no stated behaviour at the boundary | ”above the limit” with no inclusive or exclusive |
| Negative test | Only the happy path is described | nothing about what happens on failure |
| Owner test | No named person can approve a change to it | owner is “the business” |
| Single test | One requirement contains two rules joined by “and” | validate and notify and log |
The Gherkin test is the one that does most of the work, and it is why this stage and the UAT scenario stage belong in the same pipeline. A requirement you cannot turn into a Then step is not a strict requirement, it is a wish.
The takeaway
Give every requirement a permanent id, a schema, and a validator, and requirements stop being prose you hope people read carefully. They become records a machine can check, a pull request can diff, and a test result can join to. The documents stakeholders read are rendered from the same source on every push, so the BRD and the FRD cannot disagree with each other or with reality.
AI does the drafting and the adversarial read; the schema does the policing; you assign the ids and make the calls. Next, get it into the tools the organisation lives in with MCP and scoped API tokens for Jira, Confluence, Xray, and Datadog. For the finished BRD and FRD templates, see The BA Deliverables Template Pack, and for turning vague business requests into testable rules, From Vague BR to Functional Requirements.
Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.
Tags: Business Analysis, Requirements, Functional Analysis, Docs as Code, YAML
About the author
Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.
Related articles
- The Requirements to UAT Pipeline: One Repository From Workshop to Sign-Off A working pipeline that carries a requirement from a workshop transcript to a signed UAT result: eight stages, three machine-readable formats, and four CI gates.
- BRD vs FRD: Two Documents, Two Jobs The difference between a Business Requirements Document and a Functional Requirements Document: what each covers, who reads it, and when you need both. With examples.
- From Business Requirement to Functional Spec: Turning Intent Into Behavior How to turn a vague business requirement into a precise functional specification: decompose intent, define inputs and outputs, and write testable behavior. With examples.
- The Functional Specification Template That Removes Ambiguity A practical functional specification template: scope, actors, data, business rules, behavior, error handling, and acceptance criteria. The structure that makes a spec buildable.
Free account
Practice on the Labs, keep your progress
A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.
Your email is used to sign you in. Nothing else, unless you ask. Privacy.