The gate and the challenger¶
How a pull request is judged: check on both sides, a diff by finding identity, and an agent that reads the change and re-opens the reviews it undermines.
Nobody expects a repository to be clean on day one; the gate expects it to
not get worse. model-wtf compliance ghate runs the whole check
twice — on the base ref, checked out into a temporary git worktree with
its own compliance.db, and on the head (the working tree by default,
so uncommitted work is gated too) — and fails only on findings the change
introduces.
# .github/workflows/compliance.yml (written by `compliance init`)
name: compliance
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}
- uses: ModelW/wtf@v1
with:
openrouter-api-key: ${{ secrets.OPENROUTER_API_KEY }} # optional
The challenger can run on Scaleway instead: pass scaleway-secret-key (an
IAM secret key), and scaleway-inference-endpoint to use a dedicated
inference deployment rather than the serverless Generative APIs. model
overrides the provider/model the credentials imply.
The action (action.yml at the root of this repository, v1 tag) installs
uv and model-wtf, runs uv sync --frozen / pnpm install in every folder
holding a lockfile so introspection works, then runs the gate. Inputs:
merge-into (default: the PR base from the event), fail-on-existing,
python-version, install-python-deps, install-node-deps. Outputs:
introduced, fixed, pre-existing. Under Actions it emits one
::error/::warning annotation per introduced finding on the head's
files, a ::notice verdict, and a Markdown table (introduced / fixed /
pre-existing per check) in the step summary.
Locally:
uv run model-wtf compliance ghate --merge-into develop # gate the working tree
uv run model-wtf compliance ghate --merge-into develop --head feature/x
uv run model-wtf compliance ghate --merge-into develop --format json
Findings are compared by identity — (scope, code, subject), where the
subject is a data id, a touchpoint id, an activity slug or file#field,
never a line number or a message. Folded lines (12 data item(s) pending)
are compared item by item, so a PR that adds an unreviewed personal field
fails with exactly that item, while a PR touching an unrelated file when
300 items were already pending passes. Fixed findings are reported too.
The base worktree gets the head's .venv / node_modules linked in when
the unit's lockfile is byte identical on both sides; when the lock
differs, the base's own environment is installed from its own lock
(uv sync --frozen, pnpm install --frozen-lockfile, ...) inside the
worktree, so introspection on both sides runs against the packages each
side actually declares. Only if that install is impossible (installer
missing, install failed) does the base run without an environment, and
the gate says so (findings there are approximate). Exit codes: 0 nothing introduced
(pre-existing findings are listed, not failed), 1 findings introduced, 3
declaration errors in the head (always the PR's fault), 4 tool error;
--fail-on-existing also fails on pre-existing findings for repositories
that are already clean.
The challenger¶
Fingerprints catch shape changes on data items (a field's type or
nullability); they cannot catch a view that starts mailing an address to a new
provider, a purge task that gets disabled, or a column re-purposed with the
same type. That is a reading job, so with an API key the gate first runs
the challenger: an agent with the full git checkout, git diff and
grep, the reviews tool (what reviewers asserted about the changed
files: classifications with their reasons, declared ops, transfers,
exemption notes — every one citing code) and one write tool, challenge.
It does not reclassify; it re-opens, with grounds citing the hunk.
reviews lists three kinds of assertion: data classifications, touchpoint
declarations and threat stamps (threat api:getOrder#AC01: mitigated —
orders/api.py:283 scoped to request.user). challenge takes any of the
three refs. A hunk that changes what a touchpoint reads, writes, returns or
sends re-opens the declaration; a hunk that removes the control a stamp
cites (a queryset scope, an auth class, a throttle, a validator, a cookie
setting) re-opens that one stamp — element#SID — and the cell is stale
(threats why shows the grounds) until re-stamped; both when both. A
!missing is a finding, not a claim: it cannot be challenged. Stores are
listed when a settings file is in the diff, since their stamps cite
settings.
A challenge is recorded on the item's review (challenge: {commit,
grounds}), in the touchpoint manifest, or on the stamp itself, and makes
the item pending:challenged (the cell stale), which the gate counts as
introduced. The record is
what keeps the non-determinism out of the gate: an item is challenged at
most once per change, a re-review (confirming is fine) moves the challenge
to answered: and the same grounds are refused afterwards — a false
positive costs one review, then silence. In CI the challenger's commit is
pushed to the PR branch (commit-challenges, default on); the developer
sees exactly which reviews to redo and why. The agent's shell never sees
the CI environment: only the OpenCode whitelist, and our own MCP server
drops GITHUB_TOKEN and friends.