Skip to content
Back to skills

Compliance Gates Arttuan

ASecurity

Wire the brief into CI so the code cannot drift away from it — new vendors, new fields, expired exclusions and overdue findings all fail the build

  • 85 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 8, 2026
ai-agentspythongosqlnodeexpressgitdocumentation

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 8, 2026

npx -y skills add ThomasMoreAI/legal-skills-open --skill compliance-gates-arttuan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Compliance Gates Arttuan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Compliance Gates Arttuan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/thomasmoreai-compliance-gates-arttuan/badge)](https://www.skillsdirectory.com/skills/thomasmoreai-compliance-gates-arttuan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
---
name: compliance-gates-arttuan
title: Compliance Gates
description: Wire the brief into CI so the code cannot drift away from it — new vendors, new fields, expired exclusions and overdue findings all fail the build
author: ArttuAn
author_url: https://github.com/ArttuAn/eu-compliance-skills/tree/main/skills/compliance-gates
license: MIT
version: 0.1.0
execution_mode: open
jurisdiction: eu
practice: regulatory
language: en
---

# Compliance Gates

Everything else in this repo produces a record of what is true today. This skill
is what keeps it true.

Compliance records rot silently. A dependency is added and a vendor now receives
user data that no DPA covers. A migration adds a field the ROPA does not mention.
The 50th person is hired and the NIS2 exclusion stops being true. A finding's due
date passes. None of that announces itself, and by the time anyone notices, the
brief describes a product that has not existed for a year.

The fix is mechanical: **the brief is a file in the repository, and CI checks
the code against it on every push.** Drift becomes a failed build in the pull
request that caused it, which is the only moment it is cheap to fix.

> **Not legal advice.** A green build means the code matches the record. It does
> not mean the record is right — that is what the review in
> `references/verification.md` Tier 3 is for.

```
  push / PR
     │
     ▼
  read compliance.yaml
     │
     ├─► dependency diff  ──► new runtime vendor with no evidence file?  FAIL
     ├─► schema diff      ──► new personal-data field not in the map?    FAIL
     ├─► exclusions       ──► any expires_if now true?                   FAIL
     ├─► findings         ──► open blocker, or a due date passed?        FAIL
     ├─► accepted risks   ──► review_by passed?                          FAIL
     ├─► live checks      ──► non-essential storage before consent?      FAIL
     └─► generated docs   ──► SBOM missing for the tag? placeholders up? FAIL
     │
     ▼
  annotate the PR with the finding and the citation, not just a red X
```

## Use this when

- Immediately after `eu-grill-me`, in the same session. A brief with no gate has
  a shelf life of about one sprint.
- When adopting this repo on an existing codebase — the gate is what tells you
  how far the code has already drifted from whatever documentation exists.
- Before a release, as a pre-tag check that the release-scoped artifacts exist.

**Do not use this when** there is no brief to check against. And do not turn
every check on at once on a legacy codebase: you will get four hundred failures,
someone will disable the job, and the gate will never come back. Start in
warn-only mode, fix the backlog, then enforce.

## Ask first

1. **"Which checks should fail the build today, and which should only warn?"**
   The honest answer on an existing codebase is "warn on everything for two
   weeks". Say so rather than shipping a red pipeline.
2. **"What is your runtime dependency boundary?"**
   Which of these packages actually receives user data? A dev dependency does
   not need a DPA and flagging it destroys the signal.
3. **"Where is the schema of record — migrations, an ORM, a `schema.sql`?"**
   The field check needs one source of truth.
4. **"Who gets the failure?"**
   A gate failing to nobody is a gate that gets disabled. Name an owner per
   check.
5. **"Do you deploy to an environment I can probe?"**
   The consent and accessibility checks need a live URL to be worth anything.

## What the law requires

No article says "run CI". Three duties make it the practical way to discharge
them:

- **GDPR Art. 5(2) accountability** — continuous, not a point-in-time claim. A
  record that was accurate at signing and inaccurate since does not demonstrate
  anything.
- **GDPR Art. 25 data protection by design and by default** — obligations attach
  at the determination of means, which happens in every pull request that adds a
  field, not only at project kickoff.
- **AI Act Art. 9** — risk management for high-risk systems is explicitly "a
  continuous iterative process … throughout the entire lifecycle", requiring
  regular systematic review.
- **NIS2 Art. 21(2)(f)** — policies to **assess the effectiveness** of the
  measures. A check that runs is evidence; a policy that says you review annually
  is a plan.

## What to produce

**1. The checks.** Each one reads the brief and a fact about the repo.

```python
from dataclasses import dataclass


@dataclass
class GateResult:
    check: str
    passed: bool
    severity: str            # "fail" | "warn"
    message: str
    citation: str = ""
    fix: str = ""            # what to do, concretely


def check_new_vendors(brief, repo) -> list[GateResult]:
    """A runtime dependency that receives user data needs an evidence file."""
    known = {v["name"].lower() for v in brief.vendors()}
    out = []
    for package in runtime_dependencies(repo):
        if package.lower() in known or not package.receives_user_data:
            continue
        out.append(GateResult(
            "new-vendor", False, "fail",
            f"{package} is a runtime dependency that can receive user data, "
            f"and has no file in compliance/vendors/.",
            citation="Art. 28 GDPR",
            fix=f"Run `eu-evidence-pack vendor add {package}`, attach the DPA, "
                f"and record the transfer mechanism."))
    return out


def check_expired_exclusions(brief) -> list[GateResult]:
    """The most valuable check here: an exclusion that stopped being true."""
    facts = brief.facts()                    # headcount, turnover, features, surfaces
    out = []
    for exclusion in brief.regimes["ruled_out"]:
        condition = exclusion.get("expires_if")
        if not condition:
            out.append(GateResult(
                "exclusion-expiry", False, "warn",
                f"{exclusion['regime']} exclusion has no machine-evaluable expires_if.",
                fix="Add a condition over brief facts so this can be checked."))
            continue
        if evaluate(condition, facts):
            out.append(GateResult(
                "exclusion-expiry", False, "fail",
                f"{exclusion['regime']} was ruled out because "
                f"{exclusion['reason']!r}. That is no longer true: {condition}.",
                fix=f"Re-run eu-applicability; the {exclusion['regime']} duties may now apply."))
    return out


def check_findings(brief, today) -> list[GateResult]:
    out = []
    for finding in brief.findings:
        if finding.status == "open" and finding.severity == "blocker":
            out.append(GateResult("open-blocker", False, "fail",
                                  f"{finding.id}: {finding.statement}",
                                  citation=finding.citation, fix=finding.needs))
        elif finding.status == "open" and finding.due and finding.due < today:
            out.append(GateResult("overdue-finding", False, "fail",
                                  f"{finding.id} was due {finding.due} "
                                  f"(owner: {finding.owner}).",
                                  citation=finding.citation, fix=finding.needs))
    for accepted in brief.accepted_risks:
        if accepted["review_by"] < today:
            out.append(GateResult("accepted-risk-expired", False, "fail",
                                  f"{accepted['finding']} was accepted by "
                                  f"{accepted['accepted_by']} for review by "
                                  f"{accepted['review_by']}. That date has passed.",
                                  fix="Re-review, then extend with a new date or resolve it."))
    return out
```

The other checks follow the same shape: new personal-data fields absent from the
data map; a privacy notice changed without a consent version bump; an SBOM
missing for a release tag; placeholder markers increasing in the AI Act technical
documentation; a live probe for non-essential storage before consent.

**2. A warn-only mode, and a migration path.** On an existing codebase,
everything starts as `warn`, with a dated plan to promote checks to `fail`:

```yaml
# compliance/gates.yaml
mode: warn                      # warn | enforce
promote:
  - {check: open-blocker, enforce_from: "2026-09-20"}
  - {check: new-vendor, enforce_from: "2026-10-01"}
  - {check: exclusion-expiry, enforce_from: "2026-10-01"}
  - {check: unmapped-field, enforce_from: "2026-11-01"}
owners:
  new-vendor: "@priya"
  exclusion-expiry: "@dana"
```

A dated promotion schedule is the difference between a gate that gets adopted
and one that gets disabled on day two. The promotion dates themselves are then
checked by the gate, which is the only way they hold.

**3. The workflow:**

```yaml
name: compliance
on: [push, pull_request]
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {fetch-depth: 0}          # the diff checks need history
      - run: pip install -e ".[dev]"
      - run: <command> gate --brief compliance/compliance.yaml --annotate
      - run: <command> gate report --out compliance/GATE.md
      - uses: actions/upload-artifact@v4
        with: {name: compliance-gate, path: compliance/GATE.md}
```

**4. PR annotations, not a red X.** A failure must say what broke, cite the
article, and give the fix:

```
::error file=package.json,line=34::[compliance] `@segment/analytics-node` is a new
runtime dependency that can receive user data and has no file in compliance/vendors/.
Art. 28 GDPR requires a written processor contract. Fix: run
`eu-evidence-pack vendor add @segment/analytics-node`, attach the DPA, and record
the transfer mechanism. Owner: @priya
```

A gate that says "compliance check failed" teaches people to re-run it. A gate
that says exactly what to do gets fixed in the same pull request.

**5. A scheduled run**, not only on push. Some conditions become true with the
passage of time rather than with a commit: a due date passes, an accepted risk's
review date arrives, a document goes stale. Run the gate nightly and open an
issue rather than failing a build nobody triggered.

**6. The hard constraint runs first.** Before any drift check, the brief must
prove its own numbers: `python3 tools/hard_gate.py --brief compliance.yaml`
derives every certainty from the `trace` and exits 2 on a number the trace
cannot support. This is the deterministic back-end — the skill is the soft
instruction; the tool is the gate. The user picks the policy each run:
`--policy block` (default, CI), `--policy record` (agent, pre-build: appends an
append-only finding per violation and normalizes the numbers), `--policy warn`
(onboarding). `EU_COMPLIANCE_POLICY` can set the default. Whatever the policy,
an inflated certainty, a no-trace brief, a fabricated article, a Directive
without a Member State, and a recorded `VAGUE` are violations — not opinions.

**Every assessment carries a certainty.** Each finding, each applicable or
ruled-out regime, and the headline of the brief carry `certainty` (0–100) — the
honest percent of the assessment that rests on verified sources and confirmed
facts rather than inference, open unknowns and interpretation. Wherever you
state an applicability, a classification, a citation-backed obligation or a
finding, show the percentage **and the single largest reason it is not higher**:
"ruled out NIS2 — certainty 66%: the national transposition was not read".
`references/certainty.md` holds the anchors; the rules that matter here: an
answer graded `UNKNOWN` caps anything resting on it at 70, an unread Directive
transposition caps at 60, a classification pending counsel caps at 50, and a
`100` requires every deciding fact SPECIFIC, every citation verified on EUR-Lex,
every transposition read and every regime screened. Certainty is visibility, not
a free pass — VAGUE stays rejected, an open blocker stays blocked.

## Failure modes

- **Everything enforced on day one.** Four hundred failures, the job gets
  disabled, and the gate never returns. Warn first, with a dated promotion plan.

- **Flagging dev dependencies.** A linter does not need a DPA. Scope to runtime
  dependencies that can actually receive user data, or the signal drowns.

- **Exclusions with no machine-evaluable condition.** Then the most valuable
  check in the gate degrades to a reminder nobody reads. `expires_if` should be
  an expression over brief facts wherever it can be.

- **A gate with no owner.** It fails, the pipeline is red, someone adds
  `continue-on-error: true`. Name an owner per check and route the failure.

- **Push-only.** Time-based conditions never fire, and overdue findings
  accumulate invisibly. Schedule it.

- **Failure messages with no fix.** "Compliance check failed" gets re-run. Give
  the article, the reason, and the command.

- **The gate treated as the review.** Green means the code matches the record.
  Whether the record is right is a question for a human, and the gate should say
  so in its own output.

- **The brief edited to make the gate pass.** The most corrosive failure. Derive
  `status` rather than reading it, require a named human and a review date for
  acceptances, and treat a brief diff in a PR as requiring review from the
  compliance owner — a CODEOWNERS entry on `compliance/` costs one line.

## Verify

Follow `references/verification.md`. For this skill specifically:

1. **Tier 0** — install, `--help`, run the gate against a fixture repo and brief.

2. **Tier 1** — `pytest -q`, offline:

```python
def test_new_runtime_vendor_fails(repo_with_new_dep, brief):
    results = check_new_vendors(brief, repo_with_new_dep)
    assert any(not r.passed and r.severity == "fail" for r in results)
    assert "Art. 28" in results[0].citation
    assert results[0].fix


def test_dev_dependency_does_not_fail(repo_with_new_dev_dep, brief):
    assert check_new_vendors(brief, repo_with_new_dev_dep) == []


def test_expired_exclusion_fails(brief):
    brief.subject["headcount"] = 60          # nis2 exclusion said >= 50
    results = check_expired_exclusions(brief)
    assert any(r.check == "exclusion-expiry" and not r.passed for r in results)
    assert "no longer true" in results[0].message


def test_exclusion_without_a_condition_warns(brief_loose_exclusion):
    results = check_expired_exclusions(brief_loose_exclusion)
    assert results[0].severity == "warn"


def test_open_blocker_fails(brief_with_blocker):
    assert any(r.check == "open-blocker" for r in check_findings(brief_with_blocker, today()))


def test_overdue_finding_fails(brief):
    brief.findings[0].due = "2026-01-01"
    results = check_findings(brief, today="2026-09-15")
    assert any(r.check == "overdue-finding" for r in results)
    assert brief.findings[0].owner in results[0].message


def test_expired_acceptance_fails(brief_with_accepted_risk):
    brief_with_accepted_risk.accepted_risks[0]["review_by"] = "2026-01-01"
    results = check_findings(brief_with_accepted_risk, today="2026-09-15")
    assert any(r.check == "accepted-risk-expired" for r in results)


def test_unmapped_schema_field_fails(repo_with_migration, brief):
    results = check_fields(brief, repo_with_migration)
    assert any("users.phone_number" in r.message for r in results)


def test_warn_mode_never_exits_nonzero(brief_with_blocker, gates_config_warn):
    assert run_gate(brief_with_blocker, gates_config_warn).exit_code == 0


def test_promotion_date_moves_a_check_to_fail(gates_config):
    gates_config.promote[0]["enforce_from"] = "2026-01-01"
    assert effective_severity("open-blocker", gates_config, today="2026-09-15") == "fail"


def test_status_is_recomputed_not_read(brief_file_claiming_clear):
    assert load(brief_file_claiming_clear).status == "blocked"


def test_every_failure_carries_a_fix(all_results):
    for result in all_results:
        if not result.passed:
            assert result.fix, f"{result.check} has no fix instruction"


def test_gate_output_makes_no_compliance_claim(report):
    text = report.lower()
    assert "compliant" not in text or "does not mean" in text
```

3. **Tier 2** — report: checks enabled, checks in warn versus enforce, failures
   by check, and the promotion schedule with dates. On an existing codebase also
   report the initial backlog size — that number is the honest measure of how far
   the code had already drifted.

4. **Tier 3** — introduce real drift deliberately. Add a dependency that
   receives user data, add a field, bump headcount past the cap. Confirm each one
   fails with a message someone could act on without asking what it means. A gate
   that has never been shown to fail has not been shown to work.

Then tell the user which checks are enforcing and which are warning, when each
promotes, and — plainly — that a green gate means the code matches the record,
not that the record is right.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.