verifiable-gates
Thai: README.th.md.
A registry of CI gates for projects built with or without AI coding agents. Every gate carries evidence of having gone red on a real defect, and the same rules ship as an agent skill, so the agent works under the constraints CI will later enforce. Of the 92 rules, nine have a checker in this package; the other 83 are written for an agent to read.
What this is not. Not a linter and not a SAST replacement. It decides configuration and process posture — pinning, CSP, ADR bookkeeping, supply chain — not code correctness. A green run means the nine checks it can decide found nothing; it says nothing about the 83 it cannot.
Quickstart
pip install verifiable-gates
cd your-project
python -m verifiable_gates.install . # writes tools/, scaffold.json, gates.yaml, a starting workflow
python3 tools/gates_doctor.py # runs the checkers the project now holds
What the two commands printed, in an empty git repository (re-run 2026-09-06, v0.5.0+4; the
install line's absolute path is shortened to <your-project>):
$ python -m verifiable_gates.install .
installed into <your-project> — 9 gates (9 scan) · check with: python3 tools/gates_doctor.py
for the instruction file your agents read (AGENTS.md, CLAUDE.md), add one line: `run python3 tools/gates_doctor.py --rules before editing`
this bundle also carries the working: 10 practices, each with the lesson behind it and the pull requests it held on — off here; read `python3 tools/gates_doctor.py --working`, turn on with `install <dest> --working`
$ python3 tools/gates_doctor.py
[ NA] actions-sha-pinned — only the bundle's own starting workflow, untouched — nothing of yours to read
[ NA] adr-index-complete — no docs/adr — this rule reads the .md records and the README.md index under docs/adr (scaffold.json adr_path)
[ NA] ci-tools-hash-pinned — only the bundle's own starting workflow, untouched — nothing of yours to read
[ NA] csp-no-inline — no app/templates — this rule reads .html, .htm, .jinja, .jinja2 and .j2 templates under app/templates (scaffold.json templates_path)
[ NA] delete-means-soft-delete — no app — this rule reads Python modules under app (scaffold.json src_path) — session.delete calls outside the purge_paths
[ pass] gates-registry-total
[ NA] image-digest-pinned — no Dockerfile — this rule reads the FROM lines of the root Dockerfile (scaffold.json dockerfiles), .github/dependabot.yml for a docker ecosystem, and — when the project named no Dockerfile — every Dockerfile* in the tree that git does not ignore
[ NA] logic-knows-no-http — no app/services — this rule reads Python modules under app/services (scaffold.json services_path) — their imports, for request-side symbols
[ NA] no-debug-entrypoint — no entrypoint — this rule reads the Python entrypoints run.py, wsgi.py, app.py and main.py (scaffold.json entrypoints), as an AST
waiting on this project's own tests: 0 gates
[exit 0]
On a fresh install the only pass is the registry index, which has to be true
about itself. Everything else is NA until the project has something to check.
Exit 0 here means nothing was measured, not that the project passed.
Add one workflow with a floating tag and an unpinned install, and the same command
answers with two findings and exits 1 (the full transcript, with the third finding
the new job itself causes, is in docs/output-semantics.md):
[found] actions-sha-pinned — Every action is pinned to a commit SHA with the version in a comment
born from: Tags move, commits do not — upload-artifact once sat on @v4 at a single call site for ten days with CI green throughout.
actions-sha-pinned: .github/workflows/lint.yml: actions/checkout@v4
…
[found] ci-tools-hash-pinned — Tools CI installs for itself are pinned by hash, on both the Python and the Node side
born from: An unpinned install command takes whatever is newest at the second the job runs, and it runs with our workflow's privileges · pinning one package at a time pins only that package while the rest of the tree still floats.
ci-tools-hash-pinned: .github/workflows/lint.yml: pip install ruff
…
** scans found problems in 3 gates: actions-sha-pinned, ci-tools-hash-pinned, gates-registry-total
[exit 1]
Reading the output
| Verdict | Means | Does not mean |
|---|---|---|
pass |
The checker looked and the rule holds. | Any rule without a checker holds. |
[found] |
The checker looked and the rule is broken; the doctor exits 1. | The build is unsafe in ways this checker does not read. |
NA |
Nothing of the kind this checker reads is here; it says what it looked for. | The rule passed. |
[error] |
The checker could not answer: crash, timeout, undecodable or oversize file, unreadable directory, malformed scaffold.json. Its stderr is passed through; the doctor exits 1. |
"Looked and found nothing." It is red without a verdict. |
[waived] |
The checker found it and a waiver in scaffold.json covers it — gate, reason, until, decided_by, optional scope; the run exits 0 for it and prints what was excused. |
The finding is gone. It is counted on every run, an expired waiver is a finding, and a waiver missing a field excuses nothing. |
Two consequences to know before trusting a green:
- A project where every rule is
NAexits 0. That is an unmeasured project, not a passed one (DECISIONS.mddoctor-all-na-exits-zero). - A path that
scaffold.jsonnames and the project does not have is a finding, notNA: a broken configuration is a defect, not an absence. - Saying "not yet" goes through the record, not around it. There is no
# noqahere and no warn-only mode; a finding a project will not fix this week gets a waiver inscaffold.json(waivers: [{gate, reason, until, decided_by, scope?}]), which the doctor prints on every run, turns red when it expires, and refuses when a field is missing. The SARIF keeps the result, with the reason as its suppression.
The full taxonomy — every NA and [error] case, the --installed record, --rules
off an edited bundle, the SARIF mapping — is in
docs/output-semantics.md.
Three ways to run it
| Entry point | Runs | Config | Default |
|---|---|---|---|
| GitHub Action | on push / pull request | uses: sayam/verifiable-gates@<commit-sha> # vX.Y.Z · optional with: sarif: gates.sarif (action.yml) |
— |
| pre-commit | before each commit | repo: https://github.com/sayam/verifiable-gates · hook gates-doctor, or one hook per rule id (.pre-commit-hooks.yaml) |
— |
| Claude Code edit hook | after every Edit / Write |
plugin installed (below) · "env": {"VERIFIABLE_GATES_AT_EDIT": "1"} in .claude/settings.json (hooks/hooks.json) |
off; reports, never refuses |
What it costs. The doctor is about 0.4 s of interpreter starts — nine scans, nine
processes — plus roughly 1.6 ms per Python file under src_path: 0.5 s on this repository
(98 modules), 2.5 s at 907, and 14.5 s at 7 256 (measured 2026-09-05, one laptop; the
numbers and the method are in .local/ of that day's audit round). Most of it is one scan,
delete-means-soft-delete, which reads every module. Every pre-commit hook here is
always_run, so each commit re-reads the whole tree however small the change — on a large tree
prefer the action, or the one hook whose rule you care about. There is no timing gate: a
threshold on wall clock is a ratchet against the runner's hardware, not against the code
(DECISIONS.md the-cost-is-stated-and-not-gated).
All three run tools/ as the project has it, and none carries a copy of the
checkers. Moving the SHA, the rev or the plugin version changes nothing about what
the project is held to (DECISIONS.md ci-runs-the-bundle-the-project-installed).
The action is listed on the
GitHub Marketplace as
verifiable-gates — pin the SHA, not the tag the listing offers. The edit hook
hands a finding back to the agent while it still holds the file, and refuses nothing
(DECISIONS.md the-edit-hook-reports-and-does-not-refuse).
What the nine checkers decide
Each of the nine stdlib-only checkers is one Python file under tools/checks/, run by
the doctor or on its own; nothing the bundle installs reaches off this machine (its one
socket asks whether a service is listening on 127.0.0.1). python3 tools/gates_doctor.py --rules prints
this list off the installed bundle, with each rule's incident.
| Rule id | What it catches | What it reads |
|---|---|---|
gates-registry-total |
A CI job with no row in the gate index, a row nothing can fail, a test file no gate claims | gates.yaml, every workflow under .github/workflows, the test files under tests_path |
actions-sha-pinned |
A uses: on a floating tag, or on a SHA with no version comment beside it |
uses: steps of workflows and composite actions under .github |
ci-tools-hash-pinned |
A tool CI installs for itself without hashes or a lock | pip, pipx, uv, poetry, pdm, pipenv, npm, npx, yarn, pnpm and python -m build lines in workflows, the scripts they run, the root Dockerfile |
image-digest-pinned |
A base image not pinned to a manifest-index digest, or pinned with nobody to move it | FROM lines of the root Dockerfile, .github/dependabot.yml |
csp-no-inline |
Inline script, style or handler in a template | .html, .htm, .jinja, .jinja2, .j2 under the templates path |
no-debug-entrypoint |
An entrypoint that can open a debug console, including one whose debug= falls back to a truthy default |
run.py, wsgi.py, app.py, main.py, as an AST |
logic-knows-no-http |
A service module importing from the request side | Python modules under the services path, their imports |
delete-means-soft-delete |
A session.delete outside the one purge path (layer business) |
Python modules under the source path |
adr-index-complete |
An ADR missing from the index, a repeated or skipped number, a supersession recorded one way | .md records and the README.md index under the ADR path |
The paths are the defaults scaffold.json carries; the project moves them there.
The 92 rules
rules.yaml — 92 rules, each carrying the incident that produced it
(born_from), because a rule with no origin is a rule nobody knows when to remove.
They are rendered into an agent skill in the layout of the
Agent Skills specification:
skills/verifiable-gates/SKILL.md is the front
page, and the full entries sit beside it in references/.
Two pipes this repository does not own install the skill without cloning:
| Pipe | Command | What lands |
|---|---|---|
| Skills CLI | npx skills add sayam/verifiable-gates |
lands the four files under skills/verifiable-gates/ and nothing else; the pipe sends the repository and skill identifiers as telemetry, off with DISABLE_TELEMETRY=1 |
| Claude Code | claude plugin marketplace add sayam/verifiable-gates, then claude plugin install verifiable-gates@verifiable-gates |
the whole repository, as a plugin |
The nine rules with a checker (script: in rules.yaml) are the ones the doctor and
the installer decide, and nothing else. The other 83 are the rule sheets an agent is held to by
reading, and the Enforced in the reference line on each says how one project turned
it into a test. What each pipe sends and fetches, and why there is no registry of this
project's own, is in docs/history.md and
DECISIONS.md distribution-is-two-pipes-nobody-here-owns.
A rule that turns out to be wrong is withdrawn in place, never deleted: retracted:
keeps it in the catalogue and on its sheet with the date and the reason, marked and out of
every count, so a reader who followed it can find out that they should stop. Nothing is
withdrawn today.
A rule may also say where it sits in a vocabulary somebody else already speaks:
maps_to: names items of OpenSSF Scorecard,
SLSA v1.0 and
NIST SSDF — 35 of them name where they sit,
and the rest deliberately name nothing, because no item of those three covers them. A
mapping says the rule would satisfy or contribute to that item, never that the two are
equal; the item names are a closed set read off the publications, so a misspelling is
refused rather than published as a map to nothing.
rules.yaml and the sheets come with the checkout, not with the wheel. The package
is the machinery that reads the catalogue:
import verifiable_gates
catalogue = verifiable_gates.rules.load("rules.yaml")
# `package_dir` is where a rule's `script:` is looked for; without it, only the
# shape of the path is checked — a checker that is not there would go unnoticed.
for problem in verifiable_gates.rules.problems(catalogue, package_dir="src/verifiable_gates"):
print(problem)
Design constraints
The two schemas — rules.py for this catalogue, registry.py for a project's
gates.yaml — encode five rules that came from real traps, not from theory:
- a gate whose
layerisinternalcannot beportable— a rule tied to one project's architecture, exported as universal, is an overclaim; that hold is in the gate schema, and the rule schema refusesinternaloutright, since a rule in this catalogue is published whole (portableon a rule is refused as a gate's field); - a key neither schema knows is refused, not skipped — a misspelt
born_frmis a rule with no origin that looks like one with; - anything exported must name the trap that created it (
born_from), because a rule with no origin is a rule nobody knows when to remove; proved_byentries must say what they caught and when — a gate nobody has seen go red is indistinguishable from a gate that checks nothing;- the vocabularies for
kind,severity,layer, andpillarare closed — and a severity is what something reads:blockingis every gate on a run (a scan is red whatever the row says),watchedis awatched_bypromise the red-streak census measures, and there is no third word, because a label no run reads is a softness nobody delivers.
A rule and its enforcement live in separate files, because they have separate
lifetimes: rules.yaml is what this project publishes; gates.yaml is what this
project is itself held to. What was deliberately not done, each with the condition
that would expire it, is DECISIONS.md.
Provenance and status
Archived at doi:10.5281/zenodo.22103110,
which resolves to the latest version; each release also gets a DOI of its own. The
tag evidence-freeze-1 is the state the measurements were taken on and v0.1.0
(2026-08-28) the state the package first shipped in — different commits on purpose.
The extraction from the reference implementation,
sayam/flask-todolist, is complete;
the stage table, the census and what stayed behind are in
docs/history.md. Consume a pinned submodule or a versioned
dependency, never main.
None of this has to be taken on trust. docs/auditing.md is the
hour: the eleven rules this repository states about itself, the command that decides each
one, and — said plainly — what no command here can answer.
Who maintains this
One maintainer, and no promise about how long. What survives if that stops — your CI, your
alerts' ids, and a fork that is complete — is in
GOVERNANCE.md,
together with how a rule is withdrawn and how you would tell whether anybody is still here.
Licence
- Code: Apache-2.0. Contributors sign
CLA.md— one line in the pull request; you keep your copyright. - Rules and documentation: CC BY 4.0.
The application this was extracted from stays AGPL-3.0-or-later. The two differ on purpose: a CI tool is not a network service, and a rule meant to be adopted inside an organisation's internal handbook must not require share-alike.
README.th.md · docs/ · CONTRIBUTING.md ·
SECURITY.md · CHANGELOG.md · the experiment
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file verifiable_gates-0.6.0.tar.gz.
File metadata
- Download URL: verifiable_gates-0.6.0.tar.gz
- Upload date:
- Size: 537.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c3ff01d590c356a5100df589b9b9cec6d1de42858832e50612e1f3de88472c0
|
|
| MD5 |
5b1d4799138f244ffe4e43358b38f18d
|
|
| BLAKE2b-256 |
f87607c9e893572c4818c61b1989c9a2f6832c636f8042944bc660aae2a75800
|
Provenance
The following attestation bundles were made for verifiable_gates-0.6.0.tar.gz:
Publisher:
release.yml on sayam/verifiable-gates
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verifiable_gates-0.6.0.tar.gz -
Subject digest:
1c3ff01d590c356a5100df589b9b9cec6d1de42858832e50612e1f3de88472c0 - Sigstore transparency entry: 2734460700
- Sigstore integration time:
-
Permalink:
sayam/verifiable-gates@29f8693bc3de323f0aef04662f84c4ca033c1c66 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/sayam
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@29f8693bc3de323f0aef04662f84c4ca033c1c66 -
Trigger Event:
release
-
Statement type:
File details
Details for the file verifiable_gates-0.6.0-py3-none-any.whl.
File metadata
- Download URL: verifiable_gates-0.6.0-py3-none-any.whl
- Upload date:
- Size: 298.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be570ce732c4ae43003397b164f77d4956e56c5879a0228c293e6b3c3827bcfb
|
|
| MD5 |
51ffb04a52124a1211c58a7ab52db447
|
|
| BLAKE2b-256 |
59fb8ab46196b2047060b8821ce3e915496d732f23bfe8d8fe1aa03f95a48059
|
Provenance
The following attestation bundles were made for verifiable_gates-0.6.0-py3-none-any.whl:
Publisher:
release.yml on sayam/verifiable-gates
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verifiable_gates-0.6.0-py3-none-any.whl -
Subject digest:
be570ce732c4ae43003397b164f77d4956e56c5879a0228c293e6b3c3827bcfb - Sigstore transparency entry: 2734462384
- Sigstore integration time:
-
Permalink:
sayam/verifiable-gates@29f8693bc3de323f0aef04662f84c4ca033c1c66 -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/sayam
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@29f8693bc3de323f0aef04662f84c4ca033c1c66 -
Trigger Event:
release
-
Statement type: