Skip to content

Keep a repository current with the law

Pinning a release is the easy half. The hard half is the day a new one is published and somebody has to answer: does moving to it change a duty one of our controls discharges?

This guide builds that answer locally, command by command, and then hands it to CI. Everything here runs against fixreg, the fixture corpus, so you can follow it end to end today.

There is deliberately no separate pin file. A control mapping already has to state which corpus its atom ids were written against, so that field is the pin, and a bump rewrites that one line.

version: 1
organisation: Fixture Bank plc
release: fixreg@2025.02
controls:
- atom: FIXREG-Art5.3-Ob1
control: CTRL-INC-014
owner: incident-response@fixturebank.example
evidence: https://grc.fixturebank.example/controls/CTRL-INC-014
- atom: FIXREG-Art5.2-Ob1
status: not_applicable
rationale: >-
The regulated entity provides no critical services from its own balance
sheet; the register is maintained centrally by FixtureCo Services GmbH
under the intra-group outsourcing agreement recorded in GRC-2291.
Reassess if any critical service is onboarded to this entity.

Two dispositions, and the difference between them is the whole design:

  • Mapped — an atom a named control discharges, with an owner to tell and evidence to point at. When that atom changes substantively, the owner has to look.
  • Waived — status: not_applicable plus a written rationale. The decision was made once and recorded; a check that re-raised it every release would train its readers to ignore it.

An atom in neither list is a third thing: a gap. Nothing fails over it, because there is no control to review and no owner to tell — but it is reported, every time, in both commands below.

That mapping is fixtures/consumers/fixture-bank/controls.yaml in the engine repository, minus its comments, and it is what every output on this page was produced from. Copy it and follow along:

Terminal window
cp openregs/fixtures/consumers/fixture-bank/controls.yaml .

1. What binds you, and what you have covered

Section titled “1. What binds you, and what you have covered”

openregs coverage answers the mapping question in the direction a regulator would ask it: for every obligation that binds this entity on this date, is it mapped, waived, or neither?

Terminal window
openregs coverage \
--map controls.yaml \
--entity-profile small_operator \
--release fixreg@2025.04 \
--as-of 2025-06-01 \
--root path/to/openregs
coverage: fixreg@2025.04 as of 2025-06-01, profile small_operator (Fixture Bank plc)
2 applicable: 1 mapped, 0 waived, 1 unmapped
gap FIXREG-Art5a-Ob1 art_5a designate a compliance contact
note FIXREG-Art5.2-Ob1 derogation: art_9 disapplies art_5__para_2 for small_operator,
and small_operator is one of those; the duty is therefore not applicable to
small_operator on 2025-06-01
note FIXREG-Art5.3-Ob1 inherited: the duty names operator and small_operator is a kind
of operator, so it binds this entity; nothing in force on 2025-06-01 disapplies it
note FIXREG-Art5a-Ob1 inherited: …

Long lines are wrapped above; each note prints on one line. Read them, not just the counts. Two different mechanisms are on display: Article 5(3) binds this entity because a small operator is an operator and the duty is inherited down the taxonomy; Article 5(2) does not bind it, because Article 9 disapplies that paragraph for small operators — and the derogation is named rather than assumed. Run the same command with --entity-profile operator and the counts change to 3 applicable: 1 mapped, 1 waived, 1 unmapped, because the derogation no longer applies and the waiver starts doing work.

Mapped plus waived plus unmapped equals applicable, by construction. The one gap here is FIXREG-Art5a-Ob1 — the compliance-contact duty inserted by the amending act, which nobody has written a control for yet.

--json <path> and --html <path> write the report to a file. The HTML is one self-contained file that fetches no stylesheet, script or font, so opening it makes no network request — which is what makes it safe to hand to an auditor.

openregs impact intersects the diff between two releases with your mapping.

Terminal window
openregs impact \
--diff fixreg@2025.02..fixreg@2025.04 \
--map controls.yaml \
--root path/to/openregs
impact: fixreg@2025.02..fixreg@2025.04 (2025.04.diff.json) against controls.yaml — Fixture Bank plc
2 changed atom(s): 1 impacted (1 substantive, 0 editorial), 0 waived, 1 unmapped
IMPACTED substantive changed FIXREG-Art5.3-Ob1 CTRL-INC-014
(incident-response@fixturebank.example) Fixture Regulation (EU) 2024/1, Article 5(3)
WARNING substantive added FIXREG-Art5a-Ob1 no control, no waiver
Fixture Regulation (EU) 2024/1, Article 5a
impact: FAIL — 1 mapped atom(s) changed substantively (FIXREG-Art5.3-Ob1); this release
move needs the control owner before it merges

Exit code 1. (Long lines are wrapped above; each row prints on one.) The full report goes to stdout as JSON — ticket-ready, carrying for each impacted atom the before and after text, the amending instruction, and the basis for the classification:

"amendment": {
"basis": "no recital of the amending act declares this change meaning-preserving",
"classification": "substantive",
"instruction": "/akn/eu/act/reg/2025/7/eng@2025-03-01/!main#art_1__point_1",
"operation": "substitution"
}

That classification is the release’s own, read from the diff manifest it shipped and never recomputed here. Whether an amendment was editorial rests on the amending act’s recitals, which your checkout does not contain.

What changedExit code
an atom mapped to a control, substantively1 — a human owns this before it merges
an atom mapped to a control, editorially0 — the words moved, the duty did not
an atom you waived0 — decided and recorded already
an atom nobody mapped0 — a warning, and a coverage gap

Nothing in the report is a function of the clock: the same manifest and the same mapping produce byte-identical output, so the check is reproducible and a diff in it means something changed.

openregs bump does the whole move on a branch: rewrites the pin, runs the impact report for exactly that move, and writes the pull request body beside it.

Terminal window
openregs bump \
--repo . \
--to fixreg@2025.04 \
--from path/to/openregs/fixtures/registry \
--trust path/to/openregs/config/trust.yaml \
--no-push
pull: fixreg@2025.04 from …/fixtures/registry/fixreg/2025.04
staged 21 file(s), 246770 byte(s) -> …/pulled/.pull-fixreg-2025.04.partial
verify: fixreg@2025.04 — …/openregs-bump-corpus-xbn2o4y4/pulled/.pull-fixreg-2025.04.partial
signature valid 8 blob signature(s) under ecdsa-p256-sha256 key 24f0b959505940ce
...
verified 5 of 5 check(s) passed
unpack -> …/openregs-bump-corpus-xbn2o4y4/pulled/fixreg/2025.04
bump: controls.yaml fixreg@2025.02 -> fixreg@2025.04 on branch openregs/bump-fixreg-2025.04
(5c3b9d1e0dec, not pushed (--no-push))
impact: FAIL — 1 mapped atom(s) changed substantively (FIXREG-Art5.3-Ob1); this release
move needs the control owner before it merges
bump: FAIL — open a pull request from openregs/bump-fixreg-2025.04 into main
wrote .openregs/branch.txt
wrote .openregs/impact.json
wrote .openregs/pull-request-title.txt
wrote .openregs/pull-request.md
wrote .openregs/verdict.txt

Exit code 1 — openregs impact’s contract unchanged, which is what makes the CI check red exactly when the bump needs a control owner.

Three things worth noticing:

  • The release was pulled and verified before a byte of it was read. --from takes the same registry path openregs pull takes and runs the same five checks over a staged copy. Without --from, the release is read from a checkout given by --corpus.
  • Your working tree was never touched. The branch is cut in a temporary clone and pushed to the repository’s origin; --no-push stops short of the push, which is the dry run.
  • The five output files land in the workspace, not on the branch. The bump commit contains the mapping and nothing else. Add .openregs/ to your .gitignore so a later job cannot commit them by accident.

Drop --no-push and the branch goes to origin. Either way the repository needs one: the remote is resolved before the branch is cut, so a repository without an origin and without --remote is refused even on the dry run, because there is nowhere to open the bump:

bump: . has no remote named 'origin', so there is nowhere to open the bump.
Pass --remote <url>: a pull request is a branch on a remote

.openregs/pull-request.md is written for the person who has to make the decision, not for the person who wrote the tool:

## Impact: FAIL — 1 mapped atom(s) changed substantively
### Impacted controls
| Control | Owner | Atom | Classification | Citation |
| --- | --- | --- | --- | --- |
| CTRL-INC-014 | incident-response@fixturebank.example | `FIXREG-Art5.3-Ob1` | **substantive** | … Article 5(3) |
#### `FIXREG-Art5.3-Ob1` — changed, substantive
- basis: amendment-substantive
- before: Operators must report to the authority any incident causing losses above EUR 5000. (effective 2024-06-01)
- after: Operators must report to the authority any incident causing losses above EUR 10000. (effective 2025-03-01)

The owner of CTRL-INC-014 reads that and does one of two things: pushes the control update onto the bump branch so the release move and the control change merge together, or replaces the mapping entry with a waiver and a written rationale in the same pull request. What nobody should do is disable the check — it is red on precisely the days it is worth something.

The commands above are what the shipped integrations run. Both live in the engine repository and are documented there:

  • GitHub Actions — a composite action that bumps the pin, posts the impact table as the pull request body, and concludes failure on a substantive change to a mapped atom. Pin it to a commit sha; a tag is a pointer its owner can move.
  • GitLab CI — the same integration as a .gitlab-ci.yml job, running the same command with the same exit code, opening the merge request with git push options instead of a forge CLI.

A consuming repository installs nothing: the action runs the CLI out of its own checkout, so the code, the releases and config/trust.yaml all arrive together when the runner materialises it. That is also why --trust never appears in the CI examples and does here — locally, you have to say which roster you accept.

Make the check required by branch protection. Everything above is machinery for producing an honest red; a red nobody has to clear is decoration.

  • fixreg is a fixture, not law. Fixture Regulation (EU) 2024/1 does not exist; it is the corpus the engine is developed against. The workflow on this page is real, the regulation it operates on is not, and a control mapped to a FIXREG atom discharges nothing.
  • The registry can be a URL, but nothing is published at one. --from reads a directory or an https registry, and openregs release assets lays a verified release out as the flat asset tree such a registry serves. What is missing is no longer the tag — fixreg@2025.04 exists, built and signed on real runners — but its publication: the publish stage failed on a missing credential, so no assets were attached and nothing is hosted. The CI examples’ registry: input still has nothing real to point at.
  • --root wants a checkout, not a pulled release. impact and coverage read releases from an engine checkout’s regimes/<regime>/releases/<tag>/ layout. A directory produced by openregs pull --into is laid out differently and will be reported as not built. Use bump --from <registry>, which pulls and verifies into its own temporary corpus, or point --root at a checkout.
  • gh pr create has never run here. The engine’s own test suite exercises these commands offline against local bare remotes, which proves the branch, the pin and the verdict — not that a forge accepts them.