Skip to main content
BlogERP

Spec-Driven Development for ERP Requirements With AI Agents

ERP requirements arrive as edge cases, not specs. How I turn them into testable acceptance criteria, a worked-example table and an ambiguity log.

Matthews Wong

Software Engineer

September 202612 min read
Spec-Driven Development for ERP Requirements With AI Agents

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.

What an ERP requirement looks like when it arrives

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:

  • A spreadsheet of edge cases — rows someone collected because the current system handled them wrong, with no statement anywhere of what right would have looked like.
  • A chat thread, where the decision is real but the reasoning is spread across the scrollback and two people in it disagree without noticing.
  • A sentence with an except in it. Approve anything up to five million, except at month end, is two independent rules and a boundary nobody has dated.
  • A screenshot of the old system, offered as the specification, including the bugs the team has quietly learned to work around.

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.

A rule nobody can write a failing test for is not a rule yet

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 worked-example table settles more arguments than the prose

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.

InputExpected resultWhy this row is in the table
PR for 4,500,000 IDR, branch approval limit 5,000,000Department manager approves, no finance stepThe happy path, so a routing regression is visible immediately
PR for exactly 5,000,000 IDRManager approves alone — the limit is inclusiveFinance 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 weekController approves; the audit trail names both people and the windowA delegation with no end date becomes a permanent second approver
PR for 40,000,000 IDR raised by the named delegateRoutes one level above, never back to the delegateSelf-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 masterTax of 220,002 IDR, rounded half up on each lineRounding the invoice total once instead gives 220,001, and the difference never reconciles
Supplier invoice dated 30 September, period already lockedPosting refused; it posts to October with the document date preservedThe 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.

Four stacked bands showing a vague requirement sentence becoming an acceptance criterion, then a worked example, then a failing test, each band tagged with who owns that step
Ownership changes at every step, and that is the part worth noticing: the sentence belongs to finance, the criterion to whoever writes the spec, the worked example goes back to finance for sign-off, and only the failing test belongs to the agent. Skipping the third band is how you get a correct implementation of the wrong rule.

The ambiguity log is the deliverable stakeholders actually engage with

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: false

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

Boundaries an agent cannot guess: period locks and rounding

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.

Why the spec, not the prompt, is the artefact worth reviewing

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.

Three panels comparing an answered requirement, an ambiguous requirement with a named owner, and an ambiguous requirement with no owner, listing what an agent does in each case
The middle panel is the state to aim for, not the left one — a written default plus a named owner is already enough to build against. The right panel is the only one that ships an unreviewed decision, and it surfaces at month-end close rather than in code review.

The order that keeps an ERP spec honest

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.

  1. Collect the edge cases as they arrive, unfiltered, and label each one rule or exception. Do not tidy them up yet — the mess is evidence.
  2. Write one acceptance criterion per rule, each with an assertion that can fail. Split anything containing the word except.
  3. Fill the worked-example table, boundary rows first: on the limit, on the lock date, on the rounding edge, on the self-approval case.
  4. Log every question the table could not answer, with an owner, a date and the default the code will ship if no answer arrives.
  5. Only then start the agent, and point it at the spec file rather than pasting the requirement into a prompt.

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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is spec-driven development for ERP requirements?

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.

How do I write an acceptance criterion for an ERP approval rule?

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.

Why does an ERP spec need a worked-example table?

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.

What goes in an ambiguity log and who owns it?

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.

Should I review the prompt or the spec when an agent builds an ERP module?

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.