Checkwright installer — design record
The mechanism behind the activation path: what the bootstraps hand to, what each verb does and refuses, what an install records, how the package is assembled, and what asserts all of it end to end. installer/README.md is the usage tier in front of this file — what an adopter runs; this file is why it is built that way.
This file is not named on installer/package.json’s files roster, so it ships in neither the npm tarball nor the Release tarball. §Layout rules that the roster and not the directory listing decides what is reachable at an installed PKG_ROOT, which is the same rule the payload applies to a kit’s SPEC.md, reaching the package one layer out.
The dependency boundary
This package is not a dependency channel. Nothing resolves at your build time, and nothing is fetched after this package itself. The installer writes no dependency reference, no lockfile entry pointing at a registry, and no install-time lifecycle script. Remove the installer afterwards and the tree it vendored keeps working, needing nothing from a package registry again. That is what one-shot vendoring means, and it is why the vendored-committed doctrine survives the installer. Both properties are asserted, not claimed: §The consumer smoke installs from a packed tarball with no registry access and runs the whole path from it, and check-installer-no-deps reds the package the moment it declares a dependency field or an install-time script.
The two registries stand in different relations to that. crates.io holds the name only (reserve/): there is nothing to cargo add. npm carries a real package, and it is an installer, not a dependency. The same payload also downloads from every tagged GitHub Release, as a tarball and its .sha256. A tarball downloaded, verified against its published digest and extracted before anything runs is the most auditable form of the same vendoring. These are two transports over one install model, not two products: both reach the same init, write the same checkwright.lock and leave the same vendored tree. Only the fetch differs.
The tarball is the primary path because it removes a runtime dependency. docs/install.md says so in prose, and its install-primary: declaration under §Install is the machine-readable form of the same claim (canon-kit/SPEC.md §check-install-claim binds the two). Both channels carry a build attestation: the pack job mints a GitHub artifact attestation on the tarball, and npm publish --provenance mints npm’s on the package, so a reader can tell which workflow built either.
The one-line install. Each system’s section of the install page leads with one line: curl -fsSL https://checkwright.dev/install.sh | sh, or irm https://checkwright.dev/install.ps1 | iex on Windows. The line fetches a script the docs site serves from this repository, docs/install.sh or its PowerShell twin. That script is the recipe below and nothing else. It downloads one pinned release and its digest, verifies it and unpacks it outside the working tree. It then runs the bootstrap from the caller’s directory with the caller’s arguments (init by default), and removes what it unpacked.
- Arguments and overrides. Arguments ride
sh -s -- <args>, or& ([scriptblock]::Create((irm …))) <args>on Windows.CHECKWRIGHT_VERSIONinstalls a named release, older or newer than the pin.CHECKWRIGHT_RELEASE_BASEreplaces the download base; it exists for the CI witness, becauseirmcannot readfile://. - The pin. The script is tracked text at a named version, so what runs is what this repository reviewed. The pin moves only to a published tag (§Versioning), so the line never names a release that does not exist yet. The script adds no dependency to the recipe’s own.
- Truncation and status. Each body is a single function called on the last line, so a truncated fetch runs nothing. The sh script exits with the bootstrap’s status. The PowerShell twin never calls
exit, because text run byiexshares the reader’s session andexitwould close it. It leaves the bootstrap’s status in$LASTEXITCODE, throws naming the step that failed before it, and scopes every preference it sets to its one function. It reads its native steps’ status from$LASTEXITCODErather than from a thrown error, because Windows PowerShell wraps a native stderr line in an error record when its output is redirected. - The attestation check. After the digest and before the extract, each script checks the tarball’s build attestation, so the install trusts the publish workflow that built the bytes and not only the host that served them.
- The floor. Each script carries one floor line below its pin line,
attest_fromin the sh script and$attestFromin the PowerShell one. Its value is the patch successor of the newest tag at the commit that landed the check, and it never moves: every release cut since carries thepackjob’s attestation, and none before it does. The version being installed, the pin orCHECKWRIGHT_VERSION, is compared with the floor over its dotted digit runs, and a version that does not parse is compared as attested, so the check fails closed. Signed in,gh attestation verifyover an unattested file and over a file failing its attestation exit alike, so only the floor tells never attested from fails it. - The verifier is present when
ghis onPATH,gh attestation verify --helpexits 0, andgh auth token --hostname github.comexits 0. All three are local reads, so only an absentgh, one without the attestation command or one holding no credential reads as not checked;gh auth statuswould ask the API and read an outage as signed out. - The verify is bound to the release. It names
--source-ref refs/tags/v<version>, the ref the tag push that ran the publish workflow records. An older attested tarball served under a newer version’s name, its digest relabelled to match, carries its own tag’s ref and fails. - The four outcomes, each one line to stderr under the
checkwright install:prefix. Below the floor,v<version> predates build attestation; checked against its digest only. With no verifier,build attestation not checked (gh is not installed or not signed in); to check it: gh attestation verify checkwright-<version>.tgz --repo checkwright/checkwright --source-ref refs/tags/v<version>. Checked,build attestation verified. Each of these three proceeds. The fourth refuses:gh attestation verify <tgz> --repo checkwright/checkwright --signer-workflow checkwright/checkwright/.github/workflows/publish.yml --source-ref refs/tags/v<version>fails, gh’s own output is relayed, thenverify failed: <tgz> carries no build attestation from checkwright/checkwright's publish workflow. The sh script exits 2 and the PowerShell one throws, as each refuses a digest mismatch. A verify that cannot reach the API, or whose stored credential is expired, refuses the same way, and gh’s relayed output names the cause. - The repository is fixed. The check names this repository whatever
CHECKWRIGHT_RELEASE_BASEsays, so a release served from elsewhere is still checked against this repository’s workflow. - Its honest limits. The check proves that this repository’s publish workflow built the bytes from a push of that version’s tag. It does not prove that the workflow or the tagged tree was sound. It binds the tag’s name and not its commit, since the scripts carry no commit to name, so a tag re-pointed and re-published passes. An outage during the verify blocks the install until it clears, or until the adopter signs
ghout.
- The floor. Each script carries one floor line below its pin line,
- The served encoding. GitHub Pages serves the PowerShell twin as
application/octet-stream:.ps1has no text mapping there, and Pages takes no header config. Windows PowerShell 5.1’sirmstill yields a string for that type, but decodes it in a single-byte charset, so a non-ASCII byte reachesiexchanged.check-portability-floor’s ASCII arm confines the twin’s non-ASCII bytes to full-line comments (gate-sdk/SPEC.md §check-portability-floor). The PowerShell install-smoke leg reads the live URL under 5.1 on every push, and asserts a string that parses and differs from the served text on comment lines only. - The honest limit. Piping runs the script before the reader reads it, unless they fetch it first. The script is served from the site’s own TLS origin, so trusting it means trusting the site: one origin more than the recipe, which trusts only the release host. The recipe stays on the page for the reader who reviews each step first.
The recipe’s shape. Below the line, each system carries the same install as four steps: download, verify, extract, run. macOS and Linux use sh, and Windows uses PowerShell. Each recipe unpacks outside the repository, because init refuses a worktree that is not clean, and an extracted package/ in the root is exactly such untracked content. The version is set once, on the recipe’s first line. The package/ prefix comes from npm pack: npm builds the asset on the release runner, and consuming it needs no Node.
The Windows recipe names tar.exe under the system directory. The windows-remedy block puts Git’s usr\bin ahead of it on PATH, and Git’s GNU tar reads a drive-letter path as a remote host. The recipe runs the bootstrap under powershell -ExecutionPolicy Bypass, scoped to that one process, because Windows’ default policy refuses a downloaded script.
Both Windows blocks stop at their first failure (canon-kit/SPEC.md §check-fence-paste-unit). Each sets $ErrorActionPreference = 'Stop' for itself, a failed cmdlet being non-terminating without it, and restores the reader’s value in a finally: dot-sourced, an unrestored preference would stay for the session. The fetch fence restores $ProgressPreference the same way. The windows-remedy block throws before it writes PATH unless git resolves and exits zero, a native exit being no terminating error under either policy.
The checksum’s honest limit. The digest travels from the same origin, over the same encrypted session, as the tarball. Verifying it catches a corrupted or truncated download, and is no evidence that the release host was uncompromised. The property that carries that is the build attestation, which the one-line install checks where gh is signed in.
Implementation
POSIX sh, up to the boundary §The install boundary rules, and that boundary is the bootstrap itself. The two bootstraps are the whole of the shell: one POSIX sh and one PowerShell, twins rather than an original and a rewrite, because the host the second exists for may run no POSIX shell. Everything they hand to the verified gate binary is Rust, every verb included, so an install is written once in one language.
npm is the delivery vehicle, never the implementation. Both bin entries are scripts, so a reader reviewing what they are about to run reads source rather than a build product. The repository’s script linter governs the POSIX one, in the dialect its #!/bin/sh selects. The PowerShell half has no linter here, and its oracle is its own install-smoke leg, the trade §The install boundary’s parity ruling accepts. What the two scripts do is fixed by that section’s step sequence, which the POSIX half labels step by step in its # spec: comments, so a reader has no third thing to review before running an install.
The POSIX half is run by the host’s own /bin/sh, so the unix install floor is git, /bin/sh and the base userland the bootstrap reads, with no bash. POSIX sh is the subset of bash the OS’s /bin/sh also runs, the reading of the bash-for-Linux-and-macOS policy this bootstrap rests on. The gates workflow’s Linux leg prints what /bin/sh resolves to, dash there, so its green is a strict-shell proof. Writing to that subset costs four things, each settled here:
- The payload directory comes from
$0. The bootstrap is executed, never sourced, so$0is its path, and npm’sbinlink is resolved with plainreadlink.CDPATHis unset before the firstcd: a POSIXcdinto a relative path consults it and prints the directory it picked, which would corrupt the$( )capturingpwd. Whetherreadlink’s answer is already rooted is asked ofgate_path_rooted, which the bootstrap carries as a byte twin of gate-sdk’s library body, held to it by a crate unit test (gate-sdk/SPEC.md §lib/gate.sh). - No
local. POSIX has none and a/bin/shthat is not dash, bash or a BSD sh may lack it, so the functions share the script’s scope and every working name is unique within the file. - No
pipefail. No pipeline’s verdict needs it: the roster test reads the lastgrep’s, and step 4 reads what it captured — a hasher that fails leaves the computed digest empty, which the empty-or-unequal test already refuses. - Pattern tests are
case, the rest[ ], and artifact selection is a glob (§The gate binary), so no array, process substitution orfindprimary is used.
macOS’s /bin/sh is bash in POSIX mode, a lenient runner, so the dialect claim rests on check-shellcheck and on the Linux install-smoke legs, whose /bin/sh is dash.
Requirements
This package reaches a tree over two transports, and each carries its own requirement. The Requirements page’s prerequisites block (docs/requirements.md) lists each transport’s tools and their floors, one row per tool, naming the systems that need it. Those tools serve a delivery path alone. The vendored battery uses Node on no path, no delivery-path tool joins the toolchain roster, and the manual vendoring path (§Vendoring without the installer) needs nothing beyond that roster. A consumer who would rather not add Node takes another path and loses nothing. The toolchain the battery does assert, with its version floors, is on the Requirements page; the contributor’s is in CONTRIBUTING.md.
One requirement belongs to init itself, whichever transport delivered it: sha256sum or shasum. The bootstrap verifies the gate binary against its published digest before running it, and a host with neither hasher is refused rather than given a smaller install (§The install boundary). This is the one step that cannot use the binary to verify the binary; behind the invoke, the binary hashes in-process. The requirement sits beside the roster rather than in it, because the roster lists what the battery requires, and the battery does not require a hasher.
The install pages’ requirement blocks, and why each reads as it does. The install pages render seven marker blocks. The Requirements page, docs/requirements.md, holds three tables: platform, toolchain and prerequisites. Each OS section of docs/install.md holds that system’s remedy block, and its section of the Manual steps page, docs/manual-install.md, holds its install block. Each block is held by a reader, and docs/install-parity-contracts.md and docs/install-block-contracts.md own those contracts: the platform and toolchain parity, and the legs of the remedy and install blocks. The grounds the page’s prose leaves out are these.
- Platform posture. Checkwright is Unix-first. The engine assumes the POSIX userland
GATE_SDK_PROGRAM_FLOORnames (gate-sdk/SPEC.md §lib/gate.sh), and no shipped adopter path needs GNU coreutils. Native Windows is a joined platform: the gate binary is published for it,install-smoke-pwsh-windowsinstalls that published build, and Git for Windows supplies the bash and the userland. The requirements state what a native Windows host must satisfy, not a promise about one. WSL is a route an adopter may choose instead, served by the Linux line. The platform block declares the narrower fact of whether an install is possible at all: a platform the roster does not carry is refused, because every step of an install is behind the binary (§The gate binary). Each Linux architecture has two artifacts. The glibc one’s floor is the glibc symbol set it binds, measured by the shared build body on a pinned image. The static musl one’s floor is the kernel, declared from Rust’s platform support, and the build body refuses it if it links anything dynamically. A host meets the glibc artifact where it runs and the musl one everywhere else (§Selection). - The macOS remedy. Stock macOS ships bash 3.2, below the 4.3 floor. That floor binds only where the tree marks a fence runnable or registers a shell gate, so no profile’s install needs the remedy. The block is the whole remedy. It installs a current bash from Homebrew and puts Homebrew’s
binahead of/binonPATH, both in~/.zprofile(the profile a new zsh Terminal reads) and in the running shell. The requirements check whatPATHactually resolves, so a Mac whose Homebrew bash is not first onPATHreaches the system’s 3.2. The two macOS install-smoke legs run the block verbatim, then open a fresh login shell and source the line it appended, so both the ordering and its persistence are measured. - The Windows remedy. Git’s installer puts only its
cmddirectory onPATHby default, and that directory holds no userland. The block adds Git’susr\binandbinfor the session and, through the userPath, for every later one.install-smoke-pwsh-windowsruns it verbatim under PowerShell.uninstallreverses neither remedy, since it touches the repository and not the machine. - The install blocks. Each OS section’s recipe is split at the network. A fetch fence only a published release can serve comes first. A marked install block (verify, extract, run) follows, and the install-smoke legs run it verbatim against a packed tarball placed where the fetch would have put it. Everything after the download is therefore measured on the host it is written for, and only the two download lines are left to the release. The Windows block runs the bootstrap under Windows PowerShell 5.1, the one PowerShell every Windows host carries, and its leg holds the bootstrap to that host. That host is why the bootstrap’s code stays ASCII outside its comments. 5.1 reads a BOM-less script in the ANSI code page, where a UTF-8 em-dash decodes to a closing double quote and ends the string around it; a BOM would break the
#!/usr/bin/env pwshline npm’s shim reads.check-portability-floor’s ASCII arm holds it at commit, over every.ps1on the configured install-path corpus (gate-sdk/SPEC.md §check-portability-floor). - The one-line witness. After the install block, the same two legs run each one-line install as an adopter runs it:
curl … | shinto the Linux runner’s dash, andirm … | iexunder Windows PowerShell. The tarball and digest the install block placed are served as a release from a local web server, with both overrides set. Each leg asserts the install’s status and a writtencheckwright.lock. It then pipes the script into a second consumer in the page’s argument form,sh -s --or the script block:init --profile starter, asserting the recorded profile, thenuninstall, asserting the lock removed. A dropped argument list runs the defaultinit, which leaves the lock, so theuninstallpass is what observes one. The local server is the witness’s own tool and no adopter’s. The witness does not reach the served copy at the live URL, the content type the site sends for.ps1, or TLS: its transport is local and unencrypted. - The attestation witness. The same two legs serve the same tarball under two more versions, its digest relabelled, since the scripts read the version only to name files and the bootstrap reads its own stamp. Each arm installs into a fresh consumer.
0.0.1, below the floor, asserts the digest-only line, status 0 and a lock. The version is fixed, because the packed tarball’s own one rises above the floor at the next release.99.0.0, above it, runs three arms. A passing stubgh, first onPATH, asserts the verified line, status 0 and a lock. A stub whoseattestation verifyfails asserts a non-zero status or a caught throw, the refusal line and no lock. The runner’s ownPATH, with noGH_TOKENsince no step sets one, asserts the unchecked line and a lock.- The stub is a POSIX
shscript on Linux and agh.cmdthe PowerShell twin’sGet-Commandresolves on Windows. Both stubs answerauth token --hostname github.comandattestation verify --help, each with nothing after it. They failauth statusas an unreachable API would, so a readiness test that reads the network downgrades the passing arm and reds it. Any other call fails, and so does a verify whose arguments after the file are not exactly the three flags, each value its own argument: the repository, the signer workflow and the source refrefs/tags/v99.0.0. Each stub checks every argument in turn and refuses one past the last. A realgh attestation verifyagainst a published Release is outside this witness. The publish workflow’s own verify of the tarball, with the same three flags, is that check at the cut.
- The pin rule. A toolchain member is pinned only where a construct the battery actually runs forces the pin, and each pinned member names that construct. A prerequisite no construct forces says
anyrather than leaving the cell blank, so a reader can tell a considered absence from an omission. A floor nobody’s code forces is an aspiration, and an aspiration is what rots. - The audience token. A toolchain row’s Needed cell names whose floor it is, and an optional row states the condition that owes it.
contributorsmarks a tool nothing on an install path reaches: its row sits inCONTRIBUTING.mdrather than on the Requirements page, anddoctorleaves it out of its verdict entirely.optional: if you register a gate that runs itholds only where a gate registered ingates.listneeds the tool.optional: if your profile includes …holds only where the profile carries a named kit, several listed where it carries any of them.requiredbinds every adopter’s machine.doctorrun outside an install names a selection-bound member as not probed rather than guessing a selection. The roster lives innative/src/toolfloor.rsbeside the floor predicate that reads it, and the two tables render it. - The
bashaudience. The kits owingbashare those shipping a file the host runs with bash or hook wiring the harness runs with it. The list is derived at probe time by the predicate context-kit/SPEC.md §bin/env-probe states, so a kit that later ships such a file joins it with nobody remembering to. Prose telling a reader to type a bash command does not meet that predicate. No kit ships such a file today, so every profile reaches the audience through its own tree alone. Once a consumer’s docs mark a fence runnable, canon-kit’scheck-fence-runruns it withbash, and canon-kit joins the list from the nextdoctor. A shell gate an adopter writes and registers joins the audience through the derivation’s registry arm, sodoctorprobesbashfor it from the next read after registration. On native Windows Git for Windows’ bash is the only shell a custom gate runs under. - What runs without
bash. Each git hook is the gate binary under the hook’s name, which git starts with no shell on any OS (gate-sdk/SPEC.md §git-hook). The unix install bootstrap (installer/bin/checkwright.sh) is POSIX sh, run under/bin/sh, so installing a profile reaches nobasheither. The hook wiring the kits ship is the harness’s exec form, which starts the gate binary with no shell (gate-sdk/SPEC.md §The harness-integration arm). On native Windows a front-end battery runs from PowerShell throughgate-sdk/bin/run-gates.ps1. Git for Windows’ bundled bash is what still takesbashthere: a registered shell gate, a marked fence, and the plugin’s hooks (plugin/SPEC.md §The guards). - The
bashfloor. 4.3 is the highest construct the shipped shell runs: a nameref (local -n) in the gate library, which every kit shell surface that reads a knob before locating the binary sources. Associative arrays,mapfileand the lowercasing case expansion reach only 4.0. A GNU-only construct the shipped shell uses is named here and cited by a# portability-declared:marker at its site. The declaration and the enforcement are so one fact with one owner, andcheck-portability-floorreds a new undeclared use. - The self-check is the binary. The page’s machine check is the gate binary’s
--emit env-probe(context-kit/SPEC.md §bin/env-probe), not a shell front end. The toolchain block statesbashas owed only where a fence is marked runnable or a shell gate registered, and a step that spawned bash would ask every reader for a tool most of them do not need. The check reports each tool’s version and its verdict against the contract, so the profile answers whether the box qualifies. - The docs-site tier. Publishing a docs site is an optional wider tier. A consumer registering site-kit’s render-fidelity gate, which re-renders every page through the GitHub Pages parser, also needs Ruby with the
kramdown-parser-gfmgem, and theliquidgem where it registers the Liquid-parse gate. A consumer publishing no site installs neither. Their versions are the prerequisites block’s rows. - The prerequisites block. It carries what the roster does not, because none of it is probed. The install path’s tools are needed before a binary exists to probe them, and the docs gates’ renderer is a knob-supplied command no
REGISTRYrow names. Arm F ofcheck-install-platformsholds that every row states a Minimum and every declared system has a row.
Neither the verbs nor anything they install need jq. The verbs read JSON (this package’s version stamp and the checkwright.lock manifest) with the crate’s own parser, so none refuses for want of an external one. guard-kit’s hook is a member of the gate binary, which reads its payload and renders its envelopes itself (guard-kit/SPEC.md §The shell guard). So jq is on the toolchain floor for contributors alone. doctor owes it to no selection, and every profile, guard-kit’s included, installs on a machine without it. CONTRIBUTING.md’s toolchain table names it for the smoke harnesses that still read JSON with it.
Layout
bin/checkwright.sh— the POSIX sh bootstrap (§The install boundary), run by the host’s own/bin/sh, and the package’sbinentry. npm’s shim generator takes the interpreter from its#!/bin/shline, which the extension-less shim MSYS runs resolves and the.cmdshim does not; a native Windows adopter is sent tocheckwright-pwshbelow, so no supported path runs through that.cmdshim.bin/checkwright.ps1— the PowerShell bootstrap (§The install boundary), the secondbinentry, namedcheckwright-pwshthere. npm generates<name>.cmdand<name>.ps1shims itself, so a second entry also namedcheckwrightwould collide with the shim npm writes for the sh target. Its#!/usr/bin/env pwshline is load-bearing: npm’s shim generator reads the target’s shebang to pick the interpreter, and without one writes a shim invoking the.ps1directly, which a Windows host cannot do.payload/— the vendored kit source, assembled at pack time from the repository’s own kit roots. It exists in the published tarball only, never in the source tree, so no second copy of any kit is checked in. The gate binary sits atpayload/artifact/<target>/, and a running binary finds its package from that location alone. A copy reached anywhere else, the oneinitplaces in a consumer’s tree above all, resolves no package and meets the no-payload refusal.recipes/— the payload recipes (§Payload recipes), assembled at pack time from the directoriesGATE_SDK_PAYLOAD_RECIPESnames, never tracked here.profiles.list— the profile rosters (below).LICENSE— the license text, placed at pack time from the repository root (§The packer), never tracked here.action.yml— the CI action (§The CI action). It sits in this directory because the activation surface does, and it is off thefilesroster, so neither package carries it.
There is no lib/. Every verb lives behind the invoke, in the gate binary the payload carries, so the package ships two bootstraps and a payload and nothing between them.
package.json’s files roster, not the directory listing, decides what is reachable at an installed PKG_ROOT. Its entries are directories, so bin/ publishes both bootstraps, and naming either on it would declare twice what the directory entry carries. A directory that stops existing must also leave the roster, a separate edit.
The verbs
init makes an install; the rest manage one after it exists. Each is a ---prefixed arm of the gate binary, reached through the bootstrap’s one argv rule (§The install boundary, step 5). The roster’s owner is the binary: a verb is advertised because the artifact carries it, so checkwright --help cannot promise one this build lacks, and the binary’s own usage arm refuses an unknown one. The same holds for flags: FLAGS beside VERBS names each flag and the verbs that accept it, and a crate test holds every parser and usage: line to it.
Every verb answers -h/--help on its own, before any precondition and from any argv position, and refuses an unknown argument at exit 2. A refusal carries the verb’s own checkwright <verb>: prefix, an optional help: line and an exit status. What each verb is for:
| verb | asks | reads |
|---|---|---|
init |
vendor this profile’s kits into my repository | the payload, and the manifest a previous run left |
doctor |
can this machine run the battery, and what is installed here? | the toolchain, and the manifest’s identity fields |
diff |
which of the files init wrote have I changed? |
the manifest’s files hashes, against the tree |
update |
bring the install here up to the version this package carries | the manifest, then everything init reads |
uninstall |
reverse the install, keeping anything I have edited | the manifest’s files roster, against the tree |
demo |
show me the adoption arc without touching my repository | the payload, in a scratch repository of its own |
The classifier is writes / does not write, and it decides --dry-run. doctor and diff write nothing, and demo writes only into a scratch repository it makes and removes (§demo), so none of the three takes one. Every verb that writes does, with one meaning: print the plan, write nothing, exit 0. §The consumer smoke asserts it behaviorally: init, update and uninstall --dry-run each leave the tree object and the worktree unchanged over a consumer the real run would change.
diff is a verb of its own, not a widening of doctor, on two independent grounds:
doctor’s exit status has one owner, the toolchain contract, andinitgates its precondition on it. §doctor already reports an artifact finding without setting the status, since reddening would block theinitre-run that fixes it. Editing a vendored file is sanctioned (§The manifest), so a folded-in drift report would be a permanent expected finding. Drift wants a status of its own,0the tree is whatinitwrote and1it diverged, which CI can gate on and which would collide with the toolchain’s number.doctorruns before every install, so per-file hashing there would make everyinitpay for a computation the install repeats moments later.
The --dry-runs do not stand in for it: they answer what would this payload change, and an adopter deciding whether to uninstall needs what have I changed without running a mutating verb.
init
checkwright init vendors the selected kit set, a profile’s adjusted by any selection (§Selecting kits and gates), out of this package’s own payload into your repository, and commits it. What governs your tree afterwards is committed and auditable on the terms gate-sdk/SPEC.md §Consumer payload sets.
No selection builds. init writes files and compiles nothing, so it asks for no build toolchain and cannot fail on one. The only toolchain it asks for is the one the vendored battery runs, which the doctor precondition checks. A compiled gate arrives as a prebuilt binary picked for your platform and checked against a published digest before it is written (gate-sdk/SPEC.md §Porting a gate to the binary substrate, criterion 5).
Three preconditions, each refusing rather than warning, and each checked before a single file is written, since a partial install is the outcome none may produce:
- You are inside a git work tree. The vendored source is meant to be committed, which is what makes it auditable. A repository git refuses is refused with the crosser’s sentence as the cause (gate-sdk/SPEC.md §The crate’s crosser) and a remedy naming repair of git’s access, never
git init;diffanduninstallrefuse it the same way. - The worktree is clean.
initmakes one commit, and a dirty tree would fold your work into it. A clean start also attributes a run’s residue: anything dirty at a recorded path when the run ends was written by that run, which lets a no-op run commit what it rewrote.--no-commitis the valve. It stages the files and leaves the commit to you, waiving the precondition and that attribution with it. -
doctorpasses. It holds you to the floor the selected kit set owes, the tools the vendored battery runs, never one that only builds Checkwright (§doctor).initresolves the kit set first and refuses at exit 2 one resolving to no kit or a selection it cannot honour, since the kit set is half of whatdoctorreads. A machine below the floor is refused before any write, never halfway through.Every refusal
initmakes about the machine is this one. This package carries no version stamp andcheckwright.lockcarries a schema this build does not know are about the package and the manifest, reached by the crate’s own JSON reader, and each means exactly what it names.
It writes the kit directories, a gates.list seeded with each kit’s starting gates, the config seams those kits need, and the manifest. It makes one commit naming the profile and the version, then prints a follow-up block: the commands that finish the setup, each with its reason. Each runs the binary init placed by its root-relative path prefixed ./, with the artifact’s executable suffix, a spelling both sh and PowerShell run with no interpreter before it. install-smoke-pwsh-windows runs the printed lines from PowerShell and commits through the hooks they place. The commands are not spelled on this page: what init prints is init’s to say. Where the update check is on, an unindented update check: paragraph precedes the INIT: line, stating the interval, the host and the knob that turns it off (§The update notice); --dry-run prints none.
The block has a stated grammar, because §The consumer smoke’s follow-up arm reads it, so a reflow reds that arm rather than silently un-covering it:
- the banner line is exactly
next:; - each following line that begins with whitespace and then a non-whitespace character is one command, and the block ends at the first other line or at the end of the output;
- on a command line, everything from the first
#is commentary for the adopter; - the target is the first token containing a
/, any token before it the interpreter, and every later token beginning with-a flag.
An install’s size is not bounded by the host’s argv width. init names every vendored path to git when it stages and when it asks what the run left behind, and a large roster can overflow the command line a host accepts (Argument list too long). Those calls go in batches, so a profile’s size is bounded by the payload alone. The binding ceiling is a native Windows process limit that getconf does not report, so the batch size is a fixed conservative budget.
Re-running is idempotent and non-destructive. A second init reads each recorded hash from the manifest. A file still at its hash is init’s to rewrite, and one changed since is yours, reported and left alone unless you pass --force. The protection outlives upgrades and a release that stops shipping the file: the path stays on the roster at init’s hash, so a release re-adding it meets the same protection. The gate binary is the one exception (§The gate binary). A re-run with nothing to change says so and exits clean.
“Nothing to change” still commits what the run rewrote. init regenerates its vendored tools’ projections on every run, and one commit is a promise about what the run leaves behind. So the no-op exit commits anything it rewrote that is uncommitted, under the vendoring commit’s message, and says so; under --no-commit the arm is off. That commit sits inside the exit, never ahead of it, because a commit against an empty index fails and init treats a failed commit as fatal.
The commit passes the hooks it vendors. A re-run on a clone with hooks installed commits through them, and an update or profile move stages the manifest beside the gate files it rewrites. check-gate-tamper counts the manifest as meta-layer (delegation-kit/SPEC.md §Verify after every agent commit), so that commit is isolated rather than refused. The consumer smoke’s hooked move arm is the witness.
A failed commit relays git’s own account. The files stay staged and init exits 1, first printing what git and any hook it ran wrote. Its own refusal line cannot say whether a hook’s red or a missing git identity stopped the commit, and the remedy differs.
The projection lands where your knob puts it. The coupling graph goes to GATE_SDK_GRAPH_ARTIFACT, resolved from your gates directory’s knob files as a battery run at the root resolves it. A re-run so writes, records and stages the file your check-graph reads. A knob pointing outside the repository gets its file written but not recorded, since files holds repo-relative paths (§The manifest). init compares no freshness, and the projection stays init’s files entry. init places no git hook: the hooks are untracked files in the clone’s git directory, placed by the --install-hooks line the follow-up block prints, the one step that puts a hook in the clone (gate-sdk/SPEC.md §install-hooks).
A payload older than the recorded install is refused as a silent downgrade. “Older” is the floor predicate’s comparison (context-kit/SPEC.md §bin/env-probe), over dotted digit runs only, so a prerelease or build suffix is unordered and never reads as a downgrade (§Versioning). --force means one thing in all three places it appears, the changed-file protection, the downgrade refusal and uninstall’s kept files (§uninstall): overwrite what init would otherwise protect.
--dry-run prints the file plan and the manifest that would be written, writes nothing, and exits 0. It is end to end: a step behind the binary invoke is passed --dry-run and honors it (§The install boundary), so the plan is the real run’s own code, and each seed arm reads one absence test in both modes.
What init seeds
Beyond the kit directories, init writes the least that makes the battery green on the tree it just made.
The config seam is derived, never listed: a kit’s consumer config is whatever templates/*-config.sh or templates/*-config.knobs it ships, seeded into your gates directory under the file’s own name, so a kit growing a template needs no edit here. Template and seeded copy are the edit seam, whatever its substrate (gate-sdk/SPEC.md §The config-seam port disposition): these are the files you edit, and a port would leave nothing to edit. A kit with static knobs ships a comment-only knob file (gate-sdk/SPEC.md §The knob file), valid in the grammar as seeded. init never deletes a file a release stopped shipping (§init), so a shell config an earlier release seeded stays at upgrade, and the reader’s legacy refusal names the migration.
The vendored tree’s kit-root set is declared, not inferred. On every install the placement op, the seam’s one writer (§The gate binary), writes GATE_SDK_KIT_DIRS into <gates-dir>/gate-sdk-config.knobs with the selected kit set, exactly what was copied in and what kits records. gate-sdk’s derived resolver admits a root by checks/ or smoke/ on disk, and the payload withholds smoke/ (gate-sdk/SPEC.md §Consumer payload). So a kit shipping no checks/ would drop out silently, and a battery over a smaller set is still green. GATE_SDK_KIT_DIRS is the override gate-sdk already carries, so the declaration mints no name. §The consumer smoke counts the resolved roots rather than reading a verdict, since green cannot witness a set that quietly shrank.
Everything init seeds takes one of two disciplines, by whether init keeps rewriting the file:
- Create-once surfaces, the queue file, the agent file, the evidence manifests, the workflow-state file and the CI workflow, are written only when absent, so a re-run never disturbs a tree that has grown since. The write is recorded unclaimed: a seed you deleted is re-seeded and recorded at its new hash, never reported as your change.
- Every-run surfaces, each kit’s config seam and gate-sdk’s
msg-patterns.list, are claimed before they are written, on the non-destructive path of any vendored file. The recipe plans the copy andinitperforms it, so your edit is compared against the recorded hash while it is still on disk; landing the copy first would destroy that evidence.
init also makes the scratch directory, which is not a seed. gate-sdk’s helper creates GATE_SDK_TMP_DIR with its self-ignoring .gitignore (gate-sdk/SPEC.md §Layout and configuration). git never sees the file, so it enters no commit or manifest record and uninstall leaves the directory alone. It is made at install so a writer running before the first battery, a harness hook among them, finds it already ignored.
The queue file’s source is resolved once per install, over the whole kit set. A kit declares itself the queue format’s owner by shipping templates/TASK-QUEUE.md, and init seeds from the first kit in the selected set that ships one. So a kit that reads the queue and ships no template cannot pre-empt one that does. Only when no kit ships one is a minimal inline skeleton written, carrying every heading of check-queue-sections’ required set at queue-kit’s defaults: the iteration header, the active sections, the deferred and done sections, then Lessons Learned (queue-kit/SPEC.md §Layout and configuration). A shipping profile takes that path: prose reads the queue and ships no template, and the section gate is on-surface, so nothing init registers would catch a missing heading.
The CI workflow is seeded when the package names a GitHub repository. init writes .github/workflows/gates.yml only when it is absent. It holds one job on ubuntu-latest:
- it triggers on a push to the branch
initran on (any branch from a detachedHEAD) and on every pull request, and checks the tree out with its full history; - it runs the CI action (§The CI action), naming
package.json’srepositoryand the payload’scommitas a full SHA with the version as a trailing comment, the form gate-sdk/SPEC.md §check-action-pinning holds, and pins the checkout step the same way; - it declares
contents: readandsecurity-events: write, and carries norun:body.
A package whose repository is not a GitHub URL, or whose commit is unstamped, seeds nothing and says so. The body is rendered from a template in native/src/installer/, the slug derived from repository.url and the checkout pin a recipe constant. The file is recorded in files, so diff, uninstall and the --dry-run plan reach it by their existing rules. Honest limits: the ref stays at the release that seeded it, while the binary it runs follows the lock. A tree hosted elsewhere carries a file nothing runs, and deleting it does not stick, since a deleted create-once seed is re-seeded.
The starting gate roster is the subset a fresh consumer begins with, not the kit’s full roster, the distinction gate-sdk’s own README draws. A gate whose subject you have not authored has nothing to read, as canon-kit’s duplication gate lacks a glossary and site-kit’s a docs host, so it is registered when the surface exists. Each kit’s README names the full roster to grow into. No lifecycle-kit gate is zero-config, for per-gate reasons. Some read a stage attestation only a stage session writes, others read surfaces a stage session authors, and check-lifecycle-registration and check-merge-attrs read what the adopter’s own --install-lifecycle arm writes.
A kit’s agent-file block is seeded at install iff a gate registered at install reads it. doctrine-kit’s is seeded because check-doctrine-registration declares zero-config, so the block is read on day one. lifecycle-kit’s is not: nothing init registers reads it, and seeding it would charge every session’s context for machinery nothing yet enforces. The agent-file membership predicate asks a different question, whether the file must exist for a kit’s starting gates.
The roster is derived from the gates themselves. The recipe’s per-kit derivation is the whole of a fresh consumer’s registry, never inferred from a kit’s full roster or this repo’s gates.list. It reads each shipped gate’s # install: <disposition> line and takes every zero-config member (gate-sdk/SPEC.md §The install disposition). It carries no gate name, which check-install-disposition’s third assertion holds, so a new zero-config gate needs no edit there. One function unions the result over a kit set, and its one caller is the registry init writes, which the recipe drops and the selection then adjust. The consumer smoke’s monotonicity assertion reads that registry across the package boundary, holding no second copy of the derivation, so containment is asserted over what each profile’s adopter receives. The roster takes the profile as well as the kit, though no disposition varies on it today, so a roster that does vary is a change to one gate and not to every caller.
What the seam can and cannot express. full is the payload-derived maximum, so monotonicity means anything a profile registers, full must register too: a gate armed “for one profile only” is simply zero-config. starter sits below every profile, so no profile may drop a gate-sdk zero-config member either. Subtraction between them has nothing to derive from, since the recipe may name no gate and no gate declares profile-varying reachability. So the parameter is additively inert and subtractively undeclarable, and a cohort a narrow profile should receive arrives as a disposition correction on each gate.
A disposition change reaches trees already installed. init rewrites gates.list from this derivation, so a gate moving on-surface → zero-config joins an existing consumer’s battery on its next run. A gate moving the other way leaves an unedited gates.list at that run. One you edited keeps it, and init reports it as retired (§Payload recipes), so a release carrying such a move still owes the sentence in its note only to a tree vendored without the installer.
Each kit’s smoke/install.sh registers a richer roster against the tree it builds, a superset of this one by contract, held by check-install-disposition. The disposition is what a kit owns; the roster is what each caller derives.
Payload recipes
A payload recipe fits the kits to a tree some other tool lays out, such as a spec toolkit’s. It is a directory under the package’s recipes/, named by the token an adopter passes, holding two kinds of file:
- each
<stem>.knobsfile holds knob lines for the seam file of the same name; unregister.listnames gates, one per line, that the registry drops.
A line opening with # is commentary in both, and any other file is not read. The packer places them (§The packer), and init --recipe <name> applies them.
--recipe repeats, in application order, and --no-recipe clears the set. A run passing neither re-applies the set the manifest records (§The manifest), as a run passing no --profile re-applies the profile, and a run passing --recipe replaces it. update forwards both flags unchanged (§update).
Applying one changes two files init already writes, and adds none. Each seam init writes is its kit template followed by each applied recipe’s lines for it, in order. The gate-sdk seam has no template, so there the lines follow the placement’s (§The gate binary), and a line a recorded recipe carries is re-derived rather than kept. The registry (§What init seeds) loses every gate an applied unregister.list names, and a name it does not carry is no finding. Both files are claimed before they are written, so a re-run with the same recipes rewrites what it wrote, and diff and uninstall read the recipe lines as init’s.
Refused before any write, at exit 2, the way an unknown profile is:
- a name the package’s
recipes/does not carry, the help naming those it does; - a recipe
.knobsfile naming a seam file the selected kit set does not write, the help naming the profiles that write every one; - a composed seam the knob grammar refuses, such as two recipes setting one scalar, naming the recipes that wrote into it.
A kept file is reported, never merged into. When you have edited a seam file or the registry, init keeps it (§init) and prints what it would have placed there:
- each recipe line the kept seam lacks;
- each dropped gate the kept registry still registers;
- each retired member, which the kept registry and the registry
initlast wrote both carry and this run’s registry does not. A dropped gate is reported as a drop only. The line names the member’s# install:disposition when its declaration resolves as §doctor resolves one, so it marks a gate that moved offzero-config.
The remedy follows: add or remove the lines by hand, keep a retired gate with --with-gate <gate> (named only when a retired line prints), or pass --force to take init’s file. A kept file already carrying every line is reported as kept and nothing more. Merging into a file init does not own is the overwrite the ownership contract forbids.
The last-written registry is the gates.list blob at the hash files records, read with git cat-file blob. That hash stays init’s while you keep the file, so a retired member is reported on every run until you remove it or name it. A missing row classifies nothing. So does a blob gone from the object store, with a note: line naming the hash; git’s pruning collects one only when a registry init --no-commit staged was edited before the commit.
--dry-run plans and prints the composed seams and the dropped gates through the same code.
The lattice stays intact. §Profiles’ monotonicity is a claim about the registries profiles write, and §The consumer smoke asserts it over recipe-free installs. A recipe is consumer config applied after the profile, recorded beside it in the manifest, and changes no kit the profile vendors. A selection stands on the same footing (§Selecting kits and gates).
Recipes sit beside payload/, never inside it. Every directory under payload/ is read as a kit, one reserved name excepted (§Profiles), so a second non-kit sibling would need a second exclusion in every derivation that walks it. The drop is data read from unregister.list, so no gate name enters the crate (check-install-disposition’s third assertion).
Selecting kits and gates
A selection adjusts a profile per install. --with-kit <kit> adds a payload kit to the profile’s kit set and --without-kit <kit> removes one. --with-gate <gate> registers a gate the derived registry does not carry, and --without-gate <gate> drops one from it. Each repeats and takes the <flag>=<value> form too, and update forwards them unchanged (§update).
The composition. The vendored kit set is the profile’s set, plus each --with-kit, less each --without-kit, in payload order. The registry is the derivation over that set (§What init seeds), less every recipe drop and every --without-gate, plus every --with-gate, so an explicit --with-gate outranks a recipe’s unregister.list. That set is what init vendors, declares in GATE_SDK_KIT_DIRS, records as kits, and hands doctor (§doctor), so the toolchain floor is the selected set’s. A selection is consumer config applied after the profile, on the recipes’ footing: the lattice is a claim about the registries profiles write, asserted over selection-free installs (§Profiles).
The recorded selection. checkwright.lock records it (§The manifest). A run passing no selection flag re-applies it, a run passing any replaces it whole, and --no-selection clears it. So the command line that made an install states its whole selection, and no re-run half-merges two.
Refused before any write, at exit 2, each with a help: line naming what the package or tree does carry:
- a kit the payload does not carry;
- a
--without-kitnaming a kit every profile carries, which is gate-sdk: the runner, the git hooks and the registry every other kit’s gates resolve through, so removing it leaves no battery; - a
--with-gatethe registry’s resolver would not resolve on this tree, the gates directory first and then each selected kit’schecks/, or whose resolved declaration reads# install: never(gate-sdk/SPEC.md §The install disposition); - a
--without-gatethat no selected kit’schecks/ships and the gates directory does not carry, which is a typo rather than a choice; - one name given to both
--with-kitand--without-kit, or to both--with-gateand--without-gate; --no-selectionbeside any other selection flag.
A --without-gate naming a shipped gate the derived registry does not carry, such as an on-surface member, is no refusal: it is recorded, so the drop holds once the member is registered some other way. A kit removed on a re-run leaves kits, GATE_SDK_KIT_DIRS and the registry, and its files stay on disk and on the roster, since init never deletes (§The manifest). --dry-run plans the selected kit set, the added and dropped gates and the recorded selection through the same code, and prints them. init --help lists the payload’s kits beside its profiles and recipes.
Replacing a gate is shadowing, and needs no flag. A gate file of the same name in your gates directory, <name>.sh or <name>.gate, resolves before every kit’s (gate-sdk/SPEC.md §Layout and configuration). init never writes it, so it is yours across every re-run, diff and uninstall pass it by, and an upgrade’s note names a kit gate that tightened under your shadow in its Tightened gates (§The upgrade contract). A gate of your own under a new name is added with --with-gate, which the manifest records, so it survives the re-run a hand edit to gates.list would not.
The install boundary
One part of an install must be written in whatever language the host already runs: the bootstrap that resolves, verifies and executes the gate binary. Everything else is conditional install logic. The interpreter policy puts all of it on the far side of that invoke, written once in Rust rather than twice, in POSIX sh and in the PowerShell a native Windows install needs. The policy binds the bootstrap’s shape, not its existence: resolve the platform, place the matching binary, invoke it, which is small enough to write twice. The bootstrap below is the whole of the shell, and every verb is an arm of the binary it invokes. This section states the bootstrap’s job, the disposition every install step carries and the test that assigns one, so a step’s side of the line is read off a rule rather than argued per step.
Two standing obligations bind every unit that touches the install path: add no new shell-only install step, and assume no POSIX shell on a host the PowerShell half serves. Both are the interpreter policy’s.
The bootstrap’s job is the whole of what is written twice:
- resolve the package’s own payload directory;
- resolve the host to its preferred Rust target triple, and that triple to its fallback where it has one;
- read the payload’s target roster and resolve each candidate’s artifact and sidecar, preferred first, refusing a declared target whose pair is incomplete;
- verify the artifact’s SHA-256 against that sidecar, and where the candidate has a fallback, run it once with
--help, passing to the fallback when it does not run; - execute the verified artifact, under one unconditional argv rule: a dashless leading token is prefixed with
--, and everything after it is forwarded verbatim.
Step 5’s rule is the only conditional shape either bootstrap carries, and it is unconditional in the sense that matters. It branches on the shape of the first token, never on which verb it is, so no verb table is kept in two languages. checkwright init --profile starter reaches the artifact as --init --profile starter; checkwright --help is forwarded unchanged. The binary therefore owns the verb roster (§The verbs), and the two halves cannot disagree about a verb word.
Every step’s disposition takes one of three values. The test that assigns one is what the step needs that the binary cannot supply at that moment:
bootstrap— the step must precede the invoke, because the binary cannot select, verify or execute itself. The steps listed above, and nothing else.behind-invoke— conditional install logic, written once, in Rust. This is the default: the interpreter policy puts everything conditional on the far side of the invoke, so a step claimingbootstrapowes a reason drawn from the previous bullet and no other.retired— the step serves only a dependency the binary removes, so it is deleted rather than moved.init’sjqpreflight was the worked case: nothing behind the invoke reads JSON withjq, because the crate reads it withserde_json.
How the port oracle reads those three values — a stated contract a cut can cite. The dispositions are a two-sided port disposition. bootstrap is the port’s irreducible in the strict sense: something must run before the binary exists on the machine, and fetching and executing the first artifact cannot itself be that artifact. A file whose whole body is bootstrap steps declares # no-port: citing this paragraph, the only no-port cause installer/ may carry. behind-invoke is a port obligation: a file carrying any such step stays owed in --emit port-blockers --tree until the step moves behind the invoke and the shell that ran it is deleted. retired steps are deleted, not declared. installer/ carries the two bootstraps, each wholly bootstrap steps and each declaring # no-port: citing this paragraph, and nothing else. The --pack-installer arm’s disposition is §The packer’s, not this section’s.
Both halves of the disposition bind. Read with bootstrap silent, the one shell surface the interpreter policy sanctions would have no cause to declare on, and would read as owed forever. Read as saying only when a step runs and not whether it ports, the rule would void its own words for behind-invoke, “written once, in Rust”, which is a port commitment.
Parity between the two bootstraps
The two bootstraps are hand-kept, and parity is held by running, not by generation. Each half is authored in its own language against the five steps above, and a per-bootstrap install-smoke leg is the oracle that holds them equal. A leg is named install-smoke-<bootstrap>-<platform>, so its first segment is the bootstrap it drives, and the count read off the names is two bootstraps, sh and pwsh. The platform segments move with the platform declaration. No other leg substitutes for the pwsh one, because every other leg drives the sh bootstrap.
A leg asserts two distinct things. Bootstrap parity is that the two halves answer the five steps the same way. Payload coverage is that a whole checkwright init completes through that bootstrap. The hand-kept-parity rule guards drift between the two bootstraps, and running the payload end to end adds coverage, not parity. A new leg states which of the two it adds.
The running oracle cannot reach one region, so a static check complements it. The PowerShell leg runs once per declared Windows triple, on that triple’s runner only. Get-HostTarget’s linux/x64 and linux/arm64 arms are therefore executed by no run this repository buys, and no run it could buy today would reach them. check-install-platforms asserts each detector’s emitted triple set statically, covering exactly the region the running oracle cannot reach and leaving the rest to the legs. It generates neither half and retires no leg. It complements the legs rather than contesting them, because running tells you that the halves differ, never where.
What a reader compares when the two legs disagree. With two hand-kept halves, a red on one leg beside a green on the other is routine. The comparison therefore has a stated order: the five steps in sequence, each with the observable that settles it. A session arriving at a divergence walks it rather than diffing two languages:
| step | what to compare | the observable that settles it |
|---|---|---|
| 1 payload directory | the resolved package root, after the symlink chain | the path each half prints when the payload is absent |
| 2 host triple | the triple each half maps this host to, and its fallback | one half’s empty string against the other’s triple, or the two naming different fallbacks |
| 3 selection | which of the four outcomes each half reached | unrostered vs. verify vs. broken payload vs. does not run, and which candidate each half took, by message and remedy, never the exit status alone |
| 4 digest | the hex compared, and its case | a mismatch on identical bytes is a case fold, not a corrupt artifact |
| 5 execution | the argv the artifact actually received | the binary’s own usage refusal, which echoes what it was handed |
Step 4 is the row that most often reads as a defect and is not one. Get-FileHash returns upper-case hex where the sidecar carries sha256sum’s lower case, so the halves agree on the digest and differ only in its spelling. Step 3 is the row where the exit status alone misleads. Three of the four outcomes refuse, so a reader comparing statuses sees one answer where an adopter is owed three. They are a platform they can do nothing about, a payload they fix by re-downloading, and a binary that does not run on their host, which they report. Compare the message and the remedy, the same economy the truth table in §The consumer smoke buys.
Two host assumptions this bootstrap rests on are measured, not assumed. Both were measured on a native Windows runner. [[ -x ]] holds on a freshly chmod +x‘d shebang script, which executes directly despite core.filemode=false. It also holds on npm’s extension-less bin shim, written mode -rwxr-xr-x beside its .cmd and .ps1 siblings. So neither -x test needs a Windows special case, and neither bootstrap carries a mode-detection branch. They are recorded because the answer cost a runner to buy, and a reader meeting either test would otherwise ask again.
One declaration generating both halves is refused. At five steps, a generator is a third artifact to maintain (a template language, a freshness gate and a projection-roster row) for a surface small enough to write twice by design. A generated twin still needs its platform leg to prove it runs, so the generator buys no oracle the legs do not already supply. Drift between hand-kept halves is a real cost, and the leg is what turns it into a red run rather than a reading.
What runs behind the invoke
Step 5 is execute, not install. The tracked copy of the binary in the consumer’s gates directory is an install artifact with ownership semantics: claimed against the manifest, carried in files[], removed by uninstall. Placing it is therefore conditional install logic, and sits behind-invoke. The bootstrap runs the artifact in place, out of the payload, where step 4 has just verified it; a copy to a scratch path to run it would be a copy with no reader. The interpreter policy’s “place the matching binary” so means make the binary runnable.
The generation step spawns only the binary init placed. The binary’s own --emit graph arm generates the coupling graph, and nothing else is generated (gate-sdk/SPEC.md §check-graph). check-graph is install: zero-config, so a fresh consumer’s day-one battery holds the graph against its emission, and the graph must exist at install: the step cannot be dropped. init runs the artifact it just placed at the consumer’s root, with GATE_SDK_ROOT in the child’s environment naming the root-vendored gate-sdk directory, as the front-end would export it (gate-sdk/SPEC.md §Layout and configuration). The front-end’s only other duties, changing to the root and locating the binary, are ones init already holds. So init spawns only git and that binary, never bash. The bootstrap, which the standing “assume no POSIX shell” obligation binds, spawns nothing but a verified artifact: the one it executes, and on Linux the preferred one it asks to run.
--install <op> is the seam both bootstraps call, specified so the two calls are byte-identical. It is a non-gate arm of the binary (gate-sdk/SPEC.md §The non-gate arm), and it reads no kit config and no knob. Its caller is a bootstrap that may not be a POSIX shell at all, and a knob read would tie the call’s meaning to whatever knob files the working directory holds, which neither bootstrap controls. So every value the arm needs arrives as argv.
- Grammar.
--install <op> [--<key> <value>]…,<op>from a closed set, an unknown<op>or an unknown key exiting 2. - Channels, because the caller is a program in two languages. stdout is a wire: one record per line, tab-separated,
<verb><TAB><field>…. stderr is the adopter-facing report. - Exit status.
0performed — or, under--dry-run, planned;1an adopter-actionable refusal;2usage or harness error, on gate-sdk/SPEC.md §Fail-closed contract’s terms. --dry-runis owed by every mutating op, on §The verbs’ existing classifier: print the plan, write nothing, exit 0.
The family carries two ops, and neither produces a 1. place-artifact’s only failures are a bad argv and a write it could not make, and queue-source reads and writes nothing, so both refuse at 2. The status is specified on the family rather than per op, so a later op that can refuse something the adopter can act on has a status to refuse with.
queue-source is a read op, and the family’s grammar is what it costs. --install queue-source --payload <dir> --kits <kit>[,<kit>…] answers which template a kit set’s queue is seeded from, the derivation §What init seeds states. It emits queue-source<TAB><path> when one is owed and an empty wire when none is, so a caller’s whole reading is nonempty means owed, with no field to interpret. It exists because the derivation has a second reader outside the package. The consumer smoke asserts init’s queue post-condition and must not carry a second copy of the rule, so it reads this one across the package boundary (§The consumer smoke). The op takes this family’s grammar, channels and exit statuses rather than a contract of its own, so the smoke calls the seam the bootstraps already call.
The adopter verbs are not ops of this family, because their output channels conflict. This family specifies stdout as a wire: tab-separated records, one per line. init’s stdout carries the adopter-facing follow-up block, whose grammar §init states and whose reader is the consumer smoke’s follow-up arm. One of the two contracts would have had to yield. So the verbs are top-level ---prefixed arms, resolved before the registry lookup (§The verbs), and this family keeps its wire for a caller that is a program in two languages. The line is not where a step runs, since every verb is behind the invoke exactly as an op is; it is which channel contract its output answers to.
Hosts refused at the bootstrap
A host that cannot run the verified binary is refused at the bootstrap. Every step of an install sits behind the invoke, so on such a host an install has no code path at all. Proceeding would be not a smaller battery but a silent non-install. Three such hosts exist, two met at selection and one at verification (§The gate binary):
- An unrostered host is refused, naming the platform. There is no adopter action, and saying so beats writing a tree whose battery cannot run.
- A host on which the preferred artifact does not run, with no fallback rostered, is refused, naming the triple. It has no adopter action either, and the remedy asks for a report of the host, the triple and the release.
- A POSIX host carrying neither
sha256sumnorshasumis refused rather than served an unverified artifact. Step 4 of the bootstrap is irreducible: a host that cannot hash cannot verify, and verifying before executing is the whole of the integrity claim. On the PowerShell half the branch is vacuous, sinceGet-FileHashis always there.
A refused host is not served a smaller battery: every registered member dispatches to the binary, so an artifact-less install would have no live member.
Nothing past step 5 needs an external hasher. The binary hashes in-process. Only the bootstrap’s own step 4 still needs one, because it cannot use the binary to verify the binary.
The gate binary
A gate whose implementation is a compiled subcommand needs that binary on disk before it can run, and init puts it there. Selecting and verifying it is the bootstrap’s job, because the binary cannot select itself; placing it in the consumer’s tree is init’s, behind the invoke. Nothing builds and nothing fetches. Selection and verification sit inside the irreducible bootstrap the vendoring ruling leaves outside the binary (gate-sdk/SPEC.md §Porting a gate to the binary substrate), and each is small enough to be written twice, in POSIX sh and in PowerShell. The section follows the bootstrap’s order: platform resolution, selection, verification, then placement behind the invoke.
Platform resolution
Platform resolution is derived and never stored. In the POSIX sh half, target_of_host() maps uname -s and uname -m to one Rust target triple, or to the empty string on a host that maps to none. It runs once per invocation, before anything else. The result stays a local. A stored copy would be a second source for a fact the host already answers, stale the first time a vendored tree moves between machines, and a vendored tree is shared by construction. It reads two fields rather than uname -a: they are the smallest input that answers the question, and a PowerShell half can answer them without parsing prose. Nothing reads the kernel version, so nothing collects it. The Windows family takes its architecture from elsewhere, and the PowerShell half answers from its own runtime; both follow below.
Each half’s mapped triple set is machine-read, so each half’s shape is pinned. The case and switch arms own the triples. A roster comment beside them would be a second copy, exactly the drift check-install-platforms exists to catch. So the gate reads the arms, and their shape is stated here:
- POSIX sh — inside the bodies of
target_of_hostand offallback_of_target, the fallback map, every mapped triple is the sole single-quoted operand of aprintfand appears nowhere else in the function. The fallback map’s case patterns name the preferred triples unquoted. - PowerShell — inside the bodies of
Get-HostTargetand ofGet-FallbackTarget, its twin, every mapped triple is the sole single-quoted operand of areturn, and the emptyreturn ''is the no-mapping arm rather than a triple. Theswitchpatterns are also single-quoted, which is why the pin is onreturnand not on quoting.
A half’s emitted set is the union of both its functions’ operands. Each body is bounded by its own closing brace at column 0. Every way that extraction can degrade is fail-closed (exit 2), because this arm’s failure mode is silence: the named function absent or renamed, its body unbounded, zero triples extracted, or either file unreadable. Each is a check that could not run, never a pass. An extraction that silently degrades to nothing is the one way a lockstep assertion reports agreement it never tested.
The map answers “which published artifact fits this host”, which is why a MinGW, MSYS or Cygwin uname maps to x86_64-pc-windows-msvc. Those uname strings report the shell environment, not the toolchain that built the artifact the adopter is about to receive. The measured runner answers MINGW64_NT-10.0-26100/x86_64. The Windows build leg publishes what that host’s own rustc reports as its host triple, x86_64-pc-windows-msvc. That triple is on native/targets.list, joined on a run that produced and exercised its artifact (gate-sdk/SPEC.md §Consumer payload), so select_artifact’s roster comparison passes for these hosts.
On the Windows family the architecture is the OS’s, read from the registry, because uname -m there names the POSIX runtime’s. The MSYS runtime Git for Windows ships is built for x86_64 only, and runs emulated on an ARM64 host. Its uname reports the architecture that emulated process is shown, so an ARM64 host answers MINGW64_NT-*/x86_64 exactly as an x64 one does, and PROCESSOR_ARCHITECTURE in the process environment is AMD64 too. host_arch() therefore reads PROCESSOR_ARCHITECTURE from HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment, the value the system environment is built from. It reads it through the runtime’s /proc/registry, with no child process. ARM64 there answers aarch64, which the MINGW*/MSYS*/CYGWIN* arm maps to aarch64-pc-windows-msvc. Any other value, or no readable value, leaves uname -m’s answer standing, so an x64 host keeps its triple. An ARM64 host is so served the native ARM64 artifact rather than the x64 one under emulation. While a triple is held, the same resolution refuses the host rather than falling back to emulation, since no run has measured the x64 artifact there.
The PowerShell half reads the runtime instead, and reaches the same triple. It asks the .NET runtime for the OS platform and architecture rather than shelling out to uname. The host this half exists for need carry no POSIX shell, so it need carry no uname. On a native Windows x64 host it resolves x86_64-pc-windows-msvc, as the POSIX half’s MINGW*/MSYS*/CYGWIN* arm does, for the same reason. On a native Windows ARM64 host the runtime answers Arm64. The half then resolves aarch64-pc-windows-msvc, the triple the POSIX half reaches through the registry. The switch matches case-insensitively, so the fallback’s ARM64 spelling reaches the same arm. Both triples are rostered, so selection passes on either host. Each half’s ARM64 arm has a running witness on the arm64 entry of install-smoke-pwsh-windows: its POSIX-bootstrap step, which installs the lattice minimum through installer/bin/checkwright.sh, for the POSIX half, and its PowerShell steps for this one.
Neither input answers the libc question, and the loader does. A Linux host maps to the glibc triple, and that triple names the static musl triple of the same architecture as its fallback. The glibc artifact runs only on a glibc at or above the floor it was linked against, while the static one runs on any Linux kernel at its floor. So the bootstrap asks the verified glibc artifact to run, and takes the fallback when it cannot (§Selection). uname -s/uname -m and .NET’s OSPlatform/OSArchitecture still answer everything the map needs, and neither bootstrap reads a C library’s name or version. A version comparison would spell each glibc floor a second time in each bootstrap, where the install page’s Minimum cell and the build body’s measurement are the owners, and it would re-ask a question the loader answers exactly. Making musl every host’s artifact, with glibc on explicit request, is refused too: it needs an adopter-facing selector nobody would set, and it publishes a glibc build no install takes.
Selection
Selection has four outcomes, only one of which proceeds, and collapsing any two is a defect. The payload carries the target roster verbatim beside the artifacts (gate-sdk/SPEC.md §Consumer payload), and the bootstrap reads it rather than inferring support from a directory’s presence. The host’s candidates are its preferred triple and, on Linux, that triple’s fallback (§Platform resolution):
| the host’s candidates | the payload holds | outcome |
|---|---|---|
| none in the roster | — | refuse: this platform is not in the support roster; there is no adopter action |
| a rostered one | nothing, or half its pair | refuse: the payload is broken |
| a rostered one, verified | its pair, and it runs or has no fallback | execute |
| a rostered one with a fallback, verified | its pair, and it does not run, with no rostered fallback | refuse: this binary does not run on this host; there is no adopter action |
A candidate that does not run passes to its fallback, and no refusal does. A preferred triple the roster lacks, which is a held one, and a verified preferred artifact that does not run both pass to the fallback. A broken pair and a digest mismatch refuse, because they are defects in what was published, and serving the fallback would hide them behind a working install. The probe runs <artifact> --help once with its output discarded; a start failure or a non-zero exit is “does not run”, which is what a glibc below the artifact’s floor or a host with no glibc loader produces. In the PowerShell half a start failure is the exception & throws, caught, and a non-zero $LASTEXITCODE. The probe runs only after verification, so nothing unverified is executed, and only where a fallback exists, so no other host runs its artifact twice.
Every refusal here names what the host was detected AS. The message carries the detected <os>/<arch>. Without it, neither an adopter nor a maintainer reading their bug report can tell an unsupported architecture from a uname that answered nothing. A refusal message is a documented surface: gate-sdk/SPEC.md §Fail-closed contract’s rule for the wrapper class, applied to this boundary’s refusals.
The four stay told apart by message and remedy, never by exit status alone, because an undeclared host, a broken payload and a binary that does not run here are different answers to an adopter. The broken-payload row is the whole reason the roster is read. Without it, the bootstrap cannot tell a platform never committed to from one committed to whose artifact went missing, and reading the second as the first turns a publisher defect into a silently smaller green battery. A payload assembled with no artifacts at all carries no artifact/ directory, so it reads as the first row, never as a payload whose every target went missing. Both bootstraps owe all four rows and answer them identically; why the first row refuses rather than omitting is §The install boundary’s.
“Its binary” is the one regular file in the target’s directory not ending in .sha256, and “its sidecar” is that name plus .sha256; zero or several such files is the broken-payload row. The POSIX half counts them with a glob over the directory rather than with find, so no primary can disagree between GNU and BSD userlands. The glob skips dotfiles, so a stray dotfile beside the binary is not a second artifact.
Every row of the selection table runs under an oracle, split across §The consumer smoke’s arms by which install can show it. The verify-then-write row is the main loop’s, on every profile, because the payload it packs carries a binary that run built. The unrostered-host row is the artifact-less refusal leg’s and the artifact arm’s undeclared-host leg’s. The broken-payload row is the artifact arm’s alone, against a payload it mutated after extraction. The does-not-run row, and the execute row’s fallback path, are the artifact arm’s no-fallback and falls-back cases, which run wherever the hand-off carries a foreign-architecture preferred artifact.
The registry’s # omitted: class is gate-sdk’s, not an install outcome. # omitted: <name> <reason> is a comment line the runner strips from the live set and reports beside its summary (gate-sdk/SPEC.md §run-gates). It is reason-agnostic: any consumer may omit a member for a cause of their own. init writes none: an unrostered host and a hasher-less host are refused before an install begins (§The install boundary), so an install that happens at all installs the whole starting roster. doctor reports whatever reason it finds and invents no remedy for it (§doctor).
Verification
The digest is verified before anything is executed. The bootstrap computes the artifact’s SHA-256, compares it against the sidecar that travelled with it, and only then runs it. The ordering is the whole of it: a consumer who cannot read the gate has nothing else standing between them and a substituted binary. A mismatch refuses: never a warning, never a run.
sha256sum is tried first, then shasum -a 256, because stock macOS ships the second and not the first. When neither resolves, the bootstrap refuses: nothing unverified is ever executed, and no path here skips the check. The placement init performs behind the invoke verifies without an external hasher, because the binary hashes in-process.
The PowerShell half has no hasher resolution, by design. PowerShell carries Get-FileHash, so that half has no hasher to choose, no sha256sum/shasum fallback and no refusal to reach. This is the one step where the twin is simpler than the original rather than parallel to it; a reader holding the halves side by side should not read the missing branch as a defect. Its one asymmetry is spelling, not logic: Get-FileHash returns upper-case hex against a lower-case sidecar, so that half folds case before comparing.
The artifact is the one path the changed-file protection skips, which makes the digest actionable. Everywhere else init writes, a file whose recorded hash disagrees with the tree is yours, so it is reported and left alone (§init). A compiled artifact has no version you authored. A binary that fails the recorded digest is corrupt or substituted, never edited. So init rewrites it from the payload copy it just verified rather than reporting it as changed, and uninstall removes it rather than keeping it (§uninstall). Without this exemption, diagnosis and remedy would come apart. §doctor reports the mismatch and names a re-run as the fix, and a re-run that classified the binary as yours would leave it in place forever. --force is therefore never needed here and means nothing extra. The exemption covers the artifact path alone; the config seam written beside it is a file you do edit, and it is claimed like everything else.
The honest bound, stated so no surface overclaims it. The digest travelled inside the same payload as the artifact, so it catches corruption and a substitution made to the artifact alone. It cannot catch a compromised publisher; no in-payload value can. It rises above a self-check because the identical bytes are published on the Release, in a versioned archive per target whose sidecar verifies with sha256sum -c once extracted, so a human can cross-check the value out of band. The claim is verified against a published digest, never reproducible.
Placement
Placement is one op, and init calls it in-process. Placing the artifact is conditional install logic, not a bootstrap step, so it sits behind the invoke as --install place-artifact (§The install boundary). init places the payload artifact the bootstrap just verified, never the installed copy, which on a first install does not exist yet. The wire form below is the same op’s flag surface. Its cross-boundary reader is the PowerShell install-smoke leg, which drives a real placement through that bootstrap:
<artifact> --install place-artifact
--root <absolute repo root>
--src <the verified payload artifact>
--dest <repo-relative path for the installed binary>
--seam <repo-relative path of <gates-dir>/gate-sdk-config.knobs>
--target <rust target triple>
--digest <the artifact's verified SHA-256>
[--lock <repo-relative manifest path>]
[--kits <kit>[ <kit>…]]
[--spec-base-url <url>]
[--force] [--dry-run]
Every key has a reader inside the op:
--rootresolves every relative path, and--srcis the copy source.--seamis the claimed path;--destis written on the rule below instead.--targetand--digestare compared against the manifest’sartifactkey and the on-disk copy, for the skip-rewrite branch.--locksupplies the recorded hash the claim compares. It is optional, and absent on a first install, where nothing is claimed.--kits,--spec-base-urland--upstreambecome the seam’s declared lines.--forceand--dry-runcarryinit’s existing meanings.
The declared-line keys are optional, and an omitted one omits its knob rather than writing a blank. An empty GATE_SDK_KIT_DIRS means derive the set, so a placeholder line would assert a configuration nobody asked for. The keys exist so this op and init’s in-process call resolve one derivation, not two. An install driven through the wire with no way to declare the kit set would leave a vendored tree inferring it, the silent narrowing §What init seeds exists against. Two stdout verbs come back, and each has one reader in the caller:
| verb | record | the caller’s reader |
|---|---|---|
own |
own<TAB><path> |
the path is recorded and joins the staged set |
kept |
kept<TAB><path><TAB><hash> |
the path joins the changed-file report and is carried forward at that hash |
A third verb telling an unchanged write from a write is deliberately absent: no caller reads the difference, the --dry-run report included, and a field with no reader is removed.
The install location has one owner. The binary is written to your gates directory, beside the gates.list seeded there. The op sets GATE_SDK_NATIVE_BIN to that path in <gates-dir>/gate-sdk-config.knobs, the optional tracked knob file both the binary and gate-sdk’s pre-binary accessors read when it exists (gate-sdk/SPEC.md §The knob file). This path is the only writer of that seam, and it writes the file on every install, so a verb reasoning about what init rewrites every run may assume it is present. Gate-sdk ships no config template for it, because the seam file’s content is resolved at install time rather than shipped. Its artifact line names the path the artifact was actually placed at, which does not exist until selection has run, so a static template could only be copied and immediately rewritten. The seam is claimed like any rewritten surface, then rewritten preserving every line except those naming a knob the op owns, so an adopter’s own knobs in that file survive every re-run.
The knob’s own default still names the crate’s build output, a relative path every door resolves at the work-tree top (gate-sdk/SPEC.md §Layout and configuration).
The owned set is the artifact path plus the caller’s declared lines, and one writer holds all of them. The declared lines are:
GATE_SDK_KIT_DIRS, the vendored tree’s kit-root set (§What init seeds);GATE_SDK_SPEC_BASE_URL, the publisher’s SPEC base (§The packer);GATE_SDK_UPDATE_UPSTREAM, the upstream an installed tree probes (§The update notice).
A second writer for them would have to claim a file this one already owns, and the two would disagree about whether an adopter’s edit had been seen. The claim would read the first writer’s own output as the adopter’s change and refuse to place the binary. So the declared values arrive as arguments, and the write stays single. A stale spelling of a declared knob is replaced rather than duplicated, exactly as GATE_SDK_NATIVE_BIN is. A consumer who edits the seam keeps the edit, because the claim reports it and leaves the file alone.
The non-destructive re-run is the op’s too, and --seam is where it applies. The seam is claimed against the hash --lock records for it, and left alone when it differs unless --force is given: it is a file you edit. --dest is not claimed. The copy is skipped when the recorded target, the recorded digest and the on-disk digest all agree with --target and --digest, and made otherwise. That one test makes a bare re-run leave the tree byte-identical and gets a substituted binary replaced.
Ordering is load-bearing. The op is called after every config seam is in place and before the generation step, which spawns this binary: the knob must name it and the file must be there.
doctor
checkwright doctor tells you whether this machine meets the toolchain the gate battery needs, and says so in its exit status, not only in its output: 0 meets the contract, 1 is below it. A CI step or init’s own precondition check can therefore gate on the answer without parsing a report, and a below-contract machine is caught before any partial install rather than halfway through one.
doctor reaches its diagnosis rather than refusing before it can report one. It renders its whole report, names every owed floor member that is missing or below its floor, and only then returns the verdict. That is why init gates on it. init calls it as its last precondition, after the manifest, the profile and its kit set are resolved and before any file is written, so a bad manifest or an empty kit set is never reported as a toolchain fault.
Which toolchain: the members the selection owes. The roster carries an audience axis (context-kit/SPEC.md §bin/env-probe). doctor walks it through that section’s owed-predicate over a selection, a kit set and a registered gate set, which each caller supplies:
inithands the selected kit set, and a gate set uniting the registry it will write with the members of a registry already on disk at<gates-dir>/gates.list.initprotects an edited registry rather than rewriting it, so a gate the adopter registered by hand still reaches the battery.- Bare
doctorinside an install reads the kit set from the manifest’skitsfield, and the gate set from the members of the registry the manifest records, before rendering the toolchain block; the output keeps its order. - Bare
doctorwith no install, or over a residue, has no selection.
The selection sorts each member three ways, and a derived audience adds one rendering rule:
- An owed member is probed, rendered and sets the verdict.
- A not owed member is not probed, not rendered, and cannot set the verdict: a contributor-side member such as
cargo, a kit-named one whose kit is not selected, aregisteredone no registered gate needs. It is left out on purpose rather than reported as informational.doctoris the adopter’s verb, and showing an adopter a tool they do not need invites them to install it. - An undecided member, a conditional one with no selection to read, is rendered unprobed and never sets the verdict. It renders as
curl not probed — owed where delegation-kit is selected; a kit list renders asowed where any of context-kit, drift-kit, … is selected, the names joined by a comma and a space, andregisteredasowed where a registered gate needs it. - A
derivedaudience is resolved to its kit list before that line is written, so the reader learns which kits reach the member, never the sentinel the roster spells the question with (context-kit/SPEC.md §bin/env-probe). A derivation that names no kit has no list to name, whether it read the kit roots and found no bash surface or reached no kit root at all. It is undecided by that section’s own rule, and renders the predicate,bash not probed — owed where a kit that ships a bash surface is selected, because a blank reach would read as owed by no one.
So DOCTOR: clean is a claim about this machine as a consumer of this selection, not about the machine in general. That narrowing makes the exit status usable as init’s precondition, since what init needs to know is whether the tree it is about to vendor into will run.
It has two behaviors, selected by where you run it rather than by a flag. It takes no argument beyond -h/--help; --profile in particular is not one. A per-profile toolchain verdict is therefore observable only through init’s own refusal, the deployment-faithful path §The consumer smoke describes.
- Run anywhere, it reports the toolchain verdict.
- Run inside a vendored repository, it also reads
checkwright.lockand reports the installed release, the upstream commit it came from, the profile and the kit set, the recipes applied, and the selection. Where a gate binary was installed, it reports the binary’s target, re-verified against its recorded digest in place. It reports any member the registry records as omitted, with whatever reason it carries.doctorinvents no remedy for that reason: the class is reason-agnostic, and the two install-time tokens that had one are retired (§The gate binary). The bare verb alone adds alatestline afterversion, the update reading (§The update notice);init’s precondition call renders none and never probes.
After the omitted lines it names each registry member that is disarmed. It resolves the member’s declaration the way the battery does: the tree’s gates directory, then each kit root’s checks/. It reads the declaration’s # armed-by: line (gate-sdk/SPEC.md §The install disposition), and resolves that knob as a battery run from the repository root resolves it, without entering the tree. A knob resolving empty renders
disarmed check-portability-floor asserts nothing until GATE_SDK_PORTABILITY_PATHS is set
with the member and knob read off the declaration, never spelled by doctor. A member declaring the content form renders disarmed check-tree-terms asserts nothing until a file GATE_SDK_MSG_PATTERN_FILES or GATE_SDK_MSG_PATTERN_FILES_LOCAL names carries a live line, read the same way, when no present file those knobs name carries one. Some members render nothing: one whose declaration does not resolve, carries no single # armed-by: line, or names a knob that refuses to resolve. So does a content form when a present file cannot be read, or no file its knobs name is present. The battery reports each at its own run, and doctor is not a second runner.
The artifact, omitted, disarmed and latest lines report without setting the exit status, and the asymmetry is deliberate. The status is the toolchain contract, and init gates its own precondition on it. Reddening here for a swapped or missing binary would block the init re-run that is the finding’s own remedy. A binary that cannot be dispatched to is caught where it is dispatched from: the battery treats it as a harness error, not a skip. A disarmed member is neither a machine below contract nor an install that failed to verify. It is configuration the adopter has not written yet, so it takes the omitted lines’ class and leaves the verdict line unchanged.
Not setting the status is not staying quiet about it, and the verdict line says which state it is in. A run carrying an artifact finding reports the toolchain clean and names the finding, rather than signing off as plainly clean. A doctor that printed DIGEST MISMATCH above and clean below would teach a reader to disbelieve the last line.
The identity block names the registry this tree’s battery runs from, beside the version, commit, profile and kit set. It is the one install fact the identity fields cannot be read off, and the one a reader most needs when the report and the tree seem to disagree. Without it, a doctor that resolved the wrong file would say nothing about which one it inspected. A recorded registry missing from disk is named as such, and a manifest recording none says so, both with the same remedy: a re-run of init.
It reports the hook state on one line and fails on none of it: not opted in, opted in with both hooks present, or opted in with a hook missing, naming --install-hooks as the remedy. Opted in means core.hooksPath names the hooks directory (gate-sdk/SPEC.md §install-hooks). The opt-in is the adopter’s choice, so its absence is no failure.
doctor defines no floor of its own. It reads the toolchain roster from the module that also holds the floor predicate (context-kit/SPEC.md §bin/env-probe), and renders whatever verdict that predicate returns, so the contract keeps one owner. Which members a selection owes, and so which absences set the verdict, is read off that roster through its owed-predicate, never listed here. §Requirements works the one case a reader arrives with, jq, and says why no selection owes it. The roster is the binary’s own, not a copy in the tree being inspected: at init time nothing has been vendored there yet, so a tree copy would not exist when the answer is needed.
A residue is read apart from an install, and reported as one. A checkwright.lock carrying files and no version is not an install: it is what uninstall leaves over files you had edited (§uninstall). version is the field an install always has and a residue never does, so its absence is the discriminator. On that reading doctor reports how many files remain and that they are yours. It prints no identity block, no artifact check and no registry report, since each is a per-install reading with nothing left to describe. Blank identity fields beside a residue message would be the same mixed-verdict shape the exit-status carve-out above refuses.
On both readings doctor closes by naming diff. It reports what was installed, never whether the tree still matches it, and keeping the two apart stops DOCTOR: clean from being read as a claim about the tree’s contents.
A third exit status, 2, means the question could not be answered, not that the answer was bad: the package carries no payload, or the manifest carries a schema key this build does not know. A build refuses an unfamiliar manifest rather than guessing at the shape behind it. A usage error takes that same status by derivation, not a new code. A verb refusing an unknown argument has not judged the tree, the package or the manifest, which is what 1 is reserved for; it could not answer.
update
checkwright update is init with one added precondition and its arguments forwarded verbatim: checkwright.lock must already exist, so update can manage an install but never make the first one. Every init flag stays valid, --profile, --force, --no-commit and --dry-run among them, because none is reimplemented here. update checks its one precondition and then becomes init.
A second init already upgrades: it compares the recorded version against the payload’s, re-reads the profile from the manifest when none is passed, and re-applies the payload around every file you have edited. So update is a name, not a second mechanism free to drift from the first, and the name is the one an adopter looks for.
It checks existence and no more. An unreadable schema, a stale downgrade and a below-contract toolchain are each init’s own precondition, one call away. Outside a git work tree, or in a repository git refuses, it falls through to init’s refusal, which names the accurate remedy, rather than misreporting an absent manifest.
Most of what you see comes from init and says so. The one refusal update owns is prefixed checkwright update:. Every other refusal (not a git work tree, a repository git refuses, an unknown schema, a dirty worktree, a stale downgrade, a below-contract toolchain) arrives prefixed checkwright init:, and the success path reports INIT:, because init produced the line. A refusal raised inside a shared module surfaces through the calling verb’s refusal shape, carrying that verb’s prefix, help: line and exit code. So a checkwright init: line answering a command typed as update is the design, not a bug.
The update notice
An installed tree learns when a newer release exists. The gate binary’s --emit update-notice arm compares the release the lock records with the upstream’s newest release tag. Bare doctor renders the same reading in process, and context-kit’s session-context hook runs the arm (context-kit/SPEC.md §The session-context hook). The arm exits 0, or 2 for a usage error, and prints nothing unless all four conditions hold:
GATE_SDK_UPDATE_CHECKresolves todailyorweekly. Its value set and default are gate-sdk’s (gate-sdk/SPEC.md §Layout and configuration), and the knob validator refuses a value outside the set, so a misspeltoffrefuses rather than reading as the default. An unresolvable value reads asoff, so a refused knob never probes.GATE_SDK_UPDATE_UPSTREAMis non-empty.initwrites it as a declared seam line from the package’srepository.url, a leadinggit+dropped (§The gate binary), so the crate carries no project URL. A package naming no repository leaves it unset, and the notice off.- A
checkwright.lockat the repository root carries aversion. A residue or a tree vendored without the installer has none. - The cache, or a probe made because the cache is stale, names a newest release above that version.
The probe is git ls-remote --tags --refs <upstream> under GIT_TERMINAL_PROMPT=0, bounded by GATE_SDK_UPDATE_TIMEOUT whole seconds; an unparseable bound takes the knob’s default. Only refs/tags/v<major>.<minor>.<patch> refs are candidates, so a prerelease or build suffix never is. They are ordered over their digit runs by the floor predicate’s comparison, the one init’s downgrade refusal uses (§init). A probe that expires, exits non-zero or yields no candidate prints nothing and records a failed attempt, so the next read inside the interval reads unknown without probing. An unreachable upstream so costs one attempt per interval, not one per read, wherever the record can be stored.
The bound reaches the probe’s process tree. git ls-remote over https runs a remote helper, and any credential helper, as children of its own, and killing the git alone leaves them running. On unix the probe leads its own process group and an expired bound kills the group; on Windows it joins a job object and the job is terminated.
The cache is one file, checkwright-update-check, in the directory git rev-parse --git-common-dir names, so every worktree of a clone shares it and nothing tracks it. It holds one line: the attempt’s Unix time, the newest version found or - for a failed attempt, and the upstream probed. Scratch would not hold it a week: the session-context hook’s scratch sweep reclaims entries older than a day. A probe is due when the file is absent or unreadable, when it names another upstream, when its time is older than the interval, or when its time lies in the future. So a changed GATE_SDK_UPDATE_UPSTREAM probes at once rather than reading another upstream’s version. Every attempt rewrites the file whole, through a sibling temporary file renamed over it, so a concurrent read meets the old record or the new, never a torn one. uninstall deletes it (§uninstall).
The line reads checkwright v<newest> is available; this tree has v<installed>. Run update to upgrade, or set GATE_SDK_UPDATE_CHECK = off to stop this check. doctor’s latest line renders every reading: v<newest> available — run update, v<newest>, current, not checked — GATE_SDK_UPDATE_CHECK is off, not checked — no upstream recorded, or unknown — the last check could not reach the upstream.
Honest limits. The probe shows the upstream host the adopter’s IP address at most once per interval, the record’s limits below aside, which is why init states the check on every run that leaves it on and the interval has off among its values. A failed attempt holds for the whole interval, so a release published during an outage is noticed up to one interval late. The starter and prose profiles carry no hook, so for them bare doctor is the reading.
The record is shared with no lock. Worktrees of one clone under different upstreams share it, so each read under the other upstream probes again. Reads that find it due together, such as doctor beside the session hook, each probe, and the last rename wins. A record that cannot be stored, as under a read-only git directory, is dropped as silently as every other failure, so each read there probes.
The tree kill misses a process that leaves the tree on its own, such as a credential helper that daemonizes, and a probe whose caller is itself killed, since the bound dies with the caller. On Windows it also misses a grandchild started before the job assignment. A host that refuses to make or join the job gets a kill of the probe alone, never a refused spawn, so the bound still holds.
diff
checkwright diff answers which of the files init wrote have I changed? It classifies every files entry against the tree with the same hash comparison init makes before it rewrites anything, so the report and the protection are never two opinions.
Three classes, two of them named apart:
- Unchanged is counted only.
- Changed means the content differs from what
initwrote, the sanctioned case the ownership contract protects. - Missing means a recorded path is not on disk. Under §The manifest’s exit rule the next
initsilently drops it from the roster and writes it fresh, which is worth a warning beforehand.
The exit status is the verdict: 0 every recorded entry matches what init wrote, 1 at least one has changed or gone missing. A CI step can gate on a pristine vendored tree without parsing a report or borrowing the status the toolchain contract owns (§The verbs).
Its preconditions are the ones its subject requires: inside a git work tree, a manifest present, and a schema this build knows. It writes nothing, so it has no --dry-run.
Run against a residual manifest, diff reports the survivors as changed, which they are: recorded at the hashes init wrote, and carrying your edits.
uninstall
checkwright uninstall reverses an install against the roster init recorded, and against nothing else. Install, see what it does, reverse it: the adoption story rests on that property.
Any release that knows the manifest’s schema runs it, whichever release made the install. The roster names what to remove, never the running payload, and the manifest’s version is read for the plan and the commit message alone. So the verb takes no version, and the one-line install’s own release reverses an install an older release made. The consumer smoke’s newer-verb reversal arm is the witness.
Preconditions, each refusing rather than warning and all checked before anything is removed, since a partial removal is the outcome none may produce:
- inside a git work tree;
- a manifest present with a schema this build knows, else a refusal naming
init; - a clean worktree, for
init’s reason: one commit is made, and a dirty tree would fold your work into it.--no-commitis the same valve on the same terms.
Unlike init, it has no arm committing what a no-op rewrote (§init): uninstall only removes, so a removal it declines writes nothing and leaves no residue.
The removal rule is the ownership contract seen from the other side. For each files entry, hash the file: remove it while the hash still matches what init recorded, and keep and report it when it differs. So it removes only files this installer wrote and you have not touched, and needs no new data, since §The manifest records every path at the hash init last wrote. A recorded path already off the tree is a no-op, not an error. --force removes what would otherwise be kept, as §init’s --force overwrites what init would otherwise protect.
A removal is not bounded by the host’s argv width either, on §init’s rule. This verb names the whole recorded roster to git twice, to ask which paths the repository tracks and then to stage them, and both calls go in batches. The first call’s status must survive. Every file is already off the worktree by then, so a failed read returning nothing would stage the manifest alone and commit it under a message announcing the removal, the deletions unstaged. A failed read is refused instead.
The gate binary is removed whatever its hash. It is never yours (§The gate binary): a binary failing its recorded hash is corrupt or substituted, not edited. Keeping it would record it as yours in the residual manifest, and the next init would rewrite it anyway. uninstall finds its row by the path GATE_SDK_NATIVE_BIN names in the recorded seam file, as §doctor does. A manifest with no artifact key has no such row, and every row meets the hash rule.
The manifest is not a files row, and it is disposed of explicitly. init appends checkwright.lock to its written set only after emitting it, so the file never records itself. With nothing kept, it is deleted. With any entry kept, it is rewritten over the survivors instead. Deleting it would disown the paths the hash rule just protected, and the next init would read them as never installed and write through your edits. §The manifest owns that residual shape.
The update notice’s cache goes with the manifest’s version. Neither disposition leaves a version, so the cache answers nothing here any more, and uninstall deletes it from the clone’s git directory (§The update notice). A deletion that fails is reported with the cache’s path, for you to finish, and refuses nothing: the removal it trails has already run. It is no files row and is never staged. Another worktree of the clone with an install of its own probes once to rewrite it.
The agent file is the one non-whole-file removal, because it is the one entry that is a span rather than a file (§The manifest). Two branches:
- Its hash matches, so
initcreated it and you never touched it, and it goes with everything else. - Its hash differs, so the file is kept and the doctrine block is trimmed out of it. That block is prose you did not write, in the file that steers agent sessions, pointing at a doctrine file this verb just removed.
The trim runs through the payload’s own copy of doctrine-kit’s installer (doctrine-kit/SPEC.md §install-doctrine), so it adds no second copy of the marker strings and still works once the vendored kit is gone. It is scoped to that span: a marker-bounded block of some other tool is one init never wrote, so it is never this verb’s to touch. The trim is left unstaged, the rest of that file being yours to review.
Directory pruning is bottom-up and removes only what is now empty. A directory still holding anything is left alone, whether a file you added or one you edited: uninstall removes files it owns, never directories it emptied around. A file you added inside a vendored directory is not on the roster, so it is never removed, and --dry-run names it before the run.
The placed hooks are removed, and the opt-in is reported, not rewritten. uninstall removes the hooks directory, gate-hooks under the clone’s common git directory (gate-sdk/SPEC.md §install-hooks), before it commits. The directory is this tool’s own and holds nothing you wrote. A hook left behind would refuse the removal’s own commit, for want of the binary just removed. When core.hooksPath names that directory, uninstall prints the git config --unset core.hooksPath line for you to run. Git config is outside the ownership roster, and a core.hooksPath naming an absent directory is inert rather than breaking. --dry-run names both.
--dry-run prints the plan, writes nothing, and exits 0. The plan is what would be removed, counted by top-level directory with a root file its own key, what would be kept and why, the residual manifest if there is one, and any file of yours left behind inside a vendored directory. It also names the update notice’s cache where one exists.
The commit is one commit naming the profile and the version, staging the removals and the manifest disposition. Files kept for you are never staged, on the reasoning that keeps init’s written set and its recorded roster apart. A run with nothing to remove says so and exits 0 without narrowing the manifest: the install is still there, so disowning it would be false. A failed commit leaves the removal staged and relays git’s account, as init’s does (§init).
demo
checkwright demo runs the adoption arc out of this package into a scratch repository of its own, and never reads or writes the directory it was invoked from. It is the adopter’s walkthrough, built on the path an adopter takes. The contributor’s, --run-demo (gate-sdk/SPEC.md §Consumer smoke), vendors kit source from the tree it runs in and runs each kit’s smoke/ recipes, which the payload withholds (GATE_SDK_PAYLOAD_WITHHOLD), so from a package it stops at its first act.
It takes no operand. -h/--help answers on its own (§The verbs). Any other word is refused with the usage line and exit 2, since the verb fixes its own profile and scratch. Run from a source checkout it meets the no-payload refusal (§The install boundary), whose help names --run-demo as the walkthrough a checkout has.
The arc.
- The scratch repository is made in-process. Its base is
DEMO_TMP_DIRwhen set and non-empty, else the platform’s temporary directory. A relative base resolves against the directory the verb was invoked from, because every act spawns inside the scratch. The directory is created with the standard library, never withmktemp, acontributorprogram (gate-sdk/SPEC.md §The program roster) this adopter-floor verb may not spawn. A drop guard removes it on every exit path, the teardown--run-demoestablished. The scratch getsgit init, a fixed demo identity andmaintenance.autofalse /gc.auto0 in its local config, then an empty seed commit. The identity is local rather than per-command so that an unconfigured host’s missinguser.namestops neither the seed commit nor the oneinitmakes; the maintenance settings keep a detached repack off the objects the teardown is removing. - Act 1, install. The verb spawns its own executable as
--init --profile full, with the scratch as its working directory, andinit’s own narration goes to the terminal: what is shown is exactly what an adopter’sinitdoes.fullis the payload-derived profile (§Profiles), so the choice names no roster member. It is chosen because it reddens on the defect below where the lattice minimum does not. - Act 2, a clean battery. The verb spawns the door
initplaced,<gates-dir>/checkwright-gates --run(§The gate binary), in the scratch. It must exit 0 and print the summary’sAll <N> gates passedtoken. The battery is spawned rather than run in-process because its subject is the installed consumer’s own door. - Act 3, a caught done claim. The verb commits what an agent claiming done writes, and no evidence. The queue’s iteration header names a demo iteration and the
## Donesection gains a demo task. The state file gains that iteration’svalidatestamp and then itsclosestamp, each well-formed (lifecycle-kit/SPEC.md §The state machine) withHEAD’s abbreviation as<head>. Each stamp carries a session token of its own stage, since an id shared across stages is a finding of its own (lifecycle-kit/SPEC.md §check-stage-evidence). Its date is today’s in UTC, computed in-process because the verb spawns nothing butgitand itself. The files are the queue and state fileinitseeded. A seeded queue carrying no iteration header or no## Donesection cannot stage the act, so that is the env verdict. The battery must exit non-zero, and some reddened block must name the demo iteration. No gate is named: the claim is that the done claim was caught, not which member caught it. Every reddened block is quoted back through its invariant line by--run-demo’s excerpt rule. The commit passes--no-verify, because the battery run is what the act shows catching it. - Act 4, withdrawn. The verb restores the queue and state file to the contents act 3 found, commits, and re-runs the battery, which must be green again. The fix is the claim, never the corpus or the gate.
The verdict is §Consumer smoke’s 0/1/2 (gate-sdk/SPEC.md), so both walkthroughs read one grammar:
- 0 — every act held.
- 1 —
DEMO: FAIL — <act>on stdout, when the battery is not green after install, the done claim is not caught, or the battery is not green after the fix. - 2 —
DEMO: FAIL(env) — <cause>on stderr, when the harness could not be stood up: no payload, a scratch that could not be created, a failedgit, a seeded queue act 3 cannot stage, orinitexiting non-zero.
What it spawns. The verb itself spawns git and its own executable, and nothing else: git is the adopter floor and the executable is the binary the bootstrap just verified, so the verb adds no member to the tool-floor roster. The battery it runs spawns whatever the full profile’s registered gates spawn, which is that profile’s own floor (context-kit/SPEC.md §bin/env-probe). A gate whose program is missing exits 2 under the fail-closed contract, so act 2 fails loudly rather than passing quietly.
Its oracles are the consumer smoke’s demo arm (§The consumer smoke), the one place a packed payload meets a walkthrough, and the PowerShell install-smoke leg in the gates workflow. That leg runs the verb once on a host whose adopter path is PowerShell, where the POSIX runs cannot answer for the full battery. That Windows oracle runs with bash on PATH. Act 1’s init does not refuse without it, since no profile owes bash through a kit (docs/requirements.md §Installing and running the shipped gates). The order stays because a demo with bash stripped is not run, so whether the full battery reaches bash on that host is unmeasured by this leg. Its no-bash step holds the starter install’s hooks, and the bash-less arm holds each profile’s install and commit path (§The consumer smoke).
The banner, the green-token matcher and the excerpt are one module both walkthroughs call; each keeps its own acts.
The CI action
installer/action.yml is a composite GitHub Action. A workflow runs the battery of a tree init installed with one step, uses: <owner>/<repo>/installer@<commit>, where <owner>/<repo> is package.json’s repository and <commit> is a release’s tagged commit. §What init seeds writes that step. Its inputs are path, the installed tree relative to the workspace or absolute (default .), version, and upload-sarif (default true); its output sarif is the log’s path under $RUNNER_TEMP.
It runs the published binary for the runner, at the version the tree installed, and never the committed one. The committed binary is the installing host’s target (§The gate binary). So the Action runs docs/install.sh from its own checkout, with CHECKWRIGHT_VERSION set to the lock’s top-level version (last under §The manifest’s key order, so no comma follows) and --run as the argument, and that script fetches and verifies the release. The bootstrap then selects the runner’s artifact and runs the battery in the tree (§The install boundary). Every member dispatches through that binary (gate-sdk/SPEC.md §run-gates), so a substituted committed binary cannot turn the check green. The version follows the lock, so update moves the binary without a workflow edit. The version input names a release for a tree vendored without the installer (§Vendoring without the installer), and a tree with neither is refused at exit 2, naming the input.
The reds reach the pull request as SARIF. The battery step sets GATE_SDK_SARIF_FILE (gate-sdk/SPEC.md §run-gates), and a second step uploads the log with GitHub’s upload-sarif action, pinned to a full commit SHA, even after a red. The upload is presentation, never the verdict. It runs with continue-on-error, since a fork’s pull request carries a read-only token and a repository without code scanning refuses the upload; the job’s status stays the battery’s. The calling job declares security-events: write.
It runs the battery and nothing else. The release the lock names was tagged on a commit whose gates run passed every fixture suite (RELEASING.md), and each further run would be a further download. A consumer’s own gates and runners are later steps of its own workflow (gate-sdk/SPEC.md §templates/gates-workflow.yml).
Honest limits. The Action trusts the Release host as the one-line install does, and its digest travels with the tarball (§The dependency boundary). The runner must carry curl, tar, a SHA-256 hasher and /bin/sh, as GitHub’s hosted runners do. A consumer’s shell member that reaches the binary through GATE_SDK_NATIVE_BIN runs the committed copy, because --run hands each member the invoking environment unchanged. A result’s path is relative to the tree and code scanning reads it from the repository root, so a tree below that root annotates the wrong path. The witness runs on Linux; other hosts are measured only by the install-smoke legs, which run the same bootstrap. The Action sets no GH_TOKEN for the script, so its install checks the digest alone and prints that the attestation went unchecked.
Profiles
You pick how much of the methodology to meet first, and which part of it: starter, delegation, prose or full. They are not four rungs on one ladder, since delegation and prose contain neither the other and answer different questions about what your repository is.
The contract is the lattice. Profiles are ordered by kit-set containment, derived from the rosters rather than declared beside them. Moving from a profile to one that contains it only ever adds, to the vendored tree and to the battery you run, and profiles that contain neither the other are alternatives, not steps. The order is bounded: exactly one profile sits below every other and exactly one above, and the one above is full by construction. So moving up never takes away or rearranges what you vendored, and a profile that is nobody’s step is still a legitimate member. A selection adjusts a profile’s set per install and is not a profile (§Selecting kits and gates).
starteris the gate SDK on its own: a battery, a pre-commit hook, and gates that red on real defects in your tree with no configuration outside your gates directory.delegationadds every kit whose subject is the agent session itself: the stage machine a session runs, the queue it selects work from and the evidence a stage produces before it can close. It adds the protocol a session follows when it spawns, the context budget it runs inside, the permission surface it acts through and the delivery doctrine it follows.proseadds canon-kit instead, the kit whose subject is authored documents. It arms link, citation, command and claim checks over everyREADME.mdat any depth and your agent file, real for a documentation repository, whose docs tree is usually a tree of READMEs. They red a dangling link or section citation, a fenced command that cannot run, an unclosed fence and a tracking claim the tree falsifies. canon-kit’s house rules, such as temporal narration, counts, one title per file and comment tiers, are yours to turn on (--with-gate, or a knob), since they red prose that is not wrong. It does not governdocs/*.md, since no kit spells one project’s prose layout: widening the corpus is your ownCANON_KIT_PROSE_SURFACE_GLOBSline in the canon-kit config seaminitwrites.fullis every kit in the payload.
The payload’s reserved sibling is not a kit. The packer writes the prebuilt binaries into a reserved directory beside the kit roots, so every directory under payload/ and every kit are different sets. The derivation excludes that one name, the constant the package resolver uses to locate itself. The exclusion reaches the manifest’s kits, the derived profile’s vendored set and the kit-root knob init declares (§What init seeds) together, since a declared root is admitted with no disk predicate and would carry the reserved directory into every kit-root sweep.
starter, delegation and prose are rosters in profiles.list, because none follows from the tree: each is a judgment about what an adopter should meet first, and the file records the criterion beside each membership. full is every kit root the payload carries, derived at run time and never listed. The shape, the order, its bounds and the monotonicity above, is computed from the rosters and the payload by the profile module behind the invoke, so a membership row and a declared parent can never disagree.
The manifest
init writes checkwright.lock at the root of the repository it vendors into, tracked like everything else it writes. It is JSON and the install-ownership record: what was installed, from which upstream state, and which files this installer owns. One module behind the invoke is its schema owner, holding the wire key, the accessors, the hash rule and the emitter, so the arm that writes a manifest and the arms that read one cannot drift apart. Being the single writer makes the shape a contract: keys are sorted at every nesting level, and a field is present exactly when its writer supplied one, so an omission leaves the key absent, never null or blank. A package with no commit stamp so leaves commit out, on the conditional footing of the artifact key.
The consumer-layout constants have one owner behind the invoke. Where the gates directory, the agent file and the queue file sit in a vendored tree are the installer module’s constants. A reader outside the crate, a smoke harness or a consumer script, takes them from that module rather than spelling a copy.
Reading it needs no external program. The crate parses it with serde_json, so a refusal an adopter meets here means exactly the condition it names (§init) and is never a misdiagnosed missing tool. The wire key is versioned (checkwright-lock v1), and a build that meets one it does not know refuses rather than guessing at the shape behind it.
| Field | Holds | Read by |
|---|---|---|
schema |
the versioned wire key | every verb, as its first act — an unknown key is a refusal |
version |
the release the payload was cut at | doctor reports it; a re-run of init compares it against the payload’s and refuses a silent downgrade |
commit |
the 40-hex commit the payload was assembled from | doctor prints it — it is what lets a reviewer resolve the vendored tree to an exact upstream state |
profile |
the profile selected | a re-run of init re-applies the same profile without asking again |
recipes |
the payload recipes applied, in application order, or absent when none were | a re-run of init re-applies them when no --recipe or --no-recipe is passed (§Payload recipes); doctor reports them beside the profile |
selection |
the kits and gates added to or removed from the profile, or absent when none were | a re-run of init re-applies it when no selection flag is passed (§Selecting kits and gates); doctor reports it beside the profile and recipes |
kits |
the vendored kit set | init’s re-run file plan, doctor’s installed-set report, and §The consumer smoke’s count assertion — the recorded set the battery’s resolved kit roots are held equal to |
files |
init’s ownership roster — each path it has written, at the content hash it last wrote there, until the file leaves the tree |
init’s changed-file detection: a file whose hash still matches is rewritten, one that has changed is reported rather than overwritten — the gate binary’s row excepted, §The gate binary — and stays on the roster so the next run reads it the same way, whether or not the running release still ships that path. uninstall walks the same roster to decide what it may remove and what it must keep, and diff classifies it against the tree |
artifact |
the gate binary’s target and its SHA-256 digest, or absent |
doctor reports the target and re-verifies the digest in place; a re-run of init compares the target against this host and skips the rewrite while the digest still holds |
Resolving one of your own seam files is an exact-path question, not a search. doctor and uninstall must know which gates.list or gate-sdk-config.knobs is yours, while the vendored kits carry fixture trees holding files of the same name. files records the repo-relative path init wrote, so the resolver asks whether the manifest records that exact path and returns it or nothing. No predicate over the recorded kit set repairs a tail match: files outlives kits, so a narrower re-run leaves the dropped kits’ fixture paths on the roster, and the residual manifest below carries no kits key at all. The consumer smoke’s narrowing arm is the oracle for both.
A recorded hash is what init last wrote at that path, on whichever run wrote it, not the tree’s state at the end of the current run. The two part company for one class: a path init left alone because you edited it. That path stays in files at init’s hash, carried forward rather than recomputed, so the next run still finds it different and still leaves it alone. Editing a file changes who may write it, never whether init tracks it. For gates.list, that hash is also how init reads its previous registry (§Payload recipes).
checkwright.lock is not on its own roster. init adds the manifest to its written set only after emitting it, so any path that walks files to decide what this installer owns disposes of the manifest explicitly.
One entry is a span rather than a file, and it is still an ordinary entry. init creates the agent file only when it is absent, and thereafter authors only the marker-bounded doctrine block inside it; everything outside that span is yours. The row is still whole-file, hashed and compared like every other, so your edit anywhere in the file marks the entry changed, a true report. That report does not stop the span’s upkeep: the block is injected before the file is claimed, so it keeps upgrading on every run. What survives inside the span is doctrine-kit’s contract, its declared-trim round-trip and the bound on it (doctrine-kit/SPEC.md §install-doctrine).
A path leaves the roster when the file leaves the tree, and at no other moment. init owns a path because it wrote the file there. A release that stops shipping the path does not end that: the file is still on disk and may carry your edits, and disowning it would let a later release re-adding the path write straight through them. Nor does an uninstall that kept the file, which is why the residual manifest below retains it.
A relinquished path is an ordinary entry, not a state of its own. Nothing visits it on the run, so it is carried forward at init’s last hash, with the same row and protection as any entry. The existence test is the reaper, so files is bounded by the files init created that still exist. init never deletes, so a path no release ships stays on disk and yours to remove.
The carry-forward is the last pass init makes before it stages, since “nothing visited it” is a claim about the whole run. A path written after it would be carried at a hash the run already superseded and left unstaged, and uninstall and diff would read that stale hash as your edit. So init produces the generated projections before that pass.
The hash carried forward is the one init wrote, not the one this payload would write, because the revert decides it: a file restored to what init put there is init’s to rewrite again only while the recorded hash is what init actually wrote. That is a change of meaning, not of shape, so the wire key stays checkwright-lock v1. A reader built before it reports such an entry changed and leaves it alone, which is what the new meaning wants.
The residual manifest is the file’s second shape. When uninstall keeps at least one file, it rewrites checkwright.lock over the survivors with schema and files only, since version, commit, profile, recipes, selection, kits and artifact describe an install the tree does not have. The survivors keep the hashes init wrote, never yours: an entry at your hash would read unchanged on the next install and be written straight through.
The wire key stays checkwright-lock v1 here too, because the existing readers tolerate the shape. init’s downgrade refusal is guarded on a non-empty version, so it skips. init re-reads profile only to default it, so it falls through to starter, what a virgin tree gets. doctor reads the identity fields’ absence as the residue itself (§doctor). A reader built before this prints blank identity fields, uninformative and never wrong-acting.
A recorded files hash is git hash-object, never sha256sum: macOS ships shasum instead, and git is already a toolchain member whose object hash is content-addressed and stable.
It is a filtered git hash-object, and that is the definition. The helper passes no --no-filters, so it records the content’s hash after the path’s text/eol attributes and core.autocrlf apply. A filtered and a raw hash disagree only for an adopter whose edit is a line-ending change. Switching to --no-filters would make init notice and refuse to overwrite such an edit on every platform, a behavior change belonging to a unit scoped to it. It is refused as a repair for the native Windows leg’s manifest disagreement (§The consumer smoke), since a cause read carries no authority to change what a files hash means.
The command has two call sites, run from different working directories: the manifest writer, and the consumer smoke’s comparison, hashed from its own scratch root (§The consumer smoke). Neither passes --no-filters, and a reader holding two values is holding two invocations.
A roster is hashed in one child, not one child per file. diff, uninstall, init’s manifest write and init’s claims over the kit files hand their paths to one git hash-object --stdin-paths. It reads each path through the attribute chain and working directory the one-file call uses, so the recorded value does not move, and it keeps spawn cost from dominating an install on native Windows. The kit files are hashed before the first is copied, exact because each destination is written once, after its own claim. init’s other claims keep the one-file call, since some are written before they are claimed. A path the batch cannot carry, one holding a carriage return or line feed or opening with a double quote, and a failed or short batch fall back to one call per file.
Two hash families answer two questions. A files hash is change detection, where staying inside git’s asserted toolchain matters more than collision resistance. The artifact’s digest is an integrity claim published for cross-checking, and git hash-object defaults to SHA-1, which would undercut the one claim §The gate binary makes.
The artifact is on both maps, split by path versus digest. The binary’s path is an ordinary files row at its git hash-object hash, because §uninstall reverses it against the same roster, and only its digest rides the artifact key. The row says whether the tree still holds what init wrote and the key whether it is the published artifact. §doctor, §uninstall and §init each read the one they mean, and §init and §uninstall read the row as ownership for every path but this one.
A new optional top-level key is additive within the versioned wire key: a reader that does not know it sees the manifest it always did, and its downgrade refusal stands in front of the run.
recipes: a release built before it re-applies no recipe, so it keeps the recipe-composed seams as the adopter’s changes.selection, an object of up to four arrays,with-kits,without-kits,with-gatesandwithout-gates, each present only when non-empty and holding names in the order given. A release built before it re-applies no selection, so it reverts the registry and kit set to the profile’s while the file protection keeps the adopter’s edits.
The artifact key carries no path. The install location has one owner, GATE_SDK_NATIVE_BIN in your gate-sdk-config.knobs, the value the battery dispatches to. A second copy could disagree with it, so every reader asking where is the binary resolves the knob, as §doctor does before re-verifying what it finds.
The knob also owns the door a kit’s prose and shell name. Every kit README, template, knob header and stage procedure names the binary through GATE_SDK_NATIVE_BIN, and gate-sdk’s gate_native_bin_spelled owns how that value is spelled as a command (gate-sdk/SPEC.md §lib/gate.sh). A kit file carrying a resolved location is a seam crossing a gate reds on (guard-kit/SPEC.md §check-door-binding). It does not reach a consumer’s settings: the adopter merges guard-kit’s recommended allowlist by hand and resolves its placeholder (guard-kit/SPEC.md §The recommended allowlist).
The files row is not that copy, though it is a path. It records where init wrote, which stays true when the knob is repointed, while the knob records where the battery dispatches. Where the two part, the row is still right about what uninstall must reverse.
The packer
--pack-installer, the gate binary’s Arm::Run payload assembler (gate-sdk/SPEC.md §The non-gate arm), assembles the payload both transports ship, the Release tarball and the npm package, out of this repo’s own kit roots, and npm-packs it in a scratch directory outside the worktree. Callers reach it through the front-end, bash gate-sdk/bin/run-gates.sh --pack-installer …. The arm’s usage lives in that front-end’s help and in this section, and the arm refuses an unknown argument. This section owns the arm’s boundary, the route this repository’s release path invokes it by, and the contracts its # spec: pointers cite.
The first act is a preflight over the programs the arm spawns. npm, git, tar and mktemp are each probed on PATH before any is reached. An absent one gets the arm’s named refusal, <tool> not found on PATH — the pack step cannot run., rather than a spawn failure mid-pack (gate-sdk/SPEC.md §Fail-closed contract). The probed set tracks the spawned set in both directions: a program the pack never reaches stays out, which is why jq is absent, and one it reaches is in, which is why mktemp is present. A program joining the spawned set joins this list in the same change, and the refusal text is parameterised by the tool name.
Nothing is written inside the worktree, and no copy of any kit is checked in. The payload’s kit set is derived at pack time from the roots the battery enumerates, so the shipped set cannot drift from the governed one. The assembly happens under INSTALLER_PACK_TMP_DIR, else TMPDIR, both read off the process environment rather than declared as kit knobs.
Each kit root is packed minus a declared shape, and installer/ is packed whole. One helper takes the withheld shape as an argument. The kit-root call site passes the shape GATE_SDK_PAYLOAD_WITHHOLD declares, and the installer/ call site passes none, since the package roster decides what of installer/ ships. The shape becomes git archive pathspec exclusions, and the helper’s symlink pre-flight reads the identical pathspec, so the fail-closed guarantee covers exactly what is packed. gate-sdk/SPEC.md §Consumer payload owns the shape, its grounds and the disclosure it changes.
The payload is stamped with the commit it was packed from, the full 40-hex object name, which is what resolves a vendored tree to an upstream state. The version beside it comes from the newest reachable tag unless --version names one, never from an edit to installer/package.json. The refusal protecting the stamp asks about the payload’s footprint, not the whole worktree (below).
A third value joins the stamp when supplied. Where GATE_SDK_SPEC_BASE_URL resolves non-empty in the packed tree, the packer records it beside the version and the commit. Where it resolves empty the key is absent, not blank, because an empty base means resolve in the tree and has no location to record. init writes the value into the consumer’s knob seam (§The gate binary), so a consumer whose kit SPECs the payload withholds still reaches the invariant a red gate names (gate-sdk/SPEC.md §Consumer payload). The value is the publisher’s, read from the packing tree’s own knob file, never a kit literal.
The packer writes into the assembly in exactly four places, a closed set:
- the stamp above, into
{asm}/package.json; - inside the kit loop, right after a kit’s tracked set is extracted, a rewrite of
{asm}/payload/{leaf}/README.md, so a link to its own or another packed kit’sSPEC.mdresolves to the published location rather than to the withheld file; - the license text, the file
GATE_SDK_PAYLOAD_LICENSEnames read from the stamped commit, at{asm}/LICENSEand at{asm}/payload/{leaf}/LICENSEfor every packed kit, so the package and each vendored kit carry the text a redistribution owes; - inside the kit loop, each kit’s carried declarations at
{asm}/payload/{leaf}/close-surfaces.txt, read from its withheld members at the stamped commit (gate-sdk/SPEC.md §Consumer payload).
The first two read the same resolved base, so it is resolved once, above the loop. gate-sdk/SPEC.md §Consumer payload owns the rewrite’s rule and its four bounds, and gate-sdk/SPEC.md §check-packed-links the gate reading its output. All four writes land in the assembly scratch, which the pack step tears down, so none reaches a tracked file.
The license text is placed where it ships, and the tree keeps one copy. No kit root holds a license file in the source tree. A tracked copy per kit root would need a generator and a freshness gate, while the packer already reads the commit’s bytes. A set GATE_SDK_PAYLOAD_LICENSE naming no file tracked at the stamped commit is a refusal, and an explicitly empty value places nothing. Each packed README’s license line states this placement from scripts/license-line.conf’s location clauses, so moving the placement moves those clauses (docs/site-architecture.md §The license line).
Recipes are extracted, like kits, from the stamped commit. For each GATE_SDK_PAYLOAD_RECIPES pair the packer extracts the directory’s tracked files with git archive into {asm}/recipes/<name>/ (§Payload recipes). It refuses at exit 2 a name outside the knob’s grammar, and a directory with no tracked file at that commit, since a recipe the package cannot carry is a broken payload. Extraction is not a write into packed content, so the write set stays four.
The footprint has three members, derived rather than listed. It is computed from the two resolvers the pack loop runs, so it cannot drift from the packed set. A hand-kept list would under-refuse silently on a dirty path, the failure the refusal exists to prevent.
- The tracked set at the stamped commit: under
installer/and each root the kit-root resolver yields, the license file, and each directoryGATE_SDK_PAYLOAD_RECIPESnames.git archivereads the commit’s bytes, so no worktree edit reaches this member. - The kit-root set itself, decided in the worktree. The resolver admits a root with a
checks/orsmoke/directory on disk, and the pack loop tests the root’s existence again, so a tracked kit root deleted in the worktree drops from the payload while the stamp names a commit carrying it. The pathspec therefore skips the pack loop’sis_dirfilter, which would blind it to that case. - The native target roster, copied from the worktree when
--artifactsis given, and a payload input only then. Where the roster knob resolves inside the packed tree, dirty bytes would ship under a stamp that does not describe them. Where it resolves outside, as the Windows leg steers it to a scratch file, no clean-tree check covers it.
Not members: the --artifacts tree, outside the worktree by contract, and every other path, which the payload neither ships nor reads.
The footprint keeps each kit root whole, withheld members included. The payload excludes each kit’s SPEC.md and smoke/ (gate-sdk/SPEC.md §Consumer payload), and the pathspec does not. A withheld SPEC.md generates the on-site mirror where a shipped pointer resolves, so a pack over a dirty SPEC stamps a commit whose published statement may differ from the one a red gate sends its adopter to. The footprint’s subject is the tree under test, not the tarball’s manifest.
The refusal is that footprint’s git status --porcelain:
- a member resolving outside the packed tree is dropped from the pathspec, since git refuses a path outside the repository;
- an untracked, unignored path inside the footprint refuses: it does not ship, but it means the tree under test and the tree packed differ, which the consumer smoke exists to notice;
- ignored paths stay invisible, which is why the pack reproduces the tracked set with
git archiverather than copying the directory.
The diagnostic names the offending entries, bounded with a total, beside the tree it checked.
The refusal has two grounds: the stamp, on the two members the worktree can reach, and the tree-under-test property, for the consumer smoke. The stamp alone does not ground it, because the first member, most of the packed set, can never make the stamp wrong. Both properties hold: the stamp describes the payload, and an edit to a shipping path refuses rather than being replaced by the commit’s bytes.
A dirty path the payload neither ships nor reads does not abort the pack, such as .workflow/gap-inbox.md, which mid-iteration sessions write. Its honest limit: §The consumer smoke’s whole-tree precondition, a separate predicate every validate run reaches before any pack call site, still refuses such a path.
Three alternatives are refused:
- keeping
.workflow/gap-inbox.mdclean across stage boundaries answers one dirty path and leaves the predicate wrong for every other; - routine use of the pre-flight valve makes overriding a correct refusal habitual, and a valve firing every iteration stops being read;
- a kit-root set derived from the stamped commit would make member two immune like member one, but it duplicates the crate’s shared kit-root derivation with nothing holding the two together.
The caller names the tree it means. --root takes the work-tree top level to pack and stamp, validated to be one rather than silently promoted from a subdirectory. Absent --root, the git toplevel of the current directory is packed.
A caller pins two decisions. The front-end resolves the gate binary from the git toplevel of the current directory, and the binary reads the knob files there. So the current directory selects whose tooling and configuration run, and --root selects which tree is packed and stamped. Every pack call site in this repo names both.
The arm lives in the crate, as native/src/emit/pack_installer.rs, reached by the bare flag --pack-installer off the arm table. Its inputs, the packed kit set, the target roster and the binary’s name, are knobs the binary resolves in process. Each target’s artifact name is discovered from its artifact directory, so no input is a crate literal.
The publishing caller builds the binary it dispatches to: ROUTE 1. --installer-smoke packs with the binary it runs as: a contributor’s build, or on a CI leg the producer’s upload placed as the tree’s gate binary. Those legs publish nothing. publish.yml’s pack: job holds the downloaded per-target artifacts and no built binary, so it runs bash gate-sdk/bin/build-native.sh ahead of its assemble step, on the runner’s preinstalled cargo, as the sibling build: job does.
Resolving GATE_SDK_NATIVE_BIN onto a downloaded artifact is refused: the job that assembles and stamps the published tarball would execute bytes it did not check out, verified against no independent source. ROUTE 1’s costs are each bounded:
- gate-sdk/SPEC.md §Consumer payload’s builds nothing itself is about the payload artifacts, not a tooling binary built there;
- a
cargo buildinpack:compiles bytes that job checked out at the tag, so the checkout-plus-bash tamper floor holds (gate-sdk/SPEC.md §templates/gates-workflow.yml); - build-then-pack in one job is rehearsed on every CI run, not first exercised by a real publish.
The disposition lives here rather than in a kit SPEC, on the provenance seam. The arm’s mechanism ships, and gate-sdk/SPEC.md §Consumer payload owns it for any consumer that redistributes kits. That mechanism is assembling a payload from kit roots, verifying each declared target’s artifact against its sidecar, restoring the mode the transports drop, stamping a version and a commit. Its inputs stay consumer config. What is repo-private is the disposition: that this repository’s release path invokes the arm, from which job, and on which grounds. A kit SPEC governing that would publish one project’s release route as kit mechanism, and §Consumer payload bounds its reach to what a gate ships. This repo-root surface already hosts that shape for the harness §The consumer smoke governs.
The consumer smoke
The gate binary’s --installer-smoke arm is the acceptor for everything above: one compiled driver, run on every install-smoke leg and registered as a validate suite, so a bit-rotted activation path is a red validate rather than a discovery at announcement. Its callers are the five install-smoke legs, the installer_smoke suite’s run command and a contributor’s bash gate-sdk/bin/run-gates.sh --installer-smoke. Bare, it runs every arm; --part <k>/<n> runs the setup arms and then the k-th of the roster’s n parts, refusing at exit 2 an n other than the roster’s. It needs a checkout, as --run-demo does, since it packs the tree it runs in. It exits 0 clean, 1 on a finding about the payload or the tree it wrote, and 2 on a harness precondition. It is implemented under native/src/emit/installer_smoke/, one module per arm family.
It installs the tree’s own pack from the author’s seat; the published artifact on a clean one is RELEASING.md §The front-door rehearsal’s.
The tree is the current directory’s git toplevel, the tree whose knob files the binary reads, so the tree packed and the tree configuring the run are one by construction. Every spawn the driver makes into that tree names it as its working directory. Each pack spawns the running binary’s --pack-installer --root <root> from the root, naming both decisions §The packer requires of every pack call site, with its steered roster and pack scratch in the child’s environment. A pack is never an in-process call, so its environment and refusal stay its own. The PACK: line names the root.
The tree must be clean, and the preflight says so before the first arm. Beside that check the preflight requires git, npm and tar, plus sh on a unix host or pwsh on native Windows, and refuses at exit 2 on any absent one. It requires no cargo, rustc or jq: the driver builds nothing and parses the manifest and package.json with the crate’s JSON reader. The packer keeps its own dirty-worktree refusal, since a concurrent edit can trip it on any of the run’s several packs. That late refusal names the root it resolved and states that the check is per-invocation, so it reads as a precondition met at an awkward moment rather than as a broken installer.
The arms run in sequence, and that is part of the contract. The printed arm headers are a parsed scenario roster in print order (evidence-kit/SPEC.md §Layout and configuration). The run’s state is one value each arm reads and writes in turn. The entry point and PATH are reassigned per arm, and the value verdicts and installed registries accumulate across the profile loop. The cross-version and newer-verb reversal arms read the payload the upgrade arm removed a file from, and the hooked move arm copies it. Concurrent arms would have to buffer their output back into header order and pass those values between processes, trading a known duration for failures that do not reproduce. A reduction starts from a per-arm profile of a finished run.
The roster divides into setup arms and parts, so separate processes can share the run. The setup arms (build, pack, install and the profile invariant) write what every later arm reads. Each part is a contiguous run of the rest, and no arm reads a value an arm of another part wrote, so a part run repeats the setup and then meets, arm by arm, the state its arms meet in a whole run. A part run declares only the arms it runs, and its clean line names its part.
The per-profile sequence. The smoke stages the gate binary it runs as, packs the package around it, and installs it from the resulting tarball with --offline. It then drives a scratch consumer once per profile, in this order:
init.- The follow-up arm: every command
initprinted in its block must resolve to an executable path in the payload just written, with every flag it names accepted. - The battery must be green and leave the worktree clean. A run’s scratch is ignored by its own directory (gate-sdk/SPEC.md §Layout and configuration), so a path the porcelain shows afterwards is a writer outside it.
check-tree-terms, which no profile registers, is then run by name and must be green, so the committed binary is still read for a leaked builder path (gate-sdk/SPEC.md §build-native). - The manifest must agree with the tree it describes, file by file.
- The withholding arm: no vendored kit root carries a
SPEC.mdor asmoke/; the kit-root set the battery resolves equals the manifest’skits; and the payload’s reserved artifact directory is in neither. - The queue post-condition: a profile whose kit set reads the queue file must have one, satisfying
check-queue-sections, and any other profile must have none. - A re-run must leave the tree object identical.
doctormust exit 0, name the installed profile, and name at least one member disarmed. Each is read offdoctor’s line rather than named by the harness, since a fresh install sets no arming knob (§doctor). Itslatestline must read the scratch upstream’s release as available, and--emit update-notice, run through the consumer’s front end, must print the notice naming that release and the installed one.- The value arm: the consumer authors one page of markdown carrying one real defect, a mistyped relative link in a
README.md. The battery’s verdict on it is recorded, the link is corrected, and the battery must be green again.
The value arm fixes the link and never the corpus, so the second run’s tree differs from the first only by the typo, and its green is the defect gone rather than the scan narrowed. A profile green on the first run must still be green on the second. The arm restores the commit it found, so the reversal below runs against the tree init wrote.
The seeded workflow is asserted on every profile, by the seed arm after the profile loop, over a copy of each profile’s seed kept before its reversal. .github/workflows/gates.yml exists, and its action step names the packed repository slug, commit and version. The payload binary’s check-action-pinning and check-action-permissions are green over it. uninstall removes it with every other unedited seed, which the reversal’s tree-object equality holds.
The close-surface arm proves the carried declarations (gate-sdk/SPEC.md §Consumer payload) reach the gate in a vendored tree. It runs after the seed arm, in a fresh consumer at the payload-derived profile:
- It reads the expected set from the tree under test, never the payload: every
close-surface:line carryingreclaim=in the canonical spec of each kit the consumer’s manifestkitsnames. It finds at least one or refuses, so the arm cannot pass by vacuity. - It creates each such path in the consumer, adds each to the consumer’s
.gitignore, as guard-kit’s and drift-kit’s READMEs instruct for their logs, and commits. - It runs
check-close-surfacesby name through the consumer’s gate binary, since no profile registers it, and asserts the clean line. - As a control, it creates one more gitignored log under the workflow directory that nothing declares, asserts the gate red naming that path undeclared, and removes it.
A payload that dropped the carry reds step 3 on every expected log.
The demo arm runs once after the profile loop, since the verb fixes its own profile (§demo). It runs checkwright demo through the installed package’s entry point, inside a fresh consumer init just wrote at the lattice minimum, with DEMO_TMP_DIR pointed at a directory the harness owns. It asserts, each as its own named failure line:
- exit 0 and the
DEMO: cleanline; - a
FAIL:verdict line between the act 3 and act 4 banners, so the red is read from the output rather than inferred from the verb’s exit; - every line of the
demo-proofblock ondocs/index.mdpresent in that output, so the front page’s act 3 is a copy this run re-reads rather than a transcript that drifts; - that consumer’s tree object and
git status --porcelainidentical before and after; - the
DEMO_TMP_DIRdirectory empty afterwards; checkwright demo extrarefused at exit 2 with the usage line.
A verb that ran green and left its scratch behind, or wrote into the invoking tree, is a red.
The companion arm runs after the demo arm and proves each payload recipe on its fixture tree (companion/SPEC.md), one header per toolkit, so each is its own scenario. Each toolkit’s install line is its companion-install block, docs/openspec.md’s for OpenSpec and the extension’s install command’s for Spec Kit, and the line’s words from init to its end are the arguments. Its companion-full block is read from the same file the same way. The arm refuses an absent or empty block, an install line carrying no --recipe, or a full line not carrying --profile full, rather than skipping it. The Spec Kit page’s companion-full line must carry the command’s words from init, and the arm fails naming both when it does not. For each toolkit it:
- asserts that the installed package carries
recipes/<toolkit>/, so the knob covers every recipe directory undercompanion/*/recipe/; - makes a scratch consumer, commits
companion/fixtures/<toolkit>/layout/into it, runsinitwith the line’s arguments from the packed tarball, turns the hooks on, and asserts one commit, a clean worktree, and the manifest’srecipesequal to the line’s; - asserts the battery green, reading its summary line, then asserts a bare
initre-run leaves the tree object unchanged, since it re-applies the recorded payload recipes; - for each
companion/fixtures/<toolkit>/defects/<gate>/, overlays its files, asserts the battery red with aFAIL: <gate>line, and restores the commit.
The Spec Kit toolkit then runs a full leg in a second consumer: init with its companion-full line’s arguments, the battery green, and each planted defect red by the gate that owns it. The OpenSpec toolkit runs a lifecycle leg in a second consumer instead. It runs init with the companion-full line’s arguments and asserts it green, overlays companion/fixtures/openspec/full/check-stage-entry/ and asserts --only check-stage-entry red naming every in-flight delta directory, then asserts it clean once the overlay’s deltas and capability specs are removed (companion/SPEC.md §The lifecycle layer). Each toolkit’s defect set must cover the gates companion/SPEC.md §The tested claim names for it, the shared roster and the toolkit’s own, so a missing defect directory reds rather than shrinking the claim. The headers are literals, which the scenario parser needs, so the arm derives the payload-recipe set from the companion/*/recipe/ directories and reds on a payload recipe no header ran.
The complement legs. The arm reads each toolkit’s companion-complement block as it reads the companion-full one, and the Spec Kit page’s line must carry the command’s words from init. Before any leg it reads companion/exclusions.list and refuses a row naming an unknown toolkit or carrying no surface, a repeated pair, a kit the payload lacks, and a toolkit with a recipe and no row. Each toolkit then runs a complement leg in another consumer with its companion-complement line’s arguments. The line’s --without-kit set must equal the toolkit’s exclusions.list kits, and steps 2 to 4 above follow. The manifest’s kits and selection.without-kits must hold the left-out kits out after the install, and again after a bare re-run and a bare update (companion/SPEC.md §The tiers).
The withholding arm’s three assertions each catch what green cannot. A kit root that silently drops out of the resolved set yields a smaller battery that is still green, so green cannot witness the kit-root declaration (§What init seeds). The second assertion therefore counts, against the manifest’s kits, the count already recorded, so no second roster is minted. The first is spelled as the two member names rather than read off the knob the packer used, since a smoke asking the packer what it excluded would compare a derivation against itself. It reds on a packer that stopped excluding, a regression that breaks no install and so passes in silence elsewhere.
The third exists because the reserved artifact directory would sit on both sides of the equality, which would then agree while both are wrong. The suite reads that name by content, from the target roster the packer writes into that directory, never off the payload/*/ listing the kit-set derivation uses (§Profiles): an oracle sharing its subject’s blind spot confirms the defect instead of catching it. The suite’s own kit-set reading, which every other assertion compares the installer’s answer against, takes the same content-derived exclusion, or it would report a corrected installer as the defect. A payload where no directory carries the roster fails the arm rather than passing it vacuously.
The follow-up arm asserts what init printed, not a copy of it. init ends by telling the adopter to run two commands, printf format literals in installer source that no markdown-fence corpus reaches. The arm parses the pair out of the captured output, under the grammar §init states, so a rename moves the printed string and the arm follows it with no edit here. Per extracted command, in this order, each making the next meaningful:
- The block is present and non-empty. Zero commands extracted is a red, never a skip, so a reflowed banner cannot turn full coverage into silent zero coverage.
- The target resolves to a file inside the consumer, and is executable where the line spells no interpreter before it, since an interpreter reads the file rather than executing it.
- Each flag the line names is accepted by that target.
--install-hooksis the live case: a path that resolves while its flag does not is the half a path check misses. The probe is the target’s own refusal behavior, two-sided so it cannot pass vacuously. A negative control runs the line with that flag replaced by a token derived from it and guaranteed unknown, and the positive run of the line as printed is measured against the refusal the control earns by name. No refusal string is spelled in the smoke: the expected one is the control’s own line with the sentinel substituted back.
The flag half is unconditional, since a verb runs only past a verified artifact, so a front end always has a binary to dispatch the flag to. The arm rides the first init --profile call, never the idempotent re-run, whose no-op branch prints no banner and would pass every profile by vacuity. The probe runs the printed command in a throwaway copy of the consumer, made from the payload just installed. The live flag places hooks and sets core.hooksPath, and a hook in the consumer itself would block later arms’ commits, including the value arm’s deliberately defective one.
Every scratch consumer sets maintenance.auto false and gc.auto 0 at creation, since a detached git maintenance run that repacks .git/objects mid-copy fails a BSD cp on each vanished path.
A printed command that does not resolve, or a flag the target refuses, is a finding about the payload init wrote, at exit 1. A block the arm could not read at all, init succeeding and printing nothing the grammar matches or a command line naming no path, is this harness’s failure, a refusal at exit 2. The arm does not assert that the commands succeed: the battery arm owns that.
A disagreeing manifest arm reports what it found, in place, before it fails. On the failure path only, the arm prints a bounded fact set and only then picks its verdict. It must be the arm, since the driver tears its scratch down at the end of the run. A CI step standing up a second consumer by the same route would duplicate this install path in YAML, unrunnable locally and free to drift from the check it diagnoses. On a green leg the arm prints only its manifest: N file(s) agree with the tree line.
It samples at most three paths, none chosen by arrival:
- the first disagreeing path, the one a reader would have opened anyway;
- the artifact’s
filesrow, §The manifest’s ordinary row for the binary, whenever the manifest records anartifactkey and that row disagrees. It is the discriminating case, because git classifies the blob binary and converts a binary blob under no configuration, while a first path that is a.mdshows only the end-of-line-shaped case. Where this sample does not resolve, through noartifactkey, an artifact no config seam names a path for, or an agreeing row, the report says which rather than printing a blank. No arm produces the middle case, since an install that ran placed a verified artifact, and the report keeps it as fail-soft; - the witness for the exit-2 verdict: the first disagreeing entry whose
wantorgotfailed the operand shape test, carried out of the loop as its<path><TAB><want><TAB><got>tuple and appended last.
The artifact row and the witness are deduplicated on the whole tuple, never the path, so a run cannot exit 2 on an entry whose bytes the report did not print. Whether the witness coincides with a sample already chosen is a check the report performs and prints, with three outcomes:
- byte-equal: the witness row is a sample already chosen, and the report says so in the shape the artifact row uses;
- path-equal, bytes differ: both blocks print, the witness block labelled as the row the verdict is computed from, and the report states that two decompositions of one recorded entry disagree, a statement about this harness and not the consumer’s tree;
- absent: the witness joins the samples.
The header states how many disagreements failed the shape test. One malformed entry out of hundreds is a statement about that path, and all of them a statement about the capture step every entry runs through.
A run-wide verdict states which entry earned it, or it is not readable. Where an arm reports a bounded sample and decides on an unbounded scan, the deciding entry joins the sample.
The witness is the first such entry rather than every one, since the report is a bounded sample by design. The other two samples stay: they answer questions the verdict’s row does not, and the artifact row is the discriminating case.
Five values per sampled path, and the truth table that reads them. Each prints labelled, as an octet dump beside its own length, with the shape test’s verdict, the call that produced it and that call’s standard error. Two are the operands the failing comparison used; three are re-reads, since a value that reached the comparison mangled and re-reads clean is invisible to a report that only asks again.
want: held.files[P]as the arm’s loop holds it, read from the lock by the crate’s JSON reader and deliberately not re-read through a second channel.got: held. The line the arm’s singlegit hash-object --stdin-pathschild answered for$C/P, read through the line reader below and paired with P by its position among the entries hashed. It is the only operand that crosses the line reader.reread:git hash-object -- "$C/P", the one-file call, at report time from the batch’s working directory.own:git -C "$C" hash-object -- "P", the same command in the repository context the installer’s own recorded-hash helper runs in.raw:git hash-object --no-filters -- "$C/P", which ignores attributes entirely and so depends on no repository: the fixed point the other re-reads are measured against.
A held value’s call line states where the value was read from and nothing more. It may not assert that the value is the one the failing comparison used: no call site can check that identity, and the claim would license reading a live defect as health. The coincidence check above is where it is checked. A re-read’s call line prints the command that produced the value, which a reader can re-run.
The reading rule lives here, not with whoever reads the log next. The first three rows read the hashing, the next two the comparison, the sixth any value’s shape, and the last this harness’s own record of the entry:
| observation | reading |
|---|---|
want == own == raw and reread != raw |
the read side’s context applies a filter the write side’s does not; the defect is at the arm’s call site and the two-call-site narrowing is confirmed |
want == own == reread |
the hashes agree and the arm could not have failed on this path — the disagreement is in the comparison, not in the hashing (whether they also equal raw says only why they agree: equal, no context filters at all; unequal, both contexts filter identically — neither changes the reading) |
own == reread == raw and want != raw |
the bytes on disk are not the bytes init hashed; the porcelain below and the artifact control say which |
got != reread |
the batch’s answer for this path is not what the one-file call gives now. Either the value was mangled when the batch was captured or read back, or the batch filed another path’s answer here. The two octet dumps name the byte and its position, and a got that is a well-formed hash of a different path is the second case |
want == got, byte-equal, both held |
the comparison received two equal values and reported them unequal, which a string comparison cannot do — so a pairing is wrong. Either the loop associated one entry’s want with another entry’s got, or the batch answer was paired off by one. Read the sampled path against the loop’s own order. The count refusal rules out a short answer, not a reordered one |
| any of the five fails the shape test | a stray byte, a truncation or a refusal will each do it and the value is then not a hash; len names a truncation outright, the octet dump shows a stray byte, and the captured standard error names the refusal. The arm acts on this row, and that is the one row it acts on: a refused operand makes the run exit 2 rather than 1. Read it with the header’s count: one entry failing the shape test is a statement about the sampled path, all of them a statement about the capture step every entry runs through |
| the coincidence outcome reads path-equal, bytes differ | the report’s witness row and an earlier sample carry one path and two different tuples, so one recorded entry has two decompositions and they disagree. The subject is this harness, not the consumer, and the two blocks printed under that path are the evidence. What it suspends is bounded: every row read off a sample block’s want or got is suspended for that run, while the witness row and the header’s counts arrive as their own operands and reread, own and raw are commands the report re-runs — none of those is implicated, and a sample whose probes returned a hash at all proves its own path field crossed intact. A repair chosen off the unimplicated operands is sound; one chosen off a sample block’s tuple is not |
The octets line carries the byte claim: the value’s bytes in hexadecimal, rendered through no construct that could normalize, so a trailing carriage return that compares unequal and prints equal shows its 0d. The tests line reads shape=<pass|fail> first=<index|none>, the index being the first byte outside 0-9a-f, so the reader gets a position and not only a refusal. The same test selects the exit code.
A refused operand is a precondition of this harness, so it exits 2, never the manifest verdict at 1. starter: N of N manifest entries disagree with the tree at exit 1 is a statement about the consumer. A value that reached the comparison malformed establishes nothing about the consumer, and a matcher refusing a well-formed hash is likewise a harness precondition: the same 2, told apart by the refusal. The arm shape-tests both operands on the failure branch and refuses after the report has printed, not at the first bad value, since diagnosing that value is what the report exists for. The refusal names the path, which of want and got was refused (or both), how many disagreements it refused, and the shape verdict for each refused operand, its first offending byte included, so the verdict reads from the last line alone. The table’s shape row tells a reader what a refusal means and this makes the arm act on it; neither replaces the other.
The verdict class is read by CI’s job verdict and a human reading the log, and by nothing else. scripts/evidence-config.knobs gives the installer_smoke suite the parse-smoke-log parser, which derives the evidence row from the log alone and never reads the process status, so exit 1 and exit 2 are indistinguishable to it. The distinction is invisible to --run-validate’s evidence, and nothing above depends on it.
What the arm must not do is normalize. Trimming the operands to their 40-hex core before comparing would turn a mangled-operand red green, the detector silently absorbing a broken producer. A later green could then not tell the tree matches from the tree matches once the arm discards whatever reached it. The same rule reaches the report’s decompositions: where two independent readings of one recorded string disagree, the disagreement is printed and named, never resolved, and one decomposition helper shared by the report and the verdict is refused.
The one byte the driver does own is the line terminator, a property of the channel a record travels over and not of the record: a hash git answered cannot end in a carriage return. The terminator handling has one owner, one line reader over every child’s line-oriented stdout: the git hash-object --stdin-paths batch, the follow-up block, doctor’s report, --emit kit-roots, the dry-run plan, the demo’s output and the hooked hop’s commit listing. It drops one trailing carriage return per line and declares the count wherever it dropped one, on the green path as on the red, which separates it from the repair the arm is closed to. A value that genuinely ended in a carriage return stays visible as a count that does not fit the channel’s shape, and a doubled carriage return still reaches the shape test and fails it. A unit test holds the reader to a synthetic CRLF stream, since no host a contributor runs emits the byte.
Once per failing profile, outside the per-path blocks, the report also prints the facts about the run, each with a reader in the table above:
- the consumer’s
git status --porcelainandgit log -1 --stat, truncated: the bytes-on-disk row’s direct witness, since a worktree diverged from whatinitcommitted is the one shape that explains a disagreement set containing a binary; core.autocrlf,core.eolandcore.safecrlfwith their origins in both repositories, the context-filter row’s mechanism;git check-attr -afor each sampled path in both repositories, the other half of it, since an attribute reaches a path the config does not.
The smoke repository’s half of that attribute lookup never answers, because the consumer is created under INSTALLER_SMOKE_TMP_DIR, outside that repository. The report prints git’s refusal labelled as a refusal, and it is itself the reading: the path’s attribute chain is reachable from the consumer alone.
The artifact digest is a filter-free control on the content question. When the manifest records an artifact key, the report recomputes the artifact’s SHA-256 with the crate’s own hasher, which no git filter or repository context touches, and prints it beside the recorded artifact.digest. Equal digests mean the artifact’s bytes are the bytes init published, so a files-row disagreement on that path is neither a content change nor an end-of-line conversion. As a control it speaks for the artifact alone.
The honest limit, stated rather than gated. The report is failure-path code that only a failing host executes, so no green run or fixture pair exercises it. Shape holds it instead: a straight-line sequence of prints with no branch that can change the arm’s verdict, so a stale line makes it less useful and never wrong-acting. It carries no knob, since a report enabled by a configuration no CI job sets is the dead-producer shape.
The CI legs run the smoke once per declared platform, and each leg’s posture is derived, never written. .github/workflows/gates.yml carries one install-smoke leg per platform docs/requirements.md §Supported systems declares, named as §The install boundary names them. native-artifacts-roster reads the declaration’s block and emits five outputs. legs is the producer’s matrix. index is the same runner and held bit keyed by triple, since a job-level expression can index an object but not filter an array. pwsh_legs is the Windows triples, pwsh_parts the native Windows leg’s entries (below). unix_legs is the other triples less the battery’s own, which crate-tests-unix tests (gate-sdk/SPEC.md §check-crate-arms). A leg reads its runs-on and continue-on-error out of that index at its own triple. So a held triple’s leg reports, a joined triple’s leg binds, and flipping the declaration moves the posture with no workflow edit. A triple that leaves the declaration resolves no runner, and the workflow reds.
The producer’s matrix is the declaration, not the roster. native-artifacts builds every declared platform, joined and held alike, through the release path’s own build body (gate-sdk/SPEC.md §Consumer payload), so a held platform is measured before it may join. A red on a joined leg is a published platform the release cannot produce; on a held leg it is why the platform stays held.
Each leg installs the producer’s upload, never a build of its own. It downloads the artifacts by pattern rather than by name, since a name would spell a triple in a workflow step. Every leg takes the host triple from the bootstrap’s detector through host-target.sh, which on Linux names the preferred glibc triple. It strips the artifact-name prefix to leave the <dir>/<target>/ layout INSTALLER_SMOKE_ARTIFACTS_DIR takes, and places that target’s artifact as the tree’s own gate binary, since the arm runs as that binary and packs with it. No leg compiles the crate or keeps a build cache. While its triple is held, a leg’s zero is the consumer half of gate-sdk/SPEC.md §Consumer payload’s join pair and licenses no roster line alone; once the triple joins, a red is a support commitment broken. Each platform leg steers the smoke’s roster at its host explicitly, so the caller-override branch has a live witness beside the baseline Linux leg’s default.
The baseline Linux leg is judged against the recorded baseline, and no other leg is. Its smoke’s status is captured, and --diff-baseline installer_smoke reds only where an arm deviates from .workflow/validate-baseline.txt (evidence-kit/SPEC.md §bin/diff-baseline.sh). The suite’s per-arm parser makes each arm a scenario baselined pass, so an abort reds the arm that failed and every arm it skipped. The other legs bind on their own exit status, as the baseline’s rows record the arms on Linux and one scenario name cannot hold a second platform’s population.
The baseline Linux leg also runs both Linux artifacts on foreign hosts. Inside an alpine:3.22 and a debian:12-slim container, each pinned by digest, it asserts that the x86_64 musl artifact’s --list exits 0 with a non-empty listing. That witnesses the static artifact serving a musl host and a glibc host below the glibc floor. It asserts that the x86_64 glibc artifact’s --help exits non-zero, the premise §Selection’s fallback rests on. The step names both triples through host-target.sh, the glibc one as the host’s and the musl one as its fallback, so no triple is spelled in the workflow.
Every leg checks out full history, because the packer stamps the newest reachable tag (§The packer) and a shallow clone carries none, so a leg would silently exercise the fallback version. Each leg’s timeout-minutes sits above its cold run with headroom and is widened before it is narrowed, since a timeout that reds on runner variance costs more than a long leg. install-smoke-pwsh-windows carries the widest, since its entries run on the slowest runners, each smoke entry repeating the setup arms.
Probe steps report and never fail, and a red leg is read from its probe first. A macOS leg probes after the page’s remedy block has run, so the probe reports the PATH an adopter following the page has. A leg whose label’s architecture is its subject prints uname -m. On arm64 Windows, Git for Windows’ POSIX layer runs emulated and reports x86_64, so the PowerShell leg’s bootstrap entry asserts that the runner’s own rustc -vV host triple is its matrix entry’s before its bootstrap steps read the host.
Every leg that installs a consumer asserts that git starts a native hook, and probes what a hook start costs. Both steps run in a scratch repository. Its core.hooksPath names a scratch directory holding a copy of the gate binary named pre-commit, with the host’s executable suffix. The assertion step fails the leg: git must start that file with no interpreter. A commit of a clean staged change lands with the hook’s summary line, and one that violates a registered member is refused with the gate’s name. The PowerShell leg holds both twice, the second time with every sh.exe under the Git for Windows root renamed away. The probe step reports and never fails, and runs after a red assertion. It prints the median of repeated starts of the hook with nothing staged, where the arm exits 0 printing nothing. It prints one figure for the native hook and one for a two-line POSIX sh handoff to the same binary, with the leg’s OS and architecture. A run’s log is the figures’ home, and no file transcribes them.
The PowerShell leg’s hook steps commit through the hooks init’s printed --install-hooks line placed, read from core.hooksPath. With every bash stripped from PATH one clean commit lands and one is refused by gate name. With every bundled sh.exe renamed away a commit must run its hooks and land, and the step’s failure message names a hook that could not start without sh.
A further step holds the launcher’s other legs on native Windows (gate-sdk/SPEC.md §git-hook), in a scratch repository whose hooks --install-hooks placed and whose registry names one pre-commit member and one commit-msg member, both native:
- a subject outside the grammar is refused by the commit-msg member’s name, and a clean one lands on that hook’s summary line;
- a violating commit from a linked worktree is refused by gate name;
- a knob naming a hook’s own name is refused at 2;
- a knob naming no file refuses the commit, naming the path and the bypass;
- a placed hook started with the knob at another executable starts that one on the hook arm and returns its status. The executable is one the system directory carries. Where Git for Windows ships an
echo.exethe step also reads the argument list whole, and it says so in a notice where it does not.
The same leg runs the front-door driver’s PowerShell twin with no operand (RELEASING.md §The front-door rehearsal) and holds its exit 2 and usage line.
A remedy step runs the page’s block verbatim and adds nothing. A macOS leg runs docs/install.md’s macos-remedy block and a Windows leg its windows-remedy block, so a member the block lacks is the page’s defect, fixed in the block. The macOS step then asserts that the adopter’s next login shell resolves bash to Homebrew’s prefix, and that the lines the block appended to ~/.zprofile do so alone, after proving that a shell reading no profile does not.
The native Windows leg checks the tree out with LF line endings first (gate-sdk/SPEC.md §Consumer payload), names a shell on every step (gate-sdk/SPEC.md §check-action-run-shell) and translates the scratch base once with cygpath. It prints the artifact name and never exports GATE_SDK_NATIVE_BIN, which would outrank the knob file init writes into each consumer. Per Windows triple it runs as parallel matrix entries, so no one entry holds the run. A bootstrap entry runs every step before the smoke. One entry per roster part runs gate-sdk/bin/run-gates.ps1 --installer-smoke --part <k>/<n> under pwsh, with the scratch base, the artifact hand-off and the roster steered at its host, as the macOS legs set them. pwsh_parts crosses pwsh_legs with those entries from a part count the workflow states once, and the smoke refuses a count its roster does not have.
The payload every profile installs carries a real gate binary, because the value claim is about the product an adopter receives. The build arm stages it and hands it to --pack-installer --artifacts before the loop starts, so each profile’s battery is the one a covered platform gets. A loop packing no artifact would have every profile’s install refused at the bootstrap, so it would measure no battery at all.
The driver stages the binary it runs as and builds nothing, because that binary is part of the subject, and a mid-run rebuild would pack a binary other than the one judging it. Without the artifact hand-off, the build arm asserts that the running binary’s baked source stamp equals the tree’s, the stamp check-gate-binary-fresh reads. When they differ it refuses at exit 2 with the remedy run bash gate-sdk/bin/build-native.sh, then the smoke. It then copies the running binary into the host’s artifact directory, on a unix host drops its executable mode as the artifact transport does, and emits its digest sidecar with the crate’s own hasher. With the hand-off it stages the handed bytes and sidecar unchanged. Either way the host triple is the host bootstrap’s detector’s (below). The running binary’s triple is baked at build time from cargo’s TARGET as CHECKWRIGHT_TARGET (native/build.rs), so a running binary built for another triple is refused by comparing two exact triples, naming the hand-off as the remedy.
A working tree’s build is a harness stand-in, and is labelled one where the rule it stands beside is cited. gate-sdk/SPEC.md §Consumer payload has the payload carry a prebuilt binary per declared target, built by the release and never from a working tree. A contributor’s run stages a working tree’s build because it has no Release to draw on, a liberty this harness takes rather than the payload rule relaxing. The publishing path still builds no payload artifact: its pack: job builds only the tooling binary the arm runs as, so a locally built binary can never substitute for a released one. The arm is still handed a directory it did not produce.
The value claim is asserted over the loop, not inside it, as two sentences: some profile catches the defect at all, and some profile below the payload-derived maximum catches it. The second is load-bearing, since a defect only full catches is value an adopter must take everything for. Neither sentence names a gate, which would be a second roster beside the recipe’s own derivation, nor a profile, for the reason the lattice assertions name none: which profiles catch a prose defect follows from the rosters. An install that is green, idempotent and reversible is still worth nothing until it catches something.
That coverage claim speaks only for a covered platform. An uncovered one gets a refusal, since with every install step behind the invoke an artifact-less host has nothing to proceed into. The refusal has its own leg: folded into the coverage verdict, a port could pass per member while emptying a value class for every such install.
The artifact-less refusal leg drives a payload the packer itself produced with no artifact directory, which no other leg reaches: the artifact arm’s refusals run against a payload the smoke mutated by hand. It asserts a refusal that wrote nothing, the tampered leg’s shape, which tells a refusal apart from a warn-then-install. The refusal names the platform and carries the remedy line that tells this outcome from the broken-payload one. The leg asserts the same refusal for doctor, for diff and for a bare invocation, since the refusal is the bootstrap’s and precedes every verb; a leg asserting only init would pass a bootstrap that had grown the per-verb branch the branchless argv rule forbids. It names a profile where the value claim names none, a choice of which invocation to make, and the refusal is reached before the profile is ever read.
The license assertion rides two places. The artifact-less leg’s extraction must carry package/LICENSE and one package/payload/<leaf>/LICENSE per package/payload/<leaf>/README.md, each equal to the repository’s root LICENSE, so §The packer’s placement is read off the publishing path’s own output. The withholding arm then asserts, per profile, that each vendored kit root carries that same LICENSE, since the artifact-less package refuses before init writes anything.
The profile lattice is asserted against the installed payload, in four parts:
- every named kit resolves in the payload;
- the derived order has exactly one minimum and exactly one maximum, so the lattice is bounded;
- that maximum is the payload-derived profile;
- gate rosters are monotone: for every comparable pair, the smaller profile’s gate set is contained in the larger’s.
The fourth earns the recipe derivation’s profile argument. What an adopter experiences is the battery, not the directory list, so “moving up only ever adds” is a claim about gates, and kit-set containment stops implying it the moment a roster varies by profile. Nothing here counts profiles; another is admitted exactly when it fits.
The plan-parity arm holds init --dry-run to §init’s rule that the plan is the run’s own code and not a second prediction of it. Before each profile’s init, it runs init --dry-run on the fresh consumer, which must leave the tree object and git status unchanged. The key set of the manifest it prints must equal the key set of the checkwright.lock the real run then writes, compared both ways, so a planned path the run never writes reds and so does a recorded path the plan left out. An empty plan reds too, so parity never holds by vacuity.
A fresh consumer has no seed decision to make, so a second leg repeats the comparison at the payload-derived profile on a consumer already holding every surface init seeds. That set is read from what this loop’s install at that profile recorded, every path outside a vendored kit and the gates directory, so a kit that gains a seeded surface is planted with no edit here. A third leg re-seeds over that same set: an installed consumer’s seeds are deleted and their recorded hashes made stale, as a seed body that changed across versions leaves them. The re-run must match its plan, record each re-seed at the hash of what it wrote, and report none as changed (§What init seeds).
The reversal arm then runs on that same consumer, so every profile is installed and reversed. In order:
diffmust exit 0 and report the freshly installed tree clean.- With one recorded
gate-sdk/file’s deletion committed,update --dry-runmust exit 0, print its dry-run verdict and plan that file(pending). The file stays absent and the tree object and worktree stay as they were. The deletion is then reset away. uninstall --dry-runmust name a non-zero removal count while leaving the tree object andgit statusexactly as they were.- After
uninstall, the consumer’s tree object must equal the one it had beforeinitran, the worktree must be clean, and nocheckwright.lockmay remain.
The two --dry-run steps assert §The verbs’ writes/does-not-write rule for update and uninstall behaviorally: a flag that parsed and then wrote anyway fails each, where a flag that merely existed would pass. The plan-parity arm holds init to the same rule.
The tree-object equality is the load-bearing assertion, and it proves more than uninstall. The per-profile check runs one direction, every recorded entry against the tree, so nothing else asserts that the manifest covers everything init wrote. A file init wrote and failed to record survives the removal and breaks this equality, for a first init here and for an upgrade hop’s own write set in the cross-version reversal arm below. It is also the assertion form of the install page’s claim: an install an evaluator can reverse.
The equality holds for a reason rather than by luck. The surfaces init seeds and then leaves alone (the queue file, the agent file, the evidence manifests, the workflow state) are recorded at init’s own hashes, and this consumer never edits them, so the ordinary hash rule removes them. The same rule keeps them on a tree where an adopter has grown them, so the arm needs no special case either way.
The download transport arm verifies the packed tarball against a digest the smoke computes and extracts it with tar rather than npm. It runs the same init, post-conditions and reversal with npm and node masked off PATH, so diff and uninstall are proved Node-free at no extra pack cost. It runs against full alone: what the per-profile loop proves is profile-dependent, while what this arm proves, the same payload reaching the tree without Node, is transport-independent, and full is the largest payload with the widest doctor toolchain read. The preflight still requires npm, because packing needs it, and the mask covers this arm’s verbs only.
The two transport arms turn two claims into assertions. The offline tarball install asserts “this is a one-shot vendoring installer, not a dependency channel”, since a package that installs and runs with no registry access resolves nothing on your behalf. The masked PATH asserts “the Release tarball needs no Node”, which an unmasked arm would pass on any machine that has Node.
The mask is asserted rather than assumed: before the arm runs, each masked name must resolve to the shim, so a PATH that failed to shadow the real interpreter reds, and a control program must still resolve and run. A unix host uses shims rather than deletion, because dropping every PATH entry carrying node would take /usr/bin with it wherever Node is installed there. The shims are #!/bin/sh scripts. The residue is that a payload merely probing for a Node binary still finds a name; one that runs it fails loudly and says which name it reached.
The toolchain-free arm points that same mask at cargo and rustc, and drives doctor and then a full init on a consumer that has neither. doctor must exit 0, report clean, and name neither masked tool: a contributor-audience member is omitted from the adopter’s verdict, not shown as informational (§doctor). Through the same post-conditions every other arm runs, init must succeed and the vendored battery be green. doctor is asserted standalone as well as inside init, which reads only its exit status, because the standalone verdict is what an adopter meets first.
A contributor’s host carries the toolchain the binary was built with, so no other arm could see that defect. Masking is per-arm: the mask rides the verbs’ PATH alone and the driver’s own reads are in process, so the arm needs no toolchain-free host, and it proves the port directive’s claim that an adopter takes the pre-compiled path and nothing on it compiles. It reuses the Node-free mask rather than a knob, which would be a test-only audience axis no adopter exercises.
The jq-less arm asserts what a machine without jq is told, and its claim is that the verbs do not need jq at all. Nothing behind the invoke reads JSON with it, so its oracle is a verb that runs clean where one shelling out to jq fails. The claim reaches past the verbs to what they install (§Requirements). In this order:
doctorin a directory with no install exits 0 and namesjqnowhere.init --profileat the lattice minimum exits 0 on thejq-lessPATH, anddoctorinside that install exits 0 and namesjqnowhere.initat a profile whose kit set carries guard-kit, found through the harness’s own profile reader and run in a fresh consumer, exits 0, anddoctorinside it namesjqnowhere. The arm names guard-kit because it drives that kit’s hook.- The installed guard answers a payload: fed a read-only command, the wired member,
--hook shell-guardon the gate binary, exits 0 with an allow envelope on thejq-lessPATH. An allow is a payload read and a render, neither the front end’s absent-binary decline nor an empty stdout. diffanduninstall --dry-runrun nodoctorprecondition, so they read the minimum’s manifest and reach their answer. Each must exit 0 and say nothing aboutjq, the positive evidence that the read itself isjq-free.
Names jq nowhere reads the verb’s own words: the arm strikes its consumer’s directory name, which carries jq, from the output first.
This mask is by absence, a different instrument from the other two. The Node-free and toolchain-free arms ask whether the payload ever reaches a program, so a shim that fails loudly and names itself is the right tool. This arm asks what a machine without jq is told, and a shim is a jq that is present. So on a unix host the arm builds a directory of links to every program on PATH except jq and runs the verbs against that; the native Windows spelling is the host table’s below. The farm is one helper shared with the bash-less arm below:
- It is derived from the live
PATH, never from a maintained list of the programs these verbs happen to use, so it cannot fall out of date. - The exclusion is the masked program’s case-folded stem. That covers
jq.exe, which shell suffix resolution would hand back, and the.cmdthe crate’sPATHEXTsearch would find, without naming any suffix. - A directory is farmed only when it carries the masked program, and every other directory stays on the arm’s
PATHverbatim. - The mask is proved in both directions. The masked program must resolve to nothing, and a control program must still resolve and run.
command -vis a stat an unexecutable entry satisfies, and a farm of unusable entries would fail every verb for a reason unrelated to the mask.
The bash-less arm proves that a profile whose kit set owes no bash installs and commits on a machine without one. It builds its PATH by the same absence farm, bash masked, and proves the mask both ways. /bin/sh is not masked: the unix bootstrap runs under it (§Implementation). The arm runs the host’s bootstrap as the page does, through the host shell resolved on the masked PATH, so nothing from the bootstrap to the hooks can find bash on PATH. Before the first verb it proves that shell runs, as it proves git does. In order:
doctorwith no install exits 0 and rendersbashasnot probed, naming the audience it is owed by. That line is asserted and no roster is taken out of it. Which of §doctor’s two renderings it carries, the resolved kit list or the predicate, depends on whether the derivation named a kit, and it names none both where the kit roots ship no bash surface and where it reached no kit root. A harness parsing names out of the predicate would classify every profile as owing nothing.- The arm’s subjects are the profiles whose own installed
doctorreport carried no probedbashrow, recorded by the profile loop above. That is §doctor’s verdict rule read directly: an owed member is probed, rendered and sets the verdict; a not-owed one is not rendered; an undecided one is rendered unprobed and cannot set it. A selection whose derived audience names no kit still renders undecided, whether its kit roots ship no bash surface or it reaches none. Every profile is that case until its tree marks a fence or registers a shell gate, so the subjects are every profile, and a probed row is the only rendering that means owed. Each selection’s verdict is read offdoctorrather than named here, and the lattice minimum must be among the subjects. - At each such profile, in a fresh consumer,
init --profileexits 0 and each command the follow-up block printed is executed and exits 0,--install-hooksamong them, placing the hooks the next two commits run through. One clean commit lands with the pre-commit hook’s summary line printed. One commit planting a shell pipeline that can lose its match is refused, the refusing gate read off the hook’s ownpre-commit: <gate> failedline and held to the consumer’s registry. Abashlookup anywhere on that path fails the step that made it, and that step’s message names the command.
Every arm runs on every leg, through the host’s bootstrap. The arms run in one order under one set of header names on every host, so the baseline rows hold. On a unix host the npm-installed node_modules/.bin/checkwright and an extracted package’s sh bin/checkwright.sh are the entry points. On native Windows the same packages, the npm-installed one included, are driven through their bin/checkwright.ps1 under pwsh -NoProfile -File, so the install arm’s npm install --offline still runs while its .bin entry is not the route under test there. The documented Windows PowerShell route, powershell -NoProfile -ExecutionPolicy Bypass -File (docs/install.md §Windows), stays the Windows leg’s page-install step’s. A consumer’s battery, --emit kit-roots and by-name runs go through its vendored front end, bash gate-sdk/bin/run-gates.sh on a unix host and gate-sdk/bin/run-gates.ps1 under pwsh on native Windows. The host triple comes from the host bootstrap’s own detector: installer/consumer-smoke/host-target.sh on a unix host, and on native Windows its detector functions, extracted from bin/checkwright.ps1 through PowerShell’s parser and run by pwsh, so neither holds a mapping of its own. Per-host spellings, each the same claim:
| construct | unix host | native Windows |
|---|---|---|
| reach mask (download, toolchain-free arms) | a PATH-first directory of shims that exit non-zero naming themselves |
the directories holding the masked program dropped from PATH; the program must then resolve to nothing |
absence mask (jq-less, bash-less, sha256sum) |
a link farm of every program on a directory that holds the masked one, less it | the directories holding the masked program dropped from PATH, the system directory spared, and Git’s cmd re-added alone when git resolves nowhere else |
| mask proof | the masked program resolves to the shim or to nothing, and a control program resolves on the masked PATH and runs at what it resolved to |
the same, the system directory’s WSL launcher not counting as a bash |
| executable bit on a printed follow-up target | asserted where no interpreter precedes it | not asserted: the host has no mode bit |
PATH separator |
: |
; |
A control is resolved on the masked PATH and spawned at what it resolved to, because the spawn funnel resolves a bare name against the driver’s own PATH (gate-sdk/SPEC.md §Fail-closed contract). Two cases skip on a stated reason rather than red, printing the reason. The foreign-architecture fallback cases need a preferred artifact that verifies and cannot start, a premise the driver proves by running the artifact’s --help first. One that starts, as an x64 build does under emulation on arm64 Windows, is no stand-in, and the three cases skip, as on a host with no such hand-off. The shasum fallback is the POSIX bootstrap’s step-4 branch; the PowerShell bootstrap hashes in process, so on native Windows it skips, naming that.
Nested invocations run under a scrubbed environment. Every spawn whose working directory is a scratch consumer, an extracted package or a throwaway copy of either receives the invoking environment less every variable named under a static kit’s prefix. It adds the values the arm sets for that call (a masked PATH, DEMO_TMP_DIR). The prefix set is derived from the crate’s static knob tables and never listed (gate-sdk/SPEC.md §The knob file), and a unit test holds it equal to their prefixes. So an exported EVIDENCE_KIT_*_FILE, EVIDENCE_KIT_LOCK_FILE, GATE_SDK_TMP_DIR, GATE_SDK_NATIVE_BIN, GATE_SDK_ROOT or steered GATE_SDK_NATIVE_TARGETS_FILE redirects no scratch consumer’s gates: each nested run reads the knob files its own init wrote. A GATE_LOCAL_ name is not scrubbed, since a child reading one refuses it unless its own gates dir declares it (gate-sdk/SPEC.md §The declaration cohort). That reaches the npm host’s install, the verbs, the batteries, the consumers’ own git and the hooks it runs. A pack spawn runs in the source tree with the invoking environment and its steering, since that tree’s configuration is the one being packed. HOME and git’s global configuration are left as they are: each scratch consumer sets its own identity and maintenance keys locally.
No scratch consumer’s update check reaches a live host. init writes GATE_SDK_UPDATE_UPSTREAM from the package’s repository.url (§The update notice), so a bare doctor in a scratch consumer would otherwise probe the project’s public repository. It would wait up to the bound when offline, and its reading no leg could assert. The install arm therefore makes a bare repository under the scratch base carrying one release tag a major version above the packed one, above every version the upgrade arm derives. Every scrubbed spawn sets GATE_SDK_UPDATE_UPSTREAM to that path ahead of the arm’s own values, and an exported scalar outranks the seam line (gate-sdk/SPEC.md §The knob file). So the probe runs offline, and step 8 witnesses both the notice and doctor’s latest reading. The probe it drives is a local path’s ls-remote, so neither the https transport nor the bound’s tree kill is exercised here; the crate’s tests hold the tree kill (§The update notice).
The driver reads in process what it does not run as a subject. It spawns git, npm, tar, the host shell, the running binary for each pack, and shasum as the fallback’s control (gate-sdk/SPEC.md §The non-gate arm). Everything else is read in process: an octet dump, a byte comparison, the manifest and package.json through the crate’s JSON reader, a version sort, the line filters, tree copies and links, modes and scratch directories. The upgrade arm’s scratch witness reads free space and the scratch tree’s size in process, and a host with no free-space reading says so. A tarball reaches tar on its stdin, since a GNU tar reads a drive letter’s colon in an archive operand as a remote host. The queue post-condition resolves check-queue-sections by its .gate descriptor in the payload’s queue-kit/checks/ and dispatches the consumer’s seam binary, so no shell sources the payload’s gate library.
The upgrade arm drives a cross-version run, since every arm above installs and re-runs at one version. It packs a second tarball a patch version higher, installs starter from the first, and has the adopter edit and commit two vendored files. It then runs the second package’s init with no flags at all, which alone reaches three behaviours:
- the manifest’s version comparison falling through in the upgrade direction rather than refusing;
- the profile re-read from the manifest when none is passed;
- the ownership claim re-applying the payload around a file that has changed since
initwrote it: left alone, reported, and still the adopter’s afterwards.
The roster is asserted directly, not only through that effect, because two different manifests leave the same intact file on this hop and neither survives the next. An entry dropped altogether reads as never installed on the following run, and an entry recorded at the adopter’s own hash reads as unchanged. Both would let the next init claim the path, so both are named apart. Before its first pack, and on either pack’s failure branch, the arm prints the free space and the scratch tree’s size through a witness that never changes a verdict, so a pack that dies printing nothing is still readable from a finished log.
The second edited file is the relinquish subject, which makes the arm reach §The manifest’s exit rule at all. Every version is assembled from one worktree, so two hops would otherwise carry byte-identical payloads and no path would ever leave a kit’s shipped set. The arm therefore deletes that path from the extracted package’s own payload/ before the hop runs. Mutating an extracted package rather than adding a flag to the publishing path leaves the publisher no way to ship a payload with a hole in it. The subject is chosen against a criterion: a starter-kit payload file init records in files that no init step and neither generated projection reads, so dropping it exercises the roster’s exit condition and nothing else. On this hop the file must be untouched on disk and still on the roster at the hash init wrote there, since disowning it would arm the next release to write through the adopter’s edits.
It then chains a third version onto the same consumer with no fresh edit, because one hop only shows the protection starting, and the second is where it persists or inverts. The already-edited, already-reported file must still be the adopter’s and still be reported, and the manifest the first hop wrote must still carry it. This hop’s payload re-adds the relinquished path, which must meet the carried claim, be refused, be reported, and keep init’s own hash on the roster. Without the exit rule this hop overwrites it silently, so the defect is reproduced end to end. Each version is derived from the one packed before it, and the arm refuses to run unless the derivation is strictly higher, so neither hop turns into a second test of the downgrade refusal.
The first hop’s clean-worktree assertion carries a tripwire, because the assertion is evidence only over a hop that rewrote something, and a hop rewriting nothing beyond the manifest passes it for a reason unrelated to correctness. Before asserting the worktree clean, the hop asserts a non-zero count of live registry members and a placed artifact in the consumer’s own manifest, reading the registry rather than the run’s output, since the registry is what a later run and a reviewer both read. Its failure names the remedy: repair the hop, never drop the assertion. The second hop’s clean-worktree assertion carries no tripwire, and widening it is a separate judgment.
Every cross-version pack carries the artifact directory the main pack used. An artifact-free payload does not install at all, so a hop packed without one would assert a refusal where the arm needs a completion. The bytes are the ones the build arm staged, so the hops differ in version and in the relinquish, and in nothing else.
The cross-version reversal arm reverses a consumer that crossed all three of those versions; every other reversal reverses a first init, and the upgrade arm reverses nothing. It is its own scratch consumer at the lattice minimum with no adopter edit, since an edit is what tree-object equality cannot host and belongs to the protection branch below. It re-runs the upgrade arm’s two already-extracted packages with no flags, costing no pack, then drives the reversal arm’s diff, uninstall --dry-run and uninstall with the latest package’s verb, what an adopter holds after an upgrade, against a roster three versions old. The tree object must come back to the one the consumer had before its first init.
Its reach is narrower than a recorded hash moving, a limit of the harness rather than the assertion. Every version comes from one worktree, so no vendored path’s content differs between hops, and a hash changing under a path that stayed is not exercised. The shape changes crossed are path additions and removals. Only this arm exercises two things. A tree whose payload lost a path on one hop and regained it on the next must still be wholly removable. The roster must cover an upgrade hop’s write set: a rewritten manifest, regenerated projections, whatever a future release adds. The arm proves its own premise before it reverses, in the tripwire idiom above: the relinquish must still be in effect, the manifest must record the third version, and the relinquished path must still be on the roster. Each failure names the re-scope, never a licence to drop the assertion.
The newer-verb reversal arm reverses an install with a later release’s verb and no upgrade between, the case the one-line install meets, since it fetches its own release whatever the manifest records (§uninstall). A fresh consumer with no adopter edit takes the lattice minimum at the first version, and its manifest is asserted to record that version. The upgrade arm’s first extracted package then runs uninstall, and its payload lacks the relinquished path, so only the roster can name that file. The tree object must come back to the one before init, with the worktree clean and no manifest left. It runs uninstall alone, since a later release’s diff and update --dry-run over an older install answer an upgrade question, which is the upgrade arm’s.
The hooked move arm commits an install’s own moves through the hooks it vendors; every arm above installs, re-runs and upgrades with no hook installed. A fresh consumer takes the lattice minimum, runs --install-hooks through the binary the seam names, and asserts an executable pre-commit hook. It re-runs init at the maximum profile, and the move must land as one commit with the worktree clean, the new registry carrying check-gate-tamper, so the move meets the gate that judges a gate file co-staged with the manifest.
It then upgrades from a copy of the upgrade arm’s first package whose payload gate library carries one appended comment line, since an unmutated hop from one worktree stages no gate file and never opens the gate’s isolation assertion. The copy leaves the package other arms read untouched. The hop must commit, move the manifest’s version and leave the worktree clean, and its commit must carry both the gate library and the manifest, or the hop never staged the shape under test. A control closes the arm: a gate-library edit co-staged with a product file must be refused, with pre-commit: check-gate-tamper failed among the hook’s lines, so the two commits are green on the manifest’s standing rather than on a disarmed gate.
The seam arm covers the two surfaces init rewrites on every run, a templates/*-config.sh destination and gate-sdk’s msg-patterns.list, a file class the upgrade arm’s ordinary claim-and-copy subject does not reach. It re-runs at the same version with no flags, since the class needs no upgrade and no --force, and an arm that only ran across versions would attribute it to a path it does not live on. It is its own scratch consumer, because an adopter edit inside the per-profile loop would break that loop’s file-by-file agreement, at the maximum profile, the only profile whose kit set the payload fixes, so both surfaces are present by construction. After the adopter edits and commits both, the re-run must leave both byte-identical, name both as changed, and still record init’s hash for each rather than the adopter’s. It reuses the installed package, costing no second pack.
The protection branch chains onto that consumer rather than onto the reversal arm, since an adopter edit is what breaks tree-object equality, and the seam arm’s two edited, committed vendored files reach uninstall’s keep branch. After its own assertions it appends a byte to the installed gate binary at the path the seam names and commits it, so the one mismatched row that is never the adopter’s rides the same run. diff must then exit 1 and name both files. uninstall must keep and report both and remove every other recorded file, the tampered binary included and never reported as kept. It must also delete an update-notice cache the branch plants in the consumer’s git directory beforehand, since a residual manifest carries no version for it to answer. It must leave a checkwright.lock carrying schema and a files map of exactly those two paths at init’s hashes, not the adopter’s. That is the upgrade arm’s apart-naming: an entry dropped reads as never-installed, one at the adopter’s hash as unchanged, and either lets the next init write through them.
That residual object is asserted for shape, not only field presence, because an accessor cannot check §The manifest’s single-writer contract: a missing key and a present-but-null key both read back as the empty string. The assertions run on the manifest as written. It must carry no artifact key, so the omitted flag left the key absent rather than null. Re-rendering the object through the crate’s JSON writer, which sorts every nesting level, must reproduce the stored file line for line through the line reader, so the writer sorted every level rather than the top one. Line-wise after the one declared strip keeps every axis the assertion is about: key order at every level, indentation, line count, and any trailing byte that is not exactly one carriage return. Both run here because this is the one call site emitting the no-identity, no-artifact shape; the per-profile loop’s fresh install exercises the other writer field by field.
The narrowing arm is the only arm that moves a consumer down the lattice: it installs the maximum profile and re-runs init at the minimum. Every other re-run holds the profile fixed, so none reaches the state where files outlives kits, the ordinary consequence of the carry-forward rule keeping every once-vendored path on the roster while the recorded kit set shrinks. For every seam path the narrowed manifest records, it asserts that the path still resolves to the consumer’s own file, and that a kits-stripped copy of that manifest, the residual shape held in memory rather than written into the consumer, resolves it the same way. doctor must then name the consumer’s own gates.list as the registry it inspected. It asserts its own premise: at least one seam path checked, and at least one of those genuinely shadowed by a vendored fixture tree, or the resolver has nothing to be ambiguous about.
Its honest limit. Only the recorded paths are asserted on, and the config seam is written on the artifact placement path alone, so on a payload carrying no prebuilt binary the arm would resolve the registry and not the seam. It runs against the main payload, which carries one, so both are checked, and it still refuses to demand the seam, which would fail it on the payload rather than on the resolver. The residual shape keeps it biting either way: with no kits key, a tail match returns a vendored fixture for gates.list too.
The selection arm runs one consumer at the lattice minimum (§Selecting kits and gates), naming no gate or kit literally: it reads each name off the package, from the payload’s kit roots, the minimum’s installed registry and each gate’s # install: line. init passes --with-kit naming a payload kit outside the minimum’s set, --without-gate naming the first member of the minimum’s registry, and --with-gate naming an on-surface member of the added kit. The arm asserts one commit, kits holding both sets, the registry lacking the dropped member and carrying the added one, the manifest’s selection equal to what was passed, and the battery’s resolved kit roots equal to kits. It does not assert the battery green, since an on-surface member may red on a tree lacking its surface. A bare re-run must leave the tree object unchanged. A re-run with --no-selection must restore the minimum’s registry and kits, record no selection, and leave the added kit’s directory on disk.
The arm then re-applies the selection and commits a comment appended to gates.list, so the registry is kept. A --no-selection re-run must print the added member’s retire: line with its on-surface disposition and leave the kept file registering it. A re-run with the selection again must print no retire: line for it. Then --without-kit naming a minimum kit, an unknown kit and an unresolvable --with-gate must each exit 2 and write nothing.
Every arm above rides §The gate binary’s placement outcome, because the payload they install carries this run’s artifact. The binary is written, an artifact key reaches the manifest, nothing is omitted, and each profile’s battery dispatches its .gate members through the placed path. That outcome is the --install place-artifact op’s, so the same arms assert that op. An artifact-less payload is told apart from a covered one before any verb runs, by the refusal leg above.
The binary is built rather than fabricated, since a stand-in with a matching digest would drive the same code while covering nothing of what most likely breaks: the real build’s digest agreeing with what init verifies before writing. The smoke asserts:
- the target
initselected, against the triple the bootstrap’s detector gives, and that triple’s architecture against the running binary’s baked build triple,CHECKWRIGHT_TARGET, the one derivation independent of the detector, present on every host; - the recorded digest, against the one the
buildarm staged; - the binary executable at the path the config seam names;
- both that path and the seam on the manifest roster.
The artifact arm covers the outcomes a single install cannot show. It takes its own extraction of that same tarball and mutates the copy, rather than packing a second time:
- a host off the roster refuses, naming the platform and offering no adopter action;
- a tampered artifact refuses with the consumer’s tree object unchanged and no manifest written;
- a declared target whose artifact is gone refuses as a publisher defect the adopter can act on.
All three are bootstrap outcomes, decided before the invoke, so the placement step behind it does not move them. The arm asserts each refusal’s own message and remedy, then asserts that the two refusals an adopter meets differ. The exit status does not tell them apart, and an adopter must be given two answers: one they can do nothing about, and one they fix by re-downloading. That comparison keeps §The gate binary’s four outcomes from collapsing into each other.
Three fallback cases follow wherever the hand-off carries a foreign-architecture preferred artifact, as every CI Linux leg’s does. That artifact verifies and cannot start here, so it stands in for a preferred artifact that does not run. Each payload is placed from producer uploads with their sidecars:
- falls back: the foreign artifact as the host’s preferred one, beside the host’s fallback upload.
initcompletes and records the fallback triple. - held preferred: the fallback alone rostered.
initcompletes on it. - no fallback: the foreign artifact alone rostered.
initmeets the does-not-run refusal, asserted distinct from the other two refusals.
A host with no such hand-off, every local run included, skips the three and says so, as does a host the foreign artifact starts on (the premise proof under the host table).
The arm also drives step 4’s shasum fallback, which no runner reaches on its own, since every CI image ships a sha256sum. It masks sha256sum by absence with the bash-less arm’s farm and proves the mask both ways: sha256sum resolves to nothing, and the farm’s shasum -a 256 -c accepts the payload’s sidecar. That control is a check, never a computation, so the smoke adds no second producer of the digest (gate-sdk/SPEC.md §check-gate-substrate-parity). Under that PATH an init through the bootstrap installs, and the tampered artifact is refused by the digest comparison with nothing written, so the branch is shown both to verify and to compare. A host with no shasum says so and skips, and so does native Windows, whose bootstrap has no such branch.
The smoke steers its own roster. A single staged host binary satisfies --artifacts only while the roster declares this host alone, and pack rightly refuses a roster target no leg built, since a payload missing a declared target is broken rather than narrower. The smoke therefore narrows its roster through GATE_SDK_NATIVE_TARGETS_FILE (gate-sdk/SPEC.md §Layout and configuration). A build for a target this host cannot run is refused, on the ground native/targets.list gives: it would publish an artifact no run has executed. A host’s native build is not that, since the smoke executes it.
That guard is unconditional where its stated ground is not. It refuses a roster wider than this host before reaching the artifact hand-off branch. A run pointed at a complete producer hand-off, which could satisfy --artifacts for every declared target, so refuses byte-identically to one holding nothing, naming a host-build remedy that does not describe the hand-off path. It blocks nothing, since every caller either steers at its own host or lets the smoke self-steer.
The smoke derives a one-line roster from this host’s triple and points that knob at it unless the caller has already set it, so:
- a caller that steers explicitly keeps its own roster, and the override branch stays a live path rather than a fixture-only one;
- a local
bash gate-sdk/bin/run-gates.sh --installer-smokesurvives a multi-line shipped roster instead of blocking on it; - the unsteered Linux leg carries no assertion that the shipped roster is packable; the
native-artifactsproducer, building every declared platform on every run, asserts that directly.
The narrowing removes one reader’s subject, and that reader gets a planted witness. Pack’s refusal of a declared target no leg built is the one verdict here that reds on finding a target instead of on finding none. A roster narrowed to the host makes it unreachable on every ordinary path, while every other affected reader is monotone under the narrowing. The pack arm therefore plants that case, packing against a roster carrying a second target the artifact directory has nothing for, and asserts both the refusal and its cause. Its log line is pack: a declared target with no artifact directory refused, not packed narrower. Read it as distinct from the artifact arm’s declared target with no artifact: refused, and not with the unrostered host's answer, which is init refusing a broken payload after the pack succeeded: one shape of mistake, two verbs, two witnesses.
A platform leg’s steered roster makes the artifact branch live on that host. The leg steers GATE_SDK_NATIVE_TARGETS_FILE at a single-host roster, which the packer copies into the payload verbatim. target_of_host() maps the leg’s host to a triple, so the roster comparison matches and select_artifact takes its artifact-present branch on a platform native/targets.list does not declare. Code below that branch is therefore live on every platform leg, and a site costed dormant because the shipped roster carries no such line is a live site.
It reads three environment values undeclared, since no static kit owns their prefix, and declares GATE_SDK_NATIVE_BIN, GATE_SDK_NATIVE_CRATE and GATE_SDK_NATIVE_TARGETS_FILE, whose caller-set value decides whether it steers, plus GATE_SDK_KIT_DIRS and LIFECYCLE_KIT_ROSTER_BASENAME, through which the close-surface arm reads the tree under test’s kit specs. INSTALLER_SMOKE_TMP_DIR is the scratch base. The smoke writes nothing inside the worktree beyond the footprint witness’s plant, which it removes, and the artifact directory it assembles lives under that scratch. So the clean-worktree precondition keeps its meaning, which the pack step needs, refusing to stamp a commit the payload does not match.
INSTALLER_SMOKE_ARTIFACTS_DIR is the artifact hand-off, the difference between a run exercising a release-shaped artifact and one exercising a harness stand-in. Set it to a <dir>/<target>/ tree a producer already filled and the smoke installs those bytes, building and recomputing nothing: the sidecar that arrives is the one the producer emitted beside the bytes, so gate-sdk/SPEC.md §Consumer payload’s one-producer rule reaches across the hop unbroken. Its live setters are exactly the install-smoke legs in .github/workflows/gates.yml that consume the producer’s upload, each a deployed configuration rather than a test-only one; outside them it is unset. Read the setter set by that predicate and never as a roster: a platform joining or leaving the declaration moves the set.
INSTALLER_SMOKE_TARBALL_OUT is the tarball hand-out: set it to an existing directory and the smoke copies the tarball it packed there, with a digest sidecar it emits beside it, before its scratch is torn down. Its live setter is the baseline Linux leg, whose next step runs the Manual steps page’s unix-install block against those bytes (§Requirements, the install blocks), moving the tarball and its sidecar rather than building, hashing and packing a second payload. That leg downloads the producer’s upload and uploads nothing, so gate-sdk/SPEC.md §check-gate-substrate-parity’s one-producer rule lets it compute no digest of its own.
No compiler joins the preflight, since the driver builds nothing: a contributor’s host and a host handed a prebuilt artifact are asked for the same programs. On a unix host the detector is read through installer/consumer-smoke/host-target.sh, which extracts target_of_host and the three functions it rests on from installer/bin/checkwright.sh on the shape §The gate binary pins, so the smoke and the bootstrap cannot disagree about which artifact a host takes. With --fallback <triple> the helper prints the triple the bootstrap’s fallback_of_target names instead, extracted the same way, which is how the artifact arm’s fallback cases and the baseline leg’s foreign-host step name the static triple without spelling it. The helper ships in no payload, which its header states.
What it costs to run. On one Linux developer machine the whole smoke takes about 67 seconds, with no build of the crate. That covers four profiles × install + battery + value arm, the artifact-less refusal leg, five packs and two refused ones, and an npm install. It fits one ordinary foreground invocation, and the clean worktree it holds is held for minutes rather than a working session. Re-measure rather than trust the figure if a decision turns on it.
Why the payload has no gate of its own. A sibling check asserting that the packed payload matches the repository’s kit roots would have to run npm pack at commit time, since the payload exists only at pack time. That puts a network-capable toolchain in the pre-commit path, breaking the hermeticity every other gate keeps. The property is covered here instead. The pack step derives the payload from the kit-root set the battery itself resolves, and the smoke runs doctor from the payload for every profile, so a payload missing the kit doctor sources its toolchain roster from fails the smallest profile. The honest residue is that no assertion compares the two sets element by element: a kit dropped from the payload that nothing in the smoke’s path reads would not be caught here.
Vendoring without the installer
init does this on an adopter’s behalf, and the manual path stays supported. It is the audit story: what lands in your tree, and why nothing the installer does has to be taken on trust.
Each kit is a self-contained top-level directory. To adopt one, copy it into your repo root and wire it in:
-
Copy the kit directory (for example
gate-sdk/) into your repository.A kit directory copied out of this repository also carries its
SPEC.mdandsmoke/, which the installer’s payload withholds: the same kits with more of the engineering record, never fewer gates. Copy the repository’s rootLICENSEinto the kit directory too, where the installer’s payload places it; the source tree keeps one copy at its root. - Register the gates it ships in your
gates.list, where the kit ships gates. - Point the kit at your layout through its external configuration — consumers never edit vendored kit files, so configuration always lives outside them.
- Opt each clone into the pre-commit hook by running the gate binary — the path
GATE_SDK_NATIVE_BINnames, which is whereinitplaces it — with--install-hooks, for example./scripts/checkwright-gates --install-hooksfrom the repository root. The same spelling runs from a POSIX shell and from PowerShell, with the.exesuffix on native Windows.
Where a kit ships adoptable skills, take each as a binding shim by default — a one-line directive that references the vendored template, so a re-vendor reaches it and its gates hold the shim thin. Copying the template and filling its slots is the sanctioned fork: kept for legitimate structural divergence, but you then own its prose and an upgrade won’t reach it. The shipping kit’s SPEC owns the shim↔template contract — lifecycle-kit/SPEC.md §templates/stages/ states the stage-skill modes.
Start with gate-sdk — the other kits register into its runner — then add kits in the order the kit map (README.md) lists them.
What a gate discloses
Worth knowing before you adopt, because it qualifies the vendored-committed claim docs/install.md opens with. A gate whose implementation is a compiled binary ships four things, withholds one, and publishes one. It ships its declaration, which carries a one-line statement of the invariant the gate holds and is printed when the gate blocks you. It ships its # spec: pointer. It ships its good/+bad/ fixture pair. It ships the binary itself, verified against a published digest before anything is written to your tree. What it does not ship is the implementation source. What it publishes rather than ships is the specification section the pointer names: it is read on the project site, and the commit your checkwright.lock records resolves the exact text your tree was vendored from. gate-sdk/SPEC.md §Consumer payload is where that is ruled and bounded.
The reason is narrow. A coding agent told to make your battery green will, if it can, read the gate blocking it and edit its way around the rule instead of fixing what the rule caught. Withholding the implementation raises the cost of analysing a gate relative to the cost of running it. It does not make the rule a secret. A binary can be taken apart. The fixture pair shows you what passes and what fails. The specification section states the invariant on purpose. What you keep is the ability to run the gate and to hold it to its own fixtures, which is what the install page means by verifying rather than trusting.
Reviewing the pre-commit hook
Step 4 of §Vendoring without the installer — and init’s own follow-up block — places the gate binary in a clone’s git directory under each hook’s name, which is worth deciding with the files open.
What the hook is. The digest-verified gate binary under the hook’s name, placed in the clone’s git directory. Started under that name it only starts the binary GATE_SDK_NATIVE_BIN names on its --git-hook arm and returns its status (gate-sdk/SPEC.md §git-hook), so what a commit runs is the installed binary and the tracked declarations it reads. The arm reads the per-gate # graph: manifests at commit time and runs the triggered subset of your registered battery; the gates outside that subset run only in the full battery.
A second hook, commit-msg, is placed beside it and runs the gates registered at that tier, printing nothing while none is. Everything this section says of the pre-commit hook holds of the commit-msg one.
What installing it changes in your clone. --install-hooks changes no tracked file. It has four effects, and an audit of what it touches wants all four:
- Two files are written to
gate-hooksunder the clone’s git directory, each a hard link to the gate binary, or a copy where the link cannot be made. core.hooksPathis repointed at that directory. That config write is what makes the hooks fire at commit time.blame.ignoreRevsFileis set to.git-blame-ignore-revs, but only when the repo carries that file; a repo without one is left untouched.check-identityruns once at opt-in, so a wrong-identity or wrong-remote mapping surfaces before your first commit. The gate’s exit status becomes the installer’s, so a failing identity check is visible to whatever ran it.
How to review before running. The hook only starts the installed binary, which doctor verifies in place against its recorded digest, and every gate that binary runs is either itself or tracked shell under the vendored kit directories and your own gates directory. Nothing is fetched at install time or at run time, so a vendoring or upgrade diff shows the whole of what you are agreeing to execute.
How to disable it. Per commit, git commit --no-verify skips the hook. Per clone, git config --unset core.hooksPath removes it. Both are supported positions and neither is an escape. gate-sdk/SPEC.md §Enforcement tiers rules the local hook a latency optimization whose guarantee lives in the CI tier, so skipping it costs only an earlier signal, provided the outer tier exists. That is why the install page asks for the battery as a required status check.
Versioning
The repository carries one semver line, applied as git tags, with the kits moving in lockstep: a kit earns its own version only if it is ever split out for independent adoption.
The release channel
The declaration is one line, Release channel: **<value>**, on docs/maintenance.md §Upgrading, where an adopter reads it. This section owns its rule and names no value, so the page is the channel’s one statement.
The two admissible values are preview and stable. The line is prose a reader sees and a token a gate reads, on a line of its own rather than in a bracketed tag, the one-line-declaration shape the close-surface: lines take (lifecycle-kit/SPEC.md §The close-surface roster).
The channel states audience and support expectations, not a second artifact stream. While it reads preview the version line is 0.x, and breaking changes ride minors under the pre-1.0 qualifier below. The tag rhythm under preview is an artifact of internal iteration, not a stability signal, and a launch announcement is a separate, later event. The channel flips to stable at v1.0.0, the first stability promise.
Mechanized on two surfaces, on two tiers. .github/workflows/publish.yml’s release job creates the GitHub Release, and while the channel is preview that creation carries --prerelease, so every Release page states the posture with no one remembering to. That creating posture is held by a gate. The accumulated posture, the flag on every Release already published, is held by a monitor, because host state is out of a precommit gate’s reach. Neither subsumes the other: the creating step can stay correct while an older Release contradicts it.
The publish path carries the channel nowhere else, and the npm publish’s missing --tag is load-bearing. A non-default dist-tag would make docs/install.md §With Node’s one-command install resolve to nothing until a reader appended the channel. That trades an honest signal for a time-to-first-value regression on the front door. It becomes the right call once docs/install.md §With Node stops promising a bare one-command install, or once a stable line exists to hold latest while preview moves off it. The pack arm’s version regex already admits a prerelease suffix, so nothing forecloses it.
The same preference decides invariant C the other way. Both trades weigh an honest channel signal against a reader who follows a default pointer. The dist-tag lost because npm install’s default tag is the documented front door (docs/install.md §With Node). No documented install path resolves the GitHub Latest pointer: that section runs the installer, and every explicit download URL it prints names its version. So the cost that kept the dist-tag is absent here, and the dist-tag’s re-entry condition above stands.
Three invariants hold the declaration, so it cannot become a comment. A and B are gate-held and read files off disk. C is monitor-held and reads the host, so it is stated after the gate’s contract. check-release-channel-parity (this repo’s scripts/) owns A and B:
- Invariant A — channel ⟷ publish posture. The channel and the prerelease posture of the Release-creating step in
.github/workflows/publish.ymlagree:previewdemands--prereleaseon that invocation,stabledemands its absence. - Invariant B — channel ⟷ version line. The channel agrees with the version line, read from the newest tag by creator date: while that version is
0.xthe channel must bepreview; fromv1.0.0onward it must bestable. The tag parses as a semver triple, with an optional leadingvand an optional prerelease or build suffix, and only its major is read.
The channel is derived from the version line, so without B a v1.0.0 could ship with preview declared and --prerelease still set: A passes because its two surfaces agree, and both are wrong. B also protects lifecycle-kit’s knob-rename compat precedent, which reads this declaration as its general-availability threshold, so a stale preview would hold that compat window open past GA.
The gate fails closed rather than passing whenever it cannot find a surface it needs. Exit 2, never a clean line, on each of:
- a declaration line that is missing or duplicated;
- a channel value it does not recognize;
- a
publish.ymlwith no recognizable Release-creating step; - a newest tag that does not parse as semver.
A repository with no tags at all has no version line, so B is dormant there while A still asserts. The gate reports that dormancy in its clean output, so a reader can tell “checked and agreeing” from “nothing to check”.
- Invariant C — channel ⟷ published Release history. Every published Release’s prerelease flag agrees with the channel its own version line implies: a
0.xtag carries the flag, a1.x-or-later tag does not.
C is A and B composed, not a new policy: B rules that a version line implies a channel, and A that preview implies the flag. So the flag on any Release follows from that Release’s own tag, and C evaluates over the whole published history what B evaluates on the newest tag. The desired host state is derived rather than stored: no tag-to-flag roster exists, and the rule holds across the v1.0.0 flip with no edit.
C is held by a monitor, not by the battery: the release-channel arm of .github/workflows/site-health.yml reads the Release list from the API on its schedule and files a site-health issue naming every Release whose flag disagrees with its own version line. A precommit gate cannot reach host state, and the tier is not weakened to reach it, the tier ruling RELEASING.md §The procedure, Verify the GitHub Release body, makes for the Release body over the same objects.
What a reader sees. Every release this project has published is 0.x, so under C no Release is Latest: the repo front page shows no green Latest badge, and the releases list labels every entry Pre-release. The API’s releases/latest endpoint answers 404, and the browser URL redirects to the releases list. docs/maintenance.md §Upgrading tells an adopter to take a release from the releases list or name an explicit version, never the Latest pointer. Nothing documented depends on it: docs/install.md §Install runs the installer, and every download URL it prints carries its own version.
The bump derives from the release note. Its declaration-bearing sections (§The upgrade contract below) declare everything phase B must reconcile, so the floor is read off the note rather than maintained beside it:
- Patch — every declaration-bearing section is “None”: a phase-A-only sync, fixes and docs that tighten nothing a consumer must reconcile.
- Minor — any section is non-empty: the release carries phase-B work, a new or stricter gate, a knob rename riding its deprecation path, or a behavior change a consumer’s tree may depend on. A blind upgrade is exactly what such a change breaks, the reasoning that floors the other two as well.
- Major — a decommission: a release that removes a deprecated surface (a release-sweep disposition executed as decommission), or any change the two-phase upgrade contract cannot reconcile from the note alone. A decommission earns a major even while the line is 0.x, outranking the pre-1.0 qualifier below. Majors are where the deprecation promises come due: the release-sweep constraint that no marker rides into the next major undispositioned binds here. A kit
bin/tool is not a deprecation-marked surface, so deleting one is no decommission. It rides a minor and is declared by path under Behavior changes, where a script of yours that called it finds its worklist item. In this repositorycheck-release-change-declaredholds that declaration. - Pre-1.0 qualifier — while the line is 0.x, breaking changes other than decommissions may ride minors (the semver 0.x convention), each still declared in the note. A decommission still earns a major, which keeps release-sweep’s no-marker-rides-past-the-major constraint anchored while 0.x.
v1.0.0is the first stability promise and is cut deliberately, never earned mechanically.
The floor has a second input: a note also inherits the floor of any outstanding deferred release. When an iteration’s criteria were met but the release was held back, its disposition line records the earned version as deferred:vX.Y.Z (lifecycle-kit/SPEC.md §templates/stages/), and those criteria stay unconsumed until a release at or above that version ships. The next qualifying note carries them in its declaration-bearing sections, composed from the release declaration surface that carried them across every deferral. So a note’s floor is the higher of what its own sections derive and what an outstanding deferral carries. check-release-bump reads both.
check-release-bump (this repo’s scripts/, a compiled subcommand of gate-sdk/SPEC.md §The declaration cohort) gates the derivable half. It orders the release notes by version and reds a patch-only bump whose note carries a bullet in any declaration-bearing section of the roster GATE_SDK_RELEASE_SECTIONS names (§The upgrade contract), and fails closed if a roster section is absent. That presence assertion binds the newest note only, so adding a fixed section to the roster above costs no historical backfill. The major criteria stay judgment, a decommission being a semantic fact no section grammar carries, so the gate holds only the floor.
The gate orders <major>.<minor>.<patch> and refuses anything else, naming the offending token, where it came from, and the grammar it failed. That is a guard rather than a closure. A prerelease or build-metadata suffix stays as admissible as the pack arm leaves it, but it has no ordering, because the candidate rules disagree: sort -V puts 1.0.0 before 1.0.0-rc1, where semver puts it after. A gate whose subject is this one semver line must not pick between them by accident. That ruling is owed by the session that first cuts such a release, and until then the first note using one reds loudly here instead of mis-ordering silently.
The hosted install pin
The one-line install’s two scripts and the install pages’ step-by-step recipes fetch one pinned release (§The dependency boundary, The one-line install). Each script names that release’s version on one pin line: pin='X.Y.Z' in docs/install.sh and $pin = 'X.Y.Z' in docs/install.ps1. All three fetch surfaces spell the names of the assets they download, the install pages reading as one surface (The install pages, below). check-install-pin (this repo’s scripts/) holds four invariants:
- Invariant A — the twins agree. Each script carries exactly one pin line and one floor line of its grammar, and the two pins are equal, as are the two floors. The floor line is
attest_from='X.Y.Z'or$attestFrom = 'X.Y.Z'(§The dependency boundary, The attestation check). It is compared with neither the pin nor the tags: it is fixed at landing, and at landing it sits above the pin. - Invariant B — the pin is the newest release. The pin equals the newest tag by creator date, read by the same reader
check-release-channel-parityuses, so the one question has one answer. - Invariant C — the fetched names are the pinned release’s. An asset token is a maximal run of
[A-Za-z0-9._{}$-]containing.tgzor.tar.gz, with each variable reference in it ($name,${name}) read as{version}. Each fetch surface carries at least one asset token. Each token equals a template of the release-assets declaration (gate-sdk/SPEC.md §Consumer payload) as the tagv<pin>carries it,<pin>beingdocs/install.sh’s. For each matched template not itself ending.sha256, that declaration also carries the template with.sha256appended, since every surface names the digest by that suffix. - Invariant D — the commit-cost figure is the pinned release’s. The install pages carry exactly one
<!-- commit-cost:begin -->…<!-- commit-cost:end -->block across their members, holding exactly onemeasured at v<X.Y.Z>token, whose version equals the pin. So the commit that moves the pin carries a new measurement (RELEASING.md §The procedure, Tag the stamp commit, then drain the release declaration surface). Honest limit: D reads the version and never the host or the figure, so a figure copied forward under a new version passes.
The install pages. GATE_LOCAL_INSTALL_DOCS, an array declared on scripts/check-install-pin.gate, names the pages the install is written across. Its readers are check-install-pin, check-install-platforms, check-install-toolchain, check-release-channel-parity and check-release-declaration-parity.
- A tree read takes every member’s text. A block, or the
Release channel:line, is read from the one member carrying it. A block no member carries is the exit 2 its absence from a single page is, and a block two members carry is exit 2 as well. - A read at a release tag takes the members that tag carries.
check-install-platforms’ arm G refuses a tag carrying none, and arm P reads it as the empty table. A tag carrying some and noplatformsblock leaves arm G’s Minimum half dormant and arm P’s base empty. So a release whose tree carries any one of the pages serves both arms from it. - Invariant C reads the members as one fetch surface, and its finding names the member a token stands on.
check-install-toolchain’s placement rule takes the member carrying thetoolchainblock as the home of every non-contributor row.- A finding names the member its block or line was read from, never the set.
- A positional page is one file and wins over the knob.
C reads the declaration at the pinned tag, not in the tree. The pin trails the tag, so a rename that has landed but not shipped leaves the surfaces correctly fetching the old names. C fires in the commit that moves the pin, which must carry the surfaces’ new names with it.
The gate fails closed, exit 2, on a missing or duplicated pin line or floor line, on a pin or floor that is not <major>.<minor>.<patch>, and on a newest tag that does not parse as that triple. It also exits 2 on an unreadable fetch surface, on a pinned tag that exists and carries no declaring doc, and on a declaring doc carrying no release-assets line or more than one. It exits 2 as well on a missing or duplicated commit-cost block, and on a block carrying no measured at token, more than one, or one naming no v<major>.<minor>.<patch>. A repository with no tags has no version line, so B is dormant there, and the clean line says so. Where v<pin> does not resolve, C is dormant too. D reads only the tree, so it has no dormancy. A shallow CI checkout carries no tags and meets both dormancies. Its positional form, check-install-pin [install-sh install-ps1 install-md pinned-doc [version]], points it at a fixture pair: the pinned declaration is read from the file pinned-doc and the version is passed explicitly, so the fixture holds still as the tags move.
Why the pin trails the tag rather than leading it. Pages deploys on every master push, and a release’s assets exist only after its tag’s publish run. A pin moved before the tag would, for that window, name a release the one-liner cannot download. Moved after the tag, it is at worst one release stale, and every release it names exists. The plugin marketplace’s pin moves with it, held by check-plugin-parity (plugin/SPEC.md §The marketplace pin).
Honest limits. A name assembled from parts, such as "checkwright-" + $v + ".tgz", is no token, so C cannot see it. The install-smoke legs spell the tarball as well and are left out, because a leg that places a name the page’s install block does not read reds that leg at push.
The front door’s verbs
Every route on the front door resolves to the newest release, so a verb the front door advertises, each flag after it, the value a --profile or --recipe names, and a gate-binary arm a page names must be ones that release carries. check-front-door-verbs (this repo’s scripts/) holds two invariants:
- Invariant A — the tables are the binary’s rosters. The first-column code spans of
installer/README.md’s verb table (the table whose header row’s first cell isverb) equal, as a set, the verbs the binary’s ownVERBScarries, read in-process. The flag table, the table whose header row’s first cell isflag, yields one pair per verb its second column lists beside the first column’s flag, and those pairs equalFLAGS’s as a set. The tables are the npm package’s README, so a release carries its own verb and flag sets in a file every tag holds. - Invariant B — an advertised verb and its flags are released. Each advertised verb must be in the pinned set: invariant A’s verb table read at the tag
v<pin>, where<pin>is §The hosted install pin’s value. Each advertised flag must be in the pinned flag set: invariant A’s flag table read at that tag, as pairs with its verb. A tag whoseinstaller/README.mdcarries no flag table has the empty pinned flag set. Each advertised operand must be in the pinned profile or recipe set, and each advertised arm in the pinned arm set (below).
Routes. A route is sh -s --, install.ps1))), npx checkwright, checkwright opening a code span, or a fenced code line whose text opens with checkwright. That last is the toolkit pages’ form, where checkwright stands for the reader’s install line (companion/SPEC.md §Applying a recipe). The token after a route, inside a code span or a fenced code line on a front-door page, is an advertised verb when it matches [a-z][a-z0-9-]*. A placeholder such as <verb> advertises none, and neither does --help or -h, the adopter’s help arm. Any other leading-dash token is a finding in its own right, since the one-line install runs init only on an empty argument list. Step 5 of §The install boundary forwards a leading flag unchanged, so sh -s -- --profile prose reaches the binary as a battery argument rather than as init’s. The front door names the verb before its flags.
Flags. After an advertised verb, each following token of the same code text is read until a stop token, one that is |, ;, &&, || or ), or that opens with # or >. A read token opening with -, other than -h or --help, is an advertised flag of that verb, cut at its first =. A placeholder verb advertises no flag.
Operands. After an advertised --profile or --recipe, the value after its =, or else the next token unless the code text ends, it is a stop token or it opens with -, is an advertised operand when it matches [a-z0-9][a-z0-9-]*, so a placeholder advertises none. A --profile operand is held to the profile set, the first column of installer/profiles.list’s rows plus the derived full (§Profiles). A --recipe operand is held to the recipe set, the keys of GATE_SDK_PAYLOAD_RECIPES in the gate-sdk knob seam file in the directory GATE_SDK_GATES_DIR names (§The packer). Each set is read at HEAD and at the tag by one reader, and a tree lacking the file has the empty set. The kit and gate names after --with-kit, --without-kit, --with-gate and --without-gate are unread.
Arms. An inline code span whose first token opens with -- is an advertised gate-binary arm, unless that token, cut at its first =, is a flag of FLAGS or of the pinned flag table. --emit <name> reads as --emit-<name>, the normalization the binary applies, and --emit before a placeholder advertises none. Fenced code lines are not read for arms. README.md’s ## This repo, governed section is not read for arms either: it describes a checkout, whose binary is built from the tree. The pinned arm set is the tag’s ARMS table in native/src/emit/mod.rs, the first string literal of each top-level tuple, with every literal of TOP_LEVEL_FLAGS in native/src/main.rs. The binary’s arm set is the same two arrays read in process. No file a release carries holds the arms as a table, so the source arrays are the record. A unit test holds the extractor equal to the binary’s own set over the crate’s source, so a reshaped array reds in the commit that reshapes it. A tag carrying neither source file predates the gate binary and has the empty arm set.
Pages. The front-door pages are README.md, docs/index.md, docs/install.md, docs/manual-install.md, docs/maintenance.md, installer/README.md, the plugin’s install skill plugin/skills/install/SKILL.md, and the toolkit pages docs/speckit.md and docs/openspec.md. The Spec Kit extension’s install command, companion/speckit/commands/install.md, is not one. It runs the release the extension’s stamped version names, and the pack step stamps the tag the extension is packed from (companion/SPEC.md §Packing the extension), so its line always names a release carrying its own flags.
The pinned sets are read from the tables rather than from VERBS and FLAGS at the tag because a tag predating either carries none, and a dormancy keyed to that absence would green the very defect the gate exists for. The same ground makes a tag without a flag table the empty set rather than a dormancy.
The pending admission. A verb, flag, operand or arm B would red is admitted while HEAD carries it and .workflow/release-disposition.txt carries no line for the iteration the queue header names. HEAD carries a verb in VERBS, a pair in FLAGS, an operand in its profile or recipe set, and an arm in the binary’s arm table. It is also admitted when that line’s field is a release, vX.Y.Z, because that release is cut from this tree. A none or deferred: field reds it. The admission makes B a release trigger held at the close’s disposition commit: an advertisement the pinned release lacks leaves the close two remedies, releasing or withdrawing the advertisement. An advertisement HEAD does not carry and the pinned release lacks is never admitted, since no release will carry it. Holding B at every commit instead would red the tree from the build that lands an advertisement until the close’s drain moves the pin, so no build could commit through it. check-install-platforms arm G admits a joined platform row the pinned release does not serve on the same terms, through the same reader (docs/install-parity-contracts.md §The install-platforms parity contract).
The gate fails closed, exit 2, on an unreadable page and on a verb table that is absent or empty at HEAD or at the tag. A flag table absent or empty at HEAD fails it closed too, while a tag’s absent flag table is the empty set, not a refusal. It also fails closed on a tag that exists and carries no installer/README.md, and on this iteration’s disposition line when its field parses as none of the three forms. It fails closed too on a tag whose native/src/emit/mod.rs or native/src/main.rs carries no ARMS or TOP_LEVEL_FLAGS array, or yields no member from one. A tag carrying one of the two files and not the other is such a tag. A seam file, at HEAD or at the tag, that the knob-file grammar refuses fails it closed as well (gate-sdk/SPEC.md §The knob file). The pin’s own refusals are §The hosted install pin’s.
Where the tag v<pin> does not resolve, as in a shallow checkout, B is dormant and the clean line says so. A still runs. Its positional form, check-front-door-verbs [head pinned disposition queue page...], points it at a fixture tree: head and pinned are directories read at the paths the bare form reads at HEAD and at the tag, so the fixture holds still as the tags move. The clean line counts the sites, flags, operands and arms the read reached, and names each advertisement it admitted as pending.
Honest limits. A verb named in a code span apart from its route, as in “or demo”, is out of reach, and so is a flag named apart from its route, as in “pass --recipe”. A kit or gate name after a selection flag is unread. An arm in a fenced command line, or reached through a named front-end such as run-gates.sh, is out of reach, and a renamed checkout heading in README.md widens the arm read to that section’s arms rather than narrowing it. The gate holds the release decision, not the live site: between a push carrying a new advertisement and the close’s tag, the site advertises something the one-liner cannot run. The pin moves in the commit after the tag (RELEASING.md §The procedure, Tag the stamp commit, then drain the release declaration surface), so the live one-liner reaches the new release on the push carrying that commit.
The upgrade contract
An upgrade runs in two phases.
Phase A — deterministic. Replace the vendored kit directories wholesale at the target tag. Because consumers never edit kit files, this sync loses nothing. Next, retire each shell config the target’s kits replaced with a knob file. A kit that now ships templates/<stem>-config.knobs in place of <stem>-config.sh refuses the old <stem>-config.sh or <stem>-config.local.sh in your gates directory. The regeneration reads those knobs, so it refuses too. Rewrite what you set in each file into its .knobs file, then delete it. A copy init seeded that you never edited is simply deleted. Then regenerate the generated artifacts. Which artifacts those are is a property of the kits you vendored rather than a list to carry here: each one’s freshness gate names its own regen command when it reds, so phase B is what tells you about any you skipped. checkwright update automates this phase (§update).
Phase B — gate-driven. Run the full battery. The set of gates that go red is your migration worklist: each red gate names the surface that moved, and the release note supplies the intent behind the move. Reconcile the red set and you are current.
Two shipped tools carry this contract. The upgrade smoke (gate-sdk/SPEC.md §upgrade-smoke) is its executable proof: it drives both phases against a scratch consumer and asserts the phase-A sync is deterministic. It also asserts the red set stays within TO’s gates-section declaration, the target note’s when TO is a tag and the release declaration surface in TO’s own tree when it is not. The upgrade skill (lifecycle-kit/SPEC.md §templates/upgrade.md) is the phase-B disposition ritual a consumer runs to register the note’s newly declared gates and disposition each red.
Release notes are dated posts under docs/posts/. Each carries a release: vX.Y.Z key in its front matter, the key that resolves a version to its note, and the sections the roster names.
The roster is the knob GATE_SDK_RELEASE_SECTIONS (gate-sdk/SPEC.md §Layout and configuration). Each element is <role>: <heading>, and element order is note order. The roles are kit constants and the headings are the consumer’s. The default holds six sections: In brief (brief), New and tightened gates (gates), Knob changes (knobs), Gate-authoring changes (authoring), Platforms (platforms) and Behavior changes (behavior). In brief is the human read, and every section after it is declaration-bearing, reconciled by a mechanical consumer. Prose elsewhere names those classes or a role rather than counting sections, so adding one does not silently falsify a sentence somewhere else. A consumer may omit a role, and every reader then treats that section as absent from the grammar.
Each role fixes its lead-token rule, whether it bears a declaration, and whether a note may state it as None:
| Role | Token rule | Declaration-bearing | None body |
|---|---|---|---|
brief |
none | no | no |
gates |
bare gate name, backticked | yes | yes |
knobs |
backticked | yes | yes |
authoring |
bolded | yes | yes |
platforms |
backticked target triple | yes | yes |
behavior |
bolded | yes | yes |
Two former headings stay readable for good. GATE_SDK_RELEASE_SECTION_ALIASES takes the same element grammar and defaults to gates: Tightened gates and knobs: Renamed knobs. A reader finds a section under its heading or any alias. The gates section is machine-read on published notes: check-tightened-gates-grammar walks every note, the upgrade smoke reads TO’s note out of TO’s archive, and check-release-bump counts the newest note’s sections. A published note cannot be migrated, and the smoke takes any historical pair, so an alias is permanent rather than a window. The smoke resolves both knobs in the invoking tree, so an archived note is read under today’s headings and aliases.
The validator refuses at exit 2 where the knob file is read; its refusals are gate-sdk/SPEC.md §Layout and configuration’s. Every reader learns a section’s names from one resolver, release_sections::roster(), and none carries a section literal.
-
In brief — placed first, immediately after the opener. It opens with the summary table, and its bullets follow it. The table’s header is
| Section | Entries | Who acts |, with one row per declaration-bearing roster role in roster order. Section is a link whose text is the section’s heading and whose fragment is that heading’s id, under the slug rulecheck-md-refsresolves anchors by. Entries is that section’s bullet count,0for aNonebody. Who acts is prose, and RELEASING.md’s skeleton gives each role its phrase. The table carries the audience keying, so the headings name what changed and the table names who acts on it.Then three to five bullets of plain language, each answering what you get or whether you must act. A bullet lead here is a plain phrase, never a gate or knob name. The declaration-bearing sections below already carry those tokens; this section exists to be readable without them. Having no
Noneform, a release with nothing worth saying to a human is a patch and says so in one bullet. It feeds no bump criterion and no allowed-red set, and nothing reads it but the human upgrader. Its position ahead of the migration detail is deliberate: the opener’s one-sentence slot is a lede, and in practice it summarizes the engineering instead of answering whether the reader must act.What holds the roster is
check-release-bump’s presence assertion, and that assertion binds a note under composition — the newest note whose declared version carries no tag yet. There every roster section is present under its own heading, an alias not standing in, since the alias reads history and a note being composed takes the new name. The summary table is present too, its rows the declaration-bearing roles in roster order, each link naming its section’s anchor and each count that section’s bullets; the audience cell is not read. A note published before a section existed is history. It is not retro-fitted with a fabricated section, and the assertion goes dormant on it, reporting which state it is in rather than going quiet; a role absent from it counts zero toward the floor. The predicate ischeck-release-declaration-parity’s, adopted so that release state is read one way across the corpus. Its residual is that sibling’s too: a note authored and drained inside a single commit is never seen under composition, so what carries the section into existence is the split choreography the release runbook prescribes, whose chrome skeleton holds its slot. -
New and tightened gates (
gates) — one bullet per gate that landed new or got stricter, the gate name the bullet’s lead token. A mechanical consumer reads these lead tokens as the release’s allowed-red set: the gates a clean upgrade may turn red, each named with the intent behind the move. The token has one canonical spelling, a backticked, unbolded bare gate name directly after the bullet marker (- `check-foo`, then the prose). Backticks because the token is a code identifier, and unbolded because bold carries no semantics and collides with the one token a machine reads.So the section resolves to an explicit empty set (a “None” body) or to a non-empty token set, and nothing else. A non-“None” section yielding no token is a defect in the release, not a declaration of nothing. The parse would compile a note naming several gates into an empty allowed-red set, passing vacuously on a green battery and failing with a false message on a red one.
check-tightened-gates-grammar(this repo’sscripts/, a compiled subcommand of gate-sdk/SPEC.md §The declaration cohort) holds that over the whole corpus with no version cutoff, becauseGATE_SDK_UPGRADE_FROMandGATE_SDK_UPGRADE_TOmake any historical pair a supported run.It also holds the release declaration surface’s gates-section bullets to the same lead-token grammar at every commit. The smoke’s untagged arm reads that surface, so a bullet it cannot read disarms the smoke long before any note is composed. An absent or still-empty section there is accumulation, never a finding. Registry membership is not asserted: a gate renamed or retired since a note shipped would make membership false about history without the record being wrong.
A sibling gate makes a separate claim, and neither is the other’s coverage. The grammar gate holds each note’s section well-formed.
check-release-declaration-parity(also this repo’sscripts/) holds a note under composition equal to the release declaration surface it was composed from, in every declaration-bearing section but Platforms, which it holds to the platforms-table diff instead (below). The equality is of lead-token sets, in both directions, because each direction costs something different.In the gates section, a name on the surface and missing from the note is a gate that tightened and shipped undeclared, licensing a red the upgrade smoke would wave through. A name in the note and missing from the surface declares a gate that never tightened, sending consumers hunting a reconcile that does not exist.
In Knob changes, Gate-authoring changes and Behavior changes, a token on the surface and missing from the note is a declared change the note dropped. A token in the note and missing from the surface is a change close reconstructed instead of the landing session declaring it: the composing session appends it to the surface in the composing commit, then transcribes.
The parity gate arms on a note whose declared version carries no tag yet and disarms once tagged, reporting its dormancy rather than letting a drained surface read as verification. It is a compiled subcommand (gate-sdk/SPEC.md §The declaration cohort) and reads the grammar through its one compiled holder (gate-sdk/SPEC.md §lib/declaration.sh). The upgrade smoke reads it through the same holder, so the two cannot read different token sets from the same bytes.
A declaration precedes its release. Because the upgrade smoke’s untagged arm reads a working tree’s release declaration surface rather than a note (gate-sdk/SPEC.md §upgrade-smoke), an allowed-red set is owed from the moment a gate is landed or tightened, not from the tag. The surface carries every section, so a behavior change or a rename is owed from the moment it lands too. Running the smoke against your checkwright clone at an untagged
TOis what reads it. - Knob changes (
knobs) — one bullet per knob renamed, removed or added. A rename isold → new, and a removal, the same residue class (own-config orphaned), isold → ∅. Their lead token is the old name, backticked and unbolded, directly after the bullet marker (- `OLD_NAME` → `NEW_NAME`), and the arrow, the new name and any prose follow it. An addition is- `NEW_NAME` — new, its lead token the new name, then what it does and its default. - Gate-authoring changes (
authoring) — one bullet per change to the gate-authoring contract: the gate library, the descriptor grammar, the skeleton or the runner’s member contract. An adopter who writes or shadows gates reconciles these, and one who only runs the battery does not. The lead token is the changed surface’s name, bolded, read as Behavior changes reads its own.check-release-change-declaredaccepts a bullet here as the declaration it requires, since a changed gate skeleton or library is a gate-authoring change. - Platforms (
platforms) — one bullet per target triple whose row in the install pages’platformsblock was added, removed, or had its Minimum or Status cell moved since the previous release, the triple backticked as the lead token. It is derived at composition, never declared: the release declaration surface carries no Platforms section.check-release-declaration-parity’s arm P holds it on a note under composition. The previous release is the newestv*tag below the note’s version, ordered ascheck-release-bumporders notes. The arm reads the install pagesGATE_LOCAL_INSTALL_DOCSnames (§The hosted install pin, The install pages), at that tag and in the tree, withcheck-install-platforms’ row reader, and holds the note’s triples equal to the changed set in both directions. Its finding prints the bullets the diff derives, so the composing session transcribes them. A tag whose pages carry noplatformsblock reads as the empty table, as does a tree with no tag below the note. A tag outside the version grammar refuses rather than being ordered by guess. -
Behavior changes (
behavior) — one bullet per shipped change that alters what the kits do without landing or tightening a battery gate: a fail-closed convergence in a shared library, a runner’s semantics, a skill or template behavior, a default’s effect. The bullet’s lead token is the changed surface’s name (the script, knob, template, or file), bolded; the rest states what moved and what, if anything, the consumer reconciles. This section keeps its own spelling — its lead tokens are legitimately prose phrases rather than identifiers, so the gates-section rule above does not reach it. Its lead token is read verbatim. It is the bolded span directly after the bullet marker, so a surface or phrase inside the bold is the token and whatever follows the closing**is prose.The knobs, authoring and behavior lead-token pins bind the note under composition only. No gate reads those tokens on a published note, and notes published before the pins spelled their leads several ways and are not retro-fitted.
“None” is a valid body for any declaration-bearing section and must be stated, not omitted — a release that tightens nothing, changes no knob, and changes no out-of-gate behavior says so on each. Its form is a bare “None.”; add a trailing clause only where it rules out a near-miss the reader would otherwise mis-classify (an advisory KPI that never joins the gate registry). A clause that only restates the heading’s own negation is a restatement to delete. This consumer-owned residue Phase A cannot touch (gates you have shadowed, templates you have copied out, knob renames in your own config, behavior your tree depends on) is that note’s checklist.
Those four residue classes map onto the sections by design: shadowed and custom gates → New and tightened gates plus Gate-authoring changes; own-config knob renames and removals → Knob changes; copied-out templates and depended-on behavior → Behavior changes. The copied-out-template class earns no section of its own because a template you have copied out that then changed is depended-on behavior diverging from your copy — it is behavior-folded, not dropped. A template init seeded that you never edited is the one exception, because init rewrites it on upgrade: you take the change rather than diverge from it. So a change that tightens a gate through such a template is declared under New and tightened gates as well.
Honest limit on Behavior changes: its bullets are declared for the human upgrader, not smoke-asserted. A non-gate change cannot red the battery, so the upgrade smoke’s containment assertion, defined over battery reds, does not read this section. The section fixes where such changes are stated, and check-release-bump that they are stated, but neither makes them executable. Gates-section bullets stay the mechanical allowed-red set; behavior-changes bullets are reconciled by reading.
Docs
https://checkwright.dev and https://github.com/checkwright/checkwright.
Apache-2.0.