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.
0. Set up
Section titled “0. Set up”uv sync --frozenmake testmake check-canonmake 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:
openregs text art_5__para_2 --as-of 2025-06-01and the terms whose definitions were in force that day, which is what the atom’s
applies_to has to be true of:
openregs defs --eid art_5__para_2 --as-of 2025-06-01If those two disagree with your reading, stop here. The rest of the pipeline is mechanical; this step is not.
2. Propose atoms
Section titled “2. Propose atoms”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:
openregs atomize --unit /akn/eu/act/reg/2024/1 --eid art_5__para_2 --model fixture-model --as-of 2025-06-01 --dry-runThe 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:
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:
openregs validate atom regimes/fixreg/atoms/FIXREG-Art5.3-Ob1.yamlmake check-canonTwo things reviewers should never have to catch by eye, both mechanical:
quoted_textmust equalsource_text[char_start:char_end], byte for byte.atomizeverifies 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, asuperseded_bypointer, and an entry inregimes/<regime>/DEPRECATIONS.yaml. Follow one with:
openregs resolve FIXREG-Art5.2-Ob14. The pull request
Section titled “4. The pull request”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:
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-maintainersOwnership comes from .github/CODEOWNERS, which is generated from
MAINTAINERS.yaml:
make gen-codeownersNever edit CODEOWNERS by hand; make test fails when it and the roster disagree.
5. Checks
Section titled “5. Checks”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:
openregs review check --repo <path-or-url> --sha <sha> --context check-canon --result success --as openregs-ciA 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.
6. Review
Section titled “6. Review”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:
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-botproposes; 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 intofixreg. - 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.
7. Merge
Section titled “7. Merge”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, typecheckMerged-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.
8. After the merge: graph, then release
Section titled “8. After the merge: graph, then release”An atom changes the cross-reference graph, so rebuild it and re-check the canon:
openregs graph build --regime fixregmake check-canonA 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:
openregs release build --regime fixreg --tag 2025.04make build TAG=2025.04openregs 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:
openregs release keygen --out ~/.openregs/keysopenregs release sign regimes/fixreg/releases/2025.04 --key ~/.openregs/keys/cosign.key \ --run-id "$GITHUB_RUN_ID"openregs verify --release fixreg@2025.04sign 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:
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:
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:
openregs serve --release fixreg@2025.04 --path vendor/fixreg/2025.04serve 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:
openregs diff fixreg@2025.02..fixreg@2025.04openregs diff fixreg@2025.02..fixreg@2025.04 --jsonopenregs diff fixreg@2025.02..fixreg@2025.06Both 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:
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:
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:
openregs compile --release fixreg@2025.04 --lang python --out src/regsopenregs compile --release fixreg@2025.04 --lang typescript --out src/regsEach 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.
If your document was quarantined instead
Section titled “If your document was quarantined instead”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:
openregs quarantine --quarantine <path> listopenregs 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 built | What 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.
Sign your work
Section titled “Sign your work”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:
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:
git commit --amend --signoff --no-editgit 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.
Why this project asks
Section titled “Why this project asks”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.
What is checked, and what is not
Section titled “What is checked, and what is not”.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.
House rules
Section titled “House rules”These are the ones that get pull requests sent back:
- Never edit a fixture, a golden file or
fixtures/MANIFEST.yamldigest 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. - 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.
- Ids are permanent — atom ids and eIds alike. Retire, never reuse.
- Determinism. Same input, byte-identical output. Sort keys, derive timestamps from the commit, never let map order leak into a file.
- Purity.
parse()andnormalize()touch no network, no wall clock, no randomness; onlydiscover()andfetch()reach the outside world. - Offline tests. No test may contact a real regulator endpoint. Network behaviour is exercised against local test servers, and git against local bare repositories.
- Provenance. Every canonical text traces to a
SOURCES.locksha256; every atom’squoted_textequals its source slice exactly. - Sign off every commit —
git 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:
uv run ruff check .uv run ruff format --check .uv run mypy --strict tooling/make check-licensesmake check-canonmake testBoth 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