Scope: every push of a commit range from a repository set up for this
pipeline (`crafter-review init`) — a release channel with real users, where a push (or
the pull request it opens) is the next release. The `/prepush` skill drives
this file. The mechanics: freeze → hash → gates → the owner; four lenses,
the functional one as Gate 2 (Jorijn, 2026-10-15), the security lens beside
the bug-prover in Gate 3 (2026-10-22).
Nothing is pushed without the owner's explicit approval of the exact frozen
hash. Local commits parked unpushed are fine and normal.
Terms: *the pipeline* is crafter-review, the Flatpak
`net.catcrafts.CrafterReview`; every command below is spelled
`flatpak run net.catcrafts.CrafterReview <command>`, because the Bash tool sees no
alias (a native build is run by its path instead). *The repository* is the
consumer checkout the driver runs in; *the owner* is whoever approves the
hash at Gate 4.
## Inputs — what the lenses see, and nothing else
- The frozen artifact: `.review-gates/push/<slug>/*.patch` — `git
format-patch --base=<base> <base>..HEAD` of the repository, cut by
`freeze`, inlined into every prompt.
- The repository at the range HEAD: the checkout itself (`--trees .`; the
driver verifies HEAD and a clean tree before every launch) or a detached
worktree under `.review-gates/trees/<repo>-<hash8>/` (`freeze --worktree`;
gitignored) — content, history and blame; the state before the range via
`git show <base>:<path>`.
-`--context-paths` for the evidence dirs the commit messages' claims rest on.
- The lens charter (`process/lenses/repo-*.md`, system text
`_common-repo.md`) and the dispositions in force.
- A lens with the `browser` or `browser+supabase` environment (the
functional lens by default; `lenses.<name>.environment` in the config):
the running application (`.review-gates/config.json``functional.*`: its
URL, the local origins, the login fixture, the owner's notes), through the
Playwright MCP server the driver starts for each of its passes, and the
exact one-time-code command the prompt names (`totp`).
- A lens with the `supabase` or `browser+supabase` environment: the local
stack's database (`supabase.dbUrl`, this machine only), through the exact
command the prompt names (`sql`) — every call one transaction rolled back
at its end.
The helper hands the SQL to the driver's broker, which runs it: the URL,
its password included, stays in the driver, and the pass's process gets
only the broker's address and its stack's token.
Never: `.review-gates/push/` (dumps, manifests, gate-state, freeze.json), the
pipeline's own files, the network beyond the application, a build tool, a test
runner, a package manager, a stack command or a database client of its own
(`psql` is denied: it commits to the database every pass shares) — the
bug-prover and the security lens prove statically unless their environment
gives them the database or the application, where a proof may also be run;
the functional lens observes, it does not build.
### How the passes share the stacks
A stack is an application with its API, its database and a login. It is
either the one the config names (`functional.*`, `supabase.dbUrl`, the first
login fixture) or one of several **slots** the operator brings up side by
side (`slots.*`). Each instrument is shared in its own way:
- **The application** (the browser environments): a pass holds a stack of
its own from its launch to its end, and no other pass with the browser
gets that stack meanwhile. So as many such passes run at once as there
are stacks up — one on the config's single stack — and a sign-out, a
setting or a row of one pass reaches no pass running beside it. A stack
is not reset between passes: what keeps a pass apart from the ones before
and after it is the rule in the APPLICATION UNDER TEST block. Create only
what you need, name it with your unit id, never change what you did not
create, and leave the account as you found it. The application's writes
are real; they last until the stack is reset. With slots, the pass's own
stack — its URL, origins and login — is named in its user message, so
the system prompt every pass shares stays the same.
- **The database** (the supabase environments): every SQL call is its own
transaction and is rolled back, so a pass never sees another pass's
writes. The application never sees them either, and nothing outlives the
call. That is why a pass with the database alone needs no stack of its
own: it uses the first. A lock a call takes (DDL, an UPDATE) holds until
that call ends; other passes wait for it, up to the `lock_timeout`
(`PgWire::LockTimeoutS`, 3 s). The guard is against interference and
leftovers, not against a lens set on escaping: a second connection opened
from inside the database (dblink) commits on its own, and a lens may open
a connection of its own with the local stack's well-known credentials.
- **Memory:** there is no per-lens number. Before a gate, and before every
launch, the driver reads the machine's free memory (`MemAvailable`). It
launches a pass only while the pass fits: free memory at the gate's start
less the reserve (`REVIEW_GATES_MEM_RESERVE_MB`, by default 10 % of the
machine), less what the passes in flight were admitted at, with the
pass's cost still free right now. A pass costs its environment's measured
peak: the driver samples every pass's own process tree (claude, the
Playwright server, Chromium), keeps the recent peaks per repository and
environment (`~/.cache/review-gates/memory.json`), and admits at the
highest. Before any is recorded it uses the defaults measured on
2026-10-22. With nothing in flight a pass runs whatever the memory says.
- **Other drivers on this machine** (2026-10-31): every driver registers
its passes in flight on one board, `~/.cache/review-gates/seat-board.json`
(`SeatPool::Board`), each
with the account it runs on, the memory it was admitted at and the stack
it holds. A launch is decided and registered in one locked section, so:
a stack another driver's pass holds is not free here (a stack is its
application's address, `Pool::StackKey`, so two configs that name one
URL share it); the other drivers' passes count against the memory budget
at what they were admitted at, and the budget's free memory is the
reading from before they ran (`Board::MemoryBase`) — "nothing in flight"
means nothing on the machine; their passes count in each seat's
projection and in an API key's slots; and `--concurrency` is the
machine's width, every driver's passes counted. A driver waiting on
what another holds re-checks every 30 s. A driver that died is swept by
its lifetime lock under the board's `drivers/`, a pass by its deadline. A
driver takes its passes with it however it ends: each pass runs under a
supervisor that kills the pass's whole tree — the Playwright server and
Chromium included — when the driver goes, SIGKILL included.
- **Stopping a run:** `flatpak run net.catcrafts.CrafterReview stop <its scratch dir>`, or
`--hash <hash8>` for every live run of that hash (the scratch dir is the
last word of the run's gate-0 log). It works from any sandbox: the driver
looks for the stop file every 2 s and handles it as SIGTERM — its running
passes killed, no later gate started, the dump written. From outside the
sandbox a SIGTERM reaches the driver only through the process group it was
launched in (`kill -TERM -<pgid>` of the `setsid` launch); a SIGTERM to the
`flatpak run` pid alone does not. `flatpak kill net.catcrafts.CrafterReview`
is the hard stop: it ends every run of the app at once, without a dump.
## The consumer's configuration
`.review-gates/config.json` at the repository root, committed; the template
is what `init` writes (`flatpak run net.catcrafts.CrafterReview doc config` prints it) and
`flatpak run net.catcrafts.CrafterReview config` prints the merged result with its problems. What it holds: `base` (the integration
branch a range is measured against) and `rangeFrom` (`upstream`: the push
delta `@{upstream}..HEAD`, the rule for a repository that pushes to its
own trunk; `base`: always the merge-base with `base`, the whole pull request
for a repository whose branches become PRs — their commits must be linear,
rebased, never a merge of the integration branch), `authors` (the emails the
freeze accepts; null = `git config user.email`), `aiTrailers` (`forbid` or
`allow`), `requireSigned`, `allowDirty` (tracked
files the local stack rewrites in place while the lenses run), `gate0` (the
commands Gate 0 runs by hand, for the skill), `lenses` (per lens, by its
name: `run` — `false` leaves the lens out of every run of this repository:
its gate disappears, the units only it reads are listed as not reviewed,
never as holes, and the run stays a full run; the ruling of record for a
repository with nothing to exercise, where `--without` is a per-run ruling
that leaves a hole — and `environment`, what its processes get beyond the
read-only tools: `none`; `browser` = a Playwright MCP server per pass,
the browser tools and `curl`, the APPLICATION UNDER TEST block, the probe
before its gate, a stack of its own per pass and the fail-closed server
check; `supabase` = the database under test: the `sql` helper as its
exact command, the DATABASE UNDER TEST block, the probe before its gate
(the database answers and its applied migrations are the range HEAD's —
by version, and by content for the migrations the range adds or changes)
and the fail-closed check that a pass whose SQL calls never reached the
database is no evidence; `browser+supabase` = both. The functional lens
is empirical and needs one with the browser while it runs; the static
lenses default to `none` and may take any. A verdict bought in one
environment is never reused, resumed or salvaged in another, and a defect
found in one drops the clean verdicts the lens holds for the same code in
the others), `supabase`, the database under test for every lens with
`supabase` or `browser+supabase`: `dbUrl` (the local stack's Postgres —
this machine only; masked in the dump and the log), `start` / `stop` (how
the operator brings the stack up when no lens drives the application;
`functional.start` covers it when one does), `notes` (the schemas that
matter, the seeded users, what to leave alone), `slots`, stacks side by
side: `dir` (where the bring-up writes `<n>/slot.json` once slot n is up —
`{appUrl, apiUrls, dbUrl, login}`; with it set, the driver uses the slots
that are up and refuses a gate with none), `start` / `stop` (the commands
for slot `{n}`; the skill runs them), `memMb` (one slot's measured memory
while a pass uses it: its containers' and processes' own, never the
machine's — `slots` sizes the slots by it), and
`functional`, the application under test for every lens with the
`browser` or `browser+supabase` environment when no slots are configured:
`appUrl` and `readyPath` (what the driver probes before that lens's gate),
`apiUrls`, `start` /
`stop` (how the operator brings the application up from the frozen tree — the
skill runs them, the driver only probes), `login.fixtures` (JSON files with
`email`, `password`, `totpSecret`; the first that exists is the stack's
login; a slot names its own in `slot.json`), `exclude` (path globs whose
hunks carry no runtime behaviour: tests, CI, docs — mechanical n/a for the
functional lens; it holds with slots too),
`extraTools` (`Bash(<prefix>:*)` or `mcp__<server>[__<tool>]` entries a lens
with the browser may also use; an entry the deny list (`Tools::Deny`) overrides — `psql`, a
package manager, a stack command — is a config problem, never a silent
no-op), `mcp` (the Playwright MCP command; null args = the pipeline's pinned
default), `notes`.
## Gate 0 — mechanical preflight (no agents)
1.**Build and tests green on the exact HEAD**, the repository's own way (the
`gate0` commands of the config, run by hand from the checkout). Keep the
output where a commit message's "tested" claim can point at it
(`--context-paths`).
2.**Freeze** — `freeze` checks, all of them, before it
exports anything: author AND committer are in `authors`; AI attribution
trailers (`Assisted-by:`, `Co-Authored-By:`) by the `aiTrailers` rule; the
tracked tree is clean (the `allowDirty` paths aside, warned); no merge
commit in the range (`git format-patch` drops them silently, so the
artifact would not be the push); the range is a fast-forward of
`@{upstream}` when there is one; an unsigned commit warns
(`requireSigned` makes it fail). The base: `--base`, else by `rangeFrom`:
`@{upstream}` (the push delta) or the merge-base of HEAD and the
configured `base` branch (the whole pull request). Then the
export, the hash, an empty `gate-state/dispositions.json` when none
exists, `freeze.json`, `run-line.txt`, and with `--worktree` the detached
checkout.
```
flatpak run net.catcrafts.CrafterReview freeze --slug <branch-or-release> [--worktree] [--base <ref>]
```
The hash is `cat $(ls *.patch | sort) | sha256sum` over the export; every
pass binds to it. An amend or a rebase changes the `From <sha>` lines and
therefore the hash: re-freeze with `--replace` into the same dir
(`gate-state/` survives, so clean unit results are reused from
`unit-state.json` — what a re-freeze re-runs is under Gates 1–3).
3.**The application and the database under test** (before a gate whose
lens has an environment with either can run — Gate 2 by default; nothing
to start when no lens that runs has one): start them from the tree the
lenses read, at the frozen HEAD — the checkout itself when `--trees .`,
the worktree when `--worktree` — with the config's `functional.start`
(or `supabase.start` when no lens drives the application). With
`slots.*`, bring up slot 1 first, then run
`flatpak run net.catcrafts.CrafterReview slots --artifact <dir>`: it prints
how many slots fit in free memory — no more than the range's browser
passes can use at once — and the `slots.start` commands for the missing
ones, which may run side by side. The driver refuses a gate while a
stack's application or database does not answer, while its applied
migrations are not HEAD's (a migration of HEAD not applied, one applied
that HEAD lacks, or one of the range applied with other content — an
amend without a reset), or while its login is not in its database; and
it aborts the run if the tree moves or gets dirty beyond `allowDirty`
while it runs. After `freeze --replace`, bring every stack up again from
the new HEAD before the rerun. A fix during a run happens in another
worktree, never in the tree the lenses read.
## Gates 1–3 (the driver) and Gate 4 (the owner)
| Gate | Lens | Question |
|---|---|---|
| 1 Approach | `repo-better-solution` (whole pass gating code blocks) | an existing mechanism? the right layer? the right commit shape? compatible with what users have? |
| 2 Functional | `repo-functional` (whole pass gating code blocks; the `browser` or `browser+supabase` environment; absent when the config turns it off) | in the running application, does the range do what its code and messages say, and does it leave the flows next to it working? |
| 3 Proof | `repo-bug-prover` (code blocks, static; with the `supabase` environment a proof may also be run in the database) | a demonstrated functional defect in the range's code |
| 3 Proof | `repo-security` (code blocks, static, beside the bug-prover; with the `supabase` or a browser environment a demonstration may also be run in the database or the application) | a demonstrated gap in a protection the repository promises: a check, policy or grant the range's code lacks, weakens or removes |
| 4 | the owner | approves this exact hash, then the push happens |
Every lens runs on Opus 5.5 at effort `xhigh` (`--effort` overrides).
`--concurrency` is 128 by default (2026-10-15), the width of the machine:
two drivers running at once share it (2026-10-31, the board). One full run
per hash: a second on the same hash is refused while the first is live
(`State::RunLock`; a test run takes a run number of its own), and no two
drivers share a scratch dir. `flatpak run net.catcrafts.CrafterReview seats` shows the
drivers running and what they hold. The seat pool still places
every process by its seat's projected 5-hour headroom