enola/ Blog/ Policy as code: what a code graph can check

Blog

Policy as code, checked against the graph.

A rule such as "reporting code must never reach cardholder data" depends on relationships across the repository. This example defines seven structural constraints and then makes three changes that pass go build and go vet but fail the constraint check.

Policy as code is common at the infrastructure layer. Terraform modules can prevent public S3 buckets, OPA can evaluate Kubernetes manifests, and cloud posture tools can flag unencrypted volumes. These systems work with structured input that is already suitable for automated checks.

Application-level controls often depend on relationships across files. PCI DSS 1.2, for example, can require reporting systems to remain outside the cardholder-data environment. Checking that boundary means determining whether reporting code can reach cardholder code through any call path. A text search cannot answer that question, and a file-level rule does not have enough context to find an indirect path.

Enola models a repository as a graph of modules, symbols, dependencies, calls, routes and storage. Structural compliance controls can then be expressed as queries over that graph and checked in CI. The examples below come from examples/policy-as-code in Enola 0.4.16; command output is reproduced unchanged.

Seven controls on a small Go module

The example uses a small payments module with directories for each part of the system:

text
cardholder/   stores card numbers
gateway/      provides the approved path into cardholder/
checkout/     handles tokens rather than card numbers
analytics/    is outside the cardholder-data scope
customers/    stores personal data
legacy/       contains card storage allowed by an older policy

Two files under enola/constraints/ define seven PCI DSS and GDPR-inspired rules that can be evaluated from repository structure.

ControlConstraintModeWhat is checked
PCI DSS 3.4protect / ownersstrictOnly gateway/ may call the card vault
PCI DSS 1.2forbid_reachstrictanalytics/ cannot reach the card vault through any measured call path
PCI DSS 12.5require_governedstrictEvery file in the cardholder-data environment is linked to a policy page
PCI DSS 12.5.2capadvisoryExpanding the audited boundary produces a warning
PCI DSS 6.5forbid_fact + governed_bystrictCode covered only by the retired policy must be removed
GDPR Art. 5(1)(f)forbid / to_namestrictCode that handles personal data cannot call log.*
GDPR Art. 30require_governedstrictEvery file that handles personal data is linked to a policy record

How one control is written

A declaration has two sections. components select measured facts, and rules define constraints on those selections. This rule prevents analytics code from reaching the cardholder-data component:

yaml
components:
  - name: cardholder-data
    match: ["cardholder/**"]
  - name: analytics
    match: ["analytics/**"]

rules:
  - id: pci-dss-1-2-analytics-stays-out-of-scope
    forbid_reach: analytics
    to: cardholder-data
    mode: strict
    because: >-
      PCI DSS 4.0 req 1.2: reporting is outside the audited boundary. Not "does
      not read a PAN today" but "cannot reach one by any measured path", because
      a path that exists is a path an incident will find.

The because: field is required and appears with every finding. It gives reviewers the control reference and the reason for the rule, rather than only a rule ID.

The mode field controls enforcement. ratchet, the default, fails only new violations. advisory reports at confidence 0.90 and does not fail the default check. strict fails even when the baseline already contains the violation. Six rules in this example are strict. The scope cap is advisory because expanding the audited boundary may be intentional and requires review rather than an automatic rejection.

The policy page is part of the check

Two rules use the knowledge pages in policy/. These Markdown files contain an enola_intent front matter block that links a policy decision to the code it covers:

markdown
---
title: Cardholder data environment
enola_intent:
  page:
    type: policy
    status: living
    scope: [policy-as-code]
    origin: [repo]
    anchors:
      - {repo: policy-as-code, path: cardholder}
      - {repo: policy-as-code, path: cardholder/vault.go}
      - {repo: policy-as-code, path: gateway/gateway.go}
---

Enola uses these links in both directions. require_governed finds files in the cardholder-data component that are not linked from any policy page. governed_by selects the files linked from a page so other rules can apply to them. In this example, a retired 3.2.1 policy page with status: superseded identifies code covered by the old standard without duplicating its file paths in the constraint declaration.

The clean run

The example starts compliant. enola check --fail-on=constraints on the initial tree:

shell
PASS — no structural regression.
could not see: 7 depends_on targets outside the graph
law: 7 rules (6 strict, 1 advisory) · 2 breaches · 2 excused (100%) · oldest excuse 38 days

Exempted by declaration (2) — carve-outs the rules themselves declare, never failed:
  - [constraints] 0.90 — Exempted from constraint pci-dss-6-5-code-under-a-retired-policy-is-removed: legacy.Append is measured in retired-policy-code
      exempted by dana since 2026-08-01 — Written only by the backfill job, which is deleted with the type.
  - [constraints] 0.90 — Exempted from constraint pci-dss-6-5-code-under-a-retired-policy-is-removed: legacy.Row is measured in retired-policy-code
      exempted by dana since 2026-08-01 — The migration reads these rows until the token backfill finishes. Tracked as CARD-4412, and the row type carries no PAN, only last four.

The output distinguishes a clean result from an incomplete one. The could not see line lists unresolved dependencies even when the check passes.

The law: summary reports seven rules and two exempted violations, with the oldest exemption dated 38 days earlier. This makes a pass with exemptions distinguishable from a pass with no violations.

Exemptions appear on every run with their owner, date and reason.

Three changes the Go toolchain accepts

The script then makes three individually plausible edits:

Both Go commands pass:

shell
########## 5. Check whether the Go toolchain accepts the changes
go build and go vet: passed

The constraint check reports three strict violations:

shell
FAIL — 3 structural regressions introduced.
could not see: 7 depends_on targets outside the graph; 1 imports targets outside the graph
law: 7 rules (6 strict, 1 advisory) · 6 breaches · 2 excused (33%) · oldest excuse 38 days

Regressions (fail):
  - [constraints] 1.00 — Strict constraint gdpr-art-5-1-f-personal-data-never-reaches-the-log violated: customers.ProfileStore.Erase -> log.Printf via calls
      forbidden calls edge
      customers/store.go:13
      }
  - [constraints] 1.00 — Strict constraint pci-dss-1-2-analytics-stays-out-of-scope violated: analytics.Reconcile reaches cardholder.ReadPAN
      reachable in 2 hop(s)
  - [constraints] 1.00 — Strict constraint pci-dss-12-5-every-in-scope-file-has-a-decision violated: cardholder/rotate.go has no governing page
      no anchor from any compiled page

Policy: fail on new findings from [constraints] at confidence >= 1.00.

New findings (advisory — below the failure policy):
  - [constraints] 0.90 — Advisory constraint pci-dss-12-5-scope-does-not-creep violated: cardholder-data has 5 members over a cap of 4
      beyond the declared cap
      cardholder/vault.go:15
      func Store(token, pan string) Card {

The exemption rate falls from 100% to 33% because the check finds four new violations: three strict and one advisory. No exemption changed.

Why repository-wide context matters

Each strict violation checks a different structural relationship.

Reachability. analytics.Reconcile calls gateway.Charge, which calls cardholder.ReadPAN. The forbid_reach rule follows the complete measured path and reports reachable in 2 hop(s). A rule that sees only the changed file would miss this indirect access.

Policy coverage. cardholder/rotate.go is inside the audited component but is not linked from a policy page. The code may be correct, but the scope inventory is now incomplete. This check compares the repository with its policy records rather than evaluating code quality.

A forbidden edge. A custom linter could ban log in the customers package. This constraint also reports why: the application log has a different retention period, access list and erasure path, so logging a subject identifier creates another copy of personal data.

The advisory finding reports that cardholder-data has 5 members over a cap of 4. It does not fail the build because expanding the audited boundary may be intentional. The team can review the change and update the declared cap when appropriate.

Recording exceptions

Exceptions need to be recorded, assigned and reviewed. Enola supports four responses to a violation:

  1. Fix it. Change the code so it satisfies the rule.
  2. Exempt it. Mark one specific finding as outside the rule's scope and record why.
  3. Suppress it. Record a temporary exception in .enola/suppressions.yaml so strict enforcement does not block remediation work.
  4. Baseline it. Use ratchet mode to allow existing violations while preventing new ones. A baseline records prior state but does not document a decision.

An exemption requires the exact finding, an owner, a reason and a date. Enola rejects incomplete entries:

yaml
    exempt:
      - witness: "legacy.Row is measured in retired-policy-code"
        owner: dana
        because: >-
          The migration reads these rows until the token backfill finishes.
          Tracked as CARD-4412, and the row type carries no PAN, only last four.
        since: "2026-08-01"

An exempted finding does not fail the check, but it appears in a separate section at confidence 0.90 with its owner, date and reason. If the exemption no longer matches a finding, Enola reports it at confidence 0.40 so the team can remove it or correct the witness.

enola constraints ledger summarizes each rule, its violations and its exemptions:

shell
law: 7 rules (6 strict, 1 advisory) · 6 breaches · 2 excused (33%) · oldest excuse 38 days
read from the snapshot generated 2026-09-08T16:27:50Z — a rule declared since then is not counted here

pci-dss-6-5-code-under-a-retired-policy-is-removed [strict] — 0 breaches reported, 2 excused · declared in enola/constraints/pci-dss.yaml
    because: The 3.2.1 page permitted card rows in legacy/ and the current page does not. Superseding a policy does not delete the code written under it, so the migration is stated here and stays visible until the directory is empty.
    exemption by dana (2026-08-01, 38 days ago) — legacy.Row is measured in retired-policy-code
        "The migration reads these rows until the token backfill finishes. Tracked as CARD-4412, and the row type carries no PAN, only last four."
gdpr-art-5-1-f-personal-data-never-reaches-the-log [strict] — 1 breach reported, 0 excused · declared in enola/constraints/gdpr.yaml
    because: GDPR Art. 5(1)(f): integrity and confidentiality. A subject's name in an application log is personal data in a system with a different retention period, a different access list and no erasure path, which turns one logging call into a second copy nobody can delete.

2 rules with no breaches and no excuses.

Tracking the exemption rate over time can show rules that need review. A high rate may mean the rule is too broad, outdated or routinely bypassed.

How teams can use these checks

Generate evidence continuously. Instead of assembling all evidence at audit time, teams can run the same declared checks on every commit. Each result refers to a timestamped snapshot of the code.

Review scope changes when they happen. The size of a PCI DSS cardholder-data environment affects assessment work. cap and require_governed report new files and paths that expand the declared boundary during pull-request review.

Keep policy records aligned with code. Linking a GDPR Article 30 record to files means that adding an uncovered file to customers/ produces a finding. The check detects when the declared record and repository structure diverge.

Route policy changes to their owners. Teams can split declarations into domain-specific files under enola/constraints/. Findings identify the source declaration, and CODEOWNERS can require the responsible team to review changes to it.

Identify checks that could not run. enola constraints lint prints how many members each component matched. Rules that lack required data report a named reason instead of passing: a cross-repository rule can be unasked, require_consumer can report no_counterparty, and require_governed can report no_compiled_pages. This prevents a missing input from looking like compliance.

What else you can declare today

The example uses seven of Enola's 21 rule forms. Other forms can express structural controls such as:

The complete rule and selector reference is in docs/CONSTRAINTS.md. The site also has a shorter guide to intent and constraints.

Checking constraints before editing

A failed pull request requires rework after the code has already been written. Showing relevant constraints during planning gives a developer or coding agent the information earlier.

enola plan reports which declared constraints apply to an intended change. The plan_check MCP tool provides the same information to a coding agent. This example checks a path before the file exists:

shell
Target cardholder/rotate.go (path)
  No measured fact lives at this path — governance below covers code about to be written.
  Component cardholder-data (declared in enola/constraints/pci-dss.yaml)
    rule pci-dss-1-2-analytics-stays-out-of-scope [strict]: analytics must not reach cardholder-data through any path over calls, depends_on, implements, imports
      because: PCI DSS 4.0 req 1.2: reporting is outside the audited boundary. Not "does not read a PAN today" but "cannot reach one by any measured path", because a path that exists is a path an incident will find.
    rule pci-dss-12-5-scope-does-not-creep [advisory]: cardholder-data must not exceed 4 members
      because: PCI DSS 4.0 req 12.5.2: scope is confirmed, not assumed. Growth here is the audited boundary getting larger, which is a decision. Advisory on purpose: the right answer is often to accept the growth and re-declare the number, and a gate cannot make that call.
    rule pci-dss-3-4-vault-reached-only-through-the-gateway [strict]: only payment-gateway may reach cardholder-data via calls
      because: PCI DSS 4.0 req 3.4: the PAN is readable in one place. Every other caller works with a token, so a second reader is a second thing an assessor has to audit and a second place the number can leak.

A report, never a gate: these verdicts are for the caller to weigh before editing.

The report lists three applicable rules with their modes, reasons and source files. For a concrete diff, enola plan --patch change.diff applies the patch to a temporary copy, generates facts for both versions and reports which violations the patch would add or resolve. It does not modify the working tree or its .enola directory.

A coding agent cannot infer from the source alone that analytics/ is outside PCI scope. With plan_check and constraints_for, it can receive the relevant constraints and exemptions before editing and check the result afterward.

This is particularly useful for agent-generated changes, which can be larger or more frequent than reviewers can inspect in detail. A two-hop reachability violation should not depend on manual review alone.

Running the checks in CI

The Enola Architecture Check GitHub Action runs the same constraint check:

yaml
name: Compliance constraints
on:
  pull_request:
  merge_group:

permissions:
  contents: read
  security-events: write

jobs:
  enola:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: enola-labs/enola-action@v2
        id: enola
        with:
          fail-on: constraints
          sarif: "true"
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: ${{ steps.enola.outputs.sarif-file }}

Four settings and outputs are relevant here.

fail-on: constraints uses the default threshold. Strict and ratchet constraints report at confidence 1.00, which meets the action's default failure threshold. No confidence setting is required.

Enforcement is opt-in. Without fail-on, the action reports findings but does not fail. It adds a job warning and summary notice so the result is clearly marked as report-only. warn-only: "true" also preserves detailed output without failing the job.

The pull-request summary includes exemptions. The job summary includes the CLI's law: line, unresolved inputs and a list of exempted findings. Reviewers can see the owner and reason for each exception alongside the check result.

SARIF preserves rule and exemption details. With sarif: "true", the action produces SARIF 2.1.0 with one entry per rule ID, the because: text as the description, the source span and a stable fingerprint. Exempted and suppressed findings use SARIF suppressions entries with their justification. Uploading the file to GitHub code scanning provides alert history and dismissal records.

Enola runs on the GitHub Actions runner. The action does not upload source code to Enola.

What this cannot check

These constraints cover only properties present in the repository snapshot.

Runtime and data properties are not measured. The example cannot check encryption at rest, key rotation, retention periods, lawful basis, access logging or cross-border transfers. It checks structural reachability and policy coverage.

Scope is declared, not discovered. Enola does not detect personal data. Someone must declare that customers/ handles it. An incomplete or incorrect declaration produces an incomplete or incorrect check.

Control identifiers are plain text. Constraints do not yet have a dedicated control-ID field. The example puts text such as "PCI DSS 4.0 req 3.4" in because:, so reports must extract the identifier from that string.

These checks do not replace an assessor, a data protection officer or a control framework. They automate a limited set of structural comparisons between policy declarations and code.

Run it

The script works on a temporary copy, leaving the example files unchanged. It prints all seven steps, including the Go and Enola checks:

shell
git clone https://github.com/enola-labs/enola
cd enola/examples/policy-as-code
./run.sh

To try it on another repository, enola constraints init . proposes rules when a built-in recipe matches the repository's directories. You can also write a declaration with two components and one rule. Run enola constraints lint . to check the syntax and see what each selector matches.

A practical starting point is one existing policy control in advisory mode. Review the initial findings before enabling enforcement.

enola is open source under Apache 2.0 and runs locally without uploading source code.

shell
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh