001
missing required field 'definition'
Required
Your glossary, constraints, business rules, features and decisions usually live as hopeful markdown that quietly rots. FastPDLC turns them into code — typed artifacts, a validated reference graph, a compiled JSON bundle, and a CI gate that goes red the moment reality drifts.
pip install fastpdlc
Every team has the documents. Almost no team has a mechanism that notices when they stop being true.
One config file, plain markdown artifacts, and a command whose exit code is the whole contract.
There is no fixed schema. You name the collections, the required fields, the id prefixes, the allowed values, and which fields must resolve to other artifacts.
# product.config.yaml
product_dir: product
output: build/product.generated.json
types:
- name: terms
dir: terms
id_prefix: "TERM-"
required: [id, term, definition]
fields: [term, definition, see_also]
references:
- field: see_also
to: terms
Markdown with YAML frontmatter, one file per artifact, in your repo, reviewed in pull requests like everything else. The prose below the fence is yours.
<!-- product/terms/TERM-payment.md -->
---
id: TERM-payment
term: Payment
definition: An instruction to move
money between two parties.
see_also: [TERM-ledger]
---
The canonical unit of work in
the system.
Compile the bundle, commit it, and let CI prove it still matches the source. Non-zero exit means the pull request does not merge.
# .github/workflows/product.yml
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: tarvitave/fastpdlc@v0.1.0
with:
config: product.config.yaml
plugin: product_hooks.py
Every finding carries a stable PAC-NNN
code. Codes are an API — CI, dashboards and humans match on the code, never the
prose. Existing codes are never renumbered.
An artifact is missing a field its type declares as required.
missing required field 'definition'
Schema
An id does not start with the id_prefix its type requires.
id 'payment' must start with 'TERM-'
Identity
The id and the filename disagree, so the artifact can't be found by either.
id 'TERM-payment' must match its filename
Identity
Two artifacts in one collection claim the same id. References become ambiguous.
duplicate id 'BR-idempotent' in type 'rules'
Identity
A reference field points at an artifact that does not exist. This is the one that catches renames and deletions before a reviewer ever opens the PR.
see_also 'TERM-leger' does not resolve to a terms id
Graph
A field value is outside the allowed set its type declares.
status 'in-progres' not in ['done', 'in-progress']
Schema
The committed bundle no longer matches the artifacts that produced it. Somebody edited the source and didn't rebuild, or edited the build and didn't touch the source. Either way, what you ship and what you wrote have parted company — and CI says so, in the diff, on the pull request.
build/product.generated.json is stale
— run: fastpdlc build (and commit it)
Plugin outputs are staleness-gated too, so a generated catalogue or a runtime manifest can never quietly fall behind.
Someone renames one term. Here is what happens with FastPDLC in the loop — and what would have happened without it.
An engineer decides TERM-payment was always the wrong word and
renames it to TERM-charge in a pull request. One file changed.
Looks harmless.
Three artifacts referenced the old id — two terms and a business rule. The graph no longer resolves, so the gate fails with the file and field named.
PAC-020 ×3Three see_also values updated, fastpdlc build run
once. The bundle regenerates deterministically — sorted keys, byte-stable
output, so the diff is exactly the change and nothing else.
The committed product.generated.json matches its sources again.
Green. The reviewer now reviews a decision, not a consistency puzzle.
The docs site, the in-app glossary, the internal catalogue and the context you hand an LLM all render from the same bundle. Nobody wrote a migration doc. Nobody had to remember.
The rename merges. Three documents keep pointing at a term that no longer exists. Nobody notices for eleven months, and by then two of them have been copied into a slide deck.
Real projects need more than schema. A plugin registers project-specific validators, enriches the bundle, and emits extra generated outputs — which is how a large codebase migrates onto FastPDLC with no loss of functionality.
links.code path actually exist on disk?
# product_hooks.py
from fastpdlc import register
def register(reg):
register("PAC-900", "links.code path does not exist")
@reg.validator
def code_paths_exist(bundle, config, root, report):
for f in bundle["features"]:
for path in f.get("code") or []:
if not (root / path).exists():
report.add("PAC-900",
f"missing {path}",
f["_file"])
@reg.bundle_transformer
def reverse_edges(bundle, config, root):
... # enrich the bundle in place
reg.extra_output("build/catalogue.json", render)
fastpdlc -p product_hooks.py validate
FastPDLC is the product-as-code engine of the pharthing / KibiPay payments platform. It was pulled out of a working system so any team could use it.
pharthing's CI runs fastpdlc validate as its sole product gate, via a plugin that adds its domain-specific checks. A byte-identical parity test proves the extracted engine produces exactly the bundle the original in-house one did — nothing was lost on the way out. That's the plugin system, doing real work, in production.
The copier template scaffolds the config, example artifacts and the CI gate.
--trust lets it run
fastpdlc build once so
the new repo is green before you've written a line.
pipx run copier copy --trust gh:tarvitave/fastpdlc my-product-repo
product.generated.json. Your docs site,
your app's in-product glossary, your internal catalogue and the context you feed an
LLM all render from that one bundle — so they can't disagree with each other.product.config.yaml. Terms and business rules are just what the quickstart
happens to use. Decisions, features, personas, risks, invariants, migrations — all the
same mechanism.9xx so they never collide with the core set. No fork, no patched engine —
this is how a large existing project migrates without losing any of its bespoke
checks.pip install fastpdlc, or use the
GitHub Action which installs it for you. What it validates is plain markdown with YAML
frontmatter, so the repository itself can be Go, TypeScript, Rust, Java or anything
else. Nothing about your build system has to change.PAC-060 possible. If the
bundle only exists inside CI, nothing can prove that what you ship matches what you
wrote. Committing it turns invisible drift into a visible diff on a pull request —
and makes the build deterministic and reviewable.Install it, declare two types, commit the bundle. The first
PAC-020 you catch will pay for the
afternoon.