pushkin
Schema-first enforcement harness that gates AI coding agents' file writes against project contracts, deterministically. An agent write that violates a contract, touches protected surface, or edits a committed test suite is denied at the hook — with the reason, the fix hint, and an auditable event trail. Humans stay un-gated where it matters; everything fails open loudly, never silently.
Documentation map
| document | what it covers |
|---|---|
DESIGN-FINDINGS.md |
canonical spec, the research it rests on, and the F-Ledger |
docs/TESTING.md |
testing design and infrastructure — the layers, the red→green protocol, read-only suites, fixtures, the floor and its accounting, CI, and the evidence for why it is shaped this way |
docs/TESTING-RUST-FOR-PYTHON-ENGINEERS.md |
the toolchain and test model taught from a Python baseline; start here if cargo and clippy are new |
docs/RESEARCH-SOURCES.md |
every external figure this repo cites, with URLs and verification dates — and an honest list of the claims that still have no source |
AGENT-INSTRUCTIONS.md |
builder conduct — the N-rules and the pass protocol |
docs/STANDING-RULES.md |
rules that apply to every pass (V2, V3, V5, N10/§4.1) |
docs/CONTINUATION-GUIDE.md |
area-by-area orientation for someone picking the repo up |
docs/ROADMAP.md |
current state, open findings, what is queued |
PHASE-LOG.md |
append-only record of every pass |
PUSHKIN-AGENT-INTEGRATION.md |
per-agent hook mechanics for all five adapters |
Install
Or from source:
Requires the binary on PATH (hooks resolve pushkin from PATH by
design — nothing bakes an absolute path). pushkin doctor names it if
resolution would fail.
After any change to gate behaviour, reinstall. A binary older than the manifest schema fails every manifest-reading verb at exit 1 before any gate logic runs, and a hook exiting 1 is non-blocking — so version skew silently disables the write-time gate while
doctorstill reports healthy. Tracked as F71; seedocs/ROADMAP.md.
Quick start
Which manifest governs
Resolution is pinned to the repository, not to your shell's working directory:
PUSHKIN_MANIFEST, if set — that file, wherever it is. If it names a file that cannot be read, pushkin fails with a named error; it never falls back to the repo's manifest behind your back.- Otherwise
<git rev-parse --show-toplevel>/pushkin.toml— the repository root's manifest, whatever directory you invoke from. - Otherwise
./pushkin.toml, and pushkin says so on stderr.
Case 3 is the honest edge: where git is missing or the tree is not a repository, pushkin cannot
tell whether a root exists, so it uses the working directory and tells you it did. In that
situation a pushkin.toml in a subdirectory still governs — pushkin doctor names every nested
manifest it finds, and carries a standing info line when git is unavailable.
Every verdict names the manifest it was decided under: repo-relative in the agent-facing prose,
absolute in --json and in the pushkin floor header.
Adapters: --agent claude | codex | auggie | hermes | opencode, plus two
pre-commit floors and a managed instructions block (--agent agents-md).
--remove-agent <name> uninstalls only what pushkin owns.
The pre-commit floors
Two interchangeable floors run pushkin check --staged --json before every
commit; both carry the same guard: fail open only on positively probed
absence (no binary, or no pushkin.toml), with a loud actionable notice —
a teammate who never installed pushkin is not blocked by a hook they never
opted into, and a check that RUNS is never second-guessed.
pushkin init --agent lefthook— merges a marker-bracketed block intolefthook.yml, preserving every foreign command byte-for-byte. Any prior pushkin marker generation upgrades in place; uninstall leaves no litter.pushkin init --agent git— writes.git/hooks/pre-commitdirectly, no hook-manager dependency. Detectscore.hooksPathoverrides and foreign hooks and prints the integration snippet instead of clobbering.
Team enforcement: CI is the backstop
The hooks above bind only machines that opted in. A teammate who never
installed pushkin (or lefthook) commits ungated — the fail-open floor is
deliberate, so local hooks alone cannot enforce the gate team-wide. The
backstop is CI: run the gate as a required status check with the
pushkin-gate composite action this repo ships
(.github/actions/pushkin-gate), and unprovisioned pushes are caught at
the pull request instead of landing silently.
name: pushkin-gate
on:
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # affected mode needs history (F19)
- uses: acoletti/pushkin/.github/actions/pushkin-gate@<TAG-OR-SHA>
with:
mode: affected # or `check` for the full repo
base-ref: origin/main
pushkin-ref: <TAG-OR-SHA> # pin; a moving ref is non-reproducible
affected gates only the diff against base-ref; check sweeps every
mapped file. Optional db-drift / db-rls inputs add the schema gates
(see the action's own docs for their prerequisites). Make the job a
required check on the default branch and local provisioning becomes a
latency optimization, not the enforcement boundary.
What the gate enforces
- Contract conformance at mapped boundaries (
contract.boundary.*). - Protected paths (
pushkin.protected_path): the manifest, schemas, CI, the floors — agent-denied at write time, unwaivable by construction. Staged protected changes draw a loud advisory at the floor, never a block (the floor runs for humans too). - Committed tests are read-only (
pushkin.read_only_path): files in git HEAD underread_only_pathsglobs deny agent edits; NEW test files and uncommitted iteration flow freely. Unwaivable. - Suppression comments (
pushkin.suppression.new) per the manifest. - Waivers (
pushkin waive): scoped, expiring, decision-logged — for rules that allow them; doctor lints stale grants.
Read-only test surfaces are not a stylistic preference. Public measurement of
frontier models finds test modification is one of four systematic cheating
strategies, and that "Read-only access provides a middle ground: it restores
legitimate performance while preventing test modification attempts" where hiding
the tests degrades performance on the original benchmark
(ImpossibleBench, arXiv:2510.20270).
docs/TESTING.md §2 collects that evidence alongside this repo's own recorded
incidents; docs/RESEARCH-SOURCES.md carries the full citations.
The mechanical floor
pushkin floor runs the [floor] table declared in pushkin.toml — one
committed list of commands that pushkin floor, the Makefile,
scripts/floor.sh and CI all read, so "CI runs the same checks you do" is a
fact rather than a promise someone has to remember.
Three properties worth knowing:
- Ignored tests are accounted for. A command declaring
reconcile_ignoredmust have its ignored count matched exactly by a command declaringcovers_ignored_of, or the floor is RED. Self-coverage is rejected at parse time, and a coverer excluded by--skipcovers nothing. This exists because a cited floor number once silently omitted two#[ignore]d benchmarks — one of which was red at the time. - The exit contract is inverted relative to ESLint/ruff, and disclosed in
--help: 0 clean, 2 findings, 1 the gate itself could not run. - Network-dependent verdicts are disclosed.
inputs = "network"on a command (e.g.cargo deny check, which consults the RustSec advisory DB) prints a note that the verdict is not a pure function of the commit.
docs/TESTING.md §6–§7 documents the table, the accounting and the exit codes
in full.
The schema-only tier (no JS runtime)
Gate-time enforcement is pure Rust. A repo that commits its canonical
schemas needs no bun, node, or JS toolchain to be gated: install the
binary, run pushkin init, done. Bun is a compile-time tool only —
pushkin compile regenerates canonical schemas and bindings from Zod
sources, and repos that author schemas directly (or vendor them) never
need it. If you never run compile, you never need a JS runtime.
Doctor
pushkin doctor verifies every installed surface (adapters, both floors,
binding epochs) and exits 1 on findings; --repair regenerates only what
pushkin owns — foreign configs are reported, never rewritten, and
advisory findings (like an unresolvable binary) never trigger a rewrite.
Repair exit code: 0 when every repair that could run succeeded; deferral
messages name the manual remedy without failing the run.
Release status
pushkin is published on crates.io — cargo install pushkin installs the
latest release. All four crates version in lockstep with exact internal pins
(=0.2.0 at the current workspace version), and Cargo.lock is a release
artifact: cargo install --locked reproduces the exact dependency graph a
release was tested against.
Published versions are immutable. A correction to shipped content — including
this README, which renders on the crate page from the published tarball —
requires a new patch release; it cannot be fixed by editing develop.
Versioning policy:
- Patch (
0.1.x) — fixes that do not change emitted output: docs, CI, the gate action, internal refactors. - Minor (
0.x.0) — new schema capability or changed emitter output. Any change undercrates/pushkin-compiler/src/**is epoch-sensitive and bumpsschema_epochinpushkin.toml, so it is at least a minor. - Tags — every published version carries an annotated
vX.Y.Ztag on the exact commit whose tree was published.
Contributing
Tests are written first, committed before the implementation, and read-only
once committed. make floor must be green at every commit. The full protocol,
the fixture patterns, and the reasoning are in docs/TESTING.md; if Rust is
new to you, read docs/TESTING-RUST-FOR-PYTHON-ENGINEERS.md first.
Phase 0 reference implementation
The original Bun/TS spike (Claude Code gate) still lives in src/ and
tests/: make test && make lint. The Rust workspace is the product;
make floor runs its full declared mechanical floor — currently seven
commands: fmt, clippy, test, bench, deny, tsc, bun-test.