Spec-Driven Development for ERP Requirements With AI Agents

Photo by Internet Archive Book Images via Wikimedia Commons (No restrictions)
It means writing the requirement down as testable rules before an AI agent writes any code, rather than describing the requirement in a prompt. For ERP that spec has three parts: one acceptance criterion per business rule, a worked-example table with real inputs and expected outputs, and a log of the questions the spec could not answer. The agent implements against the spec, and the spec is what gets reviewed and kept.
Write it so that a test can fail today and pass once the rule is implemented. Name real people rather than roles, state one assertion per line, and include the delegation window with its start and end dates. If you cannot express the assertion in a single clause, you are looking at two rules and should split them.
Because prose about approval limits gets nodded through in review while a concrete row gets argued about, and the argument is the point. Fill the boundary rows first: the amount sitting exactly on the limit, the approver who is also the requester, the invoice dated on the day the period locks. A table of comfortable mid-range examples reviews quickly and proves nothing.
Each entry holds the question, a named person who can answer it, the date it was asked, and the default behaviour the code will ship if no answer arrives. Naming a department instead of a person means nobody owns it. The default matters most, because it turns an open question into shippable behaviour plus a recorded risk, and the test asserts that default so the change is obvious when the real answer comes back.
Review the spec. A prompt is discarded when the session ends, while the spec is read months later by whoever is auditing a number that came out wrong. Keep the spec file in the repository next to the module it governs, so a change to a business rule shows up as a diff you can put in front of a finance person.

Photo by Internet Archive Book Images via Wikimedia Commons (No restrictions)
Key Takeaway
ERP requirements arrive as edge cases, not specifications. Before prompting an agent, write one testable acceptance criterion per rule, a worked-example table carrying the boundary rows finance argues about, and an ambiguity log naming who owns each unanswered question. The spec outlives the prompt, so the spec is what gets reviewed.
The requirement arrived as three things in one afternoon: a spreadsheet of purchase requests that had routed to the wrong approver, a WhatsApp thread where the branch manager explained which of them were fine anyway, and a meeting where finance said the limit was five million rupiah — except at month end. None of it was a specification, and no amount of prompting turns it into one.
This post is about the part of spec-driven development that happens before any agent is involved: turning that material into acceptance criteria a test can fail, a worked-example table finance will sign, and a log of the questions the spec could not answer. The examples are the ERP rules that cause the most argument in review — an approval hierarchy with a delegation path, a posting-period lock, tax rounding and landed-cost apportionment.
ERP requirements are rarely written down, because the business already knows them. They live in the exceptions, and the exceptions surface only when something routes to the wrong person. What lands on your desk is almost always one of four shapes, and none of them is a rule:
All four share the same defect: they describe behaviour by example without saying which examples are the rule and which are the exception. Hand any of them to an agent and you get something plausible, because plausible is all the input supports. The work is not prompting better. It is deciding, in writing and before the session starts, which of those rows are law.
This is the only filter I apply to every line of an ERP spec: can I write a test that fails today and passes when the rule is implemented? If not, the line is a preference, an aspiration or a summary. Keep it in the background section and stop calling it a requirement. ISO/IEC/IEEE 29148, the standard that superseded IEEE 830 in 2011, asks for the same thing in its own vocabulary — individual requirements are to be necessary, appropriate and unambiguous.
# specs/purchasing/pr-approval.feature
# Wrong: nothing in this line can fail, so it is not a rule yet.
# Then high-value requests should be approved faster
# Right: one rule, one assertion per line, real people, a dated delegation.
Rule: A purchase request above the branch limit needs finance approval
Background:
Given branch JKT-01 has an approval limit of 5000000 IDR
And Rina is the finance head for JKT-01
And Rina has delegated approval to Budi from 2026-09-14 to 2026-09-21
Scenario Outline: Routing by amount, delegation and self-approval
Given a purchase request for <amount> IDR raised by <requester>
When the department manager approves it
Then the next approver is <next_approver>
And the audit trail records <trail>
Examples:
| amount | requester | next_approver | trail |
| 4500000 | Sari | none | manager only |
| 5000000 | Sari | none | manager only, inclusive |
| 40000000 | Sari | Budi | delegated from Rina |
| 40000000 | Budi | Finance Director | delegate raised it |Gherkin is not the point here; the shape is. One rule per block, one assertion per line, and named people rather than roles. Writing that Rina has delegated approval to Budi from 14 to 21 September forces the delegation window into the spec, and the window is exactly what implementations forget: a delegation with no end date is a permanent second approver that nobody audits until an auditor asks who signed.
Write the assertion before you write the rule text. If you cannot state the assertion in one clause, the rule is two rules — split it. An approval limit and a month-end freeze sound like one sentence in the meeting, and they are two independent rules with two different owners in the spec.
The table is the artefact I bring to the review, not the prose. Prose about approval limits gets nodded at; a row saying a request for exactly 5,000,000 is approved by the manager alone gets argued about, and the argument is the deliverable. Cucumber's Example Mapping calls these the green cards, concrete examples sitting under each blue rule, and the claim matches what happens in an ERP review: examples expose disagreement that rule statements hide.
| Input | Expected result | Why this row is in the table |
|---|---|---|
| PR for 4,500,000 IDR, branch approval limit 5,000,000 | Department manager approves, no finance step | The happy path, so a routing regression is visible immediately |
| PR for exactly 5,000,000 IDR | Manager approves alone — the limit is inclusive | Finance said up to five million; the first implementation read it as under five million |
| PR for 40,000,000 IDR, finance head delegated to the controller for one week | Controller approves; the audit trail names both people and the window | A delegation with no end date becomes a permanent second approver |
| PR for 40,000,000 IDR raised by the named delegate | Routes one level above, never back to the delegate | Self-approval is a control, not a preference, so it needs its own row |
| Two invoice lines of 1,000,005 IDR at the 11 percent rate on the item master | Tax of 220,002 IDR, rounded half up on each line | Rounding the invoice total once instead gives 220,001, and the difference never reconciles |
| Supplier invoice dated 30 September, period already locked | Posting refused; it posts to October with the document date preserved | The boundary finance asks about first, in every single review |
Every row in that table exists because someone got it wrong once. The boundary rows do the work: the amount sitting exactly on the limit, the approver who is also the requester, the invoice dated on the day the period closes. A table of comfortable mid-range rows proves nothing and reviews in thirty seconds, which is precisely why writing one feels productive.

Every ERP module spec I have written produced more questions than rules on the first pass, and the questions turned out to be the valuable half. Example Mapping makes the same move with its red cards — as the Cucumber write-up puts it, you have just turned an unknown unknown into a known unknown. The ERP-specific hardening is that each question gets an owner, a date and a default.
# specs/purchasing/pr-approval.ambiguities.yaml
# Every question the worked-example table could not settle. Read in review.
- id: AMB-01
question: 'Does up to 5,000,000 include exactly 5,000,000?'
owner: Rina, Finance Manager # a person, never a department
asked: 2026-09-02
due: 2026-09-05
default: 'inclusive - the highest amount a manager may approve alone'
blocks: false # the default ships, and the test asserts it
- id: AMB-02
question: 'When the named delegate raises the request, who approves it?'
owner: Rina, Finance Manager
asked: 2026-09-02
due: 2026-09-05
default: none # no safe default exists for this one
blocks: true # self-approval is a control, not a preference
- id: AMB-03
question: 'Does the month-end freeze stop approvals, or only posting?'
owner: Andri, Controller
asked: 2026-09-02
due: 2026-09-08
default: 'posting only - approvals continue through close'
blocks: falseTwo fields make this a working document rather than a list of complaints. The owner field names a person, because a question owned by finance is owned by nobody. The default field states what the code will do if no answer ever arrives, which converts an open question into shippable behaviour plus a recorded risk — and the test asserts the default, so on the day the real answer comes back the failing test tells you exactly what to change.
The failure mode is a spec written after the code. It reads like a specification, passes review, and is in fact documentation of whatever got built, including the parts nobody decided. You can spot one by its missing boundary rows: a spec written afterwards has none, because the implementation already answered every boundary question silently.
Two classes of rule cause most of the post-go-live arguments, and neither is guessable from the surrounding code. The first is the posting-period boundary. Odoo, as a documented example, ships two of them: a Lock Everything date that prevents modifying posted entries and posting new ones on or before that date, and an irreversible Hard Lock date that, in Odoo's words, cannot be changed or overridden regardless of access rights. Which of those your rule means is a business decision, and the period is closed does not say.
The second is rounding, where a specification earns its keep, because the arithmetic is decided by where you round rather than how. Two invoice lines of Rp 1,000,005 at the 11 percent rate configured on the item master come to 220,002 rupiah when each line is rounded half up, and 220,001 when the invoice total is rounded once. One rupiah, on every invoice, and a reconciliation task that never closes. Landed-cost apportionment has the same shape with the sign reversed.
// specs/purchasing/landed-cost.spec.ts - written before the module existed.
//
// Rule LC-03: import freight is apportioned across receipt lines by line
// value, rounded half up to the rupiah, and the rounding residual lands on
// the highest-value line so the apportioned total equals the freight exactly.
const halfUp = (n: number) => Math.floor(n + 0.5);
function apportion(freight: number, lineValues: number[]): number[] {
const total = lineValues.reduce((a, b) => a + b, 0);
const shares = lineValues.map((v) => halfUp((freight * v) / total));
// Naive rounding invents money: three equal lines of a 2,000,000 freight
// each round to 666,667, which sums to 2,000,001. The extra rupiah has to
// be taken back from one named line, decided once, in the spec.
const drift = freight - shares.reduce((a, b) => a + b, 0);
shares[lineValues.indexOf(Math.max(...lineValues))] += drift;
return shares;
}
test("LC-03 puts the residual on one named line, not on every line", () => {
const shares = apportion(2_000_000, [1_000_000, 1_000_000, 1_000_000]);
expect(shares).toEqual([666_666, 666_667, 666_667]);
expect(shares.reduce((a, b) => a + b, 0)).toBe(2_000_000);
});The comment in that test is the specification. Naive rounding invents one rupiah of freight, so the residual has to land somewhere on purpose. Which line receives it is genuinely arbitrary — highest value, first line, last line are all defensible — but it has to be written down, because otherwise three developers pick three different arbitrary answers and the inventory valuation stops tying out to the supplier invoice.
A prompt is discarded at the end of the session. The spec is read by the next person, and on an ERP module the next person is usually auditing a number that came out wrong in a month you have long forgotten. GitHub's Spec Kit takes this to its logical end, describing a process in which specifications become executable and directly generate working implementations rather than just guiding them, with specify, plan, tasks and implement as separate commands. What I take from it is narrower: the reviewable unit is a spec file in the repository, next to the code it governs.
In practice the spec lives in a specs folder alongside the module, the ambiguity log sits beside it, and a change to a rule becomes a diff you can put in front of a finance person. I review the diff of the spec, not the transcript of the session that implemented it. The transcript tells me what the agent was told; the spec tells me what the business decided, and only one of those is still true next quarter.

The order matters more than the format. Written in this sequence a spec stays a specification; written in any other order it slowly becomes a description of the code.
The step that gets skipped under deadline is the fourth, and skipping it is the whole difference between a module with three documented open decisions and a module with three undocumented ones. Both ship on the same day. Only one of them can be audited afterwards.
Spec-driven development in ERP is not a prompting technique. It is the old requirements discipline — one testable criterion per rule, examples over prose, questions recorded against a named owner — done before an agent is allowed to write anything, because an agent will cheerfully implement an ambiguity instead of reporting it. Write the spec so the failing test comes first, and the implementation stops being the hard part.
Sources and further reading