From a one-line request to a living spec: spec-driven development in practice
- By Bruno Souza
- AI engineering
- 17 min read
On this page
Code
A one-line request
Picture the logistics team of an online shop. They need customer service to check an order’s delivery without opening three systems, so they send the development team a one-line message:
We need an API that, given the order number, returns the delivery status.
The scene is made up, but the request has the shape of most real ones: short, reasonable and incomplete. It doesn’t say what an order number is. Digits only? Is PED-123 valid? Is 01001 the same order as 1001? Nor does it say which statuses exist, what the response carries besides the status, or what happens when the order doesn’t exist: a client error, a resource not found, or an “unknown” status?
Developers answer these questions while writing the code, usually without noticing. An AI agent does the same, only faster: it picks a format, picks an error code, delivers something that works and moves on. The decisions exist, but they live inside the code, and nobody approved them.
This article follows that request from start to finish with spec-driven development (SDD), using OpenSpec. The project is real and public, at brunosouzas/spec-driven-delivery-api: a small Mule 4 API built in two changes. Every spec excerpt, command and response shown here comes from that repository, and the commit history keeps every version.
What spec-driven development is
Birgitta Böckeler, from Thoughtworks, sums up the idea in one sentence: SDD means writing a spec before writing code with AI, and that spec becomes the source of truth for both the human and the AI (martinfowler.com, October 2025). The spec describes the expected behavior: what goes in, what comes out, what happens in the error cases. The agent implements from it.
In practice, this changes who decides: the person who approves the spec decides what the system does.
The Thoughtworks Technology Radar placed SDD in the Assess ring in November 2025, as an emerging approach whose definition is still evolving. What these approaches share is that they start from a structured functional specification and break it down, in steps, into smaller pieces, solutions and tasks.
The underlying idea is much older than agents. In 1986, in a technical report, Bertrand Meyer described Design by Contract: each routine declares preconditions, postconditions and invariants, and that contract is the specification of what it promises. In 2004, Jonathan Ostroff, David Makalsky and Richard Paige were already using the name in Agile Specification-Driven Development, presented at the XP conference. They combined TDD with contracts and argued that tests and contracts are two kinds of specification. What is new now is who reads the spec: besides people, an agent that writes the code from it.
There are several tools for this. Böckeler looked at Kiro, spec-kit (from GitHub) and Tessl. This example uses OpenSpec, version 1.14.1, which organizes work into changes that go through three main steps: propose (/opsx:propose), apply (/opsx:apply) and archive. There is also an optional exploration step before proposing. What follows applies, under other names, to the other tools.
Setting up the project
It all starts with one command at the root of the repository:
openspec init --tools none
Without the --tools option, init asks which AI assistants you use and installs the workflow commands for each of them, such as /opsx:propose. Here the option was none because the agents in the example were already set up separately. The result is an openspec/ folder inside the project, next to the code:
openspec/
├── config.yaml
├── specs/
└── changes/
└── archive/
specs/describes what the system does today. At first it is empty, because the system doesn’t do anything yet.changes/holds the changes in progress, one folder per change.changes/archive/receives finished changes, with the date in the name.config.yamlgives the project context and the rules every artifact follows.
config.yaml is where the project teaches the agent how to propose. In this example it says the API is Mule 4, that data lives in a variable instead of a database, and that each scenario will be proven with a real call. It also has rules per artifact. Three of them matter for the rest of the story:
rules:
proposal:
- State what is out of scope.
...
- When the request is unclear, list the open question instead of choosing an answer.
specs:
...
- Describe observable HTTP behaviour (status, body, headers), not implementation.
In the repository: openspec/config.yaml
The open-questions rule is the most important. It stops the agent from doing what it would do by default, which is to choose silently.
Outside the folder, the repository has two simple safeguards. AGENTS.md tells any agent that work comes from an approved change and that, if a decision is missing, it stops and asks. And a CI workflow runs openspec validate --all --strict on every pull request, which blocks a malformed spec before the merge.
The first change
With the project set up, the one-line request went to Claude, in a clean session, with OpenSpec’s propose command (/opsx:propose). There is still no code. What comes out is a change called add-delivery-status:
openspec/changes/add-delivery-status/
├── proposal.md
├── design.md
├── tasks.md
└── specs/
└── delivery-status/
└── spec.md
proposal.mdsays why the change exists, what changes, what is out of scope and what is still unknown.specs/delivery-status/spec.mdis the delta: the requirements this change adds to the system, each with scenarios in WHEN/THEN form.design.mdrecords technical decisions. Here it stayed a few lines long, because there was no real technical alternative to choose between.tasks.mdlists the work, and each task says how it will be verified.
The proposal suggested a path, GET /api/orders/{orderNumber}/delivery-status, and responses for an unknown order and a malformed number, but it marked each suggestion as a proposal and listed what the sentence didn’t answer:
## Open Questions
1. **Order number format**: digits only? Length? Prefixes such as `PED-123`?
2. **Status values**: which statuses exist, and in Portuguese or English?
3. **Response content**: only the status, or also fields like last update time?
4. **Unknown order**: `404`, or `200` with an "unknown" status?
5. **Consumers and access**: who calls the API, and does it need authentication?
6. **Path and naming**: is there an existing URL convention to follow?
These are the same questions as in the opening, now written down, numbered and addressed to the people who can answer them. The Out of Scope section did the opposite job: it made explicit that creating or cancelling orders, tracking history, authentication and integration with real systems are not part of the request.
Answer, review, approve
The logistics team answered the six questions. The order number is digits only, from 1 to 20. The statuses are PENDING, IN_TRANSIT and DELIVERED, in English. The success response has two fields, orderNumber and status. An order that doesn’t exist gets 404.
The last answer took some debate. The question was whether a missing order is a business error (400) or a resource that wasn’t found (404). Both readings are defensible. The proposal records the decision and the reason: the order is the resource the URL names, and that resource doesn’t exist; 400 is kept for malformed requests. Without the spec, the developer would have picked one of the two silently, and the logistics team would only find out which when the first error came back.
With the answers in place, the change went through an independent review. Another agent, Codex, in a clean session with read-only access, was asked one question: could someone with no other context implement exactly the intended behavior from these artifacts alone? Findings were rated blocker, major or minor, and the verdict was GO or NO-GO.
The first verdict was NO-GO, with four major findings:
- The spec allowed leading zeros, but didn’t say whether
01001identifies order1001. - Methods such as
POSTon the endpoint had no contract at all. - The “only two fields in the response” rule contradicted the error responses, which carried a
messagefield. - The verification tasks didn’t cover the whole behavior: no 20-digit test, no leading-zero case, only one status.
The next version fixed all four, and the second review was also NO-GO, this time over finer details. The rule “any method other than GET gets 405 with a JSON body” included HEAD, and a response to HEAD cannot have a body. And POST /api/orders/abc/delivery-status fell under two rules at once: malformed number (400) and unsupported method (405). Depending on the order of the checks, two correct implementations would give different answers.
The fourth version closed both points: POST, PUT, PATCH and DELETE get 405 before any check of the number, and HEAD and OPTIONS were left out of scope. The decision about leading zeros became a requirement and a scenario:
#### Scenario: Leading zeros are a different identifier
- **WHEN** a client sends `GET /api/orders/01001/delivery-status`, order `1001` exists and no order `01001` exists
- **THEN** the API responds `404`
- **AND** the body is `{"message": "Order 01001 not found"}`
In the repository: openspec/changes/archive/2026-10-09-add-delivery-status/specs/delivery-status/spec.md
Two rejections may look like waste. Yet every fix was made in a few lines of markdown, before a single line of code existed. The same problems, found later, would have been production bugs or arguments with the API’s consumers. The precedence between 400 and 405, for example, only shows up when someone hits the exact combination of wrong method and invalid number.
Finally, approval applies to a specific commit of the change, the fourth version. Opening the pull request or passing validation doesn’t count as approval: only an explicit decision by the owner of the request releases the implementation.
Implement and prove
From there, the agent implements what the tasks say and nothing more; in the OpenSpec workflow, this is the /opsx:apply step. AGENTS.md doesn’t allow re-planning an approved change, and a decision the change didn’t make is a reason to stop and ask. That wasn’t needed here, because the spec already answered everything.
The result is a Mule flow of about 50 lines. An HTTP listener receives the call, and a DataWeave transformation decides the response. The orders live in a variable that stands in for the database. The order of the checks mirrors the spec:
if (attributes.method != "GET")
{ code: 405, body: { message: "Method not allowed" } }
else if (!(orderNumber matches /[0-9]{1,20}/))
{ code: 400, body: { message: "Invalid order number: $(orderNumber)" } }
else if (orders[orderNumber] == null)
{ code: 404, body: { message: "Order $(orderNumber) not found" } }
In the repository: src/main/mule/delivery-status.xml
If you read the previous section, you will recognize each line: the method comes first because of the second review, the format is 1 to 20 digits because of the logistics team’s answer, and 404 covers the missing order for the reason recorded in the proposal.
The proof doesn’t use automated tests. Each spec scenario becomes a curl call against the running application, and the real response is recorded in the README. The final spec has ten scenarios, and nine calls cover them all: the shape of the error body shows up in every error response. Two of them show the decisions the review forced:
$ curl -si http://localhost:8081/api/orders/01001/delivery-status
HTTP/1.1 404 Not Found
content-type: application/json
{
"message": "Order 01001 not found"
}
$ curl -si -X POST http://localhost:8081/api/orders/abc/delivery-status
HTTP/1.1 405 Method Not Allowed
content-type: application/json
{
"message": "Method not allowed"
}
In the repository: README.md
The curl call is a choice of this example: it keeps the proof readable in the README, and anyone can repeat it. In a real project, each scenario also becomes an automated test (in Mule, an MUnit test) that runs in CI. The recorded response proves the behavior on delivery day; the test keeps it from breaking in the next change.
With the tasks done, the change is archived:
openspec archive add-delivery-status
Archiving does two things. It moves the change folder to changes/archive/2026-10-09-add-delivery-status/, where it stays as a record of how the decision was made. And it merges the delta into openspec/specs/delivery-status/spec.md. The specs/ folder, empty since init, now describes what the API does. This is the living spec: the next request starts from it, not from the code or from someone’s memory.
The second request
A few days later, in the story, the logistics team comes back with one more line:
Now we also need to show the estimated delivery date.
It looks like the kind of change that needs no ceremony: one more field in the JSON. The add-estimated-delivery-date change was proposed the same way, by an agent in a clean session, but this time with something the first one didn’t have: the living spec. The agent read what the API already promised and proposed a delta that modifies an existing requirement instead of creating a new one:
## MODIFIED Requirements
### Requirement: Return delivery status by order number
...
A successful response SHALL have status `200`,
header `Content-Type: application/json` and a body with exactly three fields:
`orderNumber`, `status` and `estimatedDeliveryDate` ...
In the repository: openspec/changes/archive/2026-10-09-add-estimated-delivery-date/specs/delivery-status/spec.md
The open questions were, again, the ones the sentence didn’t answer. Does a delivered order show the old estimate, the actual date (which would be another field), nothing, or no field at all? Can a pending order have no estimate? Does the date include a time and a time zone? Is it a fixed or a calculated value?
The impact section raised something nobody had asked about:
- Breaking for clients that reject unknown fields: the body no longer has
"exactly two fields".
In the repository: openspec/changes/archive/2026-10-09-add-estimated-delivery-date/proposal.md
The first change’s spec promised that the success response had exactly two fields. Nobody would remember that in a code review that adds one line to a JSON body. The request said nothing about a contract, and the people asking for the date probably didn’t even know that promise existed. The spec knew: it shows what the request forgot. A client that validates the body strictly and rejects unknown fields would break on deployment day.
Warned before any code, the owner of the request knowingly accepted the break and recorded the reason in the proposal. For this example, versioning the API would be more change than the request asked for. In another context, the same information could lead to a v2 or to a conversation with the consumers.
The other answers were recorded too: a delivered order gets null, a pending or in-transit order always has a date, the date has no time (YYYY-MM-DD), and the value is fixed per order. The independent review gave GO in the first round, with a single minor finding: the requirement’s wording allowed a date for a delivered order, unlike what the proposal decided. The fix was one sentence. Then came the implementation, the re-run curl calls and the archive. To pass OpenSpec’s validator, which limits the length of each requirement, the merged requirement was split in two in the living spec, without changing behavior or scenarios. It now says “exactly three fields”.
What the example teaches, and when it isn’t worth it
The example is small on purpose, but it shows four mechanisms that hold for larger systems.
The living spec is the first. openspec/specs/ describes the system’s current behavior in requirements and scenarios, and every archived change updates it. Anyone new to the project, person or agent, can read there what the API promises, without having to infer it from the code.
The second is the delta instead of a rewrite. The second change didn’t rewrite the spec: it declared which requirement it modified and left the others untouched. The review looks only at what changes, and the comparison with what existed is what makes the impact visible.
The third is review before code. The first change’s two rejections cost edits in markdown, not rework. It doesn’t replace code review: in this example, the independent review stayed on the spec; in a real project, the diff also goes to a reviewer who checks the implementation against the scenarios.
The fourth is proof per scenario. Every scenario has a check anyone can repeat: here, the curl call; in a real project, the test in CI.
Böckeler’s classification has three levels of SDD. In spec-first, the spec is written first and used for the task at hand. In spec-anchored, it is kept after the task and used to evolve the system. In spec-as-source, the spec becomes the main source and people don’t edit the code directly. This example sits at the second level: the spec stays after delivery and is the starting point for the next change, but the code is still code, reviewed and editable.
None of this comes for free. The first change took four versions and two reviews for a 50-line flow. Böckeler recounts that asking Kiro to fix a small bug felt like using a sledgehammer to crack a nut, and notes that reviewing lots of markdown files can be more tiring than reviewing code. Liu Shangqi, also from Thoughtworks, points out that over-formalized specs can cause trouble and slow down change and feedback cycles, as happened in the early days of waterfall development, though he believes this time is different (December 2025). The Radar itself warns of the risk of relearning that handcrafting detailed rules for AI doesn’t scale.
The bureaucracy objection is fair for small tasks. Even so, after quite a few years of writing code, my experience is that what looks like bureaucracy today can save you tomorrow, in a bug, an improvement or a new feature. Documentation is bureaucracy for many people until the day someone needs it, in detail. The second change in this example is that day: the “exactly two fields” promise only surfaced because it was written down. That is why it is worth it to me: the process starts with a requirement and ends with artifacts that can carry the project forward, with or without AI.
A practical rule helps decide when to open a change: open one when the people who use the system will need to know about it, such as a new endpoint, a business rule, a field or a contract. Both requests in this example pass that test: the first creates an endpoint, the second changes a contract.
Fixing a bug that restores behavior already specified, adjusting a log line, upgrading a dependency or refactoring without changing behavior doesn’t need an OpenSpec change; the pull request is the record. In those cases, the spec would only slow the work down. When in doubt, start without one; if the review shows that behavior changes, open one before the merge.
How the story ended
The logistics team has the API it asked for: GET /api/orders/{orderNumber}/delivery-status returns the status and the estimated delivery date. Every scenario in the spec has proof in a real call recorded in the README. The decisions the one-line request didn’t make are written down: the number format, 404 for a missing order, 405 before any check, null for a delivered order, and the breaking change accepted on purpose, with the reason next to it.
When the next request arrives, the next person to pick up the work starts from openspec/specs/delivery-status/spec.md, which says what the API does today, and from openspec/changes/archive/, which shows why it does it that way.
To see the process from the inside, clone the repository and read in this order: the living spec, the two archived changes and the review comments on pull requests #1 and #4. The commit history shows every version of the proposal, from the first one, written from a single line, to the one that was approved. Then pick a short request from your own backlog, run openspec init in a small project and see how many questions the proposal asks that the request didn’t answer.
Authorship note: this text followed the same process. The position and the decisions are mine; agents wrote and reviewed it from the spec, and I corrected and approved it.