Docs / guides / ci-policy-checks · Edit on GitHub

CI Policy Checks

rgctl can enforce architecture rules in CI and before merge: impact-zone limits, centrality alerts, and domain isolation. Policy files are JSON; commands exit 0 on pass and 1 on failure.

This guide focuses on which command to use, how to wire pull-request gates, and copy-paste CI recipes. For the full policy schema see policy-format.md. For engineering internals see ci-policy-checks-design.md.


Which command?

GoalCommandGraphs neededBlocks on
Pre-commit / dirty working treecheckOne (discover.rgctl/)Any violation in git scope
PR gate: only new breakage vs mainpr-check (default)Base cache + delta headnew (+ regression by default)
Same as pr-check from checkcheck --temporalBase cache + delta headSame as pr-check
Preview uncommitted edits temporallypr-check --synthetic-head worktreeBase cache + HEAD snapshot + worktreeSame as pr-check
One-off symbol reviewblast-radius SYMBOL --policy-fileOneThat symbol only

Rule of thumb

  • check — “Did my local edits touch functions that violate policy?” (single snapshot, git-scoped symbols).
  • pr-check — “Did this PR introduce new policy violations compared to main?” (base vs head, temporal classes, graph diff).

Most teams want pr-check on pull requests with scope.new_violations_only: true so legacy debt on main does not block every PR.


Quick start: PR gate on main

1. Index the repo once (per machine / cache refresh):

rgctl -r "$REPO" discover .

2. Save a base artifact from main:

git checkout main
rgctl -r "$REPO" discover .
mkdir -p "$REPO/.rgctl-base" && cp -a "$REPO/.rgctl" "$REPO/.rgctl-base/"
git checkout -   # back to your branch

3. Run the temporal gate:

rgctl -r "$REPO" -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main \
  --head-ref HEAD \
  --strict

By default pr-check does not require a second full discover on the PR branch. It copies the base snapshot into .rgctl/, applies the git name-status delta (with optional caller cascade), and evaluates policy on scoped entities only.

Exit 1 when passed is false. With the sample PR policy, only new and regression violations fail the gate.


One-time setup

Graph artifacts

After discover, artifacts live under {repo}/.rgctl/:

.rgctl/
  graph.snapshot.bin       # required
  analysis_results.bin     # optional; speeds centrality reuse
  violation_ledger.jsonl   # appended by pr-check (violation timeline)

For PR gates, cache main (or merge-base) separately:

.rgctl-base/.rgctl/graph.snapshot.bin     # local default for --base-artifact
# or
$RGCTL_BASE_ARTIFACT/.rgctl/graph.snapshot.bin
# or
.rgctl-cache/<sha>/.rgctl/                # CI cache per commit (see below)

Resolution order for the base snapshot: --base-artifact$RGCTL_BASE_ARTIFACT{repo}/.rgctl-base/.

Deterministic node IDs (migration)

Node UUIDs are now stable across re-indexing when file_path + name are known. Upgrade once after pulling a release that includes this change:

rm -rf .rgctl .rgctl-base
rgctl discover .

Then rebuild .rgctl-base/ from main as above. See release notes.


Policy files

Example PR policy shipped with rgctl: rgctl-tests/rgctl-pr-policy.json.

{
  "max_impact_nodes": 50,
  "centrality_alert_threshold": 0.15,
  "scope": {
    "new_violations_only": true,
    "fail_on_regression": true
  },
  "size_limits": {
    "max_changed_files": 500,
    "max_scoped_entities": 5000
  }
}
FieldRole in PR CI
max_impact_nodesBlast impact zone cap per scoped function
centrality_alert_thresholdCascade hazard when high-betweenness nodes are reached
scope.new_violations_onlytrue → only new / regression fail the gate
scope.fail_on_regressionFail when a resolved violation reappears (ledger-backed)
size_limits.*Abort if the PR scope is too large (runaway PR protection)
temporal.*Grace periods, SLA aging, sunset dates (optional)

Stricter smoke-test policies: examples/policy-strict.json, examples/policy-permissive.json.

Full schema: policy-format.md.


rgctl check

Evaluates policy on functions touched in a git diff, using one graph snapshot (.rgctl/).

# Working tree vs last commit (default scope)
rgctl -r "$REPO" -f json check --policy-file policy.json

# Commits on current branch
rgctl -r "$REPO" -f json check \
  --policy-file policy.json \
  --base-ref origin/main \
  --head-ref HEAD \
  --strict
FlagEffect
(none)Scope = git diff --name-only HEAD (uncommitted + staged vs HEAD)
--base-ref + --head-refScope = paths changed between those commits
--strictFail if git scope is empty (no “check everything” fallback)
--temporalRun the same pipeline as pr-check (base cache + delta head)

Policy field scope.strict_diff: true also enables strict mode for check.

When to use: local pre-commit hooks, nightly jobs on a single snapshot, or quick gates without maintaining a main cache. For merge gates that compare against main, prefer pr-check (or check --temporal).

Example: CoolStore

rgctl -r example/coolstore discover .
rgctl -r example/coolstore -f json check \
  --policy-file example/coolstore/policy.json

A strict max_impact_nodes policy will report many scale failure violations on lodash helpers — expected on a large dependency graph. That illustrates why PR workflows use new_violations_only instead of failing on all existing debt.


rgctl pr-check

Temporal PR gate: compare base (main) vs head (PR), classify each violation, and optionally report graph diff stats.

How it works (default: delta head)

  .rgctl-base/          git diff              PR source tree
  (main snapshot)   base_ref..head_ref      (checkout)
        │                    │                      │
        └──────── seed ──────┴── delta compact ─────┘
                              │
                         .rgctl/  (synthesized head)
                              │
                    scoped policy eval → JSON + exit code
  1. Open base snapshot from cache (.rgctl-base/, $RGCTL_BASE_ARTIFACT, or --base-artifact).
  2. Copy base into {repo}/.rgctl/ and apply changed paths from git diff --name-status (incremental extract + compact).
  3. Optionally expand scope to caller files (--cascade-depth, default 1).
  4. Evaluate blast-radius policy only on entities in the git scope.
  5. Classify each violation temporally; apply calendar rules; append to violation_ledger.jsonl.

Legacy mode: pass --full-snapshots and provide both --base-artifact and --head-artifact (or pre-built .rgctl/ on the PR branch). Use when you already run two full discover jobs in CI.

Temporal classes

ClassMeaningFails default PR gate?
newViolation on head, not on baseYes
existingViolation on both snapshotsNo (new_violations_only)
resolvedFixed on head (debt paid down)No — reported as progress
regressionReappeared after ledger marked it resolvedYes (fail_on_regression)

CLI flags

FlagDefaultPurpose
--policy-file(required)JSON policy
--base-reforigin/mainLeft side of git diff
--head-refHEADRight side of git diff
--base-artifact.rgctl-base/ or $RGCTL_BASE_ARTIFACTBase graph cache root
--head-artifact(omit for delta mode)Pre-built head; skips synthesis
--full-snapshotsoffRequire pre-built head; no delta synthesis
--strictoffFail when git reports zero changed files
--cascade-depth1Re-index caller files when callees change (0 = off)
--bisectoffAdd introduced_in_commit per new/regression violation
--synthetic-head worktreeoffScope + head from uncommitted changes vs HEAD
--strict-calendaroffTreat calendar warn as failure (grace / sunset windows)

What passes?

With the sample PR policy (new_violations_only: true, fail_on_regression: true):

  • Pass: no violations, or only existing / resolved, or calendar warnings during grace (unless --strict-calendar).
  • Fail: any new or regression, or calendar-forced failures (post-grace existing, SLA breach, sunset).

JSON output (schema v2)

rgctl -r . -f json pr-check --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main --head-ref HEAD --strict \
  | jq '{passed, violations_summary, scope, graph_diff}'
{
  "schema_version": "2",
  "passed": true,
  "violations": [],
  "violations_summary": {
    "new": 0,
    "existing": 0,
    "resolved": 0,
    "regression": 0
  },
  "graph_diff": {
    "nodes_added": 0,
    "nodes_removed": 0,
    "nodes_changed": 0,
    "edges_added": 0,
    "edges_removed": 0
  },
  "scope": { "files": 3, "entities": 2 }
}

Each violation includes symbol, classification, stable_key, violation (tagged union), optional introduced_in_commit (with --bisect), and optional severity (warn | fail) from calendar rules.

Shape reference: json-api.md § pr-check.

Violation ledger

Each pr-check run appends to .rgctl/violation_ledger.jsonl keyed by (stable_key, rule_id). The ledger powers:

  • regression — violation was previously resolved in the ledger
  • SLA enforcementtemporal.enforce_sla + violation_sla_days vs ledger first_seen

Calendar policies (optional)

Add a temporal block to defer hard failures during rollout:

{
  "max_impact_nodes": 50,
  "scope": { "new_violations_only": true },
  "temporal": {
    "effective_from": "2026-09-01",
    "grace_period_days": 30,
    "severity_during_grace": "warn",
    "fail_existing_after_grace": true,
    "violation_sla_days": 30,
    "enforce_sla": true
  }
}

During grace, violations emit severity: warn and exit 0 unless you pass --strict-calendar. After grace, existing violations can fail when fail_existing_after_grace is set.


CI on GitHub Actions

Canonical example workflow in this repo: .github/workflows/rgctl-pr-check.yml.

It:

  1. Caches .rgctl-cache/<base-sha>/ per merge-base commit.
  2. Runs discover on cache miss only.
  3. Sets RGCTL_BASE_ARTIFACT and runs delta pr-check (no PR-branch discover).

Trigger: workflow_dispatch or PR label rgctl-pr-check.

Minimal workflow (copy-paste)

name: Architecture PR gate

on:
  pull_request:
    branches: [main]

jobs:
  pr-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install rgctl
        run: cargo build --release --bin rgctl   # or download a release binary

      - name: Cache base graph
        id: base-cache
        uses: actions/cache@v4
        with:
          path: .rgctl-cache/${{ github.event.pull_request.base.sha }}
          key: rgctl-base-${{ github.event.pull_request.base.sha }}

      - name: Build base graph (cache miss)
        if: steps.base-cache.outputs.cache-hit != 'true'
        run: |
          mkdir -p ".rgctl-cache/${{ github.event.pull_request.base.sha }}"
          git checkout "${{ github.event.pull_request.base.sha }}"
          ./target/release/rgctl -r . discover .
          cp -a .rgctl ".rgctl-cache/${{ github.event.pull_request.base.sha }}/"
          git checkout -

      - name: Temporal policy gate
        env:
          RGCTL_BASE_ARTIFACT: .rgctl-cache/${{ github.event.pull_request.base.sha }}
        run: |
          ./target/release/rgctl -r . -f json pr-check \
            --policy-file rgctl-tests/rgctl-pr-policy.json \
            --base-ref origin/${{ github.base_ref }} \
            --head-ref HEAD \
            --strict

Two-job pattern (main always fresh)

JobBranchAction
index-mainmaindiscover → upload .rgctl/ artifact
pr-checkPRdownload artifact → RGCTL_BASE_ARTIFACTpr-check

Use when you do not want per-SHA cache logic on the runner.

CI cache layout

.rgctl-cache/
  <merge-base-sha>/
    .rgctl/
      graph.snapshot.bin
      file_hashes.json

Point RGCTL_BASE_ARTIFACT at the directory that contains .rgctl/ (not the snapshot file itself).


Recipes

Local pre-commit (check)

rgctl -r . discover .    # if sources changed materially
rgctl -r . check --policy-file policy.json

Uncommitted preview (temporal)

Option A — worktree synthetic head (if .rgctl/ reflects HEAD):

rgctl -r . -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --synthetic-head worktree

Option B — check --temporal (same evaluator, commit refs):

rgctl -r . -f json check --temporal \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main --head-ref HEAD

PR against main (recommended CI)

export RGCTL_BASE_ARTIFACT="$PWD/.rgctl-base"   # or CI cache path
rgctl -r . -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main \
  --head-ref HEAD \
  --strict

No discover on the PR branch required in delta mode.

Compare two release tags (dual snapshots)

When you need exact graphs from two full indexes (not delta synthesis):

git checkout v1.0 && rgctl -r . discover .
cp -a .rgctl /tmp/snapshots/v1.0-rgctl

git checkout v2.0 && rgctl -r . discover .

rgctl -r . -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-artifact /tmp/snapshots/v1.0-rgctl \
  --head-artifact . \
  --base-ref v1.0 --head-ref v2.0 \
  --full-snapshots --strict

Find the introducing commit (--bisect)

rgctl -r . -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main --head-ref HEAD \
  --bisect \
  | jq '.violations[] | {symbol, classification, introduced_in_commit}'

Save a report artifact

rgctl -r . -f json pr-check \
  --policy-file rgctl-tests/rgctl-pr-policy.json \
  --base-ref origin/main --head-ref HEAD --strict \
  > "reports/pr-check-$(git rev-parse --short HEAD).json"

Troubleshooting

SymptomLikely causeFix
Graph not foundNo .rgctl/Run discover (or ensure base cache exists for pr-check)
strict diff scope: no changed filesEmpty git diff with --strictRemove --strict or change refs
All violations existing, gate passes, but you expected failurenew_violations_only: trueIntentional — only new debt fails
Gate fails on legacy codenew_violations_only: falseSet scope.new_violations_only: true for PR CI
worktree head synthesis requires...No .rgctl/graph.snapshot.bindiscover on HEAD before --synthetic-head worktree
Cross-file edges wrong after partial re-indexStale pre-deterministic IDsrm -rf .rgctl .rgctl-base && discover
changed file count exceeds...Large PRRaise size_limits.max_changed_files or split PR

Related

DocContent
policy-format.mdFull JSON schema
json-api.mdcheck / pr-check response shapes
ci-policy-checks-design.mdArchitecture diagram, Rust module map
graph-diff-design.mdSnapshot diff + cascade internals
discovering-and-indexing.mddiscover prerequisites
blast-radius-analysis.mdPer-symbol policy via blast-radius --policy-file