Files
gstack/docs/pregate.md
T
Garry Tanandcapy-ai[bot] 088bfc5914 v1.91.364.0 fix: pregate --policy-from head only adds lanes, and timeout_s reaches preflight and finish (GST-419, GST-420) (#3676)
* pregate: --policy-from head only adds lanes; timeout_s policy default; preflight forwards --policy-from (GST-419, GST-420)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (head record fingerprints the trusted user policy; remote test timeout)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: --remote under a bootstrap head policy is refused (Step 11 adversarial)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: pregate timeout order, PREGATE_TIMEOUT_S, head-policy follow-ups in TODOS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: regenerate gstack-doctor-components.sh for PREGATE_HEAD_POLICY_LOOSENS

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 11:29:20 +00:00

69 KiB
Raw Blame History

The pre-gate: generated files, cheap checks, issue links

/ship Step 5 opens with gstack-pregate: the free checks that catch what the multi-agent wave kept paying remote gates to find (stale generated files, a scratch script at the repo root, a nightly-only test still asserting an old constant, a Windows-only change merged with its lane unrun, a | tail-hidden failure). Plan: docs/designs/MULTI_AGENT_WAVE_2026_10_10.md C4, C6, C8. Result codes: docs/troubleshooting.md.

Three bins, all over lib/:

Bin Pin file (read from origin/<base>) Does
gstack-regen .gstack/generated.json generated files regenerate, never hand-merge
gstack-pregate .gstack/pregate.json two-stage cheap gate, pregate.json record, publication check
gstack-issue-links — Fixes #N only on explicit closure intent; Related PRs and validated flake issues

Trust boundary

A pin file names commands. Its presence never authorizes execution: both tools read the committed copy on origin/<base> (a PR cannot smuggle a command into its own gate; gstack-pregate run alone also reads a user-owned file outside the repository when the base has none, see pregate v1) and run commands only with --allow-repo-commands, which /ship passes. Standalone, gstack-regen check lists the commands and exits 3 (REPO_COMMANDS_NOT_ALLOWED); gstack-pregate marks regen and lanes incomplete, never pass. --policy-from head (a PR that introduces or changes a pin file) is accepted only with --allow-repo-commands and the record says policy head (unreviewed). Under it the branch's pregate.json may only add test lanes over the trusted policy (the base copy, or the user-owned file when the base has none): a new dependencies glob with at least one test, or more tests on a glob the trusted policy already maps. A removed or shrunk glob, a new [] declaration, or any other key that differs (runner, guards, warn, allowlist, timeouts, the remote gate, ...) refuses the run with PREGATE_HEAD_POLICY_LOOSENS. A branch behind its base is measured from the copy at its merge base and runs the base tip's policy plus its additions. With no trusted policy at all, the head file is a bootstrap and runs as written. .gstack/generated.json is still read from the branch under --policy-from head. So an allowlist entry or a [] declaration a branch adds counts only once it is on the base, and a lane it adds counts on the base policy only once it lands; PREGATE_NO_LANE's fix line says so when the branch edits the file (troubleshooting). An unresolvable base exits 3 with POLICY_SOURCE_UNAVAILABLE. Globs are repo-relative (no .., no absolute paths, at most 64 per list); commands are one line under 512 bytes.

.gstack/generated.json (generated-v1)

{ "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1",
  "entries": [ { "id": "skill-docs", "command": "bun run gen:skill-docs --host all",
                 "outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "hosts/**"] } ] }
  • gstack-regen check regenerates every entry (or --only <id>, or --touched <paths> for entries whose inputs or outputs intersect) in a scratch git worktree seeded from the working tree, with node_modules linked rather than copied, and reports stale outputs (REGEN_STALE, exit 1) without touching the tree. When outputs differ but package.json declares dependencies and node_modules is missing, the entry is REGEN_DEPS_MISSING (install, then rerun) instead of stale. An entry with no command is tracked only (goldens regenerated by another entry).
  • gstack-regen write regenerates in place: what gstack-restamp and the Step 3 conflict rule use. On a conflict in a registered output, take either side, then gstack-regen write; the agent never hand-merges a generated file.
  • list, validate, init [--explain] [--force].

gstack's entries: skill-docs (every host render, the agents digest, llms.txt, the doctor component table) and ship-goldens (bun scripts/gen-ship-goldens.ts, the byte-pinned /ship renders in test/fixtures/golden/).

.gstack/pregate.json (pregate-v1)

Key Meaning
runner the local test command with {files} (gstack: bun test {files}); unset, it is detected (bun, vitest, jest, pytest, node --test, and flutter test/dart test per Dart package root, through fvm when that package or an ancestor pins it with .fvmrc or .fvm/fvm_config.json and fvm is on PATH; cargo test -p <name> per Rust crate, run from the root of the workspace the crate is a member of (Cargo's members globs, exclude and package.workspace), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its test script) runs from that package, with package-relative paths, through the package manager its packageManager field or the nearest lockfile up to the repo root names: pnpm exec, yarn, bun x, else npx; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a .dart file with no pubspec.yaml above it, a .rs file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, _ and -), is incomplete (the runnable groups still run, so a real failure still fails)
dependencies glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; [] declares "no lane needed" (docs, licences)
empty_lanes extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a join() of path segments, an import, or a dependencies entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without .md, and so does licence text under any vendored name (roboto-LICENSE.txt, LICENSE-MIT, licenses/*.txt). Source code and files under a test directory never qualify. A test that reads the file still selects that test
selection workflow.yml/job → a command printing the lane's file list, for matrices a planner computes (gstack: scripts/test-free-shards.ts --list)
warn check ids downgraded to warn; secrets and required lanes never
strays.allow intentional root files or new top-level directories
local workflow.yml/job lanes a local run satisfies: a platform-agnostic toolchain (an ubuntu-latest suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming requires-remote, except that on a machine under 8 cores with a remote_gate or a remote test_backend the small-machine rule still records the test tier as a requires-remote obligation. --explain notes an id that matches no workflow lane
guards [{ id, when: [globs], command, fix?, timeout_s? }]: repo commands a lane must pass when a touched file matches when (manifest checks, subprocess goldens the import graph cannot see); see guards
serial_patterns test-file globs the lanes check runs one process per file, apart from the unit files (default **/*.serial.test.*)
sealed sealed or eval output that must never reach a public destination; read by the pre-push hook and gstack-pregate sealed (see sealed output)
price_file repo-relative path of the paid gate's call-plan data file (GSTACK_EVAL_CALLS v1); gstack-eval-plan price-gate prices from it and never executes anything (docs/eval-calls.md)
stall_timeout_s seconds a test command may print nothing before its process group is killed and the check is incomplete (PREGATE_STALLED); default 300, 0 disables; --stall-timeout overrides (see supervision)
timeout_s seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate's deadline) when --timeout is not passed; 1 to 3600, default 600. gstack-ship-preflight and the finish fallback run pregate without --timeout, so this is how a repo with long lanes sets theirs
remote_gate the repo's remote test gate command; under 8 cores the local test tier becomes a requires-remote obligation naming it, and --remote runs it (see remote gate)
allowlist [{ finding, paths?, pattern?, reason }]: benign findings the secrets check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never env.value (see allowlist)

Only the origin/<base> copy or the user-owned file below is honoured, so a downgrade is reviewed (or is the user's own) before it takes effect. A repo that keeps .gstack/ out of git (a shared repo where gstack is one contributor's tool) can hold the same file at <state root>/projects/<slug>/pregate.json. The state root is GSTACK_STATE_ROOT, else GSTACK_HOME, else GSTACK_STATE_DIR, else a gstack plugin's data directory, else ~/.gstack (docs/state-root.md); <slug> is what ~/.claude/skills/gstack/bin/gstack-slug prints as SLUG= in the repository (GSTACK_PROJECT_SLUG overrides it). It is read only when the base carries none (under --policy-from head it is the trusted policy the head file is compared with), it lives outside the repository so a PR still cannot reach it, and the record names it (policy user <path>) and fingerprints its bytes (policy_digest, and each check's inputs.policy); verify and the ship receipt treat an edited or deleted user file, or one that appears (or a link on its path that starts pointing nowhere) after a run under the defaults, as PREGATE_STALE. A path that is relative, resolves inside the repository, or reaches it through any symlink on the way (a state root linked into the checkout, or a link hop inside it, even when the last hop points back outside) is ignored, since a PR could commit it or choose the file; --explain says so, or says (absent) when no file is there. The slug and state root come from the caller's environment, never the checkout's .env: the bin's shebang passes --no-env-file, and a bare bun gstack-pregate launched beside any .env file refuses (POLICY_SOURCE_UNAVAILABLE). verify reads both paths from the record, not from its own environment. It covers gstack-pregate run only: sealed and price_file are read from the base branch's copy, so the user file refuses them (PREGATE_CONFIG_INVALID) rather than holding a policy nothing reads. A user file that exists but cannot be read, or a symlink on its path that points nowhere, refuses the same way instead of falling back to defaults. A base copy git cannot read (and git ls-tree cannot confirm is absent) refuses with POLICY_SOURCE_UNAVAILABLE instead of handing the run to the user file. gstack-pregate validate / init cover the working-tree file.

No policy yet. A repository with no .gstack/pregate.json on the base branch and no user-owned file runs under the defaults, and a touched file that maps to no test is then a warning, not the hard PREGATE_NO_LANE: the lanes row is warn with PREGATE_NO_POLICY, the selected tests still run (a red lane is still fail, an unknown platform matrix is still a requires-remote obligation), and the table prints a starter policy drafted from the repository (PREGATE_STARTER: lines: the detected runner, the test directories, the CI lanes, then the JSON between begin and end, with a dependencies entry per unmapped touched file mapped to the tests nearest it or sharing its stem, never [], and a TODO line for each file nothing covers). run writes nothing; gstack-pregate init is the explicit write. Once any policy exists (committed on the base, or the user-owned file), an unmapped file is PREGATE_NO_LANE again: the warning is for the first /ship in a new repository (three Captains lost their first pass to it), never a way to hide missing coverage. --policy-from head with a head policy is strict too.

gstack-pregate init writes a draft built from the repo, not a fixed example. It prints the languages it found and the runner each language's tests would get, then maps every file the import graph cannot place to the tests of the nearest directory above it that holds at most 25 of them, up to the 256-entry schema limit. The draft never writes [], which would clear a file without a test. A file with no nearby test, and a lane whose matrix a planner computes, is printed as a TODO. Review each entry before committing the file on the base branch.

.gstack/always-run.json

{ "tests": [globs] } names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the lanes selection adds every tracked test file a glob matches. --explain prints each one as <test> (always_run, .gstack/always-run.json), and a glob that matches no test as <glob> matches no test file. A docs-only branch adds nothing.

The list lives in its own file, not in pregate.json, because older pregate readers reject unknown keys there. A repository that ignores .gstack/ (gstack does) tracks the file with git add -f, as it does pregate.json. A missing file means an empty list. A file that is not JSON, or whose tests is not a list of non-empty strings, stops the check with that message. gstack's own list matches 16 test files; the 13 with a seed in scripts/free-test-durations.json (recorded 2026-10-19) total about 13 s there, and bun-entry-isolation, launch-env-echo-ratchet and review-packets-removed have no seed yet.

gstack-pregate

gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
               [--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init

Two stages with separate clocks, both printed:

Stage 1, mechanical preflight (the two-minute promise; gstack measured ~2.5 s for regen and under 200 ms for each of the others):

  • regen: every registry entry whose inputs or outputs are touched is fresh (gstack-regen check in a scratch worktree). A fresh entry with touched outputs then runs once more with those outputs deleted; the ones the command recreates byte-identically are the check's proven paths, which gstack-ship-receipt carry reads. The verdict comes from the first run: an output the second run does not recreate, or a second run that fails, is simply not proven.

  • secrets: gstack-redact over the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in the secret category, fails; other MEDIUM findings warn. Cannot be downgraded.

  • strays: a new file at the repository root, a new top-level directory, or a scratch name (zz-*, tmp*, scratch*) anywhere.

  • literals: for every changed exported constant or named timeout/retry literal, the whole test tree (nightly- and Heavy-only files included) is searched for the old value; a consumer the branch did not update fails with file:line. A version-history row, a line that starts a bracketed row with the old value ([45, "<digest>"]), is skipped when the same table (a run of consecutive lines starting with [) also holds a row for the new value that the branch added (the file at the merge base, read through a rename git detects, has no new-value row). A table the branch did not extend, a file that already held a new-value row at the merge base, a merge base that cannot be read, and every other line holding the old value still fail. Only the row itself is exempt: the old value after its closing bracket on the same line still fails. Rows in object form ({ v: 45 }) or spread over several lines are not recognized as history rows.

  • timing: added or changed test lines that compare an elapsed wall-clock or CPU time against a bound, or sleep and then assert, print TIMING_ASSERTION: <file>:<line> <pattern> and a TIMING_ASSERTIONS: found=<n> escaped=<n> files=<n> summary. It warns (PREGATE_TIMING_ASSERTION); it does not block. See timing.

  • guards: each guard in the active policy (.gstack/pregate.json, or the user-owned file) whose when globs match a touched file (renames included) runs its command in its own process, under the verified launch Bun, with its own timeout_s (default --timeout). A non-zero exit fails pregate naming the guard and its fix (PREGATE_GUARD_FAILED); a guard that could not start, or whose own binary is off PATH (exit 127), is the machine's, PREGATE_ENVIRONMENT (incomplete), not a guard failure; a timeout is incomplete; without --allow-repo-commands the selected guards are listed and the check is incomplete. Guards are read from the base branch's copy (or the user-owned file when the base carries none) and cannot be downgraded to warn. See guards.

Stage 2, test execution:

  • lanes: touched files map to tests through lib/pregate/import-graph.ts (static imports ∪ repo-path string literals ∪ the bin/<name> → test/<name>*.test.ts convention ∪ dependencies; Dart import/export/part directives resolve package:<name>/x through every tracked pubspec.yaml, and *_test.dart files under a test/ directory are tests, except under integration_test/ and test_driver/: they need a device, so pregate never runs them, and a touched one has no lane until you declare "**/integration_test/**": [] (and "**/test_driver/**": []) under dependencies on the base branch and let CI run them (a [] only on the branch is refused under --policy-from head); Dart files, device tests included, are source code, so a dependencies [] declaration clears them and empty_lanes does not); in a Rust crate (the nearest Cargo.toml with a [package] name) a .rs file is a test when it sits under <crate>/tests/, holds a #[cfg(test)] (any cfg predicate naming test) or a test attribute (#[test], #[tokio::test]), and every .rs file of the crate and its Cargo.toml select the crate's tests and those of every crate whose manifest depends on it, directly or through other crates (cargo runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has no lane, and a workspace-only Cargo.toml or Cargo.lock needs a dependencies entry), transitively through non-test files; deleted and renamed files select the tests that named them. A removed test (a deleted one, or a rename's old name) that no remaining test selects owes no lane, and prints removed (no lane owed): <path> — <why> (the lanes detail adds N removed path(s)), when it is a JS, TS, Python, Dart or bats test and no remaining file imports it (directly or through a directory it reads; otherwise still imported by …). It runs the tests of each remaining non-Markdown file that mentions its name without the final extension anywhere (alpha.test, test_alpha: a path with or without the extension, or a dotted module), such as a curation list (files naming it run: …), and fails closed when such a file has no test (still named by …, which no test covers). A test renamed to another test path then defers to its new name (old name of <new>). A deleted test, or one renamed to a non-test path, also runs its owning suite, the remaining tests that directly exercise the files its merge-base text exercised (owning suite runs: …; test helpers and fixtures are not subjects), and names each file left with no test at all (coverage removed: <file> has no remaining test, and M subject(s) lost their last test in the detail). Every other removal owes what an edit owes, as before: a deleted or renamed Go or Ruby test (it may define helpers its package still uses), a deleted test whose merge-base text cannot be read (a symlink, invalid UTF-8, over 1 MiB), and any removed non-test path, a renamed source file's old name included, are still PREGATE_NO_LANE until a test or a dependencies entry covers them. A modified file whose only test the branch deleted still has no lane, and a remaining test that reads a deleted file or directory is selected. /review lists a deleted test as a gate edit (RH-1) either way. An extensionless JS/TS import (relative, a tsconfig/jsconfig paths alias or baseUrl) tries the exact path, then .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, then index.ts, index.tsx, index.js, index.jsx and index.mjs inside the directory; the first file that exists wins. A touched source file with no lane is FAIL lanes — <file> has no test lane once any policy exists (the base copy, the user-owned file or --policy-from head); with no policy anywhere it is the warn PREGATE_NO_POLICY with a starter printed (no policy); never a pass. Markdown that no test depends on is a docs/no-tests empty lane instead (docs/no-tests: N documentation file(s)), including when the base branch has no .gstack/pregate.json. The dependency is the same import graph as every other file, so a test that reads the Markdown (a fixture or a doc) still selects that test. empty_lanes adds globs for other documentation; it cannot clear a source file or a file under a test directory. Files no test runner loads take a second empty lane by the same rule (no-tests (assets and tool config): …): images, fonts, media and PDFs outside snapshot, golden and fixture directories, plus an allowlist of tool configuration (.editorconfig, .gitignore, .pre-commit-config.yaml, lint and format config, .vscode/, .idea/, .impeccable/, and GitHub issue templates, CODEOWNERS and dependabot config). Stylesheets (they import each other), any other dotfile (.rspec, .husky/ hooks, .env*, version pins), CI workflows and actions, and .gstack/ still need a lane. A release manifest the branch only re-stamped is waived as version-stamp-only (version-stamp-only (no lane needed): …): the file's diff against the merge base changes nothing but its version values. Three file kinds qualify, each by its own exact rule (lib/pregate/stamp-only.ts). A JSON file (*.json) whose two sides parse to objects of the same shape (the same own keys in the same order in every object, the same length in every array, the same type at every leaf), whose only differing leaf is the document's top-level version with a bare version on both sides (1.2.3, v1.2.3.4, 1.2.3-rc.1; never a range), and whose text is line-for-line identical once that line has its old quoted version replaced by the new one. A plist whose differing lines are all <string> versions under <key>CFBundleShortVersionString</key> or <key>CFBundleVersion</key>. A VERSION or VERSION.txt file (any case) holding one bare version, with at most a trailing LF, on both sides. Texts are compared byte for byte outside the stamped value, so a line-ending change or a BOM is content. Nothing nested in JSON qualifies, so a dependency pin or range, a lockfile entry, a catalog or a package named version is never a stamp; nor is a changed description, a new, removed or reordered key, a floor such as minHostVersion, schemaVersion or MinimumOSVersion, a reflow, a change the parser would erase (a big integer, a duplicate key), a file that is not valid UTF-8 or is over 1 MiB, a mode change or a symlink on either side, an added, deleted or renamed manifest, or any other file kind: each keeps its PREGATE_NO_LANE. The rule runs only after the import graph, the declarations, the regen registry and both empty lanes found nothing, so a manifest a test reads still selects that test, and never under a test, fixture, golden, snapshot, .gstack/ or CI-workflow tree, whose JSON a test may compare against by a path the graph cannot see. --explain prints the verdict per file (version-stamp-only against <merge-base> (version 1.0.0 → 1.0.1) or not version-stamp-only (<reason>)). In a Django project (a manage.py or code importing django, plus Python code, not a string or comment, that assigns INSTALLED_APPS or ROOT_URLCONF, or a config file names DJANGO_SETTINGS_MODULE), the files Django loads by name get framework edges: every Python test depends on each settings module; a dotted string in a settings module and an include("app.urls") argument count as imports; an app's migrations, admin.py, templates/, static/, templatetags/, management/, fixtures/ and locale/ files select that app's tests and the tests that reach its models.py (the settings module, so the suite, when the app has no tests of its own); a template under a project-level templates/ directory selects the suite through the settings module. Lanes and platform tiers come from .github/workflows/*.yml (gstack-pregate lanes prints them; a test path a step names resolves against that step's working-directory, else the job's or workflow's defaults.run.working-directory): local ones run through the runner under set -o pipefail, judged on the runner's summary line (zero tests ran is a failure, a | tail-hidden exit is still an exit; cargo's test result: lines, one per test binary and doc-tests included, are summed); a platform lane this machine cannot run becomes a requires-remote obligation naming workflow.yml/job, unless local names that lane. --explain prints each test's provenance and every uncertain mapping (a planner-computed matrix without a selection manifest selects conservatively). Test files matching serial_patterns (default **/*.serial.test.*) run one process per file after the unit files. The runner and every guard run with the verified launch Bun first on PATH: the first of $BUN_INSTALL/bin and ~/.bun/bin whose Bun satisfies gstack's and the project's engines.bun (the row prints runtime: bun <v> from <dir>), so a Capy host's older image Bun cannot flip the verdict.
  • Every test-tier command (the lanes runner, each guard, the --remote gate) runs supervised: see supervision. --timeout (else the policy's timeout_s, else 600 s) bounds each runner process (the unit-file group, each serial file, each guard without its own timeout_s), not their total; the stall timeout catches a hung or silent one sooner.
  • Under 8 cores, a remote_gate in the active policy or a remote test_backend (gstack-config set test_backend ubicloud) records the local test tier as a requires-remote obligation instead of running it: see remote gate.

Every check records the input identity it ran against (the working-tree fingerprint before and after, the selection, the command hash); a tree that moved during a check, a timeout or a missing tool is incomplete, never pass. The record is .gstack/tmp/pregate.json (gstack-artifact validate --as pregate).

Publication. gstack-ship-receipt write --pregate .gstack/tmp/pregate.json (Step 18) refuses unless the record is current for the tree being published (PREGATE_STALE: rerun after the final stamp), green (PREGATE_LANE_FAILED, PREGATE_INCOMPLETE, or PREGATE_ENVIRONMENT when every incomplete check is the machine's) and every requires-remote obligation carries a receipt (PREGATE_REMOTE_UNCLEARED: dispatch the job, then gstack-pregate clear --remote <workflow.yml/job> --run-url <url>). The ship receipt then carries pregate and requires_remote. gstack-pregate verify runs the same check standalone.

By tree. Every record gstack-pregate writes is also mirrored, byte for byte, to <state root>/pregate/by-tree/<tree>.json (the state root from docs/state-root.md). gstack-ship-queue ready reads the checkout's .gstack/tmp/pregate.json first; when that file is missing or was recorded for another tree, or is current but still waits on a remote receipt, it looks the current tree up in the mirror and uses that record when it verifies, printing PREGATE: … (from the state root's pregate/by-tree/<tree12>.json, recorded by another checkout). This is for the lane whose gstack-pregate --remote had to run from a separate clone (a worktree cannot host it): the clone's record clears the lane's own checkout as long as both hold the same tree. Only a run that holds the tests stage stands in (what --stage tests --remote writes, checks=[lanes]; a --only timing or preflight-only run is "a run without the tests stage"), and it must be recorded against origin/<base> and not under --policy-from head; otherwise the PREGATE: line says why the mirror cannot stand in. A record whose own checks failed or stayed incomplete is final and no mirror overrides it. An explicit --pregate <file> that verifies is used as before; a mirror for a different tree changes nothing. Writing the mirror is best effort (a temp file renamed into place) and never fails the record itself.

Unknown flags exit 2 with usage on every verb (an agent told to run a flag this gstack lacks never gets a green it did not ask for). Tier 2 checks still to come (ratchets, lint, links, hermetic, patches, integrator) are named in --only's help.

Supervision: progress, stalls, deadlines

GBRA-95 (gbrain, 4 cores): a core edit selected 3,683 local files, 358 of them serial; the test stage printed nothing for 90 minutes: each runner process ran up to its own --timeout (one per serial file) with its output buffered until it returned, and nothing noticed a process that had gone quiet. Now each test-tier command:

  • runs in its own process group (setsid), with stdout and stderr appended to a log file, never a pipe, so a backgrounded grandchild that keeps the output open cannot hold the check; after the command exits, the group's leftover members are killed;
  • reports live on stderr (stdout stays the table, or one JSON document with --json):
    PREGATE_TEST: <n>/<total> <file> start
    PREGATE_TEST: <n>/<total> <file> pass|fail <seconds>
    PREGATE_HEARTBEAT: <label> running <s>s; last file <file>; log <bytes> bytes, last growth <s>s ago (<log>)
    PREGATE: tests <verdict> in <s>s — lanes: <detail>
    
    Per-file lines come from a progress adapter where the runner's reporter provides them: bun test (its <file>: headers) is the only adapter today; a one-file run (each serial file) reports its file from the launch for any runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats only. A heartbeat follows each 60 s with no progress line;
  • is killed as stalled when its log does not grow for the stall timeout (--stall-timeout <s>, else stall_timeout_s, else 300; 0 disables): the check is incomplete with PREGATE_STALLED and names the last file started; the next serial file still runs;
  • is killed as timed out when that runner process reaches its timeout (--timeout <s>, else timeout_s, else 600; per runner process, so many serial files that each fit still pass): the check is incomplete, its detail names which of the three set the timeout, and the next serial file still runs.

A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores SIGTERM still dies). SIGINT or SIGTERM to gstack-pregate itself SIGKILLs every running group, writes no pregate.json and exits 130 or 143. Windows has no process groups here: the kill is taskkill /T /F on the shell's tree, which misses a descendant that was reparented or started detached.

Small machines and the remote gate

Under 8 cores (os.availableParallelism()), when the active policy (the base branch's .gstack/pregate.json, or the user-owned file) declares remote_gate or gstack-config get test_backend is not local, the lanes check runs nothing locally: it records the selected local files as a requires-remote obligation with the command to run, pregate.json/remote_gate (the declared command) or test_backend/<backend> (gstack's scripts/ubicloud/ubi-runner.sh run --src <repo> -- '<runner> <files>'). Without either, the tier runs locally under the supervision above. Close the obligation one of two ways:

  • after running it, gstack-pregate clear --remote pregate.json/remote_gate --receipt <file> with the gate's output, validated as --remote validates it (a run URL is refused, PREGATE_RECEIPT_INVALID for a bad receipt; a receipt for a tree that differs from this one only by the repo's stamp rule is accepted and recorded, see below), or gstack-pregate clear --remote test_backend/<backend> --run-url <url>, which has no machine-checkable receipt and is recorded as attested:<url>; or
  • gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands, which runs the declared remote_gate now (under --policy-from head, still the trusted copy's gate; a test_backend without remote_gate cannot be run this way). The gate runs in the repository root with PREGATE_TREE (the working-tree fingerprint being tested), PREGATE_FILES_FILE (the selected files, one per line), PREGATE_SPEND_CAP_USD and PREGATE_TIMEOUT_S (this run's per-process timeout in whole seconds), and must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>], reporting the tree it actually tested. The obligation clears only when that tree is PREGATE_TREE, tests > 0, failed = 0 and the gate exits 0; anything else leaves it uncleared and the check incomplete. The cap defaults to $GSTACK_SPEND_CAP_USD; there is no default spend. Spend prints as spent, reserved, unknown: a gate that prints no spent= is unknown, counted at the cap.

A receipt from a stamp-only predecessor tree (gate ahead of the stamp)

The receipt clear --receipt validates names the tree the gate tested, and pregate.json names the tree being published. A repo whose full gate runs before its turn in the queue ("gate ahead": the gate tests commit G, then /ship's restamp moves the version files, so the head H it publishes is G plus the stamp) could never clear the obligation with that run, and paid a second full gate on H for a diff that was nothing but stamps. clear now accepts that receipt for H when the two trees differ only as a stamp does:

  • gstack diffs the gated tree against pregate.json's tree (git diff --unified=0 <gated> <head>, both trees as objects in this repository: the gated commit's tree once it is fetched, and the head tree bin/gstack-wtree wrote) and classifies every differing file with the tree receipt's own rule, lib/tree-receipt.ts under .gstack/ship-policy.json on origin/<base> (ship-policy). That is the rule gstack-tree-receipt, gstack-pregate regate and the train's verify --train apply: with stamp_files set only those files can be stamps, each hunk in them must be the exact old → new rewrite under stamp_patterns (literal {version} templates) or the built-in forms ((was X), since: X, vX), and the version file, package.json's version, a lockfile's root version keys and a CHANGELOG re-heading (under changelog_fragments, also the fold of the fragments the head deletes) have their own built-in rules. There is no second list and no looser rule here: the file verdicts clear reaches on a pair are the verdicts gstack-tree-receipt --gated G --current H reaches, and its gate rule holds too: a policy whose stamp_sensitive_gates names the test lane (tests, as gstack's own does because its tests read VERSION and the agents digest) is refused outright, since the tree receipt never marks such a lane reusable. The policy is the one on this repository's base branch (origin/HEAD, as regate reads it) at its current tip, never a branch pregate.json names: a pregate.json recorded against another base is refused. What the receipt does not compare, exactly as a same-tree receipt does not, is the gate's execution identity (Bun, OS, lockfiles); that stays the evidence bundle's job.
  • Accepted, the obligation's receipt reads remote-gate tree=<gated> tests=<n> accepted-for=<head> stamp-only=<file count> receipt=<file> sha256=<digest>, and pregate.json records the substitution beside it: substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }, the policy label (origin/<base>@<sha>:.gstack/ship-policy.json) and every differing path with the rule that classed it (version-file, version-manifest, manifest-version, lockfile-root-version, changelog-heading, changelog-fragments for the CHANGELOG under the fold, changelog_fragments for a folded fragment's deletion, stamp_files, stamp_paths, release_outputs). clear prints the same as PREGATE_RECEIPT_SUBSTITUTED: …, verify repeats it on its ok line, and the ship receipt carries the receipt string. The receipt's own tests and failed checks apply as before (its exit status is checked only when --remote runs the gate itself), and verify refuses a recorded substitution whose gated_tree is not the receipt string's tree=.
  • Refused, with PREGATE_RECEIPT_INVALID and a fix naming the offending path: any differing path outside the rule (lib/x.ts (stamp_files: not in the repo's stamp_files allowlist, line 1)), a stamp path whose hunk is not the stamp rewrite (plugin.json (stamp_files: "…description…" -> "…", line 4)), a gated tree that is not a 40- or 64-hex object id or not an object here (fetch the gated commit first), a base branch whose policy cannot be read or is invalid, a pregate.json recorded with --base against another branch than this repository's base (origin/HEAD), a policy whose stamp_sensitive_gates names the test lane (tests or remote_gate; gstack's own policy does, so gstack itself never substitutes), or a patch that does not account for every path git diff-tree lists between the two trees. The version rule is the tree receipt's: a stamp_files, stamp_paths or release_outputs hunk is a stamp only as the rewrite to the head's version from the gated tree's version, or from a newer tentative version of the repo's width that the stamp moved (a *_KEY_VERSION written as the next minor, #3512) (so two trees at the same version refuse such a hunk as other), while the built-in version-file, manifest, lockfile and CHANGELOG rules compare content and do not check the direction of the version move. The obligation stays uncleared; rerun the gate on the head, or make the extra change a stamp under the repo's rule.

A consuming repo declares its stamp paths once, in .gstack/ship-policy.json on its base branch (stamp_files, stamp_patterns, changelog), the same file the tree receipt and restamp already read; nothing is declared in .gstack/pregate.json for this. gbrain's is in gbrain worked configs.

The gate must have run on a clean, committed checkout: PREGATE_TREE is the bin/gstack-wtree fingerprint, which equals the commit's tree only when nothing was uncommitted or untracked there, and clear can compare only trees that are objects in this repository. A receipt whose tree is not here is refused, never guessed. The sequence that reuses a gate-ahead run, on a machine under 8 cores:

git fetch origin <gated sha>                      # the gated commit's tree must be here
gstack-pregate --allow-repo-commands              # on the stamped head: records pregate.json/remote_gate uncleared
gstack-pregate clear --remote pregate.json/remote_gate --receipt <the gate's output for the gated tree>
gstack-pregate verify                             # PREGATE_VERIFY: ok …; pregate.json/remote_gate: gated tree … accepted for tree …

gstack's own example: .gstack/pregate.json declares "remote_gate": "bash scripts/ubicloud/pregate-remote.sh". With UBICLOUD_API_KEY and UBI_OWNER set the script runs the selected files on one Ubicloud VM with bun run test:ubicloud's environment, refuses before creating the VM when the cap cannot buy a 20-minute run at the size's list price (UBI_SIZE, default standard-16), checks the tree and the planned file set on the VM before any test runs, and reports spent= from billed minutes. Without a key it runs the same files locally in parallel shards (test-free-shards.ts --files-from) and reports spent=0, after a backend=local (N cores; not the CI environment) line. Paid test files in the selection (skill-e2e and the other paid globs in test/helpers/paid-test-set.ts) are named and left to the paid PR gate; a selection of only paid files reports tests=0, which never clears the obligation. The command is the same either way (--remote always needs a cap; the keyless path spends nothing).

pregate kills the gate at its --timeout (default the policy's timeout_s, else 600 s) with a 5 s grace, so the script finishes before that deadline: it reads the deadline from PREGATE_TIMEOUT_S, which pregate sets to that same timeout, else from the gstack-pregate command it runs under (its --timeout, else 600), reserves 120 s for teardown plus 15 s margin, and tells the VM to stop at whichever comes first, that point or the cap's wall (at most 45 minutes, minus 3 for teardown). A deadline that leaves the VM under 300 s refuses before any VM (PREGATE_REMOTE_TIMEOUT_TOO_SHORT). At create time it prints the VM's name and the exact bash scripts/ubicloud/ubi-runner.sh down <name> line to destroy it if the run is killed before its teardown line. The whole script is parsed before it runs, so editing it during a run changes nothing. --timeout 2700 gives a whole-tier run room inside the 45-minute wall:

gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands

Measured (October 2026): keyed, all 1,280 free files on standard-16 took 404 s from start to teardown (create 24 s, setup 72 s, tests 250 s) for $0.06. Keyless on 4 cores, 98 files took 103 s, and GSTA-35's 798-file selection took 3,550 s, so keyless is practical for small selections only. A large selection on a small machine needs a key, or a --timeout longer than the run. Keyless also runs in your environment, not CI's: a file that needs what setup-free-suite.sh installs (Playwright's Chromium, Xvfb) fails locally until you install it.

Every refusal line starts with a reason code:

code exit meaning
PREGATE_REMOTE_USAGE 2 an input is missing or malformed; run it through gstack-pregate --stage tests --remote
PREGATE_REMOTE_TREE_MISMATCH 3 the working tree is not PREGATE_TREE; nothing uploaded, nothing spent
PREGATE_REMOTE_TREE_MOVED 3 the tree changed during the run (the moved paths are listed); no result reported
PREGATE_REMOTE_VM_TREE 3 the VM's unpacked tree or planned file set differs from what was checked locally
PREGATE_REMOTE_NO_RESULT 1 test-free-shards printed no result (a rejected file list)
PREGATE_REMOTE_PAID_ONLY 0 every selected file is a paid test file; tests=0 cannot clear
PREGATE_REMOTE_CAP_TOO_LOW 5 the cap cannot buy a 20-minute run at the size's price; nothing created
PREGATE_REMOTE_NO_PRICE 5 UBI_SIZE has no list price in the script
PREGATE_REMOTE_TIMEOUT_TOO_SHORT 5 pregate's deadline leaves the VM under 300 s after the 135 s teardown reserve; nothing created
PREGATE_REMOTE_OWNER_REQUIRED 2 UBI_OWNER is unset; nothing created (every VM must name its owner)
PREGATE_REMOTE_INFRA 4 create, setup, SSH or the stop deadline failed, or the runner's UBICLOUD: left= line is not 0; the VM, its teardown line and spent= follow
PREGATE_REMOTE_INTERRUPTED 130 TERM or INT; ubi-runner tears the VM down on its own

Guards and the lane-done rule

A lane runs gstack-pregate run --allow-repo-commands on its branch before it reports done, and its handoff carries the PREGATE: line (the coordinator contract says so word for word). The unit tier covers the changed files' tests; guards cover the repo's mechanical gates the import graph cannot see:

{ "guards": [
    { "id": "serial-manifest", "when": ["test/**/*.serial.test.ts", "scripts/serial-files.tsv"],
      "command": "bun test test/scripts/serial-files.test.ts",
      "fix": "add the file's row to scripts/serial-files.tsv" } ] }

dependencies still maps globs to extra tests the lanes check selects; a guard is a command with its own fix line. Older gstack refuses unknown keys (PREGATE_CONFIG_INVALID), so a repo commits guards only after the gstack that reads them ships. The worked gbrain configuration is in gbrain worked configs.

Allowlist: benign findings the secrets check accepts

The secrets check cannot be downgraded, and that is right for a key. It also refused 1,800 per-trial dummy tokens in eval receipts ("key": "sk-trial-<id>" reads as env.kv), an 11-digit Actions run id read as a phone number (one after a run word such as run, screen or gate is exempt in the scanner since the GSTA-121 follow-up; the allowlist still covers one after any other word), and excalidraw.com's public Firebase key that every rebuild of a bundle re-adds (harness-evals #81/#83, GSTA-102, GSTA-106). allowlist in the policy names those, each with its scope and its reason:

{ "allowlist": [
    { "finding": "env.kv", "paths": ["evals/**/receipts/*.json"], "pattern": "sk-trial-[0-9a-f-]+", "reason": "dummy per-trial meter ids" },
    { "finding": "pii.phone.e164", "pattern": "\\d{11}", "reason": "GitHub Actions run ids in reports" },
    { "finding": "google.api_key", "paths": ["lib/render/dist/bundle.js"], "pattern": "AIza[0-9A-Za-z_-]{35}", "reason": "the vendor's public web key, restricted server-side" } ] }
  • finding is a gstack-redact finding id (env.kv, google.api_key, pii.phone.e164, internal.hostname, …) or one of the categories pii, internal, hygiene, legal. A secret is always named by its id; the secret category cannot be allowlisted whole.
  • paths are contained globs over the added file's repo path; pattern is a regular expression matched at the finding's position in the added line and ending at a token boundary, so a dummy token's shape never covers a real key that shares its line or starts the same way. One of the two is required; reason always is, and the record repeats it.
  • env.value is never allowlisted. It is the real value of a credential-named variable of the environment pregate ran in (#3449), found in the added lines whatever its shape. Validation refuses an entry naming it (PREGATE_CONFIG_INVALID), and the matcher never consults the list for it, so a stale or hand-edited policy cannot silence it either. The scanner's own engine.* refusals (an oversize input fails closed) are refused and kept the same way: they are the scanner declining its input, not a finding in it.
  • A pattern entry needs the finding's position. When the scanner gave none, the entry is inert for that finding and the row says so (allowlist[0] env.kv not tested on env.kv (<file>): the scanner gave no position for its pattern; the finding is kept) rather than silently keeping it.

The secrets row says N allowlisted (<ids>) and lists each allowlisted finding with the entry (allowlisted env.kv (evals/receipts/t1.json, added line 3) by allowlist[0] env.kv: dummy per-trial meter ids); inputs.allowlist carries the entries' digest, so a changed list is a changed input. The list is read from the base branch's copy (reviewed) or the user-owned file, like every other key; an unparsed scanner report keeps its exit-code verdict and is never allowlisted.

Environment failures, apart from diff failures

A lane that could not run is not a lane that failed. When a check could not run its command because of the machine (truther #617: a root runner with a vitest config that found no file, a corpus fetch that stalled on one host and passed on another), the check is incomplete under its own code, PREGATE_ENVIRONMENT, with cause: "environment" and an environment: { kind, evidence, proof } object in pregate.json, an environment: <kind> — <evidence> table line, and a fix that says so. A failure the branch caused carries cause: "diff" (PREGATE_LANE_FAILED, PREGATE_GUARD_FAILED, PREGATE_SECRETS). Kinds:

Kind Evidence
missing-binary exit 127, command not found, a spawn ENOENT, a runner tool not on PATH (dart, flutter, cargo, a package manager)
network ENOTFOUND, ECONNREFUSED, ECONNRESET, ETIMEDOUT, EAI_AGAIN, Could not resolve host, fetch failed, a TLS handshake or certificate failure
quota quota, 429 Too Many Requests, rate limit, insufficient_quota (a vCPU quota on the remote backend)
disk ENOSPC, No space left on device, EDQUOT
runtime the install verdict or the pinned Bun refused (BUN_RESOLUTION_DRIFT, INSTALL_UNVERIFIED)
tool gstack's own scanner (gstack-redact) did not finish or exited outside its contract

The classifier is conservative. A local lane that exited non-zero is read from its output only when its runner reported no test summary (nothing ran) and the runner was not seen starting a test file; a run whose summary counts N ran, M failed is the diff's failure whatever its log says, so a test that failed on ECONNREFUSED is still a red test. A remote gate that exited without a PREGATE_REMOTE: line is read from its preamble only (its first 40 lines, before any test output could stream): a VM refused for quota or an unreachable API there never ran the tests, so it is environment and the obligation stays uncleared, while a quota in a streamed test name is not. A guard that ran and said no is the diff's. Exit 127 and command not found count as missing-binary only when the command's own binary is off PATH: a runner, guard or gate script the diff controls that exits 127 (a package script naming a tool that is not installed) is the diff's failure. A stall or a timeout keeps PREGATE_STALLED / PREGATE_INCOMPLETE: a hang has no single cause, and so does a runner killed by a signal.

proof records how the cause was established. launch is certain: the command could not start, its binary is off PATH, the runtime or a gstack tool refused; the fix ends the diff is not at fault. output means a rule matched the command's text before any test was counted, which the diff's own output could have written (a test that prints fetch failed and exits the process before the summary); the table line says (inferred from output) and the fix asks for confirmation on another machine or with --remote before the diff is treated as clear. Neither proof ever passes the check.

gstack-pregate verify reports PREGATE_ENVIRONMENT: <n> check(s) blocked by the environment (<kinds>), not by the diff when every incomplete check is environment-caused, naming any check whose cause was inferred from output alone, and names the kinds beside the generic PREGATE_INCOMPLETE otherwise; --json carries cause and environment per check. A lane that waives such a run cites the kind, the evidence and the proof, not a test name, and clears the lane remotely (--remote, or gstack-pregate clear with a run URL) rather than on the broken machine. Incomplete never passes.

Scratch: one directory per run, under the state root

Every run owns <state root>/projects/<slug>/pregate/runs/<run id>/ (mode 0700; <run id> is --run <id> reduced to [A-Za-z0-9._-], else <UTC stamp>-<pid>-<4 hex>). The secrets scan's added-line copies (secrets/, deleted after the scan), the lane log (lanes/lanes.log), each guard's log (guards/<id>.log) and the remote gate's log and file list (remote/) live there, never under os.tmpdir(): two pregates in parallel (two lanes on one machine, a subagent beside its parent) never share a path, and the log a fix: line names survives a host that wipes /tmp between wakes. pregate.json records the directory as scratch and the table prints scratch: <dir>. A --run <id> whose directory already exists (a finished run under the same name, or a second run beside a live one) is never shared, since a second run would truncate the first's logs: the run takes <id>-2 … -9 and the record names the directory it got. Runs older than 7 days under the same project are pruned when a new run starts (the current run never is). An unwritable state root falls back to a private directory under os.tmpdir(), still one per run, and --explain says so.

Timing assertions

timing reads only the lines the branch added or changed in test code (test files and files under test/, tests/, spec/ or __tests__/; never fixture trees). It reports three patterns:

Pattern Example
wall-time-bound expect(Date.now() - t0).toBeLessThan(400), expect(elapsedMs).toBeLessThanOrEqual(2_000), assert elapsed < 0.5
cpu-time-bound expect(cpuMs(() => parse(x))).toBeLessThan(150), assert time.process_time() - c0 < 0.2
sleep-then-assert await Bun.sleep(50); expect(calls).toBe(1) (a fixed sleep, not a polling loop)

A comparison with 0, a named bound (timeoutMs, TTL_MS), a timestamp ordered against a bare Date.now() and a while (… Date.now() < deadline) poll are not reported. Changing the bound of an existing assertion is a changed line, so it is reported.

Escape. When the bound is the contract (a hang check with wide headroom, a CPU-time bound on a pure function), put gstack-timing-ok: <reason> in a comment, in any comment syntax (//, #, /* */, --), on the line or the line above. The reason is required; an empty one is still reported with (gstack-timing-ok needs a reason). --explain lists every escape and its reason.

Warn, not block (measured 2026-10-16). The detector was run over:

  • gstack's own test tree: 263 hits in 123 of 1,566 test files (176 wall-time-bound, 87 sleep-then-assert), mostly real timing assertions, most of them generous "does not hang" bounds.
  • gstack's last 51 commits on main: 9 of the 47 that touched tests would have been flagged (29 hits; #3190 alone has 7).
  • gbrain's last 100 commits on master: 37 of the 95 that touched tests would have been flagged (147 hits).
  • The five gbrain timing-flake fixes (#6050, #6493, #6726, #6746, #6862): 31 of 38 fix sites were flagged before the fix. The misses are flakes with no timing comparison on the line (an injected deadline in #6050, a Promise.race with a sleep and a deadline parameter in #6862) and ratio or percentile checks computed far from the clock read. The correct fix in #6726 (wall clock to thread CPU time) is still a bound and is reported 21 times.

Blocking would stop about a fifth of gstack's and two fifths of gbrain's test-touching commits, including correct fixes, so the check warns. A repo that wants it stricter keeps the warning in its review rule (the /review checklist has the same rule) and /investigate's flake playbook says what to do with a timing flake: never raise the bound.

Sealed output: the pre-push hook and gstack-pregate sealed

{ "sealed": {
    "paths": ["heldout/**", "**/sealed/**"],
    "transcripts": ["runs/**/transcript*.jsonl"],
    "allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
    "answer_markers": ["SEALED ANSWER:"],
    "seed_patterns": ["shuffle_seed=\\d+"],
    "local_paths": true,
    "case_insensitive": true } }

A repo with a sealed key gets a configured local check against accidental publication whenever the managed pre-push hook (gstack-redact install-prepush-hook, no reinstall needed) or the standalone preflight runs:

gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--json]
  • Boundary. Every commit the push publishes: git rev-list <local sha> --not --remotes=<remote> (and the remote tip when the ref exists); a destination with no refs means everything reachable. --range names the commits explicitly.
  • Rules. sealed.path and sealed.transcript check every path a commit adds, renames or copies, whatever its content (binary, empty, deleted again in a later commit); sealed.answer, sealed.seed and sealed.local_path (/home/<user>/, /Users/<user>/, C:\Users\<user>\, including after a shell default such as ${VAR:-…} or ${VAR-…}) check each commit's added lines. Known limit: a path glued to the text before it, by a hyphen (docs-…, $VAR-…, ${VAR}-…) or a letter (-I…, -L…), is not matched. Findings print rule, file[:line] and commit, never the text.
  • Globs. paths, transcripts and allow_paths match the whole repo-relative path from the repository root. * and ? stay inside one path segment; **/ spans zero or more directories; a ** not followed by / matches across directories (heldout/** covers every depth under heldout/); [ and { are literal. So sealed/** misses tasks/x/sealed/a.json: to guard a sealed/ directory wherever it sits, write **/sealed/**.
  • Case. Matching is case-sensitive. case_insensitive: true folds case for paths and transcripts only, so **/heldout/** also catches HELDOUT/ and HeldOut/. Separator and spelling variants (hold-out/, held_out/, heldout-v2/) are not folded: list each as its own glob. The effective value is the base branch's OR the pushed one (it only tightens).
  • Exceptions. allow_paths exempts publishable files that sit next to sealed data (a MANIFEST.json of hashes and counts) from sealed.path and sealed.transcript, applied after the deny globs and before waivers. They are read from the destination base branch only: an exception a push adds takes effect once it lands on the base branch, so a branch can never widen its own. They never fold case. An entry whose file name contains **, or is only wildcards before its extension (**, sealed/*, **/*.json, sealed/**.json), is refused; a partial wildcard such as MANIFEST*.json is accepted and matches every file it spells, so name the file. An exempt file is checked by the text rules only, so configure answer_markers when you exempt files next to sealed data. Each exempt path is listed on every push (exempt <rule> <file> commit <sha> (sealed.allow_paths <glob>)) and counted as exempt=<n> on the SEALED: line.
  • Version skew. A gstack older than the release whose CHANGELOG adds sealed.allow_paths refuses allow_paths and case_insensitive as unknown keys, so every push and gstack-pregate run in the repository fail on that machine. Raise min_gstack_version in .gstack/ship-policy.json to that release before landing either key.
  • Policy. The union of the destination base branch's sealed and the pushed commit's: a push can tighten it, never relax it (allow_paths come from the base branch alone, and a pushed deny glob cannot override one). The SEALED: line names both as policy=<remote>/<base> + commit <sha12>. gstack-pregate sealed only checks and never pushes, so confirm a push with git ls-remote <remote> <ref>. A malformed or unreadable policy refuses. Waivers come only from the base branch's .gstack/sealed-allow.tsv (rule<TAB>path<TAB>sha<TAB>reason) and are listed on every push. A waiver counts only when sha is the finding's full commit sha (40 hex characters, 64 in a SHA-256 repository); a shortened or mistyped one is ignored, so the finding still refuses, and stderr names it (ignored waiver <rule> <path> sha <value> (...)) with the finding's full commit, which needs a review before anyone waives it. --json lists them as ignoredWaivers.
  • Visibility. sealed.visibility from the base branch, else a cache under the state root (sealed-visibility.json: a public answer for 24 hours, a private one for 1 hour before it may turn a refusal into a warning), else gh repo view --json visibility (5 s). Unknown, unreadable or newly created counts as public. Public refuses (SEALED_OUTPUT_REFUSED, exit 1); private warns.

Capy's pull-request tool may not run local git hooks, so an agent runs gstack-pregate sealed before any push or PR creation in a repo with a sealed config (the coordinator contract says so). Hosted CI on the public repository only detects a bypass after the upload.

gstack-pregate regate: does the gate still cover HEAD

gstack-pregate regate --against <gated-head> --bundle <evidence-bundle.json> [--require <lane,...>]
                      [--pr <n>] [--from <gate-ahead.json>] [--predecessor-pr <n> | --predecessor-ref <ref>]
                      [--base <branch>] [--json]
gstack-pregate --against <gated-head> ...      the same verb

It evaluates a tree receipt now (never a stored one) between the gated head and HEAD under the base branch's ship policy, so the repo's stamp_files and stamp_patterns decide what a stamp is (ship-policy). Reuse needs an evidence bundle produced on the gated tree, the same execution identity, and a predecessor head that is still the recorded one (read from --from, default .gstack/tmp/gate-ahead.json when it names this gated head). It prints each differing file (a non-stamp difference with its first line), then:

STAMP_ONLY: yes|no files=<n> [changed=<n>]
LANE: <label> reusable|rerun|not-reusable (<reason>)      one per bundle lane and per --require lane
MIGRATION_COLLISION: none (...) | unknown (...) | <id> <file> vs <origin/main|PR #n> <file>
REGATE: not-needed | needed (<every reason>)

REGATE: not-needed (exit 0) only when the tree is stamp-only, every lane in the bundle and in --require is reusable (a stamp-sensitive lane reruns, so it needs a regate), the predecessor head is known and unmoved, and the migration lookup finished with no collision. Anything else is needed (exit 1); no bundle prints REGATE: needed (no gate evidence for <head> ...).

Migrations come from the ship policy's migrations (dir, id regex with one capture group). The numbers this branch adds under dir are compared with the base branch's tree and with every other open PR (gh pr list --state open --limit 1000 --json number,headRefOid,files; the PR itself is skipped by head sha or --pr). A PR listing 100 files is truncated by GitHub, so its full list is fetched with gh api --paginate. A failed query, 1000 listed PRs or an unfetchable file list is MIGRATION_COLLISION: unknown (...), which blocks not-needed (MIGRATION_COLLISION).

gbrain worked configs

gbrain commits these on master after the gstack that reads the new keys ships (older gstack refuses unknown keys). Its .gitignore ignores .gstack/* today, so the change also adds !.gstack/pregate.json and !.gstack/ship-policy.json; both changes are gbrain's.

.gstack/pregate.json: guards for the gates the import graph cannot see.

{
  "dependencies": {
    "test/**/*.serial.test.ts": ["test/scripts/serial-files.test.ts"],
    "scripts/serial-files.tsv": ["test/scripts/serial-files.test.ts"],
    "src/cli.ts": ["test/cli-goldens.test.ts"],
    "src/commands/**": ["test/cli-goldens.test.ts"],
    "src/core/operations*.ts": ["test/cli-goldens.test.ts"]
  },
  "guards": [
    { "id": "serial-manifest", "when": ["test/**/*.serial.test.ts", "scripts/serial-files.tsv", "scripts/run-serial-tests.sh"],
      "command": "bun test test/scripts/serial-files.test.ts",
      "fix": "add the serial file's row to scripts/serial-files.tsv" },
    { "id": "cli-goldens", "when": ["src/cli.ts", "src/cli/**", "src/commands/**", "src/core/operations*.ts", "src/core/ops/**"],
      "command": "bun test --timeout=60000 test/cli-goldens.test.ts test/cli-dispatch-phase.test.ts test/export-surface-golden.test.ts",
      "fix": "bun run regen:all, then justify each changed golden in the PR body" },
    { "id": "regen-check", "when": ["src/**", "skills/**", "templates/**", "plugin/**"],
      "command": "bun scripts/regen-all.ts --check",
      "fix": "bun run regen:all and commit the regenerated files" },
    { "id": "migration-order", "when": ["src/core/schema-migrations/**"],
      "command": "bun scripts/check-schema-migration-order.ts",
      "fix": "renumber the migration to the next free number on master" }
  ]
}

.gstack/ship-policy.json: the stamp rule and migrations. On a pure restamp of a gbrain master tree from 0.60.188.0 to 0.60.189.0 the tree receipt classifies all 16 changed files as stamps under this policy; an added line in src/core/behavior-change-notice.ts stays a change.

{
  "bump": "patch",
  "queue_mode": "stamp-at-merge",
  "changelog": "CHANGELOG.md",
  "stamp_files": [
    "VERSION", "package.json", "CHANGELOG.md", "openclaw.plugin.json",
    ".claude-plugin/plugin.json", ".codex-plugin/plugin.json",
    "plugin-variants/*/.claude-plugin/plugin.json", "plugin-variants/*/.codex-plugin/plugin.json",
    "plugin-variants/*/README.md", "plugin/README.md",
    "BOOTSTRAP_FOR_AGENTS.md", "templates/bootstrap/template-repo/README.md",
    "src/core/behavior-change-notice.ts"
  ],
  "stamp_patterns": [
    "\"version\": \"{version}\"",
    "since: '{version}'",
    "<!-- gbrain-plugin-tree-stamp: {version} -->",
    "<!-- gbrain-template-stamp: {version} -->",
    "<!-- gbrain-runbook-stamp: {version} -->"
  ],
  "migrations": { "dir": "src/core/schema-migrations", "id": "^v(\\d+)-" }
}

gbrain's bun run release:restamp fetches master, picks its own version, refuses a dirty tree and commits, so it cannot be restamp_command as it stands; the stamp rule above lets gstack's own restamp writer do the work. A gbrain mode that takes the version and writes only stamps would make it one.

gbrain-evals .gstack/pregate.json: the sealed paths. The globs below are placeholders in the shape the guard reads; gbrain-evals fills in its own held-out and transcript layout.

{ "sealed": {
    "paths": ["heldout/**"],
    "transcripts": ["runs/**/transcript*.jsonl"],
    "answer_markers": ["SEALED ANSWER:"],
    "seed_patterns": ["shuffle_seed=\\d+"],
    "local_paths": true } }

gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]...
                   [--no-auto-links] [--author <login>] [--repo <owner/repo>] [--out issues.json] [--json]

Reads origin/<base>..HEAD. Fixes #N only on explicit closure intent: a Fixes/Closes/Resolves #N trailer line or --issue N. A bare mention (a subject's (#N) included), a partial fix, a deferred ask and every cross-repo owner/repo#N are Refs. A parenthesised named reference, (name #N, #M), (name PR #N) or (name issue #N), where the name is one token that is not a closure, reference, filler or version word, is a cross-repo Refs name#N, never a same-repo Refs line or a promotion candidate; (see #12), (closing #12) and (#301) stay bare refs. Same-repo references are printed as promotion candidates (reference #N ← <commit line> (promote: fix #N) on stderr, candidates in --json): the owner's fix #N reply reruns with --issue N; an unattended run keeps them Refs and lists them in the handoff. Each closure issue is read through gstack-issue-guard; one that cannot be read is Refs #N (unreadable …) with ISSUE_UNREADABLE (exit 1), never Fixes.

Each --ci-run <id> is searched among open issues whose body names it and rendered Flake: #N (run <id>) only for a bot-opened issue of this repository that names that run (its flake:json first_seen.run_id, or this repository's actions/runs/<id> URL) and a test (flake:json file › test, or a Flake: <test> title). Two issues for the same test keep the lower number; every refused match is printed with its reason.

Open PRs by other authors (not this branch, and not this PR's author: --author, else the gh viewer; when the viewer is an app such as capy-ai[bot], the HEAD commit's GitHub user, else the owner of a user-owned repository, else the gap is ISSUE_LOOKUP_FAILED) whose closing set overlaps this PR's Fixes are Related: #N (@author), read page by page from each issue's closing-PR references, in unattended runs too. Supersedes #N replaces that line and Contributed by @author is added (the credit lines of lib/contributor-mode.ts) only with --supersedes N / --credit N, after the owner confirms. --no-auto-links skips the lookup. A failed lookup prints ISSUE_LOOKUP_FAILED and keeps the other lines. The stderr preview names the source of each line.

The printed lines go under ## Issues in the PR body; --out writes the object (fixes, refs, flakes, related) gstack-ship-receipt write --issues @file embeds. With --body, that write refuses a body missing any of those lines (ISSUE_LINKS_MISSING).