FifeRouter

4 September 2026 · product-as-code process

Product-as-code — intent that fails the build

Features, specs and decisions are files in the repository, validated in CI. A pull request that breaks the graph does not merge.

product/ is a directory of markdown files with frontmatter. Features, specs, decisions, constraints, business rules, terms, and a roadmap that references them. It is validated on every pull request, and a broken graph fails the build.

That is the whole idea. The interesting part is what "broken" means.

What the validator checks

Structure, first: a roadmap item must resolve to a feature file, a feature must appear in exactly one horizon, a links.spec must exist on disk, a governed_by must resolve to a decision.

Then the part that does the work — every acceptance criterion names tests, and every test reference must resolve to a real test:

acceptance:
  - text: >-
      A refund never takes back credit that was never given.
    tests:
      - tests/site/test_credits.py::TestReversalNeverExceedsCredit::test_a_refund_with_no_credit_behind_it_is_refused

Rename that class and the build fails with PAC-902. Not a warning in a report nobody reads — a red check on the pull request that renamed it.

Why that specific check earns its place

It is the one that catches drift in the direction drift actually happens.

Nobody edits a spec to make it wrong. What happens is that the code changes and the document quietly stops describing it — and there is no natural moment at which anybody re-reads a paragraph about a feature they are not currently working on.

A test reference is a claim that is resolvable. It is the one part of a specification a machine can check against reality, so it is the thread that keeps the rest attached.

It caught us twice in one day. Moving the starting grant from sign-up to email verification renamed two test classes, and the gate failed until the feature file was corrected — which forced re-reading the acceptance text, which turned out to say "a new account receives" where it now had to say "a verified account receives". That sentence was the thing that changed, and the mechanical check dragged a human to it.

Authority flows one way

product/ defines what. Delivery proves how, and reports contradictions back as diagnostics for a person to act on. Delivery never silently rewrites intent.

The distinction matters because the tempting automation is the other direction: generate the spec from the code, so it can never be out of date. That produces documents that are always accurate and never normative — a description of what was built rather than a statement of what was agreed, and therefore useless for noticing that what was built is not what was agreed.

The definition of done

status: shipped is gated. A feature claiming it needs a PR link, a code link, a resolving test per acceptance criterion, a deployment manifest, and a monitor that watches it.

Everything in this repository is building or idea. Nothing is shipped, including features that have been in production for weeks and are working.

That looks like pedantry and is the point. shipped means someone can tell if this breaks, and until there is a named probe in the monitoring script, nobody can. Being unable to claim it is the mechanism working.

What it costs

Every feature is a file before it is code, and the file is reviewed. Adding payment-reversals meant writing why refund could not carry a negative amount before writing the migration that added reversal.

That is slower. It is also where the actual thinking happened — the argument that a kind with two directions turns "unrepresentable" into "we are careful" was made in a spec, and the code that followed was the easy part.

The engine is not ours

The validator is KeelSpec, pinned exactly. This repository's own rules — the definition of done, roadmap resolution, traceability — are a plugin, not a fork. Diagnostic codes 0xx06x are the library's; 9xx are ours.

Codes are an API: retire, never renumber. There is a decision record about the one renumbering that adopting the library forced, and why it happens exactly once.


← All posts