A Table Cannot Stay Silent: Finding Contradictions in Requirements Before Any Code Exists
Part 5 of 9 in the series on agentic software development. Part 1, The Agent Writes the Code has the two settings, the numbers, and the ten sentences the series works through.
Two settings run through this series: a multi-product SaaS platform where I have to develop the requirements myself, and an event-driven service in a client project, built in eleven days with 806 tests, where the requirements came from outside. How I proceed depends heavily on which of the two I am in, and the difference sits almost entirely in the first phase.
Requirements from outside, or requirements to be developed
In a client project the requirements usually arrive from outside, and there are concrete interfaces already: message schemas, API specifications, existing systems to integrate with. That is a large advantage. The specification is the oracle: it is the thing that says what the correct answer is, and it was written by someone else. I can derive tests from it before a line of code exists, and when a test and the code disagree, there is a document to point at.
In open product work, a new product or a new module, nobody hands you the requirements. You develop them yourself: analyses of the domain, of legal requirements, of what users actually do. The analyses take the place of the specification, and the tables are built from them, with one difference: when a cell stays empty, there is nobody outside to ask. The risk there is not bad code. It is plausible features that nobody needs, a failure mode that gets its own part later in this series.
The rest of the process is the same in both settings. But the first phase is different, and the project case is worth describing in detail, because it shows most clearly what the combination of tables, tests and an agent can do.
The first step: analyse the specification, then translate it into tables
When I receive a specification for a service, the first thing I do is have the agent analyse it and translate it into equivalence class tables, using the Nanook skill. Nanook is a table engine: it reads decision tables from an Excel workbook and generates test cases from them. The skill teaches the agent the table format, so the translation is a task it can do on its own and I can review.
Two forms of table matter here.
- A decision table describes what something is. Fields, their classes (valid, boundary, missing, unknown enum value, and so on), and cases that cover combinations of classes. The engine computes what percentage of the combinations is covered and which ones are missing.
- A matrix table describes what something becomes. Rows are states, columns are events, and each cell is the reaction.
An entity with a schema gets a decision table. A lifecycle gets a matrix. Most services have both, and the service in question ended up with 13 sheets, of which 11 generate cases. The coverage figure is what makes the decision table more than a neat notation: the engine does not ask whether the cases look reasonable, it counts which class combinations have no row, and every missing combination is a question that still has to be answered by someone.
Why a table finds what prose hides
A table demands three things prose does not: every field needs classes, every combination needs a row, every row needs a result. Whoever fills that in, human or agent, can no longer stay politely vague. There is no cell for “usually” and no cell for “to be clarified” that the engine will not count as uncovered. Each of the three demands does a different job. Classes force a decision about what “missing” or “unknown” means for this particular field. Rows for every combination are where the race conditions and the odd pairings surface. A result for every row is where two documents that quietly disagree finally collide, because one row can only hold one answer.
Prose does not lie. It stays silent, exactly at the places where the work is. A table cannot stay silent.
This is the point where an agent reading prose falls short, and it is not a model problem. The agent can read a 40-page specification and summarise it perfectly. It will not tell you that page 12 and page 31 contradict each other in a case nobody spelled out, because nobody asked about that case, and a summary answers the questions the text raises, not the ones it avoids. Filling in a table forces the question for every single case, including the race conditions and the combinations that nobody thought about because nothing in the text mentioned them.
Eight things the tables found before any code existed
The designs for the service were good: twelve documents, written on the first day, cleanly cut. Building the tables from them still found the following.
- A contradiction between two of our own artefacts. A sheet claimed a record would be shown with a hint; the mapping rule excluded exactly that record. Both cited the same design decision.
- Seven filter criteria that had no effect, despite tests running against a real Postgres. An empty filter arrives correctly even if the implementation throws it away, and the existing test had been built around that gap. The table’s classes set / empty / multiple asked what happens for set.
- A combination with no answer. Priority 0 without a feeder was unclear in the domain. It is now an explicit exception in the table instead of a row that asserts a result nobody decided.
- A fourth cancellation case that was really its own state. In prose, a variant. In the matrix it reacts differently to three events, so it got its own row.
- Two numbers that did not match. The UI showed 50 rows per page; the
database design said
LIMIT 20. Both had been written down for days, in two documents. - A misnamed event. Filling in the matrix showed that a “deadline expired” event sends no message at all; it only changes what the UI shows. It was renamed after its real trigger. The wrong name would have produced an outbound message that must not exist.
- Database columns demanded by cells. Two columns exist because one matrix cell each requires them. No design had asked for them.
- “Ignore” separated from “error”. In prose both read as “nothing happens”. In the matrix they are two different reactions: an expected non-reaction versus a log entry.
None of these is a test finding. There was no code to test. Four of the eight came out of the matrix, where a state that reacts differently, an event that sends nothing, or a cell that needs a column the schema does not have cannot be written around. The rest came out of the decision tables, from a class that asked for a case the prose had skipped or from two documents that had to agree on one row. They are defects in the requirement, found by the act of writing the requirement down in a form that has no room for silence. The table is not a test artefact; it is a verification tool for the requirement. It costs about a day and finds things that would otherwise be found in production.
Three rules from the SaaS platform
The same approach runs through the SaaS platform, at larger scale. End-to-end tests across products are decision tables read as data by one shared runner; the e-invoicing rules are tables derived from pinned official specifications. Both have been described in earlier field reports, one on the platform’s test suite and one on the e-invoicing tables. Three rules proved essential there, and they apply to the project case just as well.
- The decision table is the point of truth. No test case without a table row. The table is never overwritten automatically; a generator that could rewrite it would make the row follow the code instead of the other way round.
- A test is derived from the specification, not from our code. The oracle is
the pinned spec artefact, never a constant from
src/. A test that reads its expected value from the code under test checks the code against itself, and passes for that reason alone. - A removed case becomes a finding in the same step. If a planned case cannot be executed because the application cannot do it, that is a defect to record, not a row to delete. The row is the only place where the gap is still visible.
The three rules share one idea with the eight findings above. The table is the place where a requirement has to commit, and everything that would let it avoid committing, an automatic rewrite, an oracle taken from the implementation, a quietly deleted row, is a way of letting the prose back in.
The rule this part comes down to: before any code exists, force the requirement into a form that cannot stay silent. Translate the specification into decision tables and matrices, let the engine tell you which combinations have no row, and treat every cell you cannot fill as a question for the people who wrote the specification, not as a row to leave out.
Previous: Part 4, What the Next Session Knows. Next: Part 6, Tests First, Then Implement All Plans.