Your product spec, compiled.

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
Get started
Zero-config CI gate Any language, any repo LGPL-3.0, link it freely
ci / product-as-code
fastpdlc validate ERROR PAC-020 product/terms/TERM-payment.md: see_also 'TERM-leger' does not resolve to a terms id ERROR PAC-060 build/product.generated.json is stale — run: fastpdlc build (and commit it) WARN PAC-030 product/features/FEAT-refunds.md: status 'in-progres' not in ['done', 'in-progress']   fastpdlc: terms 42, rules 18, features 39 — 2 error(s), 1 warning(s). echo $? 1

The rot

You wrote it down, but…

Every team has the documents. Almost no team has a mechanism that notices when they stop being true.

“The glossary contradicts the API docs.”
“Three features reference a rule we deleted in March.”
“The spec says v2. We shipped v3 last quarter.”
“Which of these four docs is canonical?”
product/spec.md
LAST TRUE: 14 MONTHS AGO

How it works

Declare it. Author it. Gate it.

One config file, plain markdown artifacts, and a command whose exit code is the whole contract.

01

Declare your types

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
02

Author the artifacts

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

Gate the build

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

The enforcers

Seven codes stand between you and drift.

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.

PAC-001

Required field

An artifact is missing a field its type declares as required.

missing required field 'definition' Schema
PAC-010

Id prefix

An id does not start with the id_prefix its type requires.

id 'payment' must start with 'TERM-' Identity
PAC-011

Filename match

The id and the filename disagree, so the artifact can't be found by either.

id 'TERM-payment' must match its filename Identity
PAC-012

Duplicate id

Two artifacts in one collection claim the same id. References become ambiguous.

duplicate id 'BR-idempotent' in type 'rules' Identity
PAC-020

Dangling reference

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
PAC-030

Enum violation

A field value is outside the allowed set its type declares.

status 'in-progres' not in ['done', 'in-progress'] Schema
PAC-060

Staleness — the one nobody else has

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.

Drift
A day in the life

Life of a pull request.

Someone renames one term. Here is what happens with FastPDLC in the loop — and what would have happened without it.

09:14

A rename lands.

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.

09:14

CI goes red before a human looks.

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 ×3
09:22

The graph gets fixed, not the prose.

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

09:23

Staleness clears.

The committed product.generated.json matches its sources again. Green. The reviewer now reviews a decision, not a consistency puzzle.

PAC-060 resolved
09:31

Merged — and everything downstream already knows.

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.

Without the gate

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.

Plugins

Your checks. Your codes. No fork.

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.

  • fn ValidatorsCross-file checks the config can't express — does this links.code path actually exist on disk?
  • Bundle transformersDerived fields computed at build time: reverse edges, rollups, denormalised views your renderer wants.
  • Extra outputsEmit a runtime catalogue or manifest alongside the bundle — and it is staleness-gated exactly like the bundle is.
  • 9xx Your own diagnostic codesRegister codes in a project range so they never collide with the core set. Your CI matches on them the same way.
# 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

Used in production

Extracted from a payments platform, not a demo.

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.

39
features under the gate
283KB
render bundle compiled
1
CI gate — the only one
0
bytes lost in extraction
Parity test

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.

Start in one command

A valid product-as-code repo on its first commit.

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
Read the quickstart View on PyPI
Questions

The obvious objections.

How is this different from Confluence, Notion, or a docs site?
Those store prose and trust people to keep it honest. FastPDLC stores typed artifacts with a declared schema and a reference graph, and it runs in CI. Nothing merges while the graph is broken. It's a compiler, not a wiki — and you can keep your wiki, rendering it from the bundle.
Does it replace my documentation site?
No. It produces 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.
My artifacts don't look like your glossary and rules.
There is no built-in schema. You declare your own collections, required fields, id prefixes, allowed values and reference edges in 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.
What if I need a check the config can't express?
Write a plugin. Register validators, bundle transformers and extra generated outputs, plus your own diagnostic codes in a project range like 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.
Does this only work in Python repositories?
The CLI is Python — 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.
Why commit the generated bundle? Isn't that a build artifact?
Because that's what makes 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.
Why LGPL-3.0?
Copyleft on FastPDLC itself, so improvements to the engine come back. It's the lesser GPL deliberately: importing it as a library, or running it in your CI, does not make your project LGPL. Your product artifacts and your plugins are yours.

Ship the gate this week.

Install it, declare two types, commit the bundle. The first PAC-020 you catch will pay for the afternoon.