Skip to content

Contributing to OpenRegs

The thing this project produces is an answer about what the law requires, with a citation attached. A wrong answer with a citation is worse than no answer, so the whole contribution process is built around one rule: a machine proposes, a person decides, and the record says which person.

This document walks one obligation atom from the text it is read out of to the release that ships it. Every command below exists today and is checked by tooling/tests/test_governance_docs.py, which runs --help on each one — a command that stops existing turns the test suite red rather than turning this document into fiction. Where a lifecycle step has no command yet, it says so in §9 instead of inventing one.

Who may say yes to what is GOVERNANCE.md. How long you should expect to wait is its §3.


Terminal window
uv sync --frozen
make test
make check-canon

make check-canon runs every canon rule over every regime: schema validation, id never-reuse, dangling graph references, temporal overlap, provenance spans, sources-lock integrity, diff classification. It must be green before you start, so that anything red afterwards is yours.

Examples below use the fixture regime FIXREG — Fixture Regulation (EU) 2024/1, Work IRI /akn/eu/act/reg/2024/1.


1. Read the law before proposing anything about it

Section titled “1. Read the law before proposing anything about it”

An eId names one text only once a date is beside it. Read the unit as it stood on the day you mean:

Terminal window
openregs text art_5__para_2 --as-of 2025-06-01

and the terms whose definitions were in force that day, which is what the atom’s applies_to has to be true of:

Terminal window
openregs defs --eid art_5__para_2 --as-of 2025-06-01

If those two disagree with your reading, stop here. The rest of the pipeline is mechanical; this step is not.


openregs atomize reads the unit, asks an extraction model what duties it states, verifies every quotation against the real text byte-for-byte, and stages the result. Look at it first — nothing is touched:

Terminal window
openregs atomize --unit /akn/eu/act/reg/2024/1 --eid art_5__para_2 --model fixture-model --as-of 2025-06-01 --dry-run

The dry run prints the atoms and the pull-request body it would push. Drop --dry-run and name the remote to actually stage them on a branch:

Terminal window
openregs atomize --unit /akn/eu/act/reg/2024/1 --eid art_5__para_2 --model fixture-model --as-of 2025-06-01 --remote <path-or-url>

This writes nothing to your working tree. It creates the commit and pushes atomize/<unit>/<as-of> to the remote — that branch is the pull request. The atom files carry method: llm_proposed, which records where the reading came from, never that it is right.

Hand-written atoms are welcome and go the same way: write the file under regimes/<regime>/atoms/, commit it on a branch, push it. The rules from §4 on do not care who wrote the proposal.


3. Check your own proposal before a human spends time on it

Section titled “3. Check your own proposal before a human spends time on it”

The proposal lives on the branch, not in your working tree, so check it out first — then validate the atom file and re-run the canon rules over the whole regime:

Terminal window
openregs validate atom regimes/fixreg/atoms/FIXREG-Art5.3-Ob1.yaml
make check-canon

Two things reviewers should never have to catch by eye, both mechanical:

  • quoted_text must equal source_text[char_start:char_end], byte for byte. atomize verifies it at proposal time and the canon’s provenance rule verifies it again at merge time.
  • Ids are permanent. Never reuse an atom id for a different duty. An atom that is wrong is retired with status: deprecated, a superseded_by pointer, and an entry in regimes/<regime>/DEPRECATIONS.yaml. Follow one with:
Terminal window
openregs resolve FIXREG-Art5.2-Ob1

There is no forge in the loop and none is needed: a pull request is a branch on the remote, an approval is a ref the approver pushes, a check result is a ref the ci identity pushes. Ask what stands behind a branch — including who owns what it changes:

Terminal window
openregs review status --repo <path-or-url> --branch atomize/<unit>/<date>
branch atomize/eu-act-reg-2024-1-art_5__para_2/2025-06-01 @ 4f2a9c1b6d07
base 1a0b2c3d4e5f contains the tip
approvals: none
checks green: none
checks missing: lint, typecheck, test, check-canon
changed paths and their owners:
regimes/fixreg/atoms/FIXREG-Art5.2-Ob1.yaml @openregs/fixreg-maintainers

Ownership comes from .github/CODEOWNERS, which is generated from MAINTAINERS.yaml:

Terminal window
make gen-codeowners

Never edit CODEOWNERS by hand; make test fails when it and the roster disagree.


Four contexts must be green at the commit being merged, published by the ci identity: lint, typecheck, test, check-canon. In CI the workflow publishes them. Against a local bare remote, by hand:

Terminal window
openregs review check --repo <path-or-url> --sha <sha> --context check-canon --result success --as openregs-ci

A red result is a durable record, not a draft: --result failure writes a ref of its own, and while it stands the merge is refused even if a green ref for the same context also exists. The way past it is new work on a new commit, not a re-run.

Two more jobs run after the merge and again on the nightly schedule, on main only: the end-to-end pipeline test, and make bench — the four performance budgets in config/perf-budgets.yaml, which fails the build when one is exceeded and publishes dist/bench/report.md as an artifact either way. It is not part of make test, because a wall-clock threshold in the test suite is a flake waiting to happen; run it by hand when you have touched the answer path, the release builder or the bundle’s schema. docs/runbooks/ci.md says what to do when it goes red, and moving a limit to make it green is not on the list.


If you are the reviewer, read the proposed atom against the text it cites — §1’s two commands are how you get that text — and then:

Terminal window
openregs review approve --repo <path-or-url> --sha <sha> --as <your-handle>

What you are actually saying, and what it costs:

  • The approval names one commit. Push more work and it is gone, structurally: an approval of an earlier sha was never an approval of this one.
  • You cannot approve your own branch, and a machine identity cannot approve at all. openregs-bot proposes; it holds no review authority.
  • Being a maintainer is not enough — you must own the paths the branch changes. An approval from the team that owns spec/ does not admit an atom into fixreg.
  • Changed your mind before the merge: openregs review approve --repo <path-or-url> --sha <sha> --as <your-handle> --withdraw

Expected turnaround for an atom PR is in GOVERNANCE.md §3 (first response 2 business days, resolution 5 business days), and what to do when it passes is §4.


Terminal window
openregs review merge --repo <path-or-url> --branch atomize/<unit>/<date> --as <your-handle>

The merge commit’s trailers are written from the approval refs on the remote and re-derived by the remote when it arrives:

Merge branch 'atomize/eu-act-reg-2024-1-art_5__para_2/2025-06-01'
Reviewed-by: Lieve Verheyen <l.verheyen@openregs.invalid>
Checks-passed: check-canon, lint, test, typecheck
Merged-by: Caesar Mukama <mcaesar@openregs.io>

A merge commit claiming a reviewer who did not approve that commit is refused, so “who reviewed what” is a checked fact rather than a convention. Every refusal names its rule; the table of the common ones is in docs/runbooks/atom-review.md §6.


An atom changes the cross-reference graph, so rebuild it and re-check the canon:

Terminal window
openregs graph build --regime fixreg
make check-canon

A release compiles the canon at one commit into what consumers use — a SQLite bundle, a graph index, embeddings, generated constants and a diff manifest against the previous tag:

Terminal window
openregs release build --regime fixreg --tag 2025.04
make build TAG=2025.04
openregs release verify regimes/fixreg/releases/2025.04

(The make target is the same builder; use whichever reads better.) A release is a function of a commit and of nothing else — the working tree is never read, the wall clock never consulted — so rebuilding a tag from the sha its release-meta.yaml records reproduces its bytes. openregs release verify recomputes every digest and compares. The full procedure, including what was pinned to make the SQLite bundle byte-identical across machines, is docs/runbooks/release-build.md.

A release nobody signed is a directory of bytes with a manifest that whoever changed the bytes could have rewritten in the same edit. Signing closes that, and verification is what a consumer runs before using anything:

Terminal window
openregs release keygen --out ~/.openregs/keys
openregs release sign regimes/fixreg/releases/2025.04 --key ~/.openregs/keys/cosign.key \
--run-id "$GITHUB_RUN_ID"
openregs verify --release fixreg@2025.04

sign writes attestation/ beside the artifacts: a cosign-format signature per file, a SLSA provenance statement linking the tag to the commit, the CI run and every snapshot sha256 in SOURCES.lock, and transparency-log entries with the inclusion proofs and the signed checkpoint they are proven against. verify then checks all of it — signatures, digests, the provenance chain and log inclusion — entirely offline, and refuses a key that is not in config/trust.yaml. The attestation/ directory is gitignored on purpose: a build is reproducible, a signature is not. The procedure is docs/runbooks/release-sign.md.

A signed release is then published through the channels a consumer already has — openregs-data-<regime> on PyPI, @openregs/data-<regime> on npm — each wrapping corpus.sqlite, release-meta.yaml verbatim and the whole attestation/ directory, so the signature travels with the data:

Terminal window
openregs release publish --release fixreg@2025.04 \
--path fixtures/registry/fixreg/2025.04 --mode dry-run --out dist/packages

--mode dry-run writes both packages into a registry on the filesystem — a PEP 503 index pip install --index-url file://… installs from, and an npm registry directory a local server answers packument requests from — and uploads nothing. --mode live additionally uploads, and fails if a credential is missing rather than skipping the upload. The release is verified before a byte of it is read, an unsigned one is refused, and --out may never point inside the release: every file in a release is signed, so an unsigned file added to one fails verification for everybody downstream. The procedure is docs/runbooks/release-publish.md.

On the consumer’s side, a release is brought in with pull, which runs those same checks over the copied bytes before any of them reach the target path:

Terminal window
openregs pull fixreg@2025.04 --from /mnt/openregs-registry --into vendor/

A registry is a directory laid out as <registry>/<regime>/<tag>/; the release lands at vendor/<regime>/<tag>/. Anything that fails a check is not unpacked — the staging directory is removed and the target is left as it was — and pulling the same release twice re-checks what is on disk and writes nothing.

That vendored release is then served with no further ingredients:

Terminal window
openregs serve --release fixreg@2025.04 --path vendor/fixreg/2025.04

serve re-runs the same checks before it serves a byte (and before it offers the MCP transport), refuses a release that fails one — naming the failing check — and stamps every response with what was checked: verification: verified for a signed release, digests for this checkout’s own unsigned build, skipped for the one case that gets past the gate, --insecure-skip-verify, which is never right in production.

The socket binds first, so GET /healthz answers 200 from boot while GET /readyz answers 503 until a verified release is loaded — and until it is, every other route answers 503 too, so a bound listener that is not ready serves no regulation. Bearer tokens, per-token rate limits, CORS origins and the SIGTERM drain come from config/serving.yaml (--config reads another file); see docs/runbooks/deploy.md. Every failure, from any layer, is {"error": {"code", "message", "request_id"}}, and that request_id is on the X-Request-Id header and in the log line for the request.

To see what moved between two built releases:

Terminal window
openregs diff fixreg@2025.02..fixreg@2025.04
openregs diff fixreg@2025.02..fixreg@2025.04 --json
openregs diff fixreg@2025.02..fixreg@2025.06

Both releases are read from their own corpus.sqlite, so the answer is what a consumer holding them would see — and diffing a tag against its predecessor reproduces the <tag>.diff.json that release shipped, byte for byte.

Without --json the changes are grouped by classification, substantive first, each with the citation the manifest gives it and the words on both sides: for an atom the source sentence the obligation was extracted from and the unit it cites, for a unit its Work IRI, eId and validity window. That grouping is the reader’s whole question — what do I have to look at — and it is a rendering, never a filter: every entry of the manifest appears under one of the headings.

A range spanning more than one release move is aggregated from the manifests of the releases it spans, one step per published tag, and never recomputed end to end: a single comparison of the two ends would tell you the threshold is now EUR 10000 but not which release moved it. Every entry names the release that reported it — [2025.04] in the human form, "release" in the JSON — and the JSON also carries a releases list with each step’s tag, dates, commit and manifest filename. A one-step range emits that release’s own manifest with nothing added, so openregs diff <previous>..<tag> --json stays pipe-compatible with <tag>.diff.json.

The releases in a range have to be the chain that was actually published: if a release’s own release-meta.yaml says its manifest was computed against some other tag, the command refuses rather than aggregate an overlap twice.

To ask what that move does to a consuming firm’s controls:

Terminal window
openregs impact --diff <regime>@<old-tag>..<regime>@<new-tag> --map <controls.yaml>

openregs impact --diff fixreg@2025.02..fixreg@2025.04 --map fixtures/controls/good.yaml exits 1, because FIXREG-Art5.3-Ob1 is mapped to a control and its change was substantive. It exits 0 when the only mapped atoms that moved moved editorially, when the change was waived with a written rationale, and when nobody had mapped it — an unmapped change is a warning here and a gap in openregs coverage, because there is no control to review and no owner to tell. Ticket-ready JSON goes to stdout on every run, the summary a person reads to stderr, and every classification is the release’s own: read from the diff manifest it shipped, never recomputed.

That firm does not run either command by hand. openregs bump is what its CI runs when a release is published — it moves the release: pin in its own controls.yaml onto a branch, runs the same impact report for that exact move, and writes the request body from it:

Terminal window
openregs bump --repo <consumer-checkout> --to <regime>@<tag> --from <registry>

The exit code is impact’s, unchanged, so a red check means a mapped duty moved and a control owner has to look before the bump merges. The reusable GitHub Action in .github/actions/release-bump/ and the GitLab job in docs/integrations/gitlab-ci.md are two wrappers around that one command; both are exercised offline against the two consuming repositories in fixtures/consumers/.

To turn a release’s executable values into constants the consuming code depends on at build time:

Terminal window
openregs compile --release fixreg@2025.04 --lang python --out src/regs
openregs compile --release fixreg@2025.04 --lang typescript --out src/regs

Each constant is emitted with its value, unit, atom id, source citation and release tag in a docstring (Python) or a JSDoc block (TypeScript), and a test is generated beside the module asserting every value against a release: point it at the release the module was compiled from and it passes, point it at a later release that moved a value (OPENREGS_RELEASE=fixreg@2025.06, or OPENREGS_RELEASE_DIR=<a pulled release>) and it fails, naming the old value, the new one and the atom. That failure is the product — a regulatory value is a build-time dependency, and a dependency whose value changed must break a build rather than a bank. The values are those in force on the release’s own as-of date, read from its corpus.sqlite after every digest it declares has been recomputed, and nothing generated carries a clock.

A source document a validation gate refused never reaches atomization; it waits in the quarantine with its failure report and the snapshot it was made from:

Terminal window
openregs quarantine --quarantine <path> list
openregs quarantine --quarantine <path> claim <item> --by <your-handle>
openregs quarantine --quarantine <path> open <item> --remote <path-or-url>
openregs quarantine --quarantine <path> close <item>

Prefer fixing the mapper and replaying (openregs replay) over hand-correcting one document: a mapper fix reaches every snapshot it touched, a hand correction reaches one. A hand-corrected document is admitted only under an explicit human_authored override (openregs quarantine ... annotate). The workflow is docs/runbooks/quarantine-triage.md, and its review clock is quarantine-item in GOVERNANCE.md §3.


9. Steps of the lifecycle that have no command yet

Section titled “9. Steps of the lifecycle that have no command yet”

Stated plainly so that nobody goes looking for them. As of this document the CLI has exactly these commands:

validate resolve ingest replay fetch-expression normalize amendments quarantine consolidate verify index graph defs atomize text review release diff coverage eval impact bump feed serve pull backup compile

The spec names these as well, and they are not built yet:

Not yet builtWhat to do meanwhile
(none)every command the spec names is built

If a step you need is on that list, the honest move is to say so in your pull request rather than to work around it silently.


Every commit in a pull request carries a Signed-off-by trailer naming its author:

Signed-off-by: Your Name <your@email>

git writes it for you. Set the identity once, then pass -s to every commit:

Terminal window
git config user.name "Your Name"
git config user.email "your@email"
git commit -s -m "graph: an edge is not a citation until both ends resolve"

Forgot it on the last commit, or on all of them:

Terminal window
git commit --amend --signoff --no-edit
git rebase --signoff <base-branch>

Both rewrite the branch, so push it again with --force-with-lease. The trailer has to name you — the address must be the commit author’s or, when a maintainer carries somebody else’s patch, that maintainer’s beside the author’s. A sign-off by a third party certifies nothing about the person whose work it is, and the check says so rather than accepting it.

The trailer is the Developer Certificate of Origin 1.1 — the same one line the kernel uses. Adding it is a statement, in your own name, that you wrote the patch or otherwise have the right to submit it under this repository’s licence, and that you understand the contribution and the record of it are public and permanent.

That matters more here than in most projects, because provenance is the product. Every canonical text traces to a SOURCES.lock sha256; every atom’s quoted_text is checked byte-for-byte against the source it cites; every release is signed and its chain re-verified offline by a stranger who trusts none of us. A contribution nobody can say where it came from is a hole in exactly the chain this project sells — and the one link in it that no digest can close, because “who had the right to give us this” is a fact about a person, not about bytes. The sign-off is that link, in the same place as every other trailer the record keeps.

It is a DCO and not a contributor licence agreement on purpose. A DCO is a statement you make; a CLA is rights you assign, and assigning rights takes a legal review that the people this project most needs — domain experts inside regulated firms, reviewing atoms — cannot get in an afternoon. The trade is real and worth stating: a DCO grants no right to relicense your contribution later, so this choice narrows what the project can do with its own licence. Why that trade was taken, and the open question it constrains, is docs/decisions/d3-license-posture.md; GOVERNANCE.md §6 records where the decision stands.

.github/workflows/dco.yml reads only the commits your pull request adds — everything already on the base branch is out of range by construction, so the rule applies from the day it landed forward. Commits made before it are not signed off and are not rewritten: a rewrite would restamp commits that published releases record, and a release is a function of the commit it names.

Merge commits are skipped, because they author nothing; the commits a merge carries are read on their own account, so merging an unsigned branch fails on that branch’s commits, by name.

The check runs on GitHub. The pre-receive hook does not enforce it — see GOVERNANCE.md §5 for the honest list of which rules hold in which of the two places.


These are the ones that get pull requests sent back:

  1. Never edit a fixture, a golden file or fixtures/MANIFEST.yaml digest to make a test pass. Fixtures are the spec. If a fixture and the code disagree, at most one of them is wrong, and it is usually not the fixture.
  2. Never weaken, skip or delete a check to get past it. Fix the code. A check that has been softened to go green is worse than no check, because it is still believed.
  3. Ids are permanent — atom ids and eIds alike. Retire, never reuse.
  4. Determinism. Same input, byte-identical output. Sort keys, derive timestamps from the commit, never let map order leak into a file.
  5. Purity. parse() and normalize() touch no network, no wall clock, no randomness; only discover() and fetch() reach the outside world.
  6. Offline tests. No test may contact a real regulator endpoint. Network behaviour is exercised against local test servers, and git against local bare repositories.
  7. Provenance. Every canonical text traces to a SOURCES.lock sha256; every atom’s quoted_text equals its source slice exactly.
  8. Sign off every commitgit commit -s, and the trailer names you. It is the one link in that chain no digest can close, because it is a fact about a person rather than about bytes. See Sign your work above.

Before you push:

Terminal window
uv run ruff check .
uv run ruff format --check .
uv run mypy --strict tooling/
make check-licenses
make check-canon
make test

Both ruff commands, not just the first — they are different checks, and only the second catches formatting.

make check-licenses runs in the lint context, so a new source file without the SPDX-License-Identifier: Apache-2.0 header on its first line fails the pull request. make fix-licenses writes it for you — above the module docstring, never in place of it. It will not rewrite a file that declares a different licence: importing third-party code is a decision for a maintainer, and the gate says so instead of quietly relicensing it. See §“Licensing” in README.md for the split itself.

Rendered from openregs/openregs@cbe9615:CONTRIBUTING.md