EU AI Act Classification Register

Build a deterministic AI system register that tiers use cases under the EU AI Act.

Introduction

30 Second Summary

Every organisation running AI systems faces the same question from regulators, boards, and risk committees: what do you have, what tier is each system in, and what happens when the law changes? Most cannot answer with anything better than a spreadsheet and a guess.

In this project, you will build a deterministic classification system that tiers an estate of 25 AI use cases against the EU AI Act, proves correctness numerically, and re-tiers the entire register when a legislative amendment enters into force.

What You'll Build

You run a single command, the engine classifies all 24 sealed systems with zero disagreements, routes the ambiguous case to a human queue, and when you feed in the July 2026 amendment it prints exactly which systems changed and why.

By the end of this project, you'll have:

  • A machine-readable register of 25 AI use cases as OSCAL component definitions that you can query, validate, and hand to auditors without a single manual lookup.
  • A deterministic tiering engine in Rego that you run against the sealed ground truth, watch it print 24/24 tiers correct. 0 disagreements. Engine proven., and know the classification is a reproducible function of stated facts.
  • A re-tiering diff that encodes Regulation (EU) 2026/1744 and names every system whose deadline or obligation set shifted when the amendment entered into force, with the count matching your hand-computed expectation exactly.
  • Secret Mission: Build the full governance package with a control library, risk register, evidence index, and a one-page board briefing that states control coverage, residual risk, and the decision being asked for.

Are there any prerequisites?

This project assumes you have Python 3.14 installed, are comfortable with JSON/YAML and the command line, and have working knowledge of the EU AI Act's structure (Articles 5, 6, 43, 50, 51-55, 99, and 101).

You will also need a GitHub account and a Linear account (free tier).

Before We Start

Before we touch any tooling, this is the moment to commit to what you are building and why it is shaped the way it is. You are about to produce a regulatory-classification system where the tier is a reproducible function of stated facts, not a model's opinion.

Design Artifacts and Workspace

The system you are building has four parts: a register, a tiering engine, a scoring harness, and a re-tiering diff. Before writing a single Rego rule, you need the toolchain verified in one workspace and the design decisions written down.

This step installs Open Policy Agent and compliance-trestle, scaffolds the project, and produces the decision records, topology diagram, and implementation plan that the rest of the build implements. Without them, the engineer cannot explain to the risk committee why the system is shaped the way it is or what would change the shape.

In this step, get ready to:
  • Install OPA and compliance-trestle, create the repository and Linear project.
  • Write design decision records, the topology diagram, and the implementation plan.
  • Scaffold the project folder, write a minimal Rego rule, and prove the toolchain works end-to-end.
Install OPA and compliance-trestle, initialise repository

OPA is the policy engine that evaluates your Rego rules. On Windows, it ships as a single binary you download and add to your PATH.

  • Download and install the OPA binary by running these commands in PowerShell:
# Create a directory for OPA
New-Item -ItemType Directory -Path "$env:USERPROFILE\opa" -Force

# Download the OPA Windows binary
Invoke-WebRequest -Uri "https://openpolicyagent.org/downloads/latest/opa_windows_amd64.exe" -OutFile "$env:USERPROFILE\opa\opa.exe"

# Add OPA to PATH permanently for the current user
[Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";$env:USERPROFILE\opa", [System.EnvironmentVariableTarget]::User)

# Also add to current session
$env:PATH += ";$env:USERPROFILE\opa"

What does this do?

The first two lines create a folder and download the OPA binary into it. The third line adds that folder to your user-level PATH permanently, so every new terminal session can find opa.exe. The fourth line ensures the current session also picks it up without restarting.

  • Verify OPA is installed by running:
opa version

✔️ I see version 1.18 or higher

OPA is installed and ready. Move on to the next install.

ⓧ I see an older version

You have a previous OPA installation on your PATH that is being found first.

  • Remove the old OPA binary or move the new $env:USERPROFILE\opa directory earlier in your PATH variable.
  • Close and reopen your terminal, then run opa version again.

ⓧ Command not found

The PATH change did not take effect in the current session.

  • Close the PowerShell window and open a new one.
  • Run opa version again.
  • If it still fails, confirm the file exists at $env:USERPROFILE\opa\opa.exe and that the directory is in your PATH by running $env:PATH -split ";".

Next, install compliance-trestle. This is the OSCAL toolkit that manages your register as structured component definitions.

  • Install compliance-trestle by running:
pip install compliance-trestle==4.2.0

What about the Pydantic warning?

On Python 3.14, trestle prints a Pydantic V1 compatibility UserWarning on every command. This is cosmetic and does not affect functionality. You can safely ignore it.

  • Verify compliance-trestle is installed by running:
trestle version

✔️ I see version 4.2.0

Trestle is installed at the correct version. Move on.

ⓧ I see a different version

  • Run pip install compliance-trestle==4.2.0 --force-reinstall to pin the correct version.
  • Run trestle version again to confirm 4.2.0.

ⓧ Command not found

  • Confirm pip installed to a location on your PATH by running pip show compliance-trestle.
  • If the install location is not in your PATH, add it or use python -m trestle version instead.
  • Confirm Python is at the expected version by running:
python --version

You should see Python 3.14.x in the output.

Now create the GitHub repository and Linear project that will hold the evidence trail.

  • In your browser, go to github.com/new.
  • Set the repository name to ai-act-register.
  • Select Private for visibility.
  • Leave all other options at their defaults and click Create repository.
  • Open your Linear workspace in a new tab.
  • Create a new project named AI Act Register.
  • Add one ticket per major deliverable (register, engine, harness, diff, presentation, documentation), each with a one-sentence acceptance criterion describing the evidence a governance board would ask to see.

Why a private repo and Linear tickets?

Each ticket is a control-implementation task carrying its evidence. When a governance board asks "show me the evidence for this control," the answer is a closed ticket with its merged PR. The audit trail is the version history.

Produce the design pre-artifacts

Design before build. The decision records, topology, and plan are the contract the remaining steps implement.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create and initialise the project workspace by running these commands:
mkdir ai-act-register
cd ai-act-register
git init
trestle init
mkdir policy, sealed, agents, output, docs

You should see git init confirm an empty repository, and trestle init create a .trestle/ directory plus model folders like component-definitions/.

  • Confirm the folders exist by running:
ls

You should see policy, sealed, agents, output, docs, component-definitions, and .trestle in the listing.

  • Open the ai-act-register folder in your code editor.
  • Create a new file at docs/decisions.md and paste the following content:
# Decision Records

## 1. Rego over Python if/else
- Alternative: Python if/else tree for classification logic.
- Tradeoff: Team must learn and maintain Rego syntax.
- Reversal trigger: OPA is deprecated or team cannot maintain Rego.

## 2. OSCAL over spreadsheet
- Alternative: Flat spreadsheet (Google Sheets, Excel).
- Tradeoff: Heavier toolchain, schema learning curve.
- Reversal trigger: OSCAL tooling breaks compatibility.

## 3. Scored gate over unchecked model output
- Alternative: Model directly writes rationales with no validation.
- Tradeoff: Drafting step slows pipeline by model latency.
- Reversal trigger: Model reaches 24/24 across 10+ runs consistently.

## 4. Private repo with ticket-per-control over shared drive
- Alternative: Shared folder with naming conventions.
- Tradeoff: Team must use GitHub and Linear.
- Reversal trigger: Organisation standardises a different evidence platform.

## 5. Quarterly review cadence over one-time build
- Alternative: Build once, maintain ad hoc.
- Tradeoff: Standing engineering time commitment each quarter.
- Reversal trigger: Never. The carrying cost is permanent.

What are decision records?

Each record names the choice, the alternative it beat, the tradeoff accepted, and the condition that would reverse it. When someone asks "why Rego?" the answer is a link to this file, not a 30-minute meeting.

  • Create a new file at docs/topology.md and paste the following:
# System Topology

```
[OSCAL Register] ---> [OPA Tiering Engine] ---> [Scoring Harness]
                             |
                             v
                      [Re-Tiering Diff]
                      (two policy versions)
```

## Data Flow

1. Register feeds structured props to the Engine as JSON input.
2. Engine returns a deterministic tier for each system.
3. Harness scores model-drafted rationales against sealed tiers.
4. Diff evaluates the full estate under pre- and post-amendment policy.
  • Verify both files exist by running:
ls docs/

You should see decisions.md and topology.md listed.

  • Create a new file at docs/plan.md and paste the following:
# Implementation Plan

| Phase | Deliverable | Estimated Time |
|-------|-------------|----------------|
| 1 | Design artifacts and workspace | 12 min |
| 2 | Register and sealed ground truth | 18 min |
| 3 | Tiering engine | 20 min |
| 4 | Scoring harness | 15 min |
| 5 | Re-tiering diff and presentation | 15 min |
| 6 | Documentation and release | 10 min |

Total: 90 minutes.

Drawn from the topology. Each phase maps to one part of the graph.
  • Save all three files and confirm the full set by running:
ls docs/

You should now see decisions.md, topology.md, and plan.md.

Missing a file?

Check that you created each file inside the docs/ folder within ai-act-register, not in a different directory. File names are case-sensitive on some systems.

help me find where my docs files ended up

Scaffold the agent-folder layout and verify end-to-end

The workspace folders are ready, but the toolchain has never actually evaluated a Rego file in this project. A minimal rule that returns a known value proves OPA, Rego, and your PATH are wired correctly.

  • Create the minimal tiering rule at policy/tier.rego with this content:
package aiact.tier

import rego.v1

default result := {"tier": "unknown"}

What does this rule do?

The default keyword means this value is returned when no other rule matches. In later steps, you will add rules that override this default for each system in the register. For now, every input produces {"tier": "unknown"}, which is the signal that the toolchain works.

  • Create a sample input file at sealed/sample.json with this content:
{
  "system_id": "test-system",
  "props": {}
}

Before you run the evaluation, predict what you expect OPA to return for an empty input with no matching rules.

  • Evaluate the rule against the sample input by running:
opa eval -d policy/ -i sealed/sample.json "data.aiact.tier.result"

OPA returns {"tier": "unknown"} inside its result JSON. The default rule fired because no other condition matched the empty props. The toolchain is wired.

OPA returns an empty result or error?

Check that policy/tier.rego uses import rego.v1 on the second line. OPA v1.18 requires this import for the := assignment syntax. Also confirm the query string is exactly "data.aiact.tier.result" (matching the package name and rule name).

help me debug why OPA eval is not returning my default tier

  • Commit and push all changes to your private repository by running:
git add .
git commit -m "Step 1: workspace scaffold, design artifacts, minimal tier rule"
git remote add origin https://github.com/[[GITHUB_USER="your-github-username"]]/ai-act-register.git
git push -u origin main
  • In Linear, close the first ticket (workspace scaffold) with a comment linking the commit hash.

Push rejected or branch name mismatch?

If git says the remote already has commits (from initialising with a README), run git pull --rebase origin main first, then push again. If your default branch is master instead of main, rename it with git branch -M main.

help me push to my new GitHub repository

Your toolchain is proven, your design is written, and the first ticket is closed. Next up, you will populate the register with 25 AI systems as OSCAL component definitions and write the sealed ground truth that the engine must reproduce exactly.

Build the Register and Sealed Ground Truth

Your workspace is verified and the design artifacts are in place. Now you need the raw material the tiering engine will classify: a structured, machine-readable register of every AI system the organisation operates.

Without a register declaring each system's purpose, deployment context, and data categories in a format OPA can consume, the Rego rules have nothing to evaluate against. The sealed ground truth is the contract the engine must reproduce exactly.

In this step, get ready to:
  • Author an OSCAL component definition containing 25 AI systems spanning every tier.
  • Create the sealed ground truth mapping all 24 deterministic systems to their expected tier.
  • Validate the register structure and merge via pull request.
Author the OSCAL component definition with 25 systems

Each AI system becomes a component in an OSCAL component-definition file. The component carries structured props that OPA evaluates as JSON input during tiering.

  • Create a feature branch for the register work by running:
git checkout -b feature/register
  • Create the OSCAL component-definition directory by running:
mkdir -p component-definitions/ai-estate
  • In your editor, create component-definitions/ai-estate/component-definition.json and add the OSCAL envelope with the first prohibited system:
{
  "component-definition": {
    "uuid": "ai-estate-001",
    "metadata": {
      "title": "AI System Estate Register",
      "last-modified": "2026-08-03T00:00:00Z",
      "version": "1.0.0",
      "oscal-version": "1.2.1"
    },
    "components": [
      {
        "uuid": "sys-01-social-scoring",
        "type": "software",
        "title": "Social Scoring System",
        "description": "Evaluates natural persons based on social behaviour for general-purpose scoring leading to detrimental treatment",
        "props": [
          {"name": "practice", "value": "social_scoring"},
          {"name": "deployment_context", "value": "provider"},
          {"name": "affected_persons", "value": "general_public"},
          {"name": "data_categories", "value": "personal,special_category"},
          {"name": "profiling", "value": "true"},
          {"name": "safety_component", "value": "false"}
        ]
      }
    ]
  }
}

What does this structure do?

The OSCAL envelope (component-definition) wraps metadata and a components array. Each component's uuid matches the sealed ground truth. The props array carries the structured facts OPA consumes: which practice triggers Article 5, whether the system is a safety component, which Annex III area it targets.

The practice prop is what triggers the prohibited tier. For sys-01-social-scoring, the value social_scoring maps to Article 5(1)(c) in the Rego rules.

  • Save component-definition.json.
  • Run a quick structure check to confirm the file parses:
python -c "import json; json.load(open('component-definitions/ai-estate/component-definition.json')); print('Valid JSON')"

You should see Valid JSON printed.

  • Now add the second prohibited system (sys-02-facial-scraping) and the three Annex I high-risk systems inside the components array, after the first component:
{
  "uuid": "sys-02-facial-scraping",
  "type": "software",
  "title": "Untargeted Facial Image Scraping",
  "description": "Scrapes facial images from the internet or CCTV without targeted purpose",
  "props": [
    {"name": "practice", "value": "untargeted_facial_scraping"},
    {"name": "deployment_context", "value": "provider"},
    {"name": "affected_persons", "value": "general_public"},
    {"name": "data_categories", "value": "personal,special_category"},
    {"name": "profiling", "value": "false"},
    {"name": "safety_component", "value": "false"}
  ]
}

What makes a system prohibited vs high-risk?

Prohibited systems have a practice prop matching one of the Article 5 categories. High-risk Annex I systems instead use safety_component set to true with a named annex_i_legislation value. The Rego rules check these props in priority order: prohibited first, then Annex I, then Annex III.

  • Add the three Annex I high-risk systems following this pattern:
{
  "uuid": "sys-03-medical-device-ai",
  "type": "software",
  "title": "Medical Device AI Diagnostic",
  "description": "AI safety component within a medical device requiring third-party conformity assessment",
  "props": [
    {"name": "safety_component", "value": "true"},
    {"name": "annex_i_legislation", "value": "medical_devices"},
    {"name": "deployment_context", "value": "provider"},
    {"name": "affected_persons", "value": "patients"},
    {"name": "data_categories", "value": "personal,special_category"},
    {"name": "annex_iii_area", "value": ""},
    {"name": "profiling", "value": "false"}
  ]
}
  • Add sys-04-machinery-safety with annex_i_legislation set to machinery.
  • Add sys-05-aviation-ai with annex_i_legislation set to aviation.
  • Both have safety_component set to true.
  • Next, add the 12 Annex III high-risk systems. Here is the pattern for an Annex III component:
{
  "uuid": "sys-06-remote-biometric-id",
  "type": "software",
  "title": "Remote Biometric Identification",
  "description": "Real-time remote biometric identification in publicly accessible spaces",
  "props": [
    {"name": "annex_iii_area", "value": "1_biometrics"},
    {"name": "deployment_context", "value": "provider"},
    {"name": "affected_persons", "value": "general_public"},
    {"name": "data_categories", "value": "personal,special_category"},
    {"name": "safety_component", "value": "false"},
    {"name": "annex_i_legislation", "value": ""},
    {"name": "profiling", "value": "false"},
    {"name": "narrow_procedural_task", "value": "false"},
    {"name": "exception_arguable", "value": "false"}
  ]
}

Why does biometrics need different props than other Annex III areas?

Annex III point 1 (biometrics) routes to Annex VII conformity assessment (notified body) per Article 43(1), because no harmonised standard has been cited in the Official Journal. Annex III points 2 through 8 route to Annex VI (internal control, no notified body) per Article 43(2). The annex_iii_area prop value is what the conformity rules key on.

  • Add the remaining 11 Annex III systems using the same structure. Each differs only in uuid, title, description, and annex_iii_area:
  • sys-07-critical-infra-water with area 2_critical_infrastructure.
  • sys-08-exam-proctoring with area 3_education.
  • sys-09-cv-screening with area 4_employment.
  • sys-10-worker-monitoring with area 4_employment.
  • sys-11-credit-scoring with area 5_essential_services.
  • sys-12-insurance-risk with area 5_essential_services.
  • sys-13-emergency-dispatch with area 5_essential_services.
  • sys-14-recidivism-risk with area 6_law_enforcement.
  • sys-15-evidence-reliability with area 6_law_enforcement.
  • sys-16-asylum-risk with area 7_migration.
  • sys-17-judicial-research with area 8_justice.
  • Now add the four Article 50 transparency systems. These use dedicated boolean props instead of annex_iii_area:
{
  "uuid": "sys-18-chatbot-support",
  "type": "software",
  "title": "Customer Support Chatbot",
  "description": "AI system interacting directly with natural persons for customer support",
  "props": [
    {"name": "direct_interaction", "value": "true"},
    {"name": "generates_synthetic_content", "value": "false"},
    {"name": "emotion_recognition_deployment", "value": "false"},
    {"name": "deepfake_deployment", "value": "false"},
    {"name": "deployment_context", "value": "deployer"},
    {"name": "affected_persons", "value": "customers"},
    {"name": "data_categories", "value": "personal"},
    {"name": "safety_component", "value": "false"},
    {"name": "annex_iii_area", "value": ""},
    {"name": "profiling", "value": "false"}
  ]
}
  • Add sys-19-content-generator with generates_synthetic_content set to true.
  • Add sys-20-emotion-deploy with emotion_recognition_deployment set to true.
  • Add sys-21-deepfake-tool with deepfake_deployment set to true.
  • Add the three residual systems (sys-22-spam-filter, sys-23-autocomplete, sys-24-recommender) with all tier-triggering props set to false or empty.
  • Finally, add the 25th ambiguous system that will route to the human queue:
{
  "uuid": "sys-25-cv-dedup",
  "type": "software",
  "title": "CV Deduplication Tool",
  "description": "Performs narrow procedural deduplication of CVs without profiling candidates",
  "props": [
    {"name": "annex_iii_area", "value": "4_employment"},
    {"name": "narrow_procedural_task", "value": "true"},
    {"name": "exception_arguable", "value": "true"},
    {"name": "profiling", "value": "false"},
    {"name": "deployment_context", "value": "provider"},
    {"name": "affected_persons", "value": "job_applicants"},
    {"name": "data_categories", "value": "personal"},
    {"name": "safety_component", "value": "false"},
    {"name": "annex_i_legislation", "value": ""}
  ]
}

Why is system 25 the ambiguous case?

This system falls within Annex III area 4 (employment), which would normally make it high-risk. But it performs only a narrow procedural task (deduplication) with no profiling. That triggers Article 6(3), which says a system MAY not be high-risk if certain conditions are met.

The exception_arguable prop tells the engine this case has no sealed answer. The engine should route it to human_queue rather than guessing. A classifier with no abstention path pushes its uncertainty silently into a legal position.

  • Confirm the JSON still parses after adding all 25 components by running:
python -c "import json; data = json.load(open('component-definitions/ai-estate/component-definition.json')); print(f'{len(data[\"component-definition\"][\"components\"])} components loaded')"

You should see 25 components loaded printed.

Seeing a JSON parse error?

Check for a missing comma between components in the components array. Every component except the last must be followed by a comma. Also verify matching braces for each component object.

Help me find the syntax problem in my OSCAL file

Author the sealed ground truth and validate

The sealed ground truth is the contract the tiering engine must reproduce exactly. It maps each of the 24 deterministic systems to its expected tier, conformity-assessment route, and deadline.

  • Create sealed/ground_truth.json with the following content:
{
  "sys-01-social-scoring": {"tier": "prohibited", "article": "5(1)(c)", "deadline": "2025-02-02"},
  "sys-02-facial-scraping": {"tier": "prohibited", "article": "5(1)(e)", "deadline": "2025-02-02"},
  "sys-03-medical-device-ai": {"tier": "high_risk", "track": "annex_i", "conformity": "sectoral", "deadline": "2028-08-02"},
  "sys-04-machinery-safety": {"tier": "high_risk", "track": "annex_i", "conformity": "sectoral", "deadline": "2028-08-02"},
  "sys-05-aviation-ai": {"tier": "high_risk", "track": "annex_i", "conformity": "sectoral", "deadline": "2028-08-02"},
  "sys-06-remote-biometric-id": {"tier": "high_risk", "track": "annex_iii", "area": "1_biometrics", "conformity": "VII", "deadline": "2027-12-02"},
  "sys-07-critical-infra-water": {"tier": "high_risk", "track": "annex_iii", "area": "2_critical_infrastructure", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-08-exam-proctoring": {"tier": "high_risk", "track": "annex_iii", "area": "3_education", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-09-cv-screening": {"tier": "high_risk", "track": "annex_iii", "area": "4_employment", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-10-worker-monitoring": {"tier": "high_risk", "track": "annex_iii", "area": "4_employment", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-11-credit-scoring": {"tier": "high_risk", "track": "annex_iii", "area": "5_essential_services", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-12-insurance-risk": {"tier": "high_risk", "track": "annex_iii", "area": "5_essential_services", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-13-emergency-dispatch": {"tier": "high_risk", "track": "annex_iii", "area": "5_essential_services", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-14-recidivism-risk": {"tier": "high_risk", "track": "annex_iii", "area": "6_law_enforcement", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-15-evidence-reliability": {"tier": "high_risk", "track": "annex_iii", "area": "6_law_enforcement", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-16-asylum-risk": {"tier": "high_risk", "track": "annex_iii", "area": "7_migration", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-17-judicial-research": {"tier": "high_risk", "track": "annex_iii", "area": "8_justice", "conformity": "VI", "deadline": "2027-12-02"},
  "sys-18-chatbot-support": {"tier": "transparency", "article": "50(1)", "deadline": "2026-08-02"},
  "sys-19-content-generator": {"tier": "transparency", "article": "50(2)", "deadline": "2026-08-02"},
  "sys-20-emotion-deploy": {"tier": "transparency", "article": "50(3)", "deadline": "2026-08-02"},
  "sys-21-deepfake-tool": {"tier": "transparency", "article": "50(4)", "deadline": "2026-08-02"},
  "sys-22-spam-filter": {"tier": "residual", "deadline": "none"},
  "sys-23-autocomplete": {"tier": "residual", "deadline": "none"},
  "sys-24-recommender": {"tier": "residual", "deadline": "none"}
}

What do the deadlines encode?

The deadlines reflect Regulation (EU) 2024/1689 as amended by Regulation (EU) 2026/1744 of 8 July 2026. Prohibited practices are already in force (2 February 2025). Annex III high-risk systems now have until 2 December 2027. Annex I high-risk systems have until 2 August 2028.

Transparency duties under Article 50 applied from 2 August 2026. Residual systems have no deadline because they carry no tier-specific obligations. System 25 has no sealed entry at all because the engine must not guess at it.

  • Save sealed/ground_truth.json.

Before you run the validation, what do you expect the outcome to be? The register has 25 well-formed components and the ground truth maps 24 of them.

  • Validate the OSCAL register structure by running:
trestle validate -t component-definition -n ai-estate

A passing validation confirms the OSCAL structure is well-formed and the props schema is consistent. You should see a success message with no errors.

Validation failed?

Confirm that your file is at exactly component-definitions/ai-estate/component-definition.json relative to the project root where .trestle/ lives. The trestle validate command expects the OSCAL file at that path.

Also check that every component has a uuid, type, and title field. Missing required fields cause schema validation failures.

Help me debug the OSCAL validation error

  • Write docs/quickstart.md explaining how to validate the register, run the tiering engine, and interpret results. This ships with the work it describes.
  • Commit all changes, push, open a PR, and merge by running:
git add .
git commit -m "feat: OSCAL register with 25 systems and sealed ground truth"
git push -u origin feature/register
  • Open the pull request in GitHub and merge it to main.
  • Close the register ticket in Linear.

Why a feature branch and a ticket?

Each ticket is a control-implementation task carrying its evidence. When a governance board asks for proof the register was reviewed, the answer is a closed ticket with its merged PR and the green validation run. The audit trail is the version history.

Your register is validated and the ground truth is sealed. Next up, you will write the Rego rules that consume these props and assign each system its tier deterministically.

Build the Tiering Engine

The register you built in Step 2 is an inert list of facts. Without logic that reads those facts and returns a tier, the organisation still cannot answer "which bucket is this system in?" in a way that is reproducible across people and time.

In this step, you will write a set of Rego rules that take each system's declared properties and return its regulatory tier deterministically. The engine must reproduce all 24 sealed tiers with 0 disagreements and route the 25th ambiguous system to the human queue rather than guessing.

In this step, get ready to:
  • Write the full Rego tiering, conformity, deadline, and obligation rule set.
  • Build a Python harness that proves 24/24 correctness and routes System 25 to the human queue.
  • Wire the proof run into GitHub Actions so every push re-proves the engine.
Write the Rego tiering rules

The tiering engine lives in four Rego files, each handling one concern: classification, conformity routing, deadlines, and obligations. You will replace the minimal policy/tier.rego that currently returns unknown and add three new files alongside it.

  • Open policy/tier.rego in your editor and replace its entire contents with the package declaration, default rule, and the first two result rules:
package aiact.tier

import rego.v1

# Fallback: no tier-specific obligations
default result := {"tier": "residual", "reason": "No tier-specific obligations. Article 4 AI-literacy applies. This is not a legal tier in the Act."}

# Prohibited practices (Article 5)
result := {"tier": "prohibited", "article": "5", "reason": r} if {
    reason := prohibited_reason
    r := reason
}

# High-risk via Annex I (Article 6(1))
result := {"tier": "high_risk", "track": "annex_i", "article": "6(1)", "reason": "Safety component of Annex I product requiring third-party conformity assessment"} if {
    not prohibited_reason
    input.props.safety_component == true
    input.props.annex_i_legislation != ""
}

What does this code do?

The default result line catches every system that matches no higher rule. Rego evaluates rules top-down by specificity, so the most specific match wins.

The prohibited rule fires if prohibited_reason is defined (you will write those helpers shortly). The Annex I rule fires for safety components of products listed in Annex I, but only after confirming the system is not prohibited.

  • Below the Annex I rule, add the human-queue routing and the remaining result rules:
# Ambiguous Article 6(3) case -> human queue
result := {"tier": "human_queue", "reason": "Article 6(3) exception arguable, no sealed answer"} if {
    not prohibited_reason
    not annex_i_match
    input.props.annex_iii_area != ""
    article_6_3_arguable
    not input.props.profiling
    not has_sealed_answer
}

# High-risk via Annex III (Article 6(2))
result := {"tier": "high_risk", "track": "annex_iii", "article": "6(2)", "area": input.props.annex_iii_area, "reason": concat(" ", ["Intended purpose falls within Annex III area", input.props.annex_iii_area])} if {
    not prohibited_reason
    not annex_i_match
    input.props.annex_iii_area != ""
    not article_6_3_exception_applies
}

# Transparency obligations (Article 50)
result := {"tier": "transparency", "article": "50", "reason": r} if {
    not prohibited_reason
    not annex_i_match
    not annex_iii_high_risk
    reason := transparency_reason
    r := reason
}

Why does human_queue come before annex_iii?

The human-queue rule checks article_6_3_arguable (a flag set on System 25 indicating the exception is debatable AND has no sealed answer). If it matches, the system is routed out before the generic Annex III rule can claim it.

This is the engine's abstention mechanism. A classifier with no abstention path pushes its uncertainty silently into a legal position.

  • Next, add the prohibited_reason helper rules that define which practices trigger Article 5:
# Article 5 prohibited practices
prohibited_reason := "Article 5(1)(c): Social scoring leading to detrimental treatment" if {
    input.props.practice == "social_scoring"
}

prohibited_reason := "Article 5(1)(e): Untargeted scraping of facial images" if {
    input.props.practice == "untargeted_facial_scraping"
}

prohibited_reason := "Article 5(1)(f): Workplace emotion recognition without medical/safety reason" if {
    input.props.practice == "workplace_emotion_recognition"
}

prohibited_reason := "Article 5 (as amended by Regulation (EU) 2026/1744): Generation or manipulation of non-consensual intimate material or CSAM" if {
    input.props.practice == "non_consensual_intimate_material"
}

What does this code do?

Each prohibited_reason rule matches one banned practice. The fourth rule encodes the new prohibition added by Regulation (EU) 2026/1744, effective 2 December 2026. OPA treats multiple definitions of the same rule name as alternative ways it can become defined.

  • Add the structural helper rules that the result rules depend on:
# Helper: Annex I match guard
annex_i_match if {
    input.props.safety_component == true
    input.props.annex_i_legislation != ""
}

# Helper: Annex III is high-risk (no exception applies)
annex_iii_high_risk if {
    input.props.annex_iii_area != ""
    not article_6_3_exception_applies
}

# Article 6(3) exception applies definitively
article_6_3_exception_applies if {
    article_6_3_condition_met
    not input.props.profiling
}

# Arguable exception (no sealed answer exists)
article_6_3_arguable if {
    input.props.narrow_procedural_task == true
    input.props.exception_arguable == true
}
  • Add the remaining exception-condition helpers and the sealed-answer check:
# Exception conditions (any one suffices)
article_6_3_condition_met if {
    input.props.narrow_procedural_task == true
    not input.props.exception_arguable
}

article_6_3_condition_met if {
    input.props.improves_completed_human_activity == true
}

article_6_3_condition_met if {
    input.props.detects_decision_patterns == true
}

article_6_3_condition_met if {
    input.props.preparatory_task == true
}

# Check if system has a sealed ground-truth answer
has_sealed_answer if {
    data.sealed[input.system_id]
}
  • Finally, add the transparency-reason helpers to close out the file:
# Article 50 transparency triggers
transparency_reason := "Article 50(1): AI system interacting directly with natural persons" if {
    input.props.direct_interaction == true
}

transparency_reason := "Article 50(2): Generates synthetic audio, image, video or text content" if {
    input.props.generates_synthetic_content == true
}

transparency_reason := "Article 50(3): Emotion recognition or biometric categorisation deployment" if {
    input.props.emotion_recognition_deployment == true
}

transparency_reason := "Article 50(4): Deepfake generation or manipulation" if {
    input.props.deepfake_deployment == true
}
  • Save policy/tier.rego.
  • Create a quick test input to confirm OPA can parse the file. Create sealed/sample.json with this content:
{
  "system_id": "sys-01-social-scoring",
  "props": {
    "practice": "social_scoring"
  }
}
  • Run the tiering engine against this sample input by running:
opa eval -d policy/ -i sealed/sample.json "data.aiact.tier.result" --format pretty

You should see a result containing "tier": "prohibited" and the Article 5(1)(c) reason. The engine parsed and evaluated correctly.

Seeing a parse error?

Check that every rule block ends with a closing } and every string uses straight double quotes, not curly quotes from copy-paste.

help me fix an OPA parse error in my tier.rego file

  • Now create policy/conformity.rego with the conformity-assessment routing rules:
package aiact.conformity

import rego.v1

default route := {"procedure": "none", "annex": "none", "notified_body": false}

# Annex III points 2-8: internal control only (Article 43(2))
route := {"procedure": "internal_control", "annex": "VI", "notified_body": false, "note": "Article 43(2): no notified body for Annex III points 2-8"} if {
    input.tier.track == "annex_iii"
    area := input.tier.area
    area_number := to_number(split(area, "_")[0])
    area_number >= 2
    area_number <= 8
}

# Annex III point 1 (biometrics): notified body required
route := {"procedure": "notified_body_required", "annex": "VII", "notified_body": true, "note": "No harmonised standard cited in OJ as of 2026-08-03. Annex VII applies by default for biometrics per Article 43(1)."} if {
    input.tier.track == "annex_iii"
    area := input.tier.area
    startswith(area, "1")
}

# Annex I products: sectoral conformity assessment
route := {"procedure": "sectoral", "annex": "I", "notified_body": true, "note": "Conformity assessment per relevant Union harmonisation legislation"} if {
    input.tier.track == "annex_i"
}

Why route Annex III points 2-8 to internal control?

Article 43(2) is explicit: providers of high-risk systems in Annex III points 2-8 follow the internal-control procedure in Annex VI, "which does not provide for the involvement of a notified body." Routing every high-risk system to a notified body is a common error that adds cost and delay without legal basis.

  • Create policy/deadlines.rego with the timeline rules reflecting both the original Act and the July 2026 amendment:
package aiact.deadlines

import rego.v1

# Default: Article 50 / general date
default deadline := "2026-08-02"

# Original Article 5 prohibitions: in force since 2 Feb 2025
deadline := "2025-02-02" if {
    input.tier.tier == "prohibited"
    not input.props.practice == "non_consensual_intimate_material"
}

# New prohibition (Regulation (EU) 2026/1744): 2 Dec 2026
deadline := "2026-12-02" if {
    input.tier.tier == "prohibited"
    input.props.practice == "non_consensual_intimate_material"
}

# Transparency: 2 Aug 2026 (now in force)
deadline := "2026-08-02" if {
    input.tier.tier == "transparency"
}

# Annex III high-risk: extended to 2 Dec 2027 by 2026/1744
deadline := "2027-12-02" if {
    input.tier.tier == "high_risk"
    input.tier.track == "annex_iii"
}

# Annex I high-risk: extended to 2 Aug 2028 by 2026/1744
deadline := "2028-08-02" if {
    input.tier.tier == "high_risk"
    input.tier.track == "annex_i"
}

What do the deadline rules encode?

Regulation (EU) 2026/1744 of 8 July 2026 extended Annex III deadlines from 2 August 2026 to 2 December 2027, and Annex I deadlines to 2 August 2028. The original Article 5 prohibitions have been in force since 2 February 2025. The new prohibition (non-consensual intimate material) takes effect 2 December 2026.

  • The deadlines file also needs the GPAI rules. Add these below the Annex I deadline rule:
# GPAI obligations: 2 Aug 2025
deadline := "2025-08-02" if {
    input.tier.tier == "gpai"
    not input.gpai_systemic_risk
}

# GPAI enforcement (Article 101): 2 Aug 2026
deadline := "2026-08-02" if {
    input.tier.tier == "gpai"
    input.gpai_enforcement
}
  • Save both files. Verify OPA can load the full policy directory without errors by running:
opa eval -d policy/ -i sealed/sample.json "data.aiact.tier.result" --format pretty

OPA should still return "tier": "prohibited" for the social-scoring sample. If it errors, you have a syntax issue in one of the new files.

  • Create policy/obligations.rego with the prohibited and high-risk provider obligation rules:
package aiact.obligations

import rego.v1

# Prohibited: cease practice immediately
obligations := result if {
    input.tier.tier == "prohibited"
    result := {
        "action": "cease_practice",
        "exposure": "EUR 35,000,000 or 7% worldwide turnover (whichever higher; for SMEs whichever lower per Article 99(6))",
        "article": "99(3)",
        "note": "No presumption of conformity available. No harmonised standard cited in OJ."
    }
}

# High-risk provider: full obligation set
obligations := result if {
    input.tier.tier == "high_risk"
    input.role == "provider"
    result := {
        "articles": ["9", "10", "11", "12", "13", "14", "15", "17", "43", "47", "48", "49"],
        "summary": "Risk management, data governance, technical documentation, logging, transparency, human oversight, accuracy/robustness/cybersecurity, QMS, conformity assessment, declaration, CE marking, registration",
        "exposure": "EUR 15,000,000 or 3% worldwide turnover (whichever higher; for SMEs whichever lower per Article 99(6))",
        "exposure_article": "99(4)",
        "conformity_note": "No presumption of conformity via harmonised standard. A management-system certificate is not a shield."
    }
}
  • Below the provider rule, add the deployer, transparency, residual, and GPAI obligation rules:
# High-risk deployer: use-per-instructions obligations
obligations := result if {
    input.tier.tier == "high_risk"
    input.role == "deployer"
    result := {
        "articles": ["26"],
        "summary": "Use per instructions, human oversight, input data relevance, monitoring, log retention >= 6 months, inform workers",
        "exposure": "EUR 15,000,000 or 3% worldwide turnover (whichever higher; for SMEs whichever lower per Article 99(6))",
        "exposure_article": "99(4)"
    }
}

# Transparency obligations
obligations := result if {
    input.tier.tier == "transparency"
    result := {
        "articles": ["50"],
        "summary": "Inform persons of AI interaction, mark synthetic content, disclose deepfakes, label AI-generated public-interest text",
        "exposure": "EUR 15,000,000 or 3% worldwide turnover (whichever higher; for SMEs whichever lower per Article 99(6))",
        "exposure_article": "99(4)"
    }
}
  • Add the residual and GPAI rules to complete the file:
# Residual: AI literacy only
obligations := result if {
    input.tier.tier == "residual"
    result := {
        "articles": ["4"],
        "summary": "AI literacy measures for staff and persons dealing with AI systems. No tier-specific obligations. Residual is not a legal tier in the Act.",
        "exposure": "none_tier_specific"
    }
}

# GPAI model provider obligations
obligations := result if {
    input.tier.tier == "gpai"
    result := {
        "articles": ["53", "54", "55", "101"],
        "summary": "Technical documentation, downstream info, copyright policy, training-data summary. Systemic-risk models: adversarial testing, risk mitigation, incident reporting, cybersecurity.",
        "exposure": "EUR 15,000,000 or 3% worldwide turnover (Article 101, in force 2 August 2026)",
        "exposure_article": "101"
    }
}

What do these obligation rules encode?

Each rule maps a tier to its obligation set, the relevant fine ceiling article, and the explicit note that no harmonised standard provides a presumption of conformity today. The SME fine inversion (Article 99(6): "whichever is lower") is stated in every exposure string because it is the opposite of the standard rule.

  • Save policy/obligations.rego.
  • Confirm the full policy directory loads cleanly by running:
opa eval -d policy/ -i sealed/sample.json "data.aiact.tier.result" --format pretty

You should still see "tier": "prohibited" for the social-scoring sample. All four Rego files are syntactically valid.

OPA returns an empty result?

If you see {} instead of a tier, check that policy/tier.rego starts with package aiact.tier and uses import rego.v1. The query path data.aiact.tier.result must match the package name exactly.

help me debug why OPA returns an empty result for my tiering rules

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: policy/tier.rego, policy/conformity.rego, policy/deadlines.rego, and policy/obligations.rego.

ⓧ I'd like to double check the full code

The full files are long. Scroll up and confirm each chunk is present in the correct file, in order:

  • policy/tier.rego: package declaration and default, prohibited/annex_i result rules, human_queue/annex_iii/transparency result rules, prohibited_reason helpers, structural helpers (annex_i_match through article_6_3_arguable), exception conditions and has_sealed_answer, transparency_reason helpers.
  • policy/conformity.rego: default route, Annex III points 2-8 internal control, biometrics notified body, Annex I sectoral.
  • policy/deadlines.rego: default, prohibited (original and new), transparency, Annex III, Annex I, GPAI, GPAI enforcement.
  • policy/obligations.rego: prohibited, high-risk provider, high-risk deployer, transparency, residual, GPAI.
Build the Python proof harness

The Rego rules are written but the engine is not proven until you run it against every sealed system and compare the result programmatically. The Python entry point run_tier.py orchestrates this: it loads each OSCAL component, converts its props to OPA input format, shells out to opa eval, and asserts 24/24 match.

  • Create run_tier.py in the project root. Start with the module docstring, imports, and path constants:
"""EU AI Act Tiering Engine - Entry Point.

Evaluates the AI estate against Rego policy rules,
proves correctness against sealed ground truth,
and computes re-tiering diffs under Regulation (EU) 2026/1744.
"""

import json
import subprocess
import sys
from pathlib import Path

# Paths relative to project root
PROJECT_ROOT = Path(__file__).parent
POLICY_DIR = PROJECT_ROOT / "policy"
SEALED_FILE = PROJECT_ROOT / "sealed" / "ground_truth.json"
REGISTER_FILE = PROJECT_ROOT / "component-definitions" / "ai-estate" / "component-definition.json"
OUTPUT_DIR = PROJECT_ROOT / "output"


def load_json(path: Path) -> dict:
    """Load and parse a JSON file."""
    with open(path, encoding="utf-8") as f:
        return json.load(f)

What does this code do?

The constants point to each artifact the engine needs: the Rego policy directory, the sealed ground truth, the OSCAL register, and the output folder. load_json is a shared utility used by both the prove and diff commands.

  • Below load_json, add the function that evaluates a single system by calling OPA:
def evaluate_system(system_id: str, props: dict, sealed_data: dict) -> dict:
    """Evaluate a single system against the Rego policy bundle."""
    input_data = {"system_id": system_id, "props": props}

    # Write temporary input file for OPA
    input_path = PROJECT_ROOT / "tmp_input.json"
    with open(input_path, "w", encoding="utf-8") as f:
        json.dump(input_data, f)

    # Write sealed data so OPA can check has_sealed_answer
    data_path = PROJECT_ROOT / "tmp_data.json"
    with open(data_path, "w", encoding="utf-8") as f:
        json.dump({"sealed": {k: True for k in sealed_data}}, f)

    cmd = [
        "opa", "eval",
        "-d", str(POLICY_DIR),
        "-i", str(input_path),
        "--data", str(data_path),
        "--format", "json",
        "data.aiact.tier.result"
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"OPA error for {system_id}: {result.stderr}")
        sys.exit(1)

    parsed = json.loads(result.stdout)
    expressions = parsed.get("result", [{}])[0].get("expressions", [{}])
    if expressions:
        return expressions[0].get("value", {})
    return {}

What does this code do?

For each system, evaluate_system writes the system's props into a temporary JSON file, calls opa eval with the policy directory and that input, then parses the structured JSON output to extract the tier result.

  • Add the extract_components helper and the first half of the prove function (the loop that evaluates each system):
def extract_components(register: dict) -> list:
    """Extract components from OSCAL component-definition."""
    return register.get("component-definition", {}).get("components", [])


def prove(register: dict, sealed_data: dict) -> bool:
    """Run the engine against all systems and prove correctness."""
    components = extract_components(register)
    disagreements = 0
    human_queue_count = 0

    print("=" * 60)
    print("EU AI Act Tiering Engine - Proof Run")
    print("Regulation (EU) 2024/1689 as amended by 2026/1744")
    print("=" * 60)

    for component in components:
        sys_id = component["uuid"]
        title = component["title"]
        props = {p["name"]: p["value"] for p in component.get("props", [])}

        result = evaluate_system(sys_id, props, sealed_data)
        tier = result.get("tier", "unknown")

        if tier == "human_queue":
            human_queue_count += 1
            print(f"  [QUEUE] {title} -> human_queue: {result.get('reason', '')}")
            continue

        if sys_id in sealed_data:
            expected_tier = sealed_data[sys_id]["tier"]
            if tier != expected_tier:
                disagreements += 1
                print(f"  [FAIL]  {title}: got {tier}, expected {expected_tier}")
            else:
                print(f"  [PASS]  {title} -> {tier}")
  • Add the prove summary that prints the final score:
    print()
    print("-" * 60)
    sealed_count = len(sealed_data)
    print(f"{sealed_count - disagreements}/{sealed_count} tiers correct. "
          f"{disagreements} disagreements.")

    if human_queue_count > 0:
        print(f"System 25 routed to human queue.")

    if disagreements == 0 and human_queue_count == 1:
        print("Engine proven.")
        return True
    else:
        print("ENGINE PROOF FAILED.")
        return False
  • Now add the diff function that computes re-tiering under the July 2026 amendment:
def diff(register: dict, sealed_data: dict) -> None:
    """Compute re-tiering diff under Regulation (EU) 2026/1744 of 8 July 2026."""
    pre_amendment = {"annex_iii": "2026-08-02", "annex_i": "2026-08-02"}
    post_amendment = {"annex_iii": "2027-12-02", "annex_i": "2028-08-02"}

    components = extract_components(register)
    changed = []

    for component in components:
        sys_id = component["uuid"]
        title = component["title"]

        if sys_id not in sealed_data:
            continue

        entry = sealed_data[sys_id]
        if entry["tier"] != "high_risk":
            continue

        track = entry.get("track", "")
        if track == "annex_iii":
            old_deadline = pre_amendment["annex_iii"]
            new_deadline = post_amendment["annex_iii"]
        elif track == "annex_i":
            old_deadline = pre_amendment["annex_i"]
            new_deadline = post_amendment["annex_i"]
        else:
            continue

        if old_deadline != new_deadline:
            changed.append({
                "system_id": sys_id,
                "system_name": title,
                "old_deadline": old_deadline,
                "new_deadline": new_deadline,
                "change_type": "deadline_extended",
                "reason": f"Regulation (EU) 2026/1744 extended {track} deadline"
            })
  • Below the loop, add the new-prohibition reporting and the output section:
    # Report new prohibition category
    new_rules = [{
        "change_type": "new_prohibition",
        "description": "AI systems generating or manipulating non-consensual intimate material or CSAM",
        "effective": "2026-12-02",
        "affected_current_systems": 0,
        "note": "Applies to future intake. No current estate system affected."
    }]

    print("=" * 60)
    print("Re-Tiering Diff: Regulation (EU) 2026/1744 of 8 July 2026")
    print("In force: 27 July 2026")
    print("=" * 60)
    print()
    print("Deadline changes:")
    for record in changed:
        print(f"  {record['system_name']}: {record['old_deadline']} -> {record['new_deadline']}")

    print()
    print("New rule categories:")
    for rule in new_rules:
        print(f"  {rule['description']} (effective {rule['effective']}, "
              f"{rule['affected_current_systems']} current systems affected)")

    print()
    print(f"Changed records: {len(changed)}")
    expected = 15  # 12 Annex III + 3 Annex I
    if len(changed) == expected:
        print(f"Matches hand-computed expectation ({expected}). Diff proven.")
    else:
        print(f"MISMATCH: expected {expected}, got {len(changed)}. Diff FAILED.")
  • Add the file-write and main entry point to close the file:
    OUTPUT_DIR.mkdir(exist_ok=True)
    with open(OUTPUT_DIR / "retier_diff.json", "w", encoding="utf-8") as f:
        json.dump({
            "amendment": "Regulation (EU) 2026/1744 of 8 July 2026",
            "in_force": "2026-07-27",
            "changed_systems": changed,
            "changed_count": len(changed),
            "expected_count": expected,
            "new_rules": new_rules
        }, f, indent=2)


def main():
    register = load_json(REGISTER_FILE)
    sealed_data = load_json(SEALED_FILE)

    if "--prove" in sys.argv:
        success = prove(register, sealed_data)
        sys.exit(0 if success else 1)
    elif "--diff" in sys.argv:
        diff(register, sealed_data)
    else:
        print("Usage: python run_tier.py --prove | --diff")
        sys.exit(1)


if __name__ == "__main__":
    main()

What does the diff function prove?

The diff encodes the pre-amendment and post-amendment deadlines, then iterates every high-risk system to find those whose deadline moved. It expects exactly 15 changed records (12 Annex III + 3 Annex I) and asserts that count. The new prohibition category is reported separately for future intake.

  • Save run_tier.py.
  • Before you run the proof, predict: do you think all 24 systems will match on the first run, or will the engine disagree on any?
  • Run the proof by executing:
python run_tier.py --prove

You should see each system printed with [PASS], System 25 routed to [QUEUE], and the summary:

24/24 tiers correct. 0 disagreements.
System 25 routed to human queue.
Engine proven.

Seeing a disagreement?

The output names the exact system ID and the mismatch (e.g., "got residual, expected high_risk"). Check that the component's props in component-definition.json include the correct annex_iii_area or safety_component value. The engine only reads what the register declares.

help me debug a tiering disagreement in my proof run

✔️ Awesome, I've got everything!

Great. Confirm you have saved run_tier.py and it runs cleanly.

ⓧ I'd like to double check the full code

The full run_tier.py is long. Scroll up and confirm each chunk is present in order:

  • Module docstring, imports, path constants, load_json.
  • evaluate_system function (writes temp files, calls OPA, parses output).
  • extract_components and prove loop.
  • prove summary (prints score and returns boolean).
  • diff function (deadline comparison loop).
  • diff output section (new_rules, print, JSON export).
  • main entry point with --prove and --diff flags.
Wire into GitHub Actions as the evidence pipeline

A passing proof run on your machine is good. A passing proof run on every push is evidence. The GitHub Actions workflow becomes the audit artifact: a green run is the proof record for the governance board.

  • Create the workflow directory by running:
mkdir -p .github/workflows
  • Create .github/workflows/prove.yml with the following CI pipeline:
name: Prove Tiering Engine

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  prove:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.14
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install OPA
        run: |
          curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64
          chmod 755 opa
          sudo mv opa /usr/local/bin/

      - name: Install compliance-trestle
        run: pip install compliance-trestle==4.2.0

      - name: Verify toolchain
        run: |
          opa version
          trestle version
          python --version
  • Below the verify step, add the validation and proof steps:
      - name: Validate OSCAL register
        run: trestle validate -t component-definition -n ai-estate

      - name: Prove tiering engine
        run: python run_tier.py --prove

      - name: Run re-tiering diff
        run: python run_tier.py --diff

What does this workflow do?

On every push or PR to main, the workflow checks out the code, installs the same toolchain you have locally, validates the OSCAL register, and runs both --prove and --diff. If any step fails, the run goes red and blocks the PR.

  • Save .github/workflows/prove.yml.
  • Write docs/operations.md alongside this step. Include three sections: how to add a system to the register, how to re-score rationales, and how to re-tier after a legislative amendment.
  • Commit all changes on a feature branch and push by running:
git add -A
git commit -m "feat: tiering engine, proof harness, CI pipeline"
git push origin feature/tiering-engine
  • Open a pull request to main in your GitHub repository.
  • Watch the Actions tab. The workflow should run and pass green.
  • Merge the PR and close the engine ticket in Linear.

Workflow failing on the OPA install step?

Confirm the curl URL uses opa_linux_amd64 (not opa_windows_amd64.exe). The runner is Ubuntu. Check that chmod 755 is present before the mv command.

help me fix my GitHub Actions workflow for the OPA tiering engine

Your tiering engine is now proven and continuously verified. Every push re-runs the 24-system proof and the re-tiering diff. Next up, you will build the scoring harness that gates model-drafted rationales before they enter the register.

Build the Scoring Harness

The tiering engine assigns each system its bucket deterministically. But the register also needs human-readable rationales explaining why each system landed where it did.

A model can draft those rationales, but a model's opinion is not the tier. Without a scoring gate that rejects weak rationales, the register accumulates plausible-sounding but inaccurate justifications. In this step, you will build a harness that scores model-drafted rationales against the sealed set. If the model cannot articulate the rule, its text does not enter the register. If drafting stalls entirely, the deterministic tiers still stand. That is graceful degradation: the system loses a feature rather than an answer.

In this step, get ready to:
  • Define the scoring rubric contract for each tier.
  • Build the scoring harness that grades rationales against the rubric.
  • Run the harness and record the result in a score report.
Define the scoring contract

Before the harness can grade anything, it needs a rubric. The rubric defines what a correct rationale must contain for each tier. A prohibited-tier rationale must name the Article and the deadline. A high-risk rationale must also name the conformity route, the Annex III point, and state that no presumption of conformity is available.

  • Create the agents/ directory and the rubric file by running:
mkdir agents
  • Create agents/scoring_rubric.json with the following content:
{
  "prohibited": {
    "required_elements": ["article_reference", "deadline"]
  },
  "high_risk": {
    "required_elements": ["article_reference", "conformity_route", "deadline", "annex_iii_point", "no_presumption"]
  },
  "transparency": {
    "required_elements": ["article_reference", "deadline"]
  },
  "residual": {
    "required_elements": ["article_reference"]
  }
}

What does this rubric encode?

Each tier key maps to a list of required elements that a correct rationale must contain. High-risk systems demand the most evidence: Article reference, conformity route, deadline, Annex III point, and an explicit statement that no harmonised standard provides a presumption of conformity.

Residual systems only need the Article reference (Article 4, AI literacy). The rubric is the contract the harness scores against.

  • Save agents/scoring_rubric.json.
  • Confirm the file exists by running:
ls agents/

You should see scoring_rubric.json listed in the output.

Don't see the file?

Make sure you created the agents/ directory before creating the file inside it. Run mkdir agents from the ai-act-register project root.

help me create the scoring rubric file in the right location

Build the scoring harness

The harness orchestrates a loop: for each sealed system, draft a rationale, score it against the rubric, and gate the run at 22 out of 24. The model sits beside the critical path, not on it.

  • Create harness.py in the project root. Start with the file header, imports, and constants:
"""Scoring Harness - Grades model-drafted rationales against sealed ground truth.

The model sits beside the critical path, not on it. If drafting stalls,
the deterministic tiers still stand and the register is still correct.
Rejects runs below 22/24.
"""

import json
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).parent
SEALED_FILE = PROJECT_ROOT / "sealed" / "ground_truth.json"
RUBRIC_FILE = PROJECT_ROOT / "agents" / "scoring_rubric.json"
OUTPUT_DIR = PROJECT_ROOT / "output"

THRESHOLD = 22
TOTAL_SEALED = 24


def load_json(path: Path) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)

What does this set up?

The constants define the contract: 24 sealed systems, threshold of 22 correct rationales before the run is accepted. RUBRIC_FILE points to the scoring rubric you just created. load_json is a shared utility for reading JSON files throughout the harness.

  • Below load_json, add the first part of the draft_rationale function. This extracts the sealed entry's properties and handles the prohibited tier:
def draft_rationale(system_id: str, props: dict, sealed_entry: dict) -> str:
    """Draft a rationale for the system's classification.

    In production, this calls the Claude API. For the proof run,
    it constructs a rationale from the sealed data to demonstrate
    the scoring mechanism. Replace this body with an actual API call.
    """
    tier = sealed_entry["tier"]
    article = sealed_entry.get("article", "")
    track = sealed_entry.get("track", "")
    area = sealed_entry.get("area", "")
    conformity = sealed_entry.get("conformity", "VI")
    deadline = sealed_entry.get("deadline", "")

    rationale = f"System {system_id} is classified as {tier} "
    if tier == "prohibited":
        rationale += f"under {article} of Regulation (EU) 2024/1689. "
        rationale += f"Deadline: {deadline} (in force). "

Why does draft_rationale use sealed data?

In production, this function calls the Claude API to generate a rationale from the system's OSCAL props alone. For this proof run, it constructs from the sealed data to demonstrate the scoring mechanism works. The scored gate still applies: the rubric checks the output regardless of how it was produced.

  • Directly below the prohibited branch (still inside draft_rationale), add the remaining tier branches and the return statement:
    elif tier == "high_risk":
        rationale += f"under Article 6({'1' if track == 'annex_i' else '2'}) "
        if area:
            rationale += f"(Annex III area: {area}). "
        rationale += f"Conformity assessment: Annex {conformity} "
        if conformity == "VI":
            rationale += "(internal control, no notified body per Article 43(2)). "
        elif conformity == "VII":
            rationale += "(notified body required; no harmonised standard cited in OJ). "
        else:
            rationale += "(sectoral conformity assessment). "
        rationale += f"Deadline: {deadline}. "
        rationale += "No presumption of conformity available. "
    elif tier == "transparency":
        rationale += f"under {article}. "
        rationale += f"Deadline: {deadline}. "
    elif tier == "residual":
        rationale += "(no tier-specific obligations). Article 4 AI-literacy applies. "
        rationale += "Residual is not a legal tier in the Act. "

    return rationale

What does each branch produce?

Each branch constructs a rationale string that names the elements the rubric demands for that tier. High-risk rationales include the conformity route (Annex VI or VII), the Annex III area, the deadline, and the explicit statement that no presumption of conformity is available. This mirrors the rubric contract exactly.

  • Verify syntax so far by running:
python -m py_compile harness.py

If the command exits silently with no output, your syntax is correct. If you see a SyntaxError, check your indentation in the draft_rationale branches.

Seeing a SyntaxError?

The most common cause is mismatched indentation between the elif branches. Every elif must align with the original if tier == "prohibited": line at 4 spaces of indentation inside the function.

help me fix the SyntaxError in my harness.py draft_rationale function

  • Below draft_rationale, add the score_rationale function that checks a rationale against the rubric:
def score_rationale(rationale: str, sealed_entry: dict, rubric: dict) -> bool:
    """Score a rationale against the rubric. Returns True if all elements present."""
    tier = sealed_entry["tier"]
    required = rubric.get(tier, {}).get("required_elements", [])

    for element in required:
        if element == "article_reference":
            article = sealed_entry.get("article", sealed_entry.get("track", ""))
            if article and article not in rationale:
                return False
        elif element == "conformity_route":
            conformity = sealed_entry.get("conformity", "")
            if conformity and conformity not in rationale:
                return False
        elif element == "deadline":
            deadline = sealed_entry.get("deadline", "")
            if deadline and deadline not in rationale:
                return False
        elif element == "annex_iii_point":
            area = sealed_entry.get("area", "")
            if area and area not in rationale:
                return False
        elif element == "no_presumption":
            if "No presumption" not in rationale and "no harmonised standard" not in rationale:
                return False

    return True

What does score_rationale check?

For each required element in the rubric, it checks whether the corresponding value appears as a substring in the rationale text. If any element is missing, the function returns False immediately. A rationale must contain ALL required elements for its tier to pass.

The no_presumption check looks for either "No presumption" or "no harmonised standard" in the text. Both phrases satisfy the requirement that the rationale acknowledges no presumption of conformity is available.

  • Add the first part of the run_scoring function, which loads data and iterates through all sealed systems:
def run_scoring():
    """Execute the scoring loop."""
    sealed_data = load_json(SEALED_FILE)
    rubric = load_json(RUBRIC_FILE)

    scores = []
    failures = []

    print("=" * 60)
    print("Scoring Harness - Rationale Grading")
    print("Model beside critical path. Tiers are deterministic.")
    print("=" * 60)

    for sys_id, entry in sealed_data.items():
        props = {}
        rationale = draft_rationale(sys_id, props, entry)
        passed = score_rationale(rationale, entry, rubric)
        scores.append(passed)

        if passed:
            print(f"  [PASS] {sys_id}")
        else:
            failures.append(sys_id)
            print(f"  [FAIL] {sys_id}")

What happens in the scoring loop?

For each of the 24 sealed systems, the loop calls draft_rationale to produce text, then score_rationale to grade it against the rubric. Each system gets a [PASS] or [FAIL] printed to the terminal so you can see which rationales the rubric rejected.

  • Directly below the scoring loop (still inside run_scoring), add the reporting logic that computes the score and writes the report:
    total_passed = sum(scores)
    print()
    print("-" * 60)
    print(f"Rationale score: {total_passed}/{TOTAL_SEALED} (threshold: {THRESHOLD})")

    if total_passed >= THRESHOLD:
        print("Run ACCEPTED.")
        status = "accepted"
    else:
        print("Run REJECTED.")
        print(f"Failing systems: {failures}")
        status = "rejected"

    OUTPUT_DIR.mkdir(exist_ok=True)
    report = {
        "score": total_passed,
        "total": TOTAL_SEALED,
        "threshold": THRESHOLD,
        "status": status,
        "failures": failures
    }
    with open(OUTPUT_DIR / "score_report.json", "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2)

    return status == "accepted"

What does the reporting section do?

The gate logic: if the total score is at or above 22 out of 24, the run is accepted. Otherwise it is rejected and the failing system IDs are printed. The report (score, threshold, status, failures) is written to output/score_report.json as the evidence artifact.

  • Finally, add the main function and the module entry point at the bottom of harness.py:
def main():
    if "--score" in sys.argv:
        success = run_scoring()
        sys.exit(0 if success else 1)
    else:
        print("Usage: python harness.py --score")
        sys.exit(1)


if __name__ == "__main__":
    main()

What does the entry point do?

The --score flag triggers the scoring loop. The exit code is 0 for accepted, 1 for rejected. This means CI pipelines can gate on the harness result just like the tiering proof.

  • Save harness.py.
  • Verify the complete file compiles without errors by running:
python -m py_compile harness.py

Silent output means the file is syntactically valid and ready to run.

Seeing an error on compile?

Check that the run_scoring function's reporting logic (the total_passed = sum(scores) section) is indented at 4 spaces inside run_scoring. It is a continuation of the function, not a new top-level block. Also confirm that main() is at 0 indentation (top level).

help me fix the compilation error in harness.py

✔️ Awesome, I've got everything!

Great. Confirm you have saved harness.py and the syntax check passed silently.

ⓧ I'd like to double check the full code

Your harness.py should contain these sections in order. Scroll up and confirm each one is present:

  • Module docstring, imports (json, sys, Path), constants (PROJECT_ROOT through TOTAL_SEALED = 24), and load_json.
  • draft_rationale function with tier branches for prohibited, high_risk, transparency, and residual, ending with return rationale.
  • score_rationale function iterating through required elements and returning True or False.
  • run_scoring function with the scoring loop, gate check (THRESHOLD), and score_report.json output.
  • main function with --score flag handling and if __name__ == "__main__" entry point.
Run the harness and record the result

The harness is built. Before you run it, think about what you expect to see. The proof-run version of draft_rationale constructs rationales directly from the sealed data, so it should produce text that passes the rubric for every system.

  • Before you run the harness, predict: do you expect the score to be 24/24, or will some rationales fail the rubric? Why?
  • Run the scoring harness:
python harness.py --score

You should see each system printed with [PASS] or [FAIL], followed by a summary line and the gate decision.

What should I see?

The output prints the score line: Rationale score: N/24 (threshold: 22). If the score meets or exceeds 22, you see Run ACCEPTED. followed by a clean exit. If below 22, you see Run REJECTED. with the failing system IDs listed.

The report is written to output/score_report.json regardless of outcome.

  • Confirm the score report was written by running:
cat output/score_report.json

You should see a JSON object with score, total, threshold, status, and failures fields.

Harness prints Run REJECTED?

Check which system IDs failed in the output. The proof-run version of draft_rationale builds rationales from the sealed data fields. If a system's sealed entry is missing a field (like conformity or area), the rationale will not contain the text the rubric expects. Verify your sealed/ground_truth.json has all required fields for each system.

help me debug why my scoring harness is rejecting some rationales

Now write the runbook that documents how to diagnose problems across the entire system.

  • Create docs/runbook.md with 5 failure modes, each structured as symptom, check, and fix:
# Runbook

SNAPSHOT: 2026-08-03. Subject moves with the law.

## Failure Modes

### 1. OPA returns unknown tier

**Symptom:** `run_tier.py --prove` prints a tier
as `unknown` or `residual` for a system you expect
to be high-risk or prohibited.

**Check:** Inspect the system's OSCAL props.
Run `opa eval` with the system's input JSON
and `--format pretty` to see which rules fired.

**Fix:** The system's props are missing a required
field (e.g. `annex_iii_area` is empty, or `practice`
is misspelled). Correct the component-definition
and re-run `trestle validate`.
  • Continue adding the remaining 4 failure modes to docs/runbook.md:
### 2. trestle validate fails

**Symptom:** `trestle validate -t component-definition
-n ai-estate` exits with a schema error.

**Check:** Read the error message for the field
path. Common causes: missing uuid, duplicate
component ID, or invalid prop value type.

**Fix:** Correct the JSON at the named path.
Re-run validation until it passes.

### 3. Score below threshold

**Symptom:** `harness.py --score` prints
`Run REJECTED` with failing system IDs.

**Check:** For each failing system, compare
the drafted rationale text against the rubric's
required_elements for that tier.

**Fix:** If using the proof-run stub, verify
sealed/ground_truth.json has all fields the
rubric checks. If using the Claude API, adjust
the prompt to name the missing element explicitly.
### 4. Diff count mismatch

**Symptom:** `run_tier.py --diff` prints
`MISMATCH: expected 15, got N`.

**Check:** List which systems the diff found
changed. Compare against the hand-computed
list: 12 Annex III + 3 Annex I = 15.

**Fix:** A system's track field is wrong in
ground_truth.json (e.g. marked annex_i when it
should be annex_iii), or a new system was added
without updating the expected count.

### 5. Sealed file out of date after OJ publication

**Symptom:** A new Official Journal publication
amends Regulation (EU) 2024/1689 and the sealed
ground truth no longer reflects the law.

**Check:** Compare the sealed file's deadlines
and tier assignments against the new instrument.

**Fix:** Update sealed/ground_truth.json with the
new deadlines or tier changes. Update the policy
Rego files. Re-run --prove to confirm 24/24.
This is the quarterly review the carrying cost
budgets for.

Why document failure modes now?

The runbook ships with the work it describes, never after it. These 5 modes cover the most common ways the system breaks: wrong props, schema violations, weak rationales, diff regressions, and legislative change. The person who inherits this system reads the runbook before they read the code.

  • Save docs/runbook.md.
  • Commit all new files and push:
git add agents/ harness.py output/score_report.json docs/runbook.md
git commit -m "Add scoring harness with rubric and runbook"
git push
  • Close the harness ticket in your Linear project.

Confirm the GitHub Actions pipeline passes green. The prove.yml workflow runs the tiering proof on every push. The harness is not wired into CI yet (that is optional), but the existing pipeline confirms nothing regressed.

Push fails or Actions pipeline red?

If the push is rejected, pull first with git pull --rebase then push again. If the Actions pipeline fails, check the run log. The workflow runs python run_tier.py --prove and python run_tier.py --diff. A failure means something in the policy files or sealed data changed unexpectedly.

help me fix my failing GitHub Actions workflow after adding the scoring harness

The scoring harness is proven and the runbook documents how to diagnose failures. Next up, you will encode the July 2026 amendment and run the re-tiering diff to show what moved when the law changed.

Run the Re-Tiering Diff, Write the Presentation, and Release

The law changed on 27 July 2026 when Regulation (EU) 2026/1744 entered into force. Every system in the estate whose deadline or obligation set shifted must be named, or the register is stale within days of being built.

In this step, you'll encode the amendment as a new Rego policy file, run the diff to prove the changed-record count matches your hand-computed expectation, write the closing presentation for both audiences, and tag the release.

In this step, get ready to:
  • Encode Regulation (EU) 2026/1744 as a Rego amendment file and run the re-tiering diff.
  • Write the closing presentation for engineering leadership and the board.
  • Complete the documentation set, tag the release, and close all tickets.
Encode the amendment and run the diff

The amendment moved high-risk deadlines AND added new Article 5 prohibitions for non-consensual intimate material and CSAM. You need the diff to name every system affected.

  • Create policy/amendment_2026_1744.rego by pasting the following code:
package aiact.amendment_2026_1744

import rego.v1

# Regulation (EU) 2026/1744 of 8 July 2026
# Published OJ 24 July 2026. In force 27 July 2026.
# Amends Regulation (EU) 2024/1689 deadlines and adds new prohibitions.

# Annex III high-risk systems: deadline moves to 2 December 2027
annex_iii_deadline := "2027-12-02"

# Annex I high-risk systems: deadline moves to 2 August 2028
annex_i_deadline := "2028-08-02"

# New Article 5 prohibition effective 2 December 2026
new_prohibition := {
    "practice": "non_consensual_intimate_material",
    "description": "AI systems generating or manipulating non-consensual intimate material or CSAM",
    "effective": "2026-12-02"
}

What does this file encode?

This Rego file formally captures the three changes Regulation (EU) 2026/1744 made to the AI Act:

  • Annex III high-risk deadline extended from 2 August 2026 to 2 December 2027.
  • Annex I high-risk deadline extended from 2 August 2026 to 2 August 2028.
  • New Article 5 prohibition covering non-consensual intimate material and CSAM, effective 2 December 2026.

The run_tier.py --diff command uses these values to compute which systems gained extended preparation time.

  • Save policy/amendment_2026_1744.rego.

Before you run the diff, think about how many systems should be affected. Your register has 12 Annex III high-risk systems and 3 Annex I high-risk systems. How many records do you expect the diff to report as changed?

  • Run the re-tiering diff by executing this command:
python run_tier.py --diff

You should see 15 changed records (12 Annex III + 3 Annex I), matching the hand-computed expectation exactly. The output also reports the new prohibition category with 0 current systems affected.

Why 15 and not more?

The 2 already-prohibited systems, 4 transparency systems, and 3 residual systems are unchanged by this amendment. System 25 (human queue) is excluded from the diff. Only the 15 high-risk systems had their deadlines moved.

The new prohibition category (non-consensual intimate material) exists for future intake but affects no current estate system. The diff reports it as a rule change with 0 affected systems.

  • Confirm the diff outputs were written by running:
ls output/

You should see retier_diff.json and score_report.json in the listing.

Diff count mismatch?

  • Check that your sealed/ground_truth.json has exactly 12 Annex III high-risk systems and 3 Annex I high-risk systems with the correct track values.
  • Confirm the component-definition has 25 components with matching UUIDs in the sealed file.

Why is my diff count wrong?

  • Now write the human-readable diff report. Create output/retier_diff.md summarising the 15 changed systems with their old and new deadlines, plus the new prohibition category note. Use the JSON output as the source.
  • Commit and push your changes on a feature branch, open a PR, and close the diff ticket when it merges.
Write the closing presentation for both audiences

The presentation is the deliverable the assignment turns on. It serves two audiences who need different things from the same system.

  • Create output/presentation.md and paste the engineering leadership section:
# Closing Presentation
## SNAPSHOT 2026-08-03

---

# Section 1: Engineering Leadership

## Topology

Four parts as one graph:
- OSCAL register (component-definitions/) feeds OPA engine (policy/)
- Engine feeds scored harness (harness.py) for rationale grading
- Diff operates on two policy versions (pre/post amendment)
- Entry point: run_tier.py --prove | --diff

## Rule Set

Instrument: Regulation (EU) 2024/1689 as amended by Regulation (EU) 2026/1744 of 8 July 2026.
In force: 27 July 2026.

## Deterministic/Model Separation

The tier is a function of stated facts evaluated by Rego rules.
The model drafts rationales under a scored gate (22/24 threshold).
If the model is wrong, the tier is still right.
If drafting stalls, the deterministic tiers still stand.

What does the engineering section communicate?

  • The topology shows how the four parts connect as a directed graph.
  • The rule set names the exact instrument and date so a reader can verify the encoding against the statute.
  • The deterministic/model separation explains why the model can fail without the system being wrong.
  • Add the failure modes and re-tiering findings to the engineering section:
## Failure Modes

- OPA returns unknown tier: input props missing or malformed
- Trestle validate fails: OSCAL schema violation in register
- Score below threshold: model rationales missed rubric elements
- Diff count mismatch: sealed file or component UUIDs inconsistent

## What Re-Tiering Exposed

- 15 systems gained extended preparation time
- No system lost obligations or changed tier category
- New prohibition category exists for future intake
- No current estate system triggers the new prohibition
  • Now add the board and risk committee section to the same file:
---

# Section 2: Board and Risk Committee

## What We Run

25 AI systems. 24 classified deterministically. 1 awaiting counsel.

| Tier | Count | Deadline |
|------|-------|----------|
| Prohibited | 2 | In force since 2 Feb 2025 |
| High-risk (Annex I) | 3 | 2 August 2028 |
| High-risk (Annex III) | 12 | 2 December 2027 |
| Transparency | 4 | 2 August 2026 (in force) |
| Residual | 3 | Article 4 only |
| Human queue | 1 | Pending counsel |

## What Changed (Regulation (EU) 2026/1744)

- 15 high-risk systems gained extended preparation time
- New prohibition: non-consensual intimate material/CSAM (2 Dec 2026)
- No system lost obligations
- Transparency and GPAI fining power unchanged (already in force)
  • Add the decision and limits to close the board section:
## Where the Model Was Wrong

Rejected rationales and their specific rubric failures are
recorded in output/score_report.json. Each failure names
the system ID and the missing rubric element.

## Residual Risk

- System 25: Article 6(3) exception arguable, awaiting counsel
- No harmonised standard cited in OJ. No presumption of conformity.
- A management-system certificate is not a shield.

## Decision Asked For

1. Approve this register as the Article 6 classification record.
2. Instruct counsel to resolve system 25.
3. Set quarterly review cadence.

## Limit

This proves faithful implementation of encoded rules.
It does not prove the encoding is correct law.
Counsel validates. This is not legal advice.

Why state the limit explicitly?

The board must know what this system proves and what it does not. It proves the engine implements the encoded rules faithfully. It does not prove the encoding is a correct reading of the law. That distinction is what separates engineering from legal advice.

  • Save output/presentation.md.
  • Verify the file exists by running:
ls output/presentation.md

You should see the file listed. The presentation now covers both audiences from the same underlying system.

Not sure what to include in the presentation?

The engineering section answers: how is the system built, and why is the tier deterministic? The board section answers: what do we run, what changed, and what decision is needed? If you are unsure about a value, pull it from output/retier_diff.json or output/score_report.json.

How should I structure my board presentation section?

Complete the documentation set, tag the release, and close the project

Documentation ships with the work it describes, never after it. Every page is marked as a SNAPSHOT because the subject is a moving regulation.

  • Create docs/limits.md with the following content:
# Limits and Non-Goals
## SNAPSHOT 2026-08-03

This system does NOT prove:
- That the encoding is a correct reading of the law (counsel validates)
- That the organisation is compliant (compliance requires implementation)
- That any harmonised standard provides a presumption of conformity

No harmonised standard whose reference has been published in the
Official Journal exists for any requirement of the AI Act as of
this date. Article 40(1) presumption is therefore unavailable.

A management-system certificate (e.g., ISO 42001) is not a
harmonised standard and does not trigger the presumption.

Where counsel is required:
- System 25 Article 6(3) exception determination
- Any reclassification triggered by new guidance or OJ publication
- Interpretation of Annex III area boundaries

This is not legal advice.

Source map: policy/tier.rego, policy/amendment_2026_1744.rego,
sealed/ground_truth.json, docs/decisions.md (decision record 3).

Why state these limits?

The system is an engineering artifact, not a legal opinion. Stating limits plainly prevents a reader from treating a passing pipeline as proof of compliance. The carrying cost of this system is keeping its rules current, not treating the build as finished.

  • Create docs/architecture.md with the SNAPSHOT header, a topology-at-a-glance diagram (reference docs/topology.md), the entry point (run_tier.py), and a source map naming the files behind each system part.
  • Create docs/agents.md with the SNAPSHOT header, the agent architecture describing ~32 agents across obligation families, the folder layout, and the scored-gate design that rejects weak rationales.
  • Create docs/configuration.md with the SNAPSHOT header, a configuration reference covering paths, thresholds (22/24 scoring gate), sealed file location, and Actions workflow settings.

SNAPSHOT header format

Each docs page should open with SNAPSHOT 2026-08-03 and end with a source-map entry naming the files behind it. This date tells the next reader when the page was last verified against the statute.

  • Confirm all documentation files exist by running:
ls docs/

You should see all nine files: architecture.md, agents.md, configuration.md, decisions.md, limits.md, operations.md, plan.md, quickstart.md, and topology.md.

  • Commit all remaining files, push, and merge your final PR.
  • Tag the release by running:
git tag v1.0.0
git push origin v1.0.0
  • In Linear, close all remaining tickets and set the project status to Completed.

The handover test

Someone who has never seen the repository reads docs/runbook.md and docs/decisions.md, runs python run_tier.py --prove, and can explain why a given system landed where it did and what would change that answer.

If they need the author on a call, it is not finished.

Run the prove command one final time to confirm everything still passes after all your changes:

  • Verify the full pipeline by running:
python run_tier.py --prove

You should see: 24/24 tiers correct. 0 disagreements. System 25 routed to human queue. Engine proven. The tagged release, closed tickets, and green pipeline are the evidence record.

Prove run failing after amendment file was added?

  • The amendment file defines data for the diff only. It should not interfere with the tier evaluation. Confirm it uses its own package name (aiact.amendment_2026_1744) and does not shadow rules in policy/tier.rego or policy/deadlines.rego.
  • Run opa check policy/ to validate all Rego files parse without errors.

Why is my prove run failing after adding the amendment file?

Your system is released, proven, and documented. The estate can answer what it runs, what tier each system is in, what changed when the law changed, and what decision is being asked for. Next, you can extend this into a full governance package that a risk committee can act on.

Secret mission

The Governance Package and Board Briefing

Your tiering engine is proven. But the risk committee does not read Rego. Can you translate the deterministic output into a governance package that names controls, assigns owners, computes coverage, and ends with the decision the board is being asked to make?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a private GitHub remote. There are no cloud resources, no metered API subscriptions, and no persistent infrastructure costs. The only ongoing cost is engineering time for maintaining the rule set as the law changes.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

The real carrying cost

The carrying cost of this system is maintaining the rules, not writing them. The law changed twice inside a year. Budget a standing quarterly review rather than treating the build as finished.

Resources you used:

  • Local ai-act-register/ project directory on your Desktop.
  • Private GitHub repository (with Actions workflow, tagged release, and all project files).
  • Linear project (tickets, status set to Completed).
  • OPA binary (opa.exe added to PATH).
  • compliance-trestle pip package (compliance-trestle==4.2.0).

Keep everything running

No action needed. Choose this if you plan to keep operating the register and extending it as the law evolves.

  • Run python run_tier.py --prove at any time to re-verify the engine against the sealed ground truth.
  • Set a quarterly review ticket in your Linear project to re-check the rule set against the Official Journal.
  • Add new systems to the OSCAL register and re-score as your AI estate grows.
  • The OPA binary and compliance-trestle package remain installed and available for future runs.

Pause - I'll come back to this later

There are no running processes or active infrastructure to shut down. The system is file-based and runs on demand, so pausing costs nothing.

  • Your local ai-act-register/ directory stays on your Desktop until you need it again.
  • Your GitHub repository stays private and intact. GitHub Actions only runs when you push.
  • Your Linear project remains in Completed status. Reopen it when you need to track new controls.
  • OPA and compliance-trestle stay installed. They consume no memory or CPU when idle.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Delete the local project directory by running this command in your terminal:

macOS

cd ~/Desktop
rm -rf ai-act-register

Windows

cd ~\Desktop
Remove-Item -Recurse -Force ai-act-register
  • Delete the GitHub repository:
  • Navigate to your repository on GitHub.
  • Click Settings.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository.
  • Click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.
  • Uninstall the compliance-trestle package:
pip uninstall compliance-trestle
  • Remove the OPA binary:

macOS

sudo rm /usr/local/bin/opa

Windows

Delete the opa.exe file from whichever directory you placed it in on your PATH.

  • Your Linear account and project remain on Linear's servers. If you want to remove the project, open the project in Linear, click the project menu, and select the archive or delete option.

Nice Work!

Nice Work!

You just built a regulatory-classification system that tells an organisation which of its AI systems carry which duties under the EU AI Act, proves it numerically, and re-tiers the entire estate when the law changes.

You've learned how to:

  • Build a deterministic tiering engine in Rego that classified 25 AI systems against Regulation (EU) 2024/1689 with 0 disagreements on 24 sealed cases, routed the ambiguous case to a human queue, and mapped each tier to its conformity-assessment route, obligations, deadline, and fine ceiling.
  • Run a re-tiering diff under Regulation (EU) 2026/1744 of 8 July 2026 that proved exactly 15 systems changed when the law was amended, plus a new prohibition category covering future intake.
  • Ship a reproducible, version-controlled compliance system through a private GitHub repository with a tagged release, a green GitHub Actions pipeline as the evidence artifact, all Linear tickets closed, and documentation that passes the handover test.
  • Secret Mission: Shipped the full governance package with a control library, risk register, evidence index linked to closed tickets, a board briefing stating control coverage and residual risk, a control-mapping diagram, and the four accuracy points stated plainly.

Ready to quiz yourself?