gate-sdk — a self-testing lint framework for prose/spec/config surfaces
Machine-gated consistency for the surfaces conventional linters ignore: markdown specs, glossaries, task queues, config projections, diagrams — any text whose drift is mechanically decidable. A gate is a small program that checks one invariant across one or more governed surfaces and blocks the commit (or the merge) when they disagree.
The kit carries the generic mechanism only; a consumer’s rule content (term lists, coupling vocabularies, glossary bodies) stays in the consumer repo.
The provenance seam
A kit ships generic mechanism only. Two classes of content never land in a kit file, and the seam between them and the mechanism is a privacy boundary before it is a design one:
- A consumer’s rule content — term lists, coupling vocabularies, glossary bodies, wire-contract couplings, product constant sets, agent rosters, probe commands, platform commitments. A kit literal carrying one consumer’s vocabulary publishes it, and is false for every other consumer the moment a second one vendors the kit. When a kit component needs such content it takes it as optional consumer config — a knob whose default is empty or universal, on the config-via-env pattern §Layout and configuration establishes and §check-graph’s vocabulary knob is the worked example of — never as a literal. The line between a consumer’s vocabulary and a universal one is what lets a knob ship with defaults: universal interpreter binaries are kit-shippable, the commands one consumer calls bad are not.
- A publisher’s own provenance — a dated authority stamp; a pointer into the publisher’s ruling record, work queue, always-loaded agent file or local-only brief; a refused alternative’s grounds carried as an attribution rather than as an argument. A kit SPEC states its rule and the rule’s engineering grounds undated and impersonally. Who decided, when and through which channel is the landing commit’s, and a pointer that resolves only in the publisher’s tree is dead in every vendored copy and on every rendered mirror.
The seam decides the voice, never the content. An unattributed engineering ground — refused: a per-gate header, because it would cost a retrospective declaration on every existing member — stays; the same ground attached to an authority, a date or a publisher-internal identifier is swept to that form. A kit SPEC carries no date. A measurement that grounds a rule is stated undated, as the ground: the figure and the run that produced it are the landing commit’s. A measurement a reader must be able to trust as current carries a measured: marker, which an oracle keeps true (canon-kit/SPEC.md §check-measured-claim). A dated figure is neither: it reads as live and is true only of one tree on one day. A specimen spells its date as a placeholder (YYYY-MM-DD), or sits in a fence. A change to data a kit writes into a consumer’s tree names the release that carried it, never a landing date.
What makes a literal consumer rule content is whose truth it is. A value true only of one tree’s configuration or practice is that tree’s content, however it is voiced. It leaves the kit: as optional config where the kit needs it, otherwise into the publisher’s own record. A value the kit itself ships — its descriptors, its table defaults, its templates — is kit content, and quoting it is a restatement that de-literalization governs rather than the seam.
canon-kit’s check-provenance-seam holds the seam. Over the kit SPECs of a tree that authors its kits it reads the publisher-provenance class’s lexical shapes, a commit or digest reference and any date among them, and the content class’s consumer-roster shape. Over the published documents a consumer declares its own it reads the provenance shapes alone. Its honest limits, the shapes it cannot see, are recorded in canon-kit/SPEC.md §check-provenance-seam.
Layout and configuration
The kit is vendored (or submoduled) into a consumer repo, conventionally at gate-sdk/. The consumer owns a gates directory (default scripts/, override with GATE_SDK_GATES_DIR) holding:
-
gates.list— the registry: one gate name per line,#comments and blank lines ignored. A listed name resolves to<gates-dir>/<name>.shfirst, then<gates-dir>/<name>.gate, then each vendored kit’schecks/<name>.shandchecks/<name>.gate:.shbeats.gatewithin a dir, and dirs are tried consumer-first. So any kit’s shipped gates are registered by name alone, and a consumer can shadow one by dropping a same-named file in its own gates dir, including shadowing a ported gate with its own shell script. The.gatespelling is the non-executable declaration of a gate whose implementation is a binary subcommand (§The# graph:manifest); resolution returns it as a declaration path, while execution resolves separately throughgate_command(§lib/gate.sh). A listed name that resolves nowhere is one red, the runner’s(unresolved)result at its registry line (§run-gates). Every other reader of the registry skips it, the gates among them counting the skip on their clean line, so a release withholding or renaming a gate declares one gates-section bullet, the gate’s own (installer/SPEC.md §The upgrade contract).One comment form is read rather than ignored.
# unregistered: <name> — <reason>declares a member the binary carries that this consumer deliberately does not register. §check-gate-substrate-parity’s assertions I and J are its readers, I in the consumer’s registry and J in each kit’s smoke registry heredoc. The grammar is §Consumer smoke’s# smoke-unregistered:on a second roster, and it points there so the two cannot drift into two dialects. It adds no member and moves none: every reader of this file drops comment lines already, so the format widens additively. The cause lives here rather than in a kit table because why a consumer declines a member is a fact about that consumer’s tree, and a kit SPEC holding it would publish one adopter’s configuration as kit content. The kit owns the grammar and the assertion.A targeted run is
run-gates.sh --only <name>...(§run-gates). It is a spelling rather than a mechanism, because two knobs already compose into one: point the positional at a scratch directory holding a one-linegates.list, and setGATE_SDK_VERBOSEto restore the per-gate banner the runner suppresses on a pass. One gate’s verdict is then reachable with every knob still resolved by the binary from the knob files. That composition stays the answer for a registry that is not a subset of the default one, a gate the consumer has not registered or one shadowed for the run;--onlyis the ergonomic form for everything inside it. - the consumer’s own
check-*.shgates (copy-edits oftemplates/check-skeleton.sh). gate-tests/— the consumer’s fixture tree (see §run-gate-tests).gate-sdk-config.knobs— optional persistent config: a knob file (§The knob file) that sets anyGATE_SDK_*knob so the override outlives the shell that set it, beside its gitignoredgate-sdk-config.local.knobsoverlay (see the knob-file paragraph below).graph-vocab.knobs— optional rule content forcheck-graph, a document in the knob-file grammar (see there).graph-theme/— optional consumer theme directory forcheck-graph’s emitted artifact, its parts read as data (see there).core-files.list— optional manifest forcheck-core-files: the repo-relative paths that must stay present and tracked (see there).identity.conf— optional manifest forcheck-identity: the git identity (committer email, remote host) this clone must resolve to (see there).root-allowlist.list— optional manifest forcheck-root-tiering: the tracked top-level entries permitted at the repo root (see there).msg-patterns.list— tracked banned-pattern list forcheck-commit-msg/check-tree-terms(generic patterns; copytemplates/msg-patterns.list);msg-patterns.local.list— its gitignored companion for private terms, which must never be tracked, since tracking the banned terms would itself be the leak. The two readers are calibrated to the stricter one, and the tree half must be exact. A commit message tripping a pattern is argued down once, at the one commit it blocks. A tracked-tree match is a standing red clearable only by rewording prose that was never wrong, which pressures the repo to bend to the heuristic. So a pattern earns a place only if it refuses nothing true in the tree, and a red the tree half raises on correct prose is fixed in the list, never by rewording the prose, least of all a quotation.portability-patterns.list— optional banned-construct roster forcheck-portability-floor(copytemplates/portability-patterns.list), read together with the corpus knob that gate’s section owns; absent, the assertion is disabled rather than failed closed (see there).
The kit set defaults to gate-sdk plus every sibling directory holding a checks/ or a smoke/ (a vendored Checkwright kit; a gateless kit is discovered by its smoke/ alone); override with GATE_SDK_KIT_DIRS (space-separated kit roots).
Environment overrides, all optional. Paths are repo-root-relative; every entry point cds to git rev-parse --show-toplevel before resolving them. That sentence states shape and mechanism and never dialect: which spelling a root arrives in, and who owes the conversion, is §The path-dialect contract.
Layout:
GATE_SDK_GATES_DIR(defaultscripts). The crate names no gates directory anywhere: the owner sentinel-stands in its place on--list, and the one place the crate resolves the directory is the owner unit test, through this knob’s own resolver (§check-gate-substrate-parity).GATE_SDK_TESTS_DIR(default<gates-dir>/gate-tests).GATE_SDK_WORKFLOW_DIR(default.workflow). The directory’s two-tier membership rule, header form and extension rule are §The workflow directory, and the governed-comment corpus takes its tracked tier.GATE_SDK_TMP_DIR(default.tmp). The directory ignores itself: whatever creates it writes<dir>/.gitignoreholding*when none is there, so no consumer’s worktree shows scratch and no consumer file is edited to hide it. An existing.gitignorethere is the consumer’s and is never rewritten, and a value naming the working directory itself gets none, since there the file would be the consumer’s root ignore. The battery runner declares it beside every member writing scratch, and such a member keeps all of its residue inside it, registering nothing in a repository outside it, so a killed run’s leftovers are scratch the reclaimers own (§upgrade-smoke).GATE_SDK_ROOT(see the locator paragraph below) andGATE_SDK_KIT_DIRS(default: gate-sdk and its siblings holding achecks/or asmoke/). A vendored tree’s kit set is set for them, to the kit set the payload installed, by the installer writing this knob into theirgate-sdk-config.knobs. The derived predicate is right where the kits are authored and wrong in a tree that merely received them (§Consumer payload).GATE_SDK_PRUNE_DIRS(defaulttarget .git node_modules .tmp gate-tests worktrees). It replaces the default set rather than extending it (§lib/gate.sh for the members’ rationale).GATE_SDK_PRUNE_EXTRA_DIRS(default empty): space-separated directory basenames appended to the resolved prune set, whichever knob supplied it, so a consumer adds a member without maintaining a drifting copy of the kit default. Removing a default member is still the replacing knob’s job, since an append cannot subtract (§lib/gate.sh).
Execution:
GATE_SDK_JOBS(default unset =available_parallelism()): the battery’s worker count,1restoring a serial run. It is env rather than argv because worker count is execution configuration and changes no member’s membership (§run-gates).GATE_SDK_VERBOSE(default unset = quiet green): any non-empty value restores the full per-gate banner roll onrun-gates.shand the placed hooks (§run-gates).GATE_SDK_SARIF_FILE(default unset = none written): the SARIF log--runwrites (§run-gates). Environment-only, because it is how the battery reports.
Governed files and artifacts:
GATE_SDK_QUEUE_FILE(defaultTASK-QUEUE.md): a compiled member valving the queue out of its corpus declares this knob.GATE_SDK_AGENT_FILE(defaultCLAUDE.md): the always-loaded agent file a consumer’s harness reads. Set it toAGENTS.mdunder an agent-file harness, andcheck-root-tiering’s built-in allowlist accepts that file at root.GATE_SDK_ENFORCEMENT_FILE(defaultdocs/enforcement.md): the committed enforcement-map page §check-enforcement-fresh compares; an explicit positional still wins.GATE_SDK_ENFORCE_SCAN_DIR(default.): the enforcement map’s monitor-marker scan root (§enforcement-map).GATE_SDK_CORE_FILES_FILE(default<gates-dir>/core-files.list),GATE_SDK_IDENTITY_FILE(default<gates-dir>/identity.conf) andGATE_SDK_ROOT_ALLOWLIST(default<gates-dir>/root-allowlist.list).GATE_SDK_REGISTRY_DOC(defaultREADME.md) andGATE_SDK_RUNNER_DOC(defaultREADME.md), forcheck-kit-registration(see there).GATE_SDK_PROJECTION_ROSTER(default empty): the consumer’s roster file of generated projections thatcheck-projection-rosterholds to the gates declaring# projection:. Empty disarms that gate, since the kit cannot know where any consumer keeps its roster (see there).GATE_SDK_PROJECTION_ROSTER_SECTION(default empty, meaning the whole file) is the heading text of the section holding that roster’s rows.
The coupling graph:
GATE_SDK_GRAPH_ARTIFACT(default<gates-dir>/CHECK-GRAPH.html): the emitted coupling-graph artifact’s path, read bycheck-graphassertion E. Set it to republish the artifact elsewhere, such as a served docs page.GATE_SDK_GRAPH_VOCAB(default<gates-dir>/graph-vocab.knobs): the graph vocabulary document (§check-graph).GATE_SDK_GRAPH_THEME_DIR(default<gates-dir>/graph-theme/): the optional consumer theme directory whose part filescheck-graph’s emitter inlines verbatim, to bring host-site tokens and chrome into the emitted artifact (see there). It replaces the retiredGATE_SDK_GRAPH_THEMEsourced-function seam, a retired name in gate-sdk’s table, so an exported value or a file line naming it is refused rather than ignored (§The knob file).GATE_SDK_GRAPH_MAX_EDGES(default100000): the render cap the emitted page declares andcheck-graphasserts against (see there).GATE_SDK_GRAPH_EXTERNAL_REFS(default empty): space-separated URL prefixes thecheck-graphexternal-ref assertion sanctions beyond the kit-seeded mermaid import, for a consumer whose theme chrome links absolute URLs (§check-graph).
Identity:
GATE_SDK_GIT_EMAIL_FILEandGATE_SDK_GIT_REMOTES_FILE(both default empty): each names a file standing in for one thing the clone itself says,git config user.emailand the<remote> <url>set, so an empty value falls through to the livegitread that is the production path (§check-identity).GATE_SDK_GH_HOSTS_FILE(default empty): the GitHub CLI’s persisted hosts file, the account kind’s actual. Empty means derive it, the CLI’s own config-dir variable first and the XDG config home second, never no file; the derivation lives in the member because it reads$HOME(§check-identity).GATE_SDK_GH_HOST(defaultgithub.com): the host whose block that file is read for, mirroring the CLI’s own host variable rather than taking a third manifest field (§check-identity).
Rule rosters:
GATE_SDK_MSG_PATTERN_FILES(default<gates-dir>/msg-patterns.list): space-separated, each tracked and required, fail-closed when missing.GATE_SDK_MSG_PATTERN_FILES_LOCAL(default<gates-dir>/msg-patterns.local.list) is gitignored, and skipped when absent so a fresh clone without the operator’s private list still commits.GATE_SDK_PORTABILITY_PATTERNS(default<gates-dir>/portability-patterns.list): space-separated banned-construct rosters forcheck-portability-floor, each optional. An absent file disables the assertion, where a present-but-unreadable one is exit 2 (see there).GATE_SDK_PORTABILITY_PATHS(default empty): space-separated pathspecs naming that gate’s install-path corpus. Empty is the shipped default because the kit cannot know any consumer’s install path, and an empty corpus disables the assertion rather than failing it closed (§check-portability-floor).GATE_SDK_HARNESS_LITERALS(default empty): space-separated fixed strings, the harness-bound literals §check-harness-literal holds to their declared sites. The kit ships none, since a default would be one vendor’s spelling.GATE_SDK_HARNESS_LITERAL_PATHS(default empty): space-separated pathspecs naming that gate’s corpus, an exclude pathspec taking a whole-file adapter surface out of it. Either knob empty disables the gate (§check-harness-literal).GATE_SDK_COMMIT_TYPES(defaultfeat fix refactor perf docs test build ci chore style): the shared commit-type roster (§check-commit-subject).GATE_SDK_JOB_REF_PATTERNS(array, default empty): the EREs whose whole-span match makes a backticked span a CI job reference. Empty is a clean skip, since which job names a tree cites is its own vocabulary (§check-action-job-ref).GATE_SDK_EXEC_GLOBS(default*/checks/*.sh */kpis/*.sh */bin/*.shplus the computed<gates-dir>/check-*.shand<gates-dir>/kpi-*.sh): the path globs whose tracked*.shmemberscheck-exec-bitholds to index mode100755.GATE_SDK_EXEC_PRUNE(defaultgate-tests fixtures templates smoke) names the path segments whose subtrees that gate exempts (see there).GATE_SDK_ASSERTION_STRENGTH_WINDOW(default8): the lines after a guarded call that §check-assertion-strength reads for failure text.GATE_SDK_LINT_EXTRA_DIRS(default empty): space-separated directories whose direct*.shmembers joincheck-shellcheck’s derived scan set, the seam for a shipped script under no kit root (§check-shellcheck).GATE_SDK_PROGRAM_FLOOR(array, default the POSIX/coreutils set the battery already rests on plusbash,shandgit): the programs the payload is entitled to assume present, so a command-position word among them is not a criterion-7 requirement. The default is written once, in gate-sdk’s knob table (§lib/gate.sh, §port-blockers).
The payload:
GATE_SDK_PAYLOAD_WITHHOLD(defaultSPEC.md smoke): whitespace-separated root-relative members the customer payload excludes from each packed kit root (§Consumer payload). The default is the kit convention’s own member names, and the knob stays steerable for a consumer whose spec file is named otherwise. An explicitly empty value withholds nothing and packs each root whole, a real configuration for a publisher who ships the engineering record, so this row’s empty does not take the default.GATE_SDK_PAYLOAD_LICENSE(defaultLICENSE): the repo-root-relative license file the packer places at the package root and in every packed kit root (installer/SPEC.md §The packer), so a redistributed kit carries its license text. Explicitly empty places nothing; a value naming no file tracked at the packed commit is a refusal.GATE_SDK_PAYLOAD_RECIPES(keyed, default empty): each pair names a payload recipe and the repo-relative directory it is packed from. The packer places each directory’s tracked files atrecipes/<name>/in the package root (installer/SPEC.md §The packer). A name is[a-z0-9][a-z0-9-]*, since an adopter types it and a directory carries it. Empty packs no recipe.GATE_SDK_SPEC_BASE_URL(default empty): where this project publishes the SPEC sections its shipped# spec:pointers name (§Consumer payload, §run-gates). Empty means resolve in the tree, the live configuration for any tree holding the SPEC files, a hand-vendored one included. The value is the publisher’s own and never a kit literal, so it sits in the publisher’s own knob file besideGATE_SDK_GRAPH_EXTERNAL_REFS, which carries the same host for the same reason.GATE_SDK_UPDATE_CHECK(defaultweekly): how often an installed tree probes its upstream for a newer release, one ofoff,dailyorweekly; the validator refuses any other value (installer/SPEC.md §The update notice).GATE_SDK_UPDATE_UPSTREAM(default empty): the git URL that probe reads, written byinit. Empty means no check.GATE_SDK_UPDATE_TIMEOUT(default5): the probe’s bound in whole seconds.GATE_SDK_RELEASE_SECTIONS(array, defaultbrief: In brief,gates: New and tightened gates,knobs: Knob changes,authoring: Gate-authoring changes,platforms: Platforms,behavior: Behavior changes): the release note’s section roster, one<role>: <heading>element per section in note order. The roles and what each fixes are installer/SPEC.md §The upgrade contract’s.GATE_SDK_RELEASE_SECTION_ALIASES(array, defaultgates: Tightened gates,knobs: Renamed knobs): former headings a section is still read under, in the same element grammar. The validator refuses an unknown role, a role the roster gives twice, an empty heading, an alias heading equal to a roster heading, and a heading either knob gives to more than one role.
The native binary:
-
GATE_SDK_NATIVE_BIN(default computed:native/target/release/checkwright-gateswith the host’s executable suffix appended). The table’s row reads the standard library’sEXE_SUFFIXandgate_native_bintakesgate_exe_suffix’s no-argument form, so the default is…/checkwright-gateseverywhere but a Windows host and…/checkwright-gates.exethere. The suffix is derived here, the one place a suffix-less cargo artifact would otherwise be assumed, rather than at any reader. A consumer pinning the knob keeps its exact value. It is stated as prose because a computed default has no literal forcheck-knob-default-couplingto couple to. It names the multi-call binarygate_commanddispatches a.gate-declared member to (§lib/gate.sh), and the binary §check-gate-binary-fresh asks for its baked source stamp.The default is a relative path deliberately: every door resolves it at the work-tree top, where git starts a hook too (§git-hook), so one value serves every clone and every linked worktree. A vendored consumer’s value is set for them, to the binary’s place in their gates directory, by the installer writing this knob into their
gate-sdk-config.knobs(installer/SPEC.md §The gate binary). Deriving the default fromGATE_SDK_GATES_DIRinstead is refused: it would silently relocate the binary for every existing reader, and make this repo’s layout the exception to a convention whose rule is that this repo’s layout is the default. GATE_SDK_NATIVE_CRATE(defaultnative): the crate root that owns the build manifest, held outside every kit root by §check-gate-substrate-parity assertion E, and the tree whose tracked source §check-gate-binary-fresh hashes into the stamp it compares. Shell reads it throughgate_native_crate, a pre-binary accessor (§lib/gate.sh).GATE_SDK_NATIVE_SRC(default derived fromGATE_SDK_NATIVE_CRATEas<crate>/src): the implementation tree §check-gate-substrate-parity assertion D holds free of manifest-class annotation. It is a path, not a language, so the knob assumes nothing about what implements a ported gate. The two stay distinct because only the crate root decides whether the implementation sits inside the vendoring set (§Consumer payload).GATE_SDK_NATIVE_TARGETS_FILE(default derived fromGATE_SDK_NATIVE_CRATEas<crate>/targets.list, so the crate’s location keeps one owner): the target roster §Consumer payload rules the platform-support surface, read throughgate_native_targets(§lib/gate.sh).GATE_SDK_NATIVE_RUNNERS_FILE(default derived fromGATE_SDK_NATIVE_CRATEas<crate>/runners.list): one<target> <runner>pair per live line in the roster’s own line grammar, read throughgate_native_runner(§lib/gate.sh). The map is a runner selection and not a support commitment, so it is a file of its own rather than a second roster column. A project measures platforms it has not yet committed to, and this map may name a triple the roster may not. It is a file rather than an inline workflow table because a project with a release path and a CI producer has two readers for it. Two copies is how a release builds a target on one runner class while CI measured it on another.GATE_SDK_NATIVE_PUBLISH_WORKFLOW(a space-separated list, default.github/workflows/publish.yml): the workflows §check-gate-substrate-parity assertion F holds roster-derived and one-producer-per-digest, every workflow that builds and hashes the release-shaped artifact. A consumer whose release rides elsewhere points the knob at it, and a named workflow that does not exist is reported rather than red. The name is singular because a scalar value already in an adopter’s knob file reads as a one-element list, where a plural rename would refuse that file at its first read.GATE_SDK_CARGO_TARGET_DIR(default derived fromGATE_SDK_NATIVE_CRATEas<crate>/target, cargo’s own placement), so the warm cachebin/build-native.shfills is the one §check-crate-arms reuses. Its only non-default reader is that gate’s fixture pair, which points it at the scratch directory.tmp, spelled relative to the case, so a fixture run leaves no build products beside a fixture manifest. Defaulting it to scratch is refused, because it would cold-build the real crate on every battery run.GATE_SDK_CRATE_TARGET_LINT_CMD(a command knob, §The knob file, default empty): the lint command §check-crate-arms spawns as its fourth arm. Empty adds no arm.
The roster and the publish workflow are consumer config, never kit literals: the knob, the line grammar and the assertions are gate-sdk mechanism, while which platforms a project commits to is its own support commitment (§The provenance seam).
GATE_SDK_ROOT is the vendored gate-sdk root, a locator read from the environment, default gate-sdk relative to the working directory. No file can set it, for the reason no file sets GATE_SDK_GATES_DIR: the binary needs it to find the kits whose knob files it reads. bin/run-gates.sh exports it on every exec, from its own location, spelled relative to the repo root when the root lies under it and absolute otherwise. A call reaching the binary without the front-end, such as a placed git hook or a harness spawning the binary directly, takes the default, which is where init vendors gate-sdk. A consumer vendoring it anywhere else exports the locator for those calls. A consumer-copied gate (templates/check-skeleton.sh) sources lib/gate.sh from the same locator.
The crate derives the kit roots from it: the gate-sdk root, then every sibling under the root’s parent holding a checks/ or a smoke/ directory, in name order, with GATE_SDK_KIT_DIRS replacing the set when it is non-empty. A corpus over the kits is derived from those roots, never from a *-kit/<file> pathspec, which also matches the on-site docs/<kit>/ mirror and every gate-tests/ fixture tree. walk::kit_roots_abs spells the roots absolute, walk::kit_roots relative to the working directory, and walk::kit_roots_rel relative to the root’s parent. The three are computed from one list, so they stay index-aligned, and an override’s relative entry passes through each unchanged. --emit kit-roots prints the working-directory spelling (§The non-gate arm).
Which spelling a reader takes follows from what it does with the root, and the two relative spellings are not interchangeable:
- A root joined onto a path that is opened, statted, walked or handed to git as a pathspec takes
walk::kit_roots, because every door sets the working directory to the repository toplevel. So does a reader matching a root against a path read from the tree: a staged path, a--foroperand, agit ls-filesline, a directory resolved off disk. The hook arm,--for,check-reads-couples,check-core-files’kit:lines, the port-blockers andcheck-gate-substrate-paritycheck-dir resolutions and the graph artifact’s labels all expand akit:couple this way. - A root matched against text that names kits from their common parent takes
walk::kit_roots_rel: acouples=field, a knob prefix,check-graph’s vocabulary layer rules. What decides is what the text is anchored to, not that it is text. A markdown link or an invoked command in a repository-root document names a repository path and takes the working-directory spelling, socheck-kit-registrationmatches repository paths in both of its assertions. - A root anchored somewhere other than the reader’s cwd takes
walk::kit_roots_at(anchor), and a git pathspec is that case.git -C <top>resolves a pathspec against<top>, andgit diff --cachedandgit ls-filesprint names against it whatever the cwd is. The two coincide at every door, and part wherever a reader is invoked from a subdirectory, such as a fixture case or a--forrelocation. - A root outside that toplevel is no pathspec at all, and
walk::kit_roots_undernarrows to those that are, since git refuses a climbing pathspec outright. A reader taking that narrowing owes the empty case its own arm, skipping the spawn and stating the empty corpus, because git handed no positive pathspec widens to the whole tree. Membership and namespace questions (live, a knob prefix) take the unnarrowed set, since such a kit is still live and still owns its prefix.
The choice is per use, not per call site: a site taking both dialects off one call binds each half separately, and a basename read off a root is no third dialect. The spellings agree at the layout init vendors and part only under a subdirectory vendoring (GATE_SDK_ROOT=tools/gate-sdk spells tools/canon-kit against canon-kit). So the wrong choice is unobservable in every fixture that does not nest, and must be made from the reader’s kind rather than from a passing run: a path reader left on the kit-parent spelling reads an empty corpus and exits clean. check-kit-roots-dialect is the rule’s executable form, over a fixture that nests the root, and a crate unit test vendors under a subdirectory and holds the hook, --for and check-reads-couples to the repository spelling.
A spawning arm absolutizes the locators it hands a child in another working directory. §run-gate-tests exports the absolutized root into every case it runs, and §lib/test-hermetic.sh exports it absolute from its own anchor. A scratch consumer’s installer, violation, battery and regeneration scripts receive that consumer’s own root (§Consumer smoke, §upgrade-smoke). A script of another tree reading the invoker’s relative locator after changing directory would source the wrong library. The binary’s own path is no anchor, because init places the binary in the gates directory and nothing relates that path to the kits.
The toplevel cd refuses outside a repository, which is why a non-gate arm may have a second caller. bin/run-gates.sh exits 2 when the toplevel does not resolve (§run-gates). An arm whose contract includes running in a non-git directory is therefore unreachable through the front-end. Widening the cd conditionally is ruled out, since it would weaken the precondition every gate in the battery rests on. The sanctioned answer is a harness locating the binary itself and invoking the arm directly (§The non-gate arm), as --enter-stage’s harness does (lifecycle-kit/SPEC.md §bin/enter-stage.sh).
gate-sdk’s knobs live in the crate’s table, native/src/knobs/gate_sdk.rs, stem gate-sdk, and the binary reads them through two layers over it, as it reads every kit’s (§The knob file). The tracked layer is <gates-dir>/gate-sdk-config.knobs, or the file GATE_SDK_KNOB_FILE names; a set-but-missing path exits 2 rather than silently running on defaults. The gitignored overlay sits above it, at <gates-dir>/gate-sdk-config.local.knobs or the file GATE_SDK_LOCAL_KNOB_FILE names, a named path refused the same way when missing. Absent files leave every default in place, so a zero-config consumer is unaffected. An exported scalar outranks both files, so a harness can make a scalar authoritative by exporting it. The file is how an override persists, since an exported value dies with the shell that exported it. Five names are set in the environment only, and a file line naming one is refused: the locators GATE_SDK_GATES_DIR (a config file cannot name its own directory) and GATE_SDK_ROOT, and the execution settings GATE_SDK_JOBS, GATE_SDK_VERBOSE and GATE_SDK_SARIF_FILE.
The knob file
A knob file is UTF-8 text read line by line. A line that is blank, or whose first non-blank character is #, is ignored. Every other line is one of four forms:
NAME = value a scalar
NAME[] = element one element of an indexed knob, in file order
NAME[key] = value one pair of a keyed knob
NAME[] <- OTHER the resolved elements of OTHER, at this position
NAME is a SCREAMING_SNAKE knob name, or a declared family’s member, whose suffix may be lowercase: the line grammar takes an uppercase letter then letters, digits and _, and the owning kit refuses a name it neither declares nor admits. The first = on the line separates the head from the value. Blanks around the head and the value are trimmed, and the value is otherwise taken verbatim to end of line: no quoting, no escapes, no expansion, and # or = inside a value is data. A key is non-empty and carries no ], = or tab. A value carrying a tab is refused, because the wire, the one serialization every in-process reader parses, tab-joins elements, and a newline is unexpressible by construction. Pairs of a keyed knob resolve sorted by key, the order the wire carries.
A knob named in a file is replaced whole: its lines in that file are its entire value, and nothing merges with the default. NAME = with an empty value sets an indexed or keyed knob to the resolved-empty value, which is the reading the wire already gives an empty serialization. A knob’s shape is its owner’s declaration, and the file does not choose it. A line whose form disagrees with the declared shape is refused with its file and line, exit 2. So are an undeclared name for the owning kit, a malformed line, a scalar or keyed pair given twice in one file, and an empty NAME = beside element, pair or reference lines for the same knob. Leading or trailing blanks inside a value are unexpressible, and no live value carries them.
A reference line splices another knob’s value. It carries no =, so no line in the three value forms can read as one. NAME and OTHER are SCREAMING_SNAKE names and blanks around <- are trimmed. The line contributes OTHER’s resolved value, whatever layer supplied it, as elements of NAME in file order among NAME’s own element lines, and NAME is then replaced whole by its lines in that file, exactly as without a reference. A reference is refused with its file and line, exit 2, when:
NAMEorOTHERis not a declared indexed knob. The form splices elements, and no scalar or keyed reference has a reader.OTHERis not statically owned, so no kit’s table declares it.OTHER’s own resolved value carries a reference, orOTHERisNAME. Resolution is one pass, the bound thecouples=knob:token takes, so no cycle detector is needed. A kit validator reads every row at the kit’s first resolution, so a nested reference refuses that first read rather than only the referencing knob’s.
A reference may cross kits. It is the one splice form the file admits, and the grammar still has no expansion inside a value: $, <- and ${…} in a value are data. A reference in a kit’s tracked file also reaches a trigger (§The # graph: manifest).
A command knob (<KIT>_…_CMD) is an indexed knob holding an argv, one element per line: CANON_KIT_MEASURED_CLAIMS_CMD[] = bash then CANON_KIT_MEASURED_CLAIMS_CMD[] = scripts/oracle.sh. Its reader spawns the argv directly and no shell parses it, so a consumer on native Windows names its own interpreter. The program is resolved by the crate’s one program resolver (§Fail-closed contract), the child inherits the reader’s working directory and environment, and an empty knob means no command, which each reader takes as its skip. A reader appending an operand appends it after the argv’s last element. What a failed spawn or a non-zero exit means stays each reader’s own contract. A command knob takes no environment override, because it is indexed. A command that is itself a front-end arm must not declare the knob that names it, or the resolution recurses.
A knob that selects behavior ships a working value. Its alternatives are values the kit implements. They are a bundled adapter named bare in the knob’s own vocabulary, a kit-shipped --emit arm reached through the front-end (bash gate-sdk/bin/run-gates.sh --emit <arm> [<operand>…]), or off, which a command knob spells as its empty value. A consumer command extends that set and is never its only working member, so no kit default names a program the kit does not ship. The front-end form is how a kit reaches a bundled adapter, so a new kit-shipped adapter is an --emit arm rather than a further bare name, and a consumer’s configured value names it the way it names its own command. A knob whose empty default disarms its gate is declared on the gate with # armed-by: (§The install disposition), so doctor names the disarmed state and an adopter never reads a clean line as coverage.
Why the grammar is lines and not a general format. It maps one-to-one onto the wire every in-process reader parses — an element per tab-joined field, a <key>=<value> pair per map entry — whose newline and tab limits are this grammar’s own. With no quoting and no expansion, every value hazard a knob carries (whitespace inside an element, = inside an element, a regex $, embedded double quotes, an apostrophe) is written verbatim, with no escape rule for an adopter to get wrong. Each line parses independently, so a refusal names its file and line; and a reader in any interpreter is a dozen lines, which is what a surface that must survive in bash and PowerShell alike needs.
What the grammar deliberately lacks, and where each lack is answered. It has no expansion inside a value, no command substitution and no loop. Every derived value a kit library computed lives in that kit’s defaults table, where a derived default is a function of already-resolved knobs. Every value a consumer config computes is either a reference, ruled above, or a generated family, ruled below: raw values, no expansion, no shell, and one owner per knob.
A derived default may read a static sibling, in its own kit or another, or one of the two locators, GATE_SDK_GATES_DIR and GATE_SDK_ROOT. Its row declares every name its derivation reads, so the set is known without running it, and a unit test runs every derived row under a recording resolver and holds the names it asks to that declaration. The derivation reads a sibling through the table and a locator from the environment or its default. --emit knob-roster renders a locator input as ${NAME}, because a locator’s value is the invoking environment’s and the arm prints defaults. It hands a derivation each locator input as a placeholder, origin Placeholder and value ${NAME}. A derivation that probes the filesystem does not probe a path built from a placeholder, and renders its first candidate instead, so the roster prints ${GATE_SDK_GATES_DIR}/kpis.list rather than an empty value that is true of no tree.
A kit’s table may carry a validator, and a malformed config gates nothing. It runs once per process per kit, at the kit’s first resolution, and collects every finding before refusing with exit 2 under the kit’s malformed-config lead line. A validator reads each row’s origin beside its value, so a rule that turns on whether a consumer set the knob, not on what the value is, is a validator rule. A unit test holds every kit default valid under its validator.
Where a static knob resolves from, highest first:
- the environment, for a scalar knob only:
NAMEexported by the invoker, an exported empty value included. An indexed or keyed knob takes no environment override, because one name meaning two grammars is the defect §lib/gate.sh’s one-grammar rule exists against. - the local overlay,
<KIT>_LOCAL_KNOB_FILEwhen set non-empty (set but missing is exit 2), else<gates-dir>/<stem>-config.local.knobs, gitignored by the consumer, for values that must not be tracked. Relocating the tracked file leaves it where it is: each layer moves by its own locator alone. - the tracked file,
<KIT>_KNOB_FILEwhen set non-empty (set but missing is exit 2), else<gates-dir>/<stem>-config.knobswhen it exists; - the kit default from the crate’s table.
Each file layer has a locator, read from the environment only. A knob file cannot say where a knob file is, so a line naming <KIT>_KNOB_FILE or <KIT>_LOCAL_KNOB_FILE in one locates nothing.
- A named overlay that is missing is exit 2, the tracked locator’s rule. An overlay is optional at its default path and never at a path someone named, where a mistyped value would drop the values that must not be tracked without a word.
- Reading a named tracked file alone, with no overlay, is refused. It would take the overlay from a consumer who relocates the tracked file and keeps untracked values beside the gates directory, which item 2 promises.
- Taking the overlay from beside the named tracked file is refused on the same promise. It would also have §lib/test-hermetic.sh’s pin read a sibling of a file in the system temp directory, which any process can create.
An empty value takes the default on a row that says so. A row flagged empty_takes_default answers its default for an empty layered scalar, from the environment or a file, where every other row keeps the empty value. The flag sits on the gate-sdk rows whose readers take an empty value as unset, and gate-sdk’s table (native/src/knobs/gate_sdk.rs) is the roster of them. A unit test holds each flagged row to the fallback.
<stem> is the kit root’s name less -kit (gate-sdk, site, doctrine, queue, lifecycle, canon, context, delegation, drift, evidence, guard), the stem its shell config used, so a consumer finds the new file where the old one was. The gates directory is GATE_SDK_GATES_DIR from the environment, default scripts. It stays env-or-default for the reason §Layout and configuration already gives: a file cannot name its own directory. That default is one literal the crate spells as knobs::GATES_DIR_DEFAULT, beside the shell callers that need the directory before any binary answers, lib/gate.sh’s gate_sdk_gates_dir and its pre-binary accessors. A locator has no table row to own it, and a crate unit test spawns lib/gate.sh and holds gate_sdk_gates_dir equal to the crate’s literal. The files are read once per process per kit and the environment at every read.
Some names under a kit’s prefix resolve from the environment only — set there by a caller, or exported by the kit to a process it spawns. A kit’s table may list them, and a file line naming one is refused at exit 2 — it is read from the environment, never from a knob file — where the generic undeclared-name refusal would only name the roster. A name a kit reads or hands through the environment takes an env_only entry, because smoke/ is withheld from the payload (§Consumer payload) and is therefore no definition site; a name that exists only as smoke test data takes none. gate-sdk lists five: the two locators, GATE_SDK_GATES_DIR and GATE_SDK_ROOT, and the three execution settings the runner reads from its own environment, GATE_SDK_JOBS, GATE_SDK_VERBOSE and GATE_SDK_SARIF_FILE (§run-gates). lifecycle-kit lists LIFECYCLE_KIT_SESSIONS_DIR and drift-kit lists DRIFT_KIT_ITERATION_START, each owning its grounds in its own SPEC, where drift-kit also owns why DRIFT_KIT_SMOKE_CUSTOM takes no entry. None of these is a row, each counts among check-docs-cmd’s static names, and none reaches a knob file in the knob-file derivation.
The environment beats the file, on purpose. A shell config’s own spelling decided, and a bare assignment won, so a harness could not make a knob authoritative by exporting it. A knob file has no spelling to choose with, so the order is chosen once, and the per-invocation value outranks the persistent one. That is what lets a harness pin a scalar knob by exporting it (§run-gate-tests).
The legacy refusals. The reader refuses with exit 2, naming the migration, when <gates-dir>/<stem>-config.sh or its .local.sh overlay exists — for gate-sdk, <gates-dir>/gate-sdk-config.sh — since a shell config left behind at upgrade would otherwise be silently ignored. Ignoring it would drop the consumer’s values, the fail-open the retired-name refusal below also closes. It refuses too when <KIT>_CONFIG_FILE, for gate-sdk GATE_SDK_CONFIG_FILE, is set and names a non-empty file. An empty file is inert in either grammar, and §lib/test-hermetic.sh pins every kit’s <KIT>_CONFIG_FILE at one empty file, so the hermetic harness cannot trip the refusal while a consumer’s real pinned config is refused rather than dropped. <KIT>_CONFIG_FILE → <KIT>_KNOB_FILE is a rename, because the grammar changed and a distinct spelling is what a grammar change buys (§lib/gate.sh).
A kit’s table may list retired names, each with its replacement. A file line naming one, or a retired scalar exported in the environment, is refused at exit 2 with the replacement named. The generic undeclared-name refusal would name only the roster, and an exported retired name would otherwise be silently ignored. gate-sdk’s table lists GATE_SDK_GRAPH_THEME, replaced by GATE_SDK_GRAPH_THEME_DIR (§check-graph).
A kit may declare its family open to consumer scalars. In such a kit’s files, a scalar line naming an undeclared name under the kit’s prefix is a consumer knob, not a refusal. It resolves through the same precedence as a declared scalar, it has no default and no validator, and the one reader of the set is the kit’s family read (knobs::family). An undeclared name written as an indexed or keyed line is still refused, and so are the kit’s own locator names, <KIT>_KNOB_FILE, <KIT>_LOCAL_KNOB_FILE and <KIT>_CONFIG_FILE, and any retired name, since a file setting one of those is a mistake the family would otherwise hide. drift-kit is the one open kit, for its plugin contract. The honest limit: a misspelled declared name in an open kit’s file reads as a consumer knob rather than a refusal.
A kit may declare a scalar family, a prefix under which each member is a scalar whose name the prefix and a suffix spell: EVIDENCE_KIT_RUN_demo = bash gate-sdk/bin/run-gates.sh --run-demo. A file line naming an undeclared scalar under a declared family prefix is a family member rather than a refusal. The suffix is non-empty and SCREAMING_SNAKE-or-lowercase identifier-shaped ([A-Za-z_][A-Za-z0-9_]*), and an indexed or keyed line under the prefix is refused. A member resolves through the scalar precedence, so an exported EVIDENCE_KIT_RUN_demo outranks the file. A declared row whose name the prefix also spells, such as EVIDENCE_KIT_RUN_ID, is never a member.
A family may carry a derivation, the generated family: a function of already-resolved knobs returning (suffix, value) members, with every name it reads declared like a derived row’s, and a unit test holds each derivation to that declaration as it holds a derived row. A derived member is the family’s default for that suffix, so a file or environment member of the same name replaces it. It is a kit-side derivation and never a loop in consumer config. A member declaring the family’s * name reaches the kits its derivation’s inputs name, as a row reaches its inputs’ kits (§The # graph: manifest).
The family is still a resolution set and never a roster. A reader looks a member up by the suffix its own roster knob names, and knobs::family(prefix) returns every derived member, every member either file sets, and every member the environment exports, the environment first. The honest limit is the open family’s, narrowed to one prefix: a misspelled member reads as a member, and it is found only by the reader whose roster names the correct spelling and does not find it. evidence-kit declares the two families, EVIDENCE_KIT_RUN_ with a derivation and EVIDENCE_KIT_PARSER_ without one.
The table is published. bash gate-sdk/bin/run-gates.sh --emit knob-roster prints every statically owned knob (§The non-gate arm), which is how an adopter reads a static kit’s names and defaults now that no shell file holds them.
lib/test-hermetic.sh and the suites sourcing it are bash test harnesses rather than seam surface. They follow a migrated kit only where a suite writes that kit’s config. A static kit’s config template is a comment-only knob file, and init’s seeding derivation reads templates/*-config.knobs beside templates/*-config.sh (installer/SPEC.md §What init seeds).
The config-seam port disposition
A kit’s config template and the copy init seeds from it are the adopter’s seam, so neither ports. A config file is the adopter’s seam rather than kit mechanism reaching it. Porting one deletes the thing there is to edit, since a knob compiled into the binary is a knob no adopter can open. A template and its seeded copy stand or fall together — they are one artifact at two points of one derivation, and the copy is the more edited of the two. Every member is a knob file (§The knob file), which is not shell, so §port-blockers never scans one and the class carries no # no-port: line; the ground is what keeps a later cut from compiling the file away. The installer’s init already treats the class as an edit surface, reporting an adopter’s edit rather than clobbering it on a re-run, and §check-template-copy-parity keeps it out of template-to-copy parity, where equality would be the defect.
The class is derived, never rostered, and the derivation has an owner: installer/SPEC.md §What init seeds rules that a kit’s consumer config is whatever config template it ships, seeded into the gates directory under the file’s own name. A kit that ships no config template is simply not a member, and this repo’s <gates-dir>/gate-sdk-config.knobs is a hand-authored consumer config for such a kit rather than a seeded copy.
What reopens it, written as a reopening condition rather than a permanence claim: the ground dissolves for a kit whose config template stops being seeded into the consumer’s gates directory, because the file is then kit mechanism reaching a seam rather than the seam itself.
The path-dialect contract
A root is a path this tree passes between components, and §Layout and configuration says which shape one has, never which spelling. On a host carrying two path dialects, an MSYS or Cygwin shell over a Windows filesystem, a root can arrive drive-lettered and backslash-separated. Every downstream resolution then composes a string no filesystem answers to, and the battery reports its entire roster unresolved rather than failing at the crossing. This section states what makes a call site judgeable: which dialect a root is in, where the boundary is crossed, and who crosses it.
The declared dialect is per-substrate. A root is spelled in its own substrate’s dialect, forward separators and absolute by that substrate’s rules, and is converted into it once, at the producer. MSYS bash holds /c/repo; a *-windows-msvc binary holds C:/repo. The rule is a property of a value anywhere inside the tree, not only at the point that produced it: no value inside the tree is in a foreign dialect, and no value is normalized twice.
Two dialects, because each substrate’s correct dialect is the other’s defect. The crate’s crosser keeps the input’s own root, so normalize_abs turns C:\repo into C:/repo: separators normalized, drive letter preserved (§The crate’s crosser). Teaching it to strip the drive is refused. A *-windows-msvc binary reaches the filesystem through std::path, which cannot resolve /c/repo, so there the drive letter is the only absolute spelling that works. An MSYS bash process wants the opposite: its own getcwd(3) answers /c/repo, so a root spelled C:/repo compares unequal to every sibling path the shell derives while naming one directory.
The split leaks nothing, because no root crosses the shell/crate boundary absolute. The crate derives the kit roots itself, from the GATE_SDK_ROOT locator and the working directory it reads through walk::cwd (§Layout and configuration). --emit kit-roots hands a shell caller each root spelled relative to that directory, so no value arrives in a substrate whose dialect it was not spelled in.
The boundary, and who crosses it. The dialect boundary is crossed exactly where a value enters from a platform-native producer. Those are git rev-parse --show-toplevel under Git-for-Windows (a native binary, answering in Windows spelling even to a POSIX shell), std::env::current_dir() in a *-windows-msvc binary, and an npm bin shim’s basedir. The crosser is the entry point that reads such a producer, and normalizing there is its obligation. A shell builtin is not a platform-native producer, but which builtin, in which form, is the whole of the shell crossing.
The shell crossing idiom, and why pwd -P is the half that crosses. bash’s cd with an absolute argument sets PWD from the argument and never calls getcwd, so cd 'C:/repo'; pwd prints C:/repo straight back: a migration written cd … && pwd changes nothing while looking like the fix. pwd -P calls getcwd(3), which under the MSYS runtime answers in the MSYS spelling. The crossing lives in -P. In the cwd-preserving form most sites take:
ROOT="$( { cd "$(git rev-parse --show-toplevel 2>/dev/null)" && pwd -P; } 2>/dev/null )"
and, where the entry point means to be at the root, the two-line form that keeps the site’s existing refusal arm verbatim:
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || { …refuse exactly as today…; }
ROOT="$(pwd -P)"
Two properties travel with the idiom. pwd -P also resolves symlinks, a no-op in practice because git rev-parse --show-toplevel already answers a physical path. And a site’s || pwd hedge survives inside the substitution untouched: it is a missing-repository guard, and confers nothing against dialect (below).
The idiom crosses a variable; a script that composes two roots must also anchor its own cwd. Both forms leave the shell standing in the producer’s spelling, because cd with an absolute argument sets PWD from that argument. PWD is exported, so the foreign spelling reaches every child process. A relative cd … && pwd, the shape an entry point uses to find its own kit from BASH_SOURCE, concatenates onto it, so a second root derived that way comes out foreign while the captured one is crossed. A shell builtin still produces no foreign value, which is why check-path-dialect’s vocabulary names none: it propagates one. A script composing the two roots by string arithmetic, such as realpath --relative-to="$A" "$B" whose operands must share a dialect, fails hard, and under set -e dies at a line that names nothing. The obligation is on the composing script and is two builtins:
cd "$(pwd -P)" # before deriving anything from BASH_SOURCE
…
cd "$(git rev-parse --show-toplevel 2>/dev/null)" || { …refuse as today…; }
REPO_ROOT="$(pwd -P)"
cd "$REPO_ROOT" # re-anchor: the toplevel cd re-entered the producer's spelling
Both are no-ops on a single-dialect host, and both are the same -P crossing applied to the shell’s cwd rather than to a name. A script with no git producer of its own owes the first builtin alone, and check-path-dialect’s cwd-anchor arm holds it. A script that merely cds to a root and reads files under it is dialect-tolerant by the consumption predicate below and owes no anchor.
No shared shell normalizer is introduced. A helper that converts a root would buy a cross-kit dependency to save a one-line idiom, since the corpus’s families source different libraries and some resolve their root before sourcing anything. It would catch no error class check-path-dialect does not already catch. Absoluteness is the exception, because it is a predicate and converts nothing. gate_path_rooted (§lib/gate.sh) answers what walk::path_root answers, every site testing a caller’s path already sources the library, and the bootstrap, which cannot source it, carries a byte twin a crate test holds.
A value already inside the tree is in the declared dialect and is never re-normalized. A second normalization turns the contract into a ritual, and erases the one place a reader can look to find where the conversion happened.
The judging predicate is about consumption rather than about the producer:
A root consumed only by
cdis dialect-tolerant. A root consumed by string concatenation is dialect-exposed.
cd accepts either spelling on an MSYS host; "$ROOT/sub" does not. So the audit question at a call site is never where did this root come from but what is done with it. Consumption is local within a file rather than always at the call site: a root handed to a shared helper is judged at the helper, not re-derived at every site reaching it. The installer’s manifest-path helper concatenates whatever root it is handed and is the sole exposure path for its update arm, so that root’s audit is at the helper. An exposed site is not thereby broken: exposure says its value must have reached it through a crosser, and the two halves are judged separately.
A second, orthogonal axis is judged by a predicate of the same shape. Which anchor a kit root is spelled against, walk::kit_roots or walk::kit_roots_rel, is settled by what the reader does with the root, and §Layout and configuration owns it. A value can be correct on this axis and wrong on that one, and the second failure is silent where this one is loud.
A || pwd fallback confers nothing against dialect. It fires only when git fails; on MSYS git succeeds, in the wrong dialect. It is a missing-repository guard, so a call site carrying it does not fall out of an audit.
Porting to Rust does not retire dialect exposure
The intuitive inference, shell is the dialect-fragile substrate, so a ported file’s dialect problem goes away, is false in this crate. Rust is dialect-safe only where Path is actually used. A port reaching for String, split('/') and format!, as a port of a shell file naturally does, re-implements by hand the POSIX assumptions std::path would have carried, and a new exposure is born there. The reader of this clause is the session porting the next kit (§Porting a gate to the binary substrate).
And Path is not the safe half either: it is the other horn. Reaching for std::path fixes reachability and breaks spelling. Path::join and DirEntry::path append MAIN_SEP, which on a *-windows-msvc host is \, a separator the declared dialect excludes. The filesystem answers such a value, so nothing fails at the composition; what fails is the reader, since every reader in this crate splits on /. A walk descending through join hands back D:/w/kit\checks\check-graph.gate, whose /-split basename is the whole tail, and a gate keying a lookup on that basename reports its entire corpus missing, the whole-roster shape this contract opens with. On a Windows leg, check-install-disposition read every zero-config gate-sdk member as unregistered in its own kit’s smoke this way.
So walk.rs owns a child speller: it is the crate’s sole path producer (§The crate’s crosser) and, for this second reason, its sole path speller. What that obliges is locality of three text-level primitives, not abstinence from construction. Testing whether a path is absolute, composing a prefix to test containment, and joining a root onto a segment are walk.rs’s. A module outside it reaches each through a named walk helper (path_root, normalize_abs, abs_against, under, at_or_under, rel_under, child) rather than re-spelling it, and the shell side owns absoluteness through gate_path_rooted. child joins with /, adding none after a parent already ending in a separator. That is / on every host and \ on Windows alone, since on POSIX a trailing \ ends the directory’s own name (canon-kit/SPEC.md §The shared spec adapters). Building a PathBuf to operate on is untouched: the subject is a spelling that escapes into a value a reader prints, matches or prefix-tests, where a \ separator turns a wrong answer into a confident one.
A test is a reader too. A test that feeds the product a path, or states the path it expects back, spells it through the same walk helper the product uses. A path the product only operates on is compared through the host fold on both sides, which is proc.rs’s folded. A test spelling with PathBuf::join(...).display() feeds the product a value no crosser produced, and its failure on Windows reads as a product defect that is not there.
The contract binds a value naming a filesystem location. A /-separated string in another namespace, a queue tag’s horizon/track field or a markdown link target, is outside it. It declares so at its site with // path-dialect-exempt: <reason>, because which namespace a string names is not decidable from its text.
The crate’s crosser
native/src/walk.rs holds the crate’s single owner of absoluteness, path_root: the test every module asks, because a leading-slash test spelled anywhere else answers false on a drive-rooted path whether or not that module touches a producer. It is also the crate’s sole platform-native producer: std::env::current_dir(), the git rev-parse spawn and std::fs::canonicalize() live there and nowhere else. Ownership of absoluteness without ownership of the producers would only move the question one call back.
walk::cwd()is public and normalizing:normalize_absofstd::env::current_dir().walk::toplevel()is the crate’s onlygit rev-parse --show-toplevelspawn, its answer passed throughnormalize_abs. The same spawn asks--show-prefix, and the answer splits as git delimits it, unquoted with one newline after each: the toplevel ends at the first newline and the prefix runs to its own, so a newline in a prefix component stays in the prefix. Git-for-Windows answers in Windows spelling even to a Rust process, so this producer crosses for the same reasoncwd()does. It has three answers besides a toplevel, kept apart because callers report them differently:- A dead
gitisproc::run’s own spawn message, propagated. - A non-zero exit at an anchor nothing marks as a repository is outside a work tree, the absent answer.
- A refused repository is a non-zero exit at a marked anchor, or a zero exit whose
--show-prefixanswer crosses a.gitentry of any type beneath the answered toplevel, where git skipped a repository it cannot read and answered the enclosing one. Two such entries are no skip. Under a non-emptyGIT_DIRnone counts, since git discovers nothing. The nearest is no mark when it is the repository git selected, which aGIT_WORK_TREEabove that repository puts on the prefix: its--absolute-git-diranswer, reached as a directory or through a gitfile, asked only once an entry is found. The two compare as physical paths. A gitfile’s target is its first line pastgitdir:with its line endings stripped and any other trailing whitespace kept, as git reads it, since git refuses a target carrying a trailing blank. Either refusal is an error naming the mark and the way to git’s reason:git -C <anchor> statusfor the first,git --git-dir=<mark> statusfor the second, since-Cthere prints the enclosing repository’s status. A refused repository read as no repository, or as the enclosing one, would fail every caller open.
The anchor is the working directory, or the
-Cdirectory resolved from it as git resolves it, physically and with no lexical step, so<symlink>/..and a POSIX name carrying\mean what the filesystem says. The mark is a non-emptyGIT_DIR, else a.gitentry of any type at the anchor or above along its physical path, where git discovers, the ascent stopping whereGIT_CEILING_DIRECTORIESstops git’s own, which ignores a relative entry. A-Canchor that is no directory is never marked, since an ascent from it would blame an enclosing repository.toplevel_opt()returns these asResult<Option<String>, String>, both errors in the error arm, andtoplevel()folds the absent answer intonot a git repository. A caller degrades on the absent answer only. One that turns the crosser’s error into a fallback, an empty result or its own sentence takes the_optform, keeps its fallback forOk(None)and passes the error through. The statusline hook, the update notice, installer doctor and drift-kit’s readouts stay soft on every answer, since they must never block.A repository probe is the crosser’s too. git’s
rev-parse --git-dirand--is-inside-work-treeread a refused repository as none, and answer the enclosing one at exit 0 where git skipped a nested repository. So a module asking whether it stands in a repository askstoplevel_optortoplevel_in_optunder the caller rule, never git directly, and a crate test reds a module outsidewalk.rsspawning either probe.Honest limit: the mark’s ascent does not stop at a filesystem boundary where git’s discovery does without
GIT_DISCOVERY_ACROSS_FILESYSTEM, so a non-repository mounted inside a repository’s tree reads as refused rather than absent. git’s stderr cannot tell a broken repository from none, and its zero exit skips one silently, so the mark is the crosser’s only discriminator on both.- A dead
walk::toplevel_in(dir)asks the same producer from another directory, because two callers compare a-Canswer against a bare one, and an uncrossed side would compare dialects rather than directories.walk::main_checkout_root()is the crate’s onegit rev-parse --git-dir --git-common-dirreader of a worktree’s main checkout. It canonicalizes both answers and returns the main checkout’s root, normalized, only when they differ and the common dir’s leaf is.git. A failed spawn reads as none.walk::canonicalize()is the crate’s onlystd::fs::canonicalize, and the one producer here that does not convert (the UNC clause below).walk::cross_arg()crosses a path argument, which enters in its caller’s spelling, such asC:\repo\docs. It only folds separators, because an argument may be relative andnormalize_absreads a rootless input as separator-rooted. Its reader crosses it on entry, asemit::targetsdoes; uncrossed,walk::childappends/onto a\-spelled target, and the walk answers in two dialects.- There is one normalizer,
walk::normalize_abs. A normalizer that silently does nothing on a backslash-spelled root is worse than none, so no module keeps its own;check-stage-evidencekeeps only the frame shift that removes the leading separator, because its result is spliced into aHEAD:-prefixed revision.
abs_against and normalize_abs are the pure half, functions of their inputs alone, converting into the declared dialect once, on entry:
- absoluteness is a two-dialect question: a separator-rooted path and a drive-rooted one are both absolute, and a leading-slash test answers false on the second, sending an already-absolute root down the join-onto-cwd arm;
- segments split on either separator, so a backslash run is not one segment;
- the composed result carries the input’s own root, never an unconditional POSIX slash prepended onto whatever arrived.
A rootless input is treated as separator-rooted, because the contract puts no relative path here and the rule is scoped to dialect rather than to that caller error.
UNC and the extended-length prefix are out of scope, and one surface does produce one. path_root reads \\?\C:\repo as separator-rooted, so normalize_abs would return the mangling /?/C:/repo. On Windows std::fs::canonicalize answers in exactly that spelling, which is why walk::canonicalize() hands its answer back unconverted. That is safe only because every caller holds one of two properties. It compares the output against another canonicalized output, which keeps the spelling symmetric and unobservable. Or it strips the prefix itself before composing anything from it, through walk::strip_extended_prefix, a pure helper, before normalize_abs runs, as --run-guard-tests and the rewrite arm’s test sandbox do. Composing from an unstripped answer breaks it, so a new caller is judged against the two properties at its call site, never against a roster here. The first Windows crate-test run failed eight sandbox seeds that composed / onto the unconverted answer, with OS error 123, which is the extended-length spelling’s refusal of /.
native/src/registry.rs appends the checks segment to each resolved root and enumerates descriptors from it, so a root resolving to nothing takes every gate with it. That is the pure pair’s honest blast radius, and the argument for keeping any change to them a change to how two functions map inputs to outputs.
How the claim is held, with no oracle that can run it
There is no CI oracle for the composition rule. The PowerShell legs run the installer’s Windows smoke, binding while their triple is joined, and their driver reaches far enough to run each scratch consumer’s battery through the shipped executable, and the Ubuntu leg only cross-compiles. A passing battery there says the roots resolved for the cwd spelling that run held, and asserts nothing about a foreign one. So the arm is local and host-independent, on the mechanism §Fail-closed contract established for on_path. The platform-dependent input, cwd()’s spelling, is injected, making the decision a pure function a Linux host can exercise. The assertion is paired with a control, the pre-repair composition kept in the test and asserted to still reproduce the string the Windows leg printed. on_path’s source scan is not owed here: once the spelling is injected the composition rule is reachable, and a scan would assert shape where behavior is available.
The honest limit, so a green board is never read as a Windows run. These tests exercise the composition rule on the string the windows-msvc leg reported. That std::env::current_dir() answers in Windows spelling there is the injected premise, held by an observation and by nothing this repo can run.
check-path-dialect (§check-path-dialect) is the standing oracle over the other half, that every producer crosses at all, and it inherits this premise rather than discharging it. Its verdict is a syntactic property of the source, so it runs anywhere; it asserts that the crossing is written, never that the crossing works.
Worked dispositions, because a judged-safe site and an unjudged one look identical
Recording a verdict is the deliverable, because an unjudged site and a judged-safe site are indistinguishable on the page. The verdict is recorded at the site, as the spec: citation check-path-dialect reads, never as a roster here, which would go stale on every ported file and duplicate a record the gate already reads.
This section keeps the one verdict a reader most needs a worked instance of, exposed-but-satisfied: a site whose consumption is dialect-exposed and which is owed no change, because its value was already crossed. drift-kit’s collator composes <kit-root>/kpis/<name>.sh from the resolved kit-root set, a dialect-exposed site born of a port exactly as §Porting to Rust does not retire dialect exposure says. It is satisfied: those roots reach it through walk::abs_against, the crate’s own crosser, so they are already in the declared dialect, and a normalization added downstream would be the re-normalization the boundary clause forbids. registry.rs’ checks append is the same shape and the same verdict. A shell site spelled cd "$(dirname …)" && pwd, without -P, is uncrossed rather than satisfied, since pwd alone converts nothing.
check-path-dialect holds the claim across the remaining port, and what it asserts is bounded: every producer in the corpus crosses, or carries a recorded verdict. It does not assert that the crossing works, which rests on the injected premise, and it holds no coverage floor, so a green verdict is the absence of an uncrossed producer and nothing more.
The workflow directory
GATE_SDK_WORKFLOW_DIR (default .workflow) is where every kit writes its committed projections and its local capture. gate-sdk owns the knob, so it owns the directory’s contract; the kits that write into it own their individual files.
Two tiers, partitioned by tracking. Every member is either a checked projection (tracked, committed, gate-read) or local capture (gitignored, advisory, drained by a named reclaim path). A member that is neither tracked nor ignored is the drift state check-workflow-tiering refuses: an uncommitted file no reviewer sees and no .gitignore line covers.
Capture has one home per clone: the main checkout. A writer of a gitignored capture path resolves a relative path against the main checkout’s root when it runs in a linked worktree whose common dir is <main>/.git, and against its own root otherwise. An absolute path is used as written. A linked worktree is disposable, and the harness reaps an isolated child’s worktree when the child ends. A capture line written there is lost before any close reads it, and the sessions it drops are the isolated ones. The rule covers every capture location a kit knob names, the workflow directory’s capture tier and a metric directory alike.
It moves writes only: a reader keeps its own root, so an isolated child still reads committed state (delegation-kit/SPEC.md §The delegation model). A writer that reads its own log to decide what it writes, a keyed rewrite or a roll witness, reads it at the resolved path, since reading one file and rewriting another would drop the lines it never saw. A tracked write from a worktree is outside this rule. It is the child’s own commit, and the three tracked capture arms refuse there instead of routing (lifecycle-kit/SPEC.md §The committed gap inbox). The resolver is walk::capture_path. A consumer’s own capture writer outside the crate meets the rule by the same test, git rev-parse --git-dir --git-common-dir.
Honest limits. The main checkout must be writable from the child; where a sandbox confines the child’s writes to its worktree, a best-effort writer drops the line, as it drops any write it cannot open. A worktree of a bare repository has no main checkout and keeps its own capture.
The header requirement follows tracking, and it must. Local capture drains by emptying the file, whether by rotation or by truncation, and either way leaves no header. A header requirement on that tier would fight the tier’s own reclaim mechanism. Either the header is re-seeded on every clear, adding a writer to a surface any appender may write, or the rule is violated by correct operation. So checked projections carry the header, and local capture carries none; that asymmetry is the substance of the tracked-vs-gitignored axis.
It follows that a tracked member which drains, drains header-preservingly. The tier admits accumulation buffers as well as one-shot projections: a surface any session appends to and one session empties at a boundary. Such a buffer cannot take local capture’s emptying drain, which erases the header and reds this gate on the very commit that drains, so it truncates to the header instead. Every draining member of this tier already does: WORKFLOW-STATE.txt at the iteration boundary, gap-inbox.md at close, consult-items.md item by item at a consultation, release-declarations.md at the tag.
A capture log a close procedure reads before draining drains by rotation, never by truncation. Truncating after a read erases every line appended between the read and the truncate, and a close runs while other sessions write. --emit capture-drain <log> (below) renames the log aside to <log>.drain and leaves an empty live log in its place. The procedure reads and acts on the drain file, then removes it with --emit capture-drain --done <log>. A writer opens its log by path for each line, so a line written after the rename lands in the live log for the next close. A log no procedure reads before draining may still truncate. Honest limit: a writer that opened the log just before the rename and writes just after lands its line in the drain file. That line is lost only if the removal runs before the write, which leaves a window of one write rather than one triage.
--emit capture-drain [--done] [--] <log>. Without --done, it moves every unread line of <log> into <log>.drain, creating the drain file if needed:
- With no drain file present, it renames
<log>to<log>.drain. - With a drain file present, left by a triage that never finished, it renames
<log>to<log>.drain.part, appends that file’s bytes to<log>.drain, and removes it. - A
<log>.drain.partfound at the start is appended to the drain file first. A crash inside the arm can therefore repeat lines and never drops them. - It then opens
<log>for append, creating it, so the live log is present and empty as a truncate left it. An append-open never truncates, so a line a writer landed after the rename survives. - It prints the drain file’s path when the drain holds a line. Otherwise it prints nothing and removes an empty drain file, so a drain with nothing to read leaves no companion behind.
With --done, it removes <log>.drain; an absent drain file is not an error. Every rename, append and removal failure exits 2 naming the path. A missing operand exits 2 with the usage line. The arm touches only the operand and its two companions, and declares no knob, since the log is an operand. Its cases are the module’s unit tests. They pin a first drain, a drain with a leftover present, recovery from a part file, and a line appended after the rename surviving in the live log. They also pin --done on a present and an absent drain, a rename under a handle held open for append, and the refusals. On native Windows the rename under an open handle succeeds, because Rust’s standard library opens files sharing delete access, and crate-tests-windows holds it by running this module’s tests there (§check-crate-arms).
The header form. A checked projection’s first line is # contract: followed by one of two ruled payloads:
- Pointer form —
<owner-path> §<section>, optionally followed by an em-dash tail (—then a line grammar or a gloss). The path is a tracked file and the section resolves;check-spec-pointeralready owns that resolution, and the em-dash tail is already stripped before heading matching, so a grammar line may ride the same header as its pointer. This is the default form. - Version-marker form —
<format-name> v<N>(^[a-z0-9-]+ v[0-9]+$), used only where a gate parses the header itself as a wire-format version. The owning SPEC states that it does — evidence-kit/SPEC.md §Evidence manifest is the one such statement in this tree.
Requiring some header would gate nothing — the tracked side already satisfies it — so the requirement is the prefix with a ruled payload. Both payload forms are machine-recognizable, which is what keeps the rule a rule rather than a description.
The coupling-graph artifact is not a member of this tier. A rendered HTML document opens with a doctype, so it can carry neither payload form and stay a valid document, and it is tracked and freshness-gated, so local capture is not open to it either. It fits no row of a partition this section calls total, so placing it here would be a defect in the emitting default, and check-workflow-tiering would red on it. Its home is the gates dir, <gates-dir>/CHECK-GRAPH.html, on two grounds:
- the gates dir is a root entry every consumer allowlists anyway, so no adopter pays a new root surface (§check-root-tiering);
- it presumes no
docs/, which a published-site path would.
A consumer wanting it on a site repoints GATE_SDK_GRAPH_ARTIFACT (§check-graph), which is what that knob is for.
The extension rule keys on writer and reader, not on tier. These are two independent axes, and conflating them is why the convention resisted statement: the directory holds tracked .md beside gitignored .md, so no extension tracks tracking.
.txt— a record file with a stated line grammar that a gate orbin/affordance parses field-wise..md— a prose surface a human reads and dispositions, machine-read only for emptiness, a bullet count, or a bullet’s lead token under a named section. The release declaration surface (§upgrade-smoke) is this last kind: a human transcribes it into a note and the holder reads its lead tokens, so a.txtline grammar would give it a second grammar beside the note’s..log— an append-only capture stream written by tooling at the moment of an event, triaged in bulk and cleared wholesale; no per-line grammar contract, which is exactly why nothing parses it field-wise.
A new file’s extension is therefore determined, not chosen: ask which of the three describes its writer and its reader. The extension rule is deliberately not gated — deciding whether a file is a record, a prose surface, or a capture stream is the judgment the rule exists to guide, and mechanizing it would mean inferring a writer’s intent from a file’s bytes. Under the enforcement-first carve-out it takes the other disposition available to an un-gateable class: a cadenced review entry on the consumer’s audit roster, due at each workflow-directory addition.
The tracked tier is also the surface canon-kit’s comment gates read — the governed comment/pointer surface admits a workflow member iff it is tracked, whatever its extension, and blesses only contract:/see there (canon-kit/SPEC.md §check-comment-tier).
The bin/-tool contract
A kit’s bin/ tools are shipped executables that are explicitly not gates: they take caller-supplied arguments, and several of them capture, appending what they were handed to a durable, committed surface. The gate model below governs none of them, so the one authoring rule every kit’s tools share is stated here, beside §check-exec-bit’s tree-wide */bin/*.sh invariant.
A bin/ tool whose positional arguments are free text validates their shape, not only their arity. Free text is an uninterpreted caller-supplied string; an argument drawn from a known set is not free text, because validating membership already validates shape. Three behaviors:
-h/--helpas the first argument prints the tool’s usage on stdout and exits 0. Usage on a successful help request is output, not a diagnostic.- A positional argument beginning with
-that is not a recognized option is a refusal: usage on stderr, exit 2. --ends option processing, so every remaining argument is taken as free text however it is spelled.
The help half binds every bin/ tool; the refusal and -- halves bind free-text positionals. Help is discoverability, owed whatever a tool’s positionals are, including none and including a forwarded argv. The one exemption is a total forwarder, a tool whose whole contract is its target’s, so the target’s own help is the answer (installer/SPEC.md §The verbs rules the bootstrap one). A forwarder that adds behavior of its own is not total, and takes -h / --help as its first argument before anything is forwarded. A .ps1 twin follows its .sh member.
An arity check is not a shape check. A tool taking exactly one free-text argument accepts --help as that argument, and a tool taking two accepts a flag in either slot; arity makes the single-argument case worst and never makes any case safe. A tool that captures turns the defect into a committed record that reads like a real filing, so the rule binds hardest there. It binds on every free-text tool all the same, because a session hunting for a mode that gets an error in place of usage works around a contract the usage text would have told it did not exist.
The -- escape is not decoration: without it the refusal makes a legitimate filing unfileable. Recording that a --list argument was captured takes bash gate-sdk/bin/run-gates.sh --emit file-gap -- "--list is captured at exit 0".
The shape half outlives a member’s port, even though this contract’s own corpus is */bin/*.sh. The refusal exists because a free-text positional absorbs whatever it is handed, which is a property of the argument, not of the substrate the argument arrives on. So the shape refusal and the -- escape cross the port. The help half does not travel with the member. A ported member holding its own case arm has its usage answered by the front-end’s --help, and an --emit- member’s usage lives at its shape refusal, which prints it at exit 2 (§The non-gate arm). --emit-file-survey, --emit-file-gap, --emit-kfric and --emit-file-install are the capture instances.
The clause does not turn on capture, and a free-text reader is bound on the same ground. An absorbed argument in a reader writes no bad line, but does equal harm in one of three forms. Each port below added the three behaviors its shell form lacked:
- a wrong number in a durable series:
--emit-scan-prompts(guard-kit/SPEC.md §scan-prompts) absorbing a one-character typo of its own flag reports a clean tree at exit 0, and a KPI records that as a trend; - a non-reading dressed as a reading: in
--usage-verdict(delegation-kit/SPEC.md §usage-verdict), a--helpabsorbed as the snapshot path prints a STALE verdict the per-dispatch budget guard routes to advise, at the exact decision point a hook grades; - a false clean: in
--diff-baseline(evidence-kit/SPEC.md §bin/diff-baseline.sh), a--helpabsorbed as a suite name matches no baseline row and printsdiff-baseline: cleanat exit 0, to a CI step whose only output is a check mark.
--emit-usage-trend (delegation-kit/SPEC.md §Trend reporter) renders no verdict, so an absorbed argument there costs discoverability alone: a file-not-found where usage belongs. A status distinction is itself protected by the clause. --wait-probe’s report (delegation-kit/SPEC.md §bin/wait-probe) exits 1 when no trials are recorded and 2 on misuse. A port may not fold the two even where the collapse would satisfy a documented sentence, since the difference is between an empty reading and a misuse.
An --emit- member declares its argument grammar on its arm-table row, and the dispatcher holds the refusal. A member reading only flags names them, none for a member taking no argument. The dispatcher refuses any other token at exit 2 before the member runs, so a surplus or misspelt argument never reads as success. A member parsing its own argv declares its usage block instead, naming the arm and no program, since one binary answers it through every door. It spells that block beneath its own shape refusal, once, since only the member can tell that refusal from a runtime one. A runtime refusal, such as a missing file or a directory outside a repository, prints its cause alone. An Arm::Run member parses its own argv.
No gate reads this rule, and that is ruled rather than deferred. A gate over §check-exec-bit’s */bin/*.sh corpus could assert only the weak static shape, whether the file contains a --help branch, which passes a tool that prints usage and captures --list anyway. The predicate that matters is behavioral. lifecycle-kit/SPEC.md §bin/enter-stage.sh rules --simulate advisory tooling owed no fixture pair and exercises it end-to-end in smoke/, and each member’s coverage follows that precedent. Enforcement-first’s clause that removing the duplication outranks gating it points at one stated rule plus behavioral coverage, never a static scanner that would green the case it was bought for.
The gate model
A gate family imposes test-grade rigor on prose and config surfaces, but a gate whose own correctness is unverified silently stops enforcing: a false green on a crashed awk, or a scanner that crashes. So the family tests, lints and constrains itself by the same standard it holds the governed tree to, through the four contracts below.
Output contract
A gate signals success with exactly one line ^<NAME>: clean (<parenthetical>)$, where <NAME> is the gate’s stable upper-token id and the parenthetical states what was checked. One canonical success token runs across the whole family, because a gate that passes silently is indistinguishable from one that did nothing. Failure is one line per finding (location and what is wrong), then a help: remedy line naming the concrete action: the model is the Rust compiler’s help:, not a restatement of the violation. A gate with several distinct failure classes gives each its own help: line. Exit codes: 0 clean, 1 violation, 2 harness/usage error. The status is the verdict whether or not stdout was read: a reader that has gone changes neither (§The non-gate arm).
A help: line may name the governing SPEC section that owns the remedy, and the citation is a pointer, never a restatement. The crate’s own refusals, bin/build-native.sh’s and the --run-consumer-smoke arm’s all take this coupling, and stating it keeps a later reader from removing it as an accident. Pointer-not-restatement is the content-tiering rule applied to refusal text, and it stops a message growing into a copy of the section it cites.
The contract is enforced at runtime, on real output, by the fixture runner: a good/ case must emit the canonical clean line and a bad/ case a help: line (§run-gate-tests owns the mechanism). That is the oracle for every fixtured member on either substrate. A source grep is only a proxy for the behavior, and reads nothing once a gate’s rule is a compiled subcommand.
check-gate-output keeps the static half for the one class the runtime assertion cannot reach: a member carrying # no-fixture:, which has no case for the runner to execute (§check-gate-fixture-coverage). For those the source grep is the only oracle, so it stays, since retiring it would silently zero those members’ output-contract coverage.
Fail-closed contract
A gate that captures a subprocess’s stdout and branches on the captured value’s emptiness false-greens when that subprocess crashes: the output is empty, so the gate prints clean, but the check never ran. The fix is to key on the subprocess exit status. The wrapper is centralized in lib/gate.sh; source it and call fail_closed right after a capture:
. "${GATE_SDK_ROOT:-gate-sdk}/lib/gate.sh"
out="$(awk '…' "$FILE")"; st=$?
fail_closed "$st" check-foo awk # non-zero status -> exit 2, never a false clean
Wrap an awk/jq capture that reads a file and feeds the verdict. A sweep must NOT blanket-wrap grep’s exit-1-on-no-match (expected, not an error), an already-status-checked capture, or an in-memory transform (a <<< here-string, a trivial field selector that cannot fail on present input). check-gate-fail-closed enforces the rule, and a genuinely safe capture opts out with # fail-closed-exempt: <reason> on the comment block immediately above it. gate-tests/lib-gate.test.sh tests the helper directly, since a well-formed awk cannot be crashed on present input and so no per-gate fixture can prove it.
On the binary substrate the defect is closed by construction. A ported member spawns nothing itself. The crate’s one shipped spawn site, the declared exception below aside, is native/src/proc.rs. Its run returns Err for a spawn failure, in the same “could not run” words, and a Completed otherwise, and Completed hands out stdout only through an accessor that has already read the exit status. So “capture stdout, ignore status” has no spelling, and a program that could not be run is an exit-2 refusal rather than an empty capture read as clean.
- The wrapper is proved directly, as the shell helper is, since no static input crashes a child. Unit tests cover a spawn that never happened, a child that exited non-zero, one that succeeded, a child that failed writing only to stdout, and one that failed writing to neither stream.
- A routing test holds the funnel. The
Commandspelling is asserted absent from every shipped module innative/srcexceptproc.rsitself, the roster shape §check-reads-couples’ unit test B uses for filesystem walks, so each new spawning member inherits the property. The corpus is the whole crate because the funnel is only a funnel if nothing goes around it, and a spawn added anywhere else would escape both the false-green half and the Windows resolution everyproc::run*applies.native/src/actions.rs, shared gate mechanism that spawns nothing, sits inside the corpus like every other module. - Test scope is dropped from the corpus, by derivation. A
#[cfg(test)]-gated item, and a modulemain.rsdeclares under#[cfg(test)], are read out of the source rather than named. The test-scope spawns inmain.rsandknobs/mod.rscheck their own status already, and pulling them in would make the production wrapper carry a cwd and an env nothing in production reads. - One exception class is declared rather than silently excluded. A shape
proc::run*cannot carry names itself with aspawn-funnel-exempt: <cause>comment above the site, on the tree’s own valve convention. Its single member is theCommandExt::execin the wait probe, which replaces this process image rather than spawning a child, so a funnel that returned would have no image to return into.
Widening proc.rs is how a member that needs more of the child’s result gets it; building its own Command is what the test refuses.
Completed::failure_report is such a widening, because a wrapper that reports “the child failed” and drops what the child said turns one line of diagnosis into a whole CI round. It hands back the failed child’s exit code and both captured streams, composed into one string. Two properties keep it off the false-clean path:
- It returns
Nonewhen the status succeeded, so it is reachable only wherestdout()already answeredNone: it adds a cause to a refusal and no path to a clean verdict. - It returns a composed
String, never&[u8]. The defect closed is a rule branching on a crashed child’s empty capture as though it were the child’s answer, and a string whose only operation is printing cannot be parsed back into a verdict. It is a report about the child, labelled with its exit code, so carrying stdout here reopens no hole, andCompleted::stdout()still reads the status first.
The report is non-empty by construction. The exit code always exists, taken through the same private exit_code §run-gates uses, so a signal-killed child reads 128 + n. Both stream labels are always present, because which stream was empty is the reading a bare refusal cannot give. A caller composes the report into its own finding line and needs no empty case.
The routing test has a sibling pin in the same module: where the crate may change its own working directory. It reuses the spawn roster’s shape with the opposite scope rule. Test scope is kept, because a test is exactly the writer it exists to catch (§lib/gate.sh — a case directory is set on a spawn and never entered). Every crate source is scanned for the code spelling set_current_dir(, never the bare word, so a directive comment naming the API states the rule rather than breaking it. A site is an offender unless cwd-write-exempt: <cause> sits in the four lines above it, the same window and named-cause valve as spawn-funnel-exempt:, and the red names the route: set the directory on the spawn.
- Both valve tokens ride a
spec:directive rather than a bare comment (// spec: <section> — spawn-funnel-exempt: <cause>), which is how they clearcheck-comment-tierwith no roster registration. - Two sites are valved, each a process entering the tree it works on at a point no test reaches. The
--pack-installerarm’spack()enters the tree it packs: its module’s cases call only its pure helpers, and its one caller is the arm’srun. The--hookarm’srun()enters the toplevel it resolved (§The harness-integration arm): its module’s cases call the parser and the envelope helpers, never the dispatch. A valve at the site replaces an allowlist in the test, so the cause travels with the code it excuses. - The red runs in both directions, an unvalved site or a valved count other than the two above, so deleting a site reds the pin until the count moves with it.
- Honest limit: a spelling roster cannot see a dependency that changes the working directory, which has no instance in the resolved graph, nor a later test reaching a valved site through the arm table.
A third pin there holds where the crate reads its own path. A bin/build-native.sh run replaces the binary’s file under a battery still in flight. Linux then answers the own-path call with the old name marked deleted, which names no file. A member that spawned or copied that name refused at exit 2 on a clean tree. So proc.rs owns the call behind two readers, and the code spelling current_exe( is asserted absent from every other crate source, test scope kept.
self_imageserves a child spawned or a copy taken while this process runs. It answers the start name while that is a file, and otherwise the process’s own image link under/proc. A child spawned or a copy taken across a replace of the file is then the build that is running.self_pathserves a path that outlives this process or is read for its directories. It answers the start name, the kernel’s mark dropped where the marked name is no file. A later reader then starts whatever build is at that name.- Honest limit: the image link is Linux’s. On a host with none, a replaced binary resolves to its start name, the new build where the host put one there.
self_imagetests the start name and then returns it, so a replace landing between that test and the child’s open of the name starts the new build in that one child. A file whose own name ends in the mark is read as itself.
A tracked-set listing whose output is read is taken NUL-terminated. Git’s line form quotes a name carrying a non-ASCII byte, a double quote or a backslash, and a quoted spelling opens no file, matches no glob and compares equal to no walked name. The failure is silent in the common shape: the member drops out of the corpus and the verdict reads clean.
- One reader where the caller’s shape allows. A caller listing tracked names under pathspecs, with or without a repository directory, calls
native/src/listing.rs’tracked, onegit ls-files -zspawn through the crate’s spawn site. The reader is a module of its own besidewalk.rs, never a gate’s module, so a member’s derived couple names it and no member imports another member’s file for it. A caller the reader cannot serve keeps its own argv on the NUL form: stage records, the untracked set, a top-relative listing, a listing through a helper its history reads share, and a read of emptiness alone. - A probe is outside the rule. An
--error-unmatchcall reads a status and no output. - A caller keeps its failure direction. The reader reports a listing git could not produce as a class of its own, and each caller decides whether that refuses or degrades.
- A name that is not UTF-8 refuses, exit 2 through the calling member, naming the member in its lossy spelling and saying its name is not UTF-8. A lossy decode would open the member under a replacement-character spelling, a path that does not exist or, worse, one that names another file. Carrying the name’s bytes is refused: the crate’s directory lister already refuses such a name, so a member listing by git and walking the same tree would read the name on one path and refuse it on the other.
- That refusal is the reader’s own class, never a failed listing. A caller that degrades a failed listing to an empty corpus would turn it into a clean verdict over members it could not name. So each degrading caller refuses on this class alone:
walk.rs’tracked_matching,emit/enum_sets.rs,spec.rs’workflow_tierandgates/door_binding.rs. - A crate unit test holds the form. It reds a quoted
ls-filesargv token in the crate’s source whose argv carries neither the NUL flag nor--error-unmatch, outside a roster the test carries, each row a file and its reason. A row names exactly one such token, so a row whose token has gone reds too. It is a unit test rather than a gate because its subject is the crate, which §check-crate-arms’ test arm runs on every commit that touches it. - The roster’s rows. Two are the source stamp’s,
fresh.rsandnative/build.rs. The stamp is one derivation in four holders, two of them shell (§check-gate-binary-fresh), and each writes a path into it as listed. Moving two holders alone would break their equality on the names the move exists for. Its corpus is the crate’s own files, and git’s hash read refuses a quoted path, so on such a name every holder refuses rather than stamps. The hook-environment test inemit/git_hook.rslists in line form to reproduce a hook’s own read. The read-only subcommand roster inemit/scan_prompts.rsis a word list and no argv. - Two crate cases list the names the rule exists for. One builds a scratch repository tracking a name with a non-ASCII character on every platform, and on Unix one with a double quote and one with a backslash. It asserts each comes back as written. The other is compiled for Linux alone, a host whose filesystem admits a name that is not UTF-8, and asserts the refusal and its text.
- Honest limit: the holder reads source text, so an argv assembled across functions, or a listing reached through a helper that hides the token, passes it. A reader that keeps an argv of its own keeps its lossy decode.
The same contract binds the dispatcher, and there the refusal is raised inside gate_command, a trap for its callers. A .gate member whose binary is absent or not executable is a harness error. gate_command (§lib/gate.sh) writes its own naming stderr and exits 2 from inside itself rather than returning. A caller invoking it through a process substitution, mapfile -t argv < <(gate_command …), never observes that status: the exit kills only the subshell and mapfile binds an empty array. The caller then reads an empty argv whose only modelled cause is gate_command’s return 1 for a member that resolves in no check dir, and tells a session a gate that could not be built does not exist.
So a caller captures gate_command’s status with a command substitution, never a process substitution, and branches on it. Status 1 is the resolves-in-no-check-dir refusal it may name itself. Any other non-zero status is a failure gate_command has already named on stderr, propagated without a second, contradicting sentence. No caller in this tree takes the process-substitution shape, so the rule is stated against the helper and binds the next caller written; where it is discharged for the pre-flight roster is evidence-kit/SPEC.md §check-evidence-manifest’s.
The wrapper contract: a member whose rule is an external program refuses with its own message, at the shell form’s own point in the order. Criterion 7’s program-is-the-rule class ports as a wrapper (§The port-candidate criteria), and run’s Err arm alone does not discharge it. proc.rs carries what it needs, so a cohort of wrappers buys it once.
on_path(program) is bash’s command -v <prog> reduced to is there an executable of this name on PATH. It exists so the refusal is the member’s own documented text rather than a generic spawn-failure string, fired at the shell form’s point. That ordering is load-bearing: check-shellcheck probes the linter before it globs its targets, so a tree with nothing to lint and no linter reports the linter, where catching the spawn failure would report the empty corpus. The point is per-member, not a class rule: check-action-run-shell validates its positional scan root first, so an absent root reports the root even with no linter installed. A refusal message is a documented surface, since a session debugging its PATH reads the message, not the exit code. run’s Err arm stays the backstop for a program that vanishes between the probe and the spawn.
Two platform axes decide whether on_path answers at all, and getting either wrong reports a program the host has as absent, the false verdict the class exists to prevent.
- The separator is
std::env::split_paths’s, never a literal':'. A WindowsPATHseparates on';'and its entries carry drive-letter colons, so a literal split shears every entry past the first into a fragment. - The name is not the bare program. A Windows toolchain installs
<program>.exeand npm installs a.cmdshim, so the probe tries the program with eachPATHEXTextension appended, falling back to.COM;.EXE;.BAT;.CMDwhen the variable is unset or empty. The list is read from the environment rather than appended as a literal, ongate_exe_suffix’s ground (§lib/gate.sh), andexe_candidatesalone owns it, so no call site spells an extension. It answers what an installed program may be named, a different question fromgate_exe_suffix’s built-artifact name, so the two substrates hold separate single owners. - The bare name is the last candidate, never the first. Windows resolves a typed name through
PATHEXTand will not execute an extensionless file. A bare-first order would let an extensionlessnpmsh script beat thenpm.cmdshim Node’s Windows install ships beside it. A Unix host passes noPATHEXT, whose arm returns the bare name alone, so the order moves no verdict there.
The resolution is a pure function of (PATH, PATHEXT, an existence predicate). Its Windows arms cannot execute on the host that develops them, so a fake predicate is the only oracle a unit test has. The separator has none even so: split_paths compiles to the host’s rule, so a test pins only that the literal has not come back, and the Windows CI leg exercises the arms. This cannot-exercise-locally doctrine has a second instance, §The path-dialect contract’s root resolution, held by the same injected-input shape and paired vacuity control; the mechanism is stated here and cited there.
The spawn resolves through the same function the probe resolves through. Every proc::run* helper resolves its program before Command::new, on Windows only, falling back to the bare name where nothing resolves. An ordinary name resolves through which, and a name carrying a system-directory twin through §check-graph’s homonym roster, the one class where falling back is the wrong answer. An argv[0] carrying a path separator is a value its caller already resolved and passes through untouched. The invariant, and the whole of the rule: on every host, if on_path(P) answers true then the spawn of P reaches the file which(P) named. One resolution serves both, so a wrapper cannot pass its preflight and then refuse at the spawn on a program the host has.
- It is a pass-through off Windows, on
resolve_floor_tool’s ground: resolving there would swap the spawned literal for an absolute path on every host the battery runs on, so every POSIX verdict is byte-identical. - It falls back rather than refusing, spawning the bare name and yielding the existing error text where nothing resolves. So it is monotone: no gate can newly red on it. §check-graph’s refusing resolver answers a different question, a system-directory homonym rather than a naming dialect, and the roster keeps the two apart per name.
dispatchis outside the funnel: itsargv[0]is a resolved binary or script path its caller computed, never a name for the platform to search.
What Command::new does with a resolved .cmd is an observation, so the repair is a ladder and a Windows run selects its rung. Rung 1 is the funnel’s own spawn, Command::new(<resolved path>), settled for a resolved .exe or .com. For a resolved .cmd or .bat it is not settled: std’s batch-file handling changed across releases, and the argument-escaping repair for CVE-2024-24576 landed in 1.77.2, above the crate’s rust-version pin. The oracle is install-smoke-pwsh-windows, whose pack step spawns the resolved npm. A green leg means rung 1 suffices and rung 2 is never built; a red leg naming a bad executable format means rung 2 is owed.
- Rung 2 is specified here so a red run costs no design round: a named helper invoking the resolved batch file through the command processor, taken by a call site that opts in. Composing a command processor’s command line is where argument injection lives and the pin’s
stddoes not escape for it, so the helper is restricted to a call site whose argv is a compile-time literal set with no caller-supplied string. - A knob naming the rung is refused: its only reader would be a human guessing at a platform question the run already answers, and it would let a host silently take the wrong rung.
- Raising the pin to 1.77.2 is declined. It would raise a toolchain floor the substrate exists to collapse (§The adopter constraints). An MSRV bump also un-suppresses clippy against unchanged code (§The settings cohort, and the crate’s first dependency), so a floor move is its own unit with its own clippy budget.
run_merged and Merged are the 2>&1 capture a wrapper’s shell form takes. Completed withholds stdout unless the status succeeded, which is right for a reader and wrong for a wrapper, since for a linter the non-zero run is the one whose report must be printed. Merged therefore reads its report whatever the status, and closes the false green on the other side: succeeded() reads the status, so a clean line is unreachable from an empty capture. The merge is two handles on one file description (try_clone is dup), the technique dispatch uses, so the streams interleave exactly as bash’s 2>&1 did.
Mergedexposes the child’s exit code onCompleted::code()’s ground, because a program grading itself by more than success and failure must be read that way. For ShellCheck, 1 is findings I lint into a report and ≥2 is a fragment I could not lint, and folding both intosucceeded()would print an error as a finding list.- Its sibling
reported_code()serves a wrapper that prints the code instead of branching on it. Bash’s$?spells a signalled child128 + n, so a report readingfailed (exit N)keeps the shell form’s number. Two accessors exist because the grading caller needs the not gradeableNonethe printing caller cannot use.
run_streamed and Streamed serve a wrapper whose program is a filter: a body written to its stdin, its stdout read back, its stderr left alone. Three differences from the two faces above are each forced.
run_with_stdinwrites its whole input before reading a byte, so a child that fills the stdout pipe stops draining stdin and both sides block. Two capture files are deadlock-free, and the size is real:check-docs-render-fidelitystreams its whole docs corpus, two orders of magnitude past a pipe buffer.- stderr stays unmerged, because a filter’s output is a framed stream its caller parses, and folded diagnostics would corrupt the framing.
- A spawn failure folds into the child’s own
code()as bash’s 127, or 126 where the file is present and will not execute. For this shape the code is a number the member prints inside its own refusal, and anErrarm would lose the message parityon_pathexists to keep.
stdout() is ungated for Merged’s reason, and the false green is closed the same way: the caller reads code(), or grades the stream by a framing count an empty capture cannot satisfy.
Who consumes on_path is derived, never listed here. A reader wanting the live consumer set greps the crate for the symbol; the arms include non-gate ones, so a gate roster would not answer it either.
A wrapper’s refusal is not always an on_path refusal. on_path serves a member that tests for its program. check-docs-render-fidelity probes its oracle by running it, two documents in and two back, because for a renderer presence on PATH is not the property the gate needs: a parser build that loads but cannot parse would pass a presence test. There the absence surfaces as the probe’s own exit status inside the member’s own sentence, which is why run_streamed synthesizes the status bash would have reported. Both shapes obey the one class rule: the refusal fires where the shell form fired it, and says what the shell form said.
Where the shell form fired no refusal at all, the exit-2 rule decides, and the port diverges deliberately. check-producer-liveness reaches ps through ek_pid_alive, which discards the program’s 127 into a boolean and reports not alive. With ps gone, the shell form prints a clean line. That is the clean because the program was missing vacuity this section closes. The port refuses, on the fallback leg only, because a kill -0 that answers never reaches the program, and only the non-unix build still has that leg. The ordering rule is the constraint where a shell refusal exists, never a licence to inherit a false green. The divergence is asserted at the member with its cost stated (evidence-kit/SPEC.md §check-producer-liveness), and the parity run reports it as a differing arm.
A wrapper’s program can be a shell builtin, and the route to it is chosen per platform. The pid predicate’s first leg is signal 0. On unix the crate calls kill(2) through libc, which returns exists but not yours as EPERM, so the unix build needs and declares no fallback program. On Windows the route opens with a native leg that answers a Windows pid from a PowerShell launch in process (evidence-kit/SPEC.md §The producer-liveness lock). Behind it the pids are an MSYS shell’s, which only that shell’s builtin resolves. So the MSYS leg stays bash -c 'kill -0', whose exit status conflates EPERM with ESRCH, and ps -p answers existence behind it; ps is on GATE_SDK_PROGRAM_FLOOR’s default (§The program roster). Spawning /bin/kill there would mint an off-floor requirement, and probing with ps alone would require it on every call. The non-unix declared set carries both, because unit test A is observed ⊆ declared.
Neither half has a fixture representation, so each wrapper’s parity run carries a constructed scenario: both implementations over the same cases with the program present, and again with PATH scrubbed of it, comparing bytes and exit codes. A committed case cannot remove a program from PATH; this is the # no-fixture: discharge shape applied to a member that has a pair, which §The port-candidate criteria criterion 2 licenses. A wrapper declaring more than one program owes the scenario per program and for the set. check-crate-arms runs it with cargo absent, rustc absent and both absent, because only the per-program arms show that one program’s absence refuses while the other’s does not. The scrub removes exactly the one name and keeps the rest of PATH, since emptying it tests a different absence: the shell form’s own library resolves kit roots through realpath.
The routing test is also what makes --needs trustworthy, so the spawn recorder lives in proc.rs. It is a #[cfg(test)] recorder on the shape of walk.rs’s read recorder. Every spawning entry point (run, run_with_stdin, run_merged, run_streamed) notes the name of the program it is about to spawn: the Program it is handed, named as name_of names its invocation, a path reduced to its final component. The requirement ground is §The # graph: manifest’s <program> line kind, and that section’s unit test A reads the note back after running a member over a fixture case, inside the child that runs it (§lib/gate.sh).
- A face added to
proc.rsthat skipped the note would un-verify A silently, so the recorder is part of what wideningproc.rscosts. - It is test-scoped deliberately, since a production recorder would be state with no reader, and no gate module can reach it.
- Because the routing test proves no gate module builds its own
Command, hooking the wrapper functions observes every spawn a member can make.
The program roster
Every program the binary may spawn is a constant in native/src/programs.rs, of a type no other module can construct, and proc’s spawning faces take nothing else. So the roster is the binary’s spawn set, held by the compiler rather than by a census.
- A consumer-named command (a knob’s argv, a gate’s
.shdeclaration) enters throughProgram::consumer, which carries the knob or declaration that named it. It is the consumer’s requirement rather than the payload’s, and a spawn failure names that ground beside the program. - A resolved invocation path is taken with
Program::at, which keeps the identity it retargets, socurrent_exe()andGATE_SDK_NATIVE_BINspawn as the payload member rather than as a second name. - Each roster member carries
toolfloor’s audience field.
Unit tests hold four relations:
- an adopter-side member (any audience but
contributor, a conditional one included) is onGATE_SDK_PROGRAM_FLOOR’s default, onPROBE_SET, or is the payload itself; - a member on both the roster and
PROBE_SEThas one audience; - every
PROBE_SETelement is a roster member; - every consumer command’s ground names a knob or the gate-declaration ground. That scan reads call syntax over shipped scope, and a ground forwarded as a
&'static strparameter is followed to its callers, which are held to the same form. A ground built at runtime would escape it, and&'static strruling out a formatted string is the rest of the floor.
The PROBE_SET walk names its members through programs::by_name, which answers only from the members that walk probes; a test holds that set equal to PROBE_SET’s names. So a row nothing spawns is still dead code, which the crate’s deny-warnings lint reds.
The roster records the union. Which member spawns a program is recorded per member in REGISTRY for gates and in prose for arms (§The non-gate arm), since a per-arm declaration nothing runs is the self-declaration §The # graph: manifest refuses. which stays a name-typed presence probe, since a name probed and never run is no requirement. The members’ audiences:
pwshandpowershellarecontributormembers:--run-front-end-parityspawns them on CI legs (§run-gates), and §with-foreign-shells probes thepwshit copies, so the floor-or-probe relation does not bind them.dockeranddasharecontributormembers on that arm’s ground alone, and members on Linux builds only.mktempandcparecontributormembers because only source-clone arms spawn them:--run-demo,--run-consumer-smoke’s builder,--upgrade-smokeand--agents-md-smokeneed the kit sources and a cargo-built binary, the payload withholdssmoke/(GATE_SDK_PAYLOAD_WITHHOLD), and--pack-installeris the publisher’s own. A later adopter-side spawn of either reclaims an adopter audience.shis acontributormember on unix builds only:--installer-smoke, which needs a checkout, spawns it for the host bootstrap’s detector (installer/SPEC.md §The consumer smoke).shasumis one on the same ground: the arm spawns it as the control of the bootstrap’sshasumfallback.dateandpsare members on non-unix builds only.bash’s spawners include canon-kit’scheck-fence-run, which spawns it only when a doc marks a fence runnable and records it on itsREGISTRYrow; the bash audience’sderivedarm owes that spawn (context-kit/SPEC.md §bin/env-probe).
Fixture-pair discipline
When a gate is written or edited, it ships with, or updates, its good/+bad/ fixture pair under <tests-dir>/<gate>/, run by the --run-gate-tests arm. A gate that prints clean on broken input is invisible to every static check. The only proof that it fails closed with the right error text is a known-bad input, the bad/ case asserting exit 1 and every line of its expect.txt (§run-gate-tests owns the semantics), paired with a good/ case asserting acceptance. check-gate-fixture-coverage enforces coverage: every registry member carries either a pair or a # no-fixture: <reason> header annotation. The valve is the reviewable escape for whole-tree scanners whose state has no static-fixture representation, such as a HEAD-vs-worktree diff. A fixture-capable gate carrying the valve as a stopgap is filed as debt and fixtured, never given a dishonest “infeasible” reason.
A behaviour change discharges the contract per case dir, and no manifest enumerates them. couples= names a gate’s corpus, never the pair that proves it, so a change re-derived from the # graph: manifest never reaches the fixture. The pair also sits in the tests-dir of whoever owns the descriptor, which need not be whoever owns the implementation module: a consumer-registered gate implemented in the shared binary has its only pair under the consumer’s tests-dir. So a rule narrowed in a module discharges the contract only once every <tests-dir>/<gate>/ for the gates that module backs has been re-run, kit-owned and consumer-owned alike. For a crate edit, check-crate-arms’ fixture arm discharges that at commit time by running every derived suite whenever the crate’s source stamp moves (§check-crate-arms). An edit outside the crate that moves a gate’s verdict, such as a descriptor’s arguments or a case’s own knob file, is re-run per case by its author.
A freshness gate’s good/ corpus is regenerated by the emitter, never hand-edited, and the emitter’s root convention differs per arm. Run the arm against the fixture’s own synthetic root. The docs-mirror arm takes an explicit --root, while the footprint and value-rollup arms ignore argv and resolve the root at cwd, so those two are regenerated from inside the case dir. An emitter’s declaration literal may carry source-level line continuations, so the emitted bytes can differ from the literal a reader copies out of the module.
An invariant whose bad/ case would itself be unshippable payload content cannot take the pair form, and takes a producer-side refusal instead. The worked instance is the packed set carries no tracked symlink (§Consumer payload). A bad/ case proving the red would have to be a tracked symlink inside a kit root, reintroducing the artifact the invariant forbids and shipping it in the payload. The prune set excluding gate-tests does not rescue it, since pruning removes a path from a gate’s corpus, never from the payload. The four-contract shape is what refuses the gate here, and pack_tracked’s pre-flight refusal is the form the invariant takes. It is stated so the next reader does not read the absent gate as an omission, the same class of disclosure as §check-tree-terms recording which arms a case dir cannot reach.
The pair ships, and under §Consumer payload it is the consumer’s whole verification oracle. init enumerates a kit’s payload without filtering, so the pair arrives whole. With the predicate withheld it is the only thing a consumer can independently check: the pair plus the --run-gate-tests arm is the entire answer to does this binary do what its SPEC section says. gate_command’s substrate-blind dispatch (§run-gate-tests) makes it work across substrates, so the property is a contract that may not be dropped to slim a payload. Opacity does not extend to it: fixtures disclose a gate’s shape, never its predicate, and withholding the consumer’s only parity oracle would spend the trust story to buy marginal opacity, since the help text and the SPEC section ship regardless.
What the pair is evidence of differs by tree. In this repo it is post-build verification. In a consumer tree nothing is built, and the artifact arrives prebuilt and digest-verified, so the pair is post-install acceptance evidence: does the artifact behave as its specification says, against cases the publisher is held to by the same meta-gates. The digest answers which artifact arrived and the pair answers what it does. It is also the consumer-side answer to the honest limit §Meta-gate conservation for the binary substrate records, since the crate-side verification of a gate’s declared read-set runs only where cargo runs.
A ported member’s pair exercises every arm of the corpus derivation it ports, not only the arm the gate’s own sources are written in. A pair covering one arm in four ships the rest proved by nothing, and the failure is invisible, because the uncovered arms run green on a clean live tree. The widening is the parity instrument, because the pair is the only oracle whose corpus is inert under a port. gate-tests is a member of GATE_SDK_PRUNE_DIRS’ default (§lib/gate.sh), so nothing inside a case dir is reachable from a live-tree walk or is a registry member, and no port can add or remove a file in one. That is the corpus criterion 4 says a self-referential port must design (§The port-candidate criteria, criterion 4).
A case dir is not its own git repository, and that decides what a git-walking arm can see inside one. A git ls-files run with cwd inside a case returns the outer repo’s index scoped to that subdirectory, printed relative to cwd. Three consequences follow:
- the
gate-testsprune never fires inside a case, because the scoping already stripped the prefix it matches on; - a non-repository arm is unreachable from any case dir;
- a file a case plants to widen an arm is invisible to the gate until it is
git added, so an unstaged plant leaves the widened arm running over nothing while the case still passes.
Two properties make the widening worth its cost. Its planted violations are standing guards: a bad/ case whose only violation lives in an arm reds forever if an edit later drops that arm from the walk. And a widened case tightens the crate-side read-set verification for free, because unit test A observes each member running over its own cases (§Meta-gate conservation for the binary substrate). The named reader of every arm is the case’s own expect.txt: an arm whose planted violation no expect.txt names has no reader and is removed rather than added.
Self-lint
Every script in the family (the consumer’s gates and the kit’s own lib/, bin/, checks/, templates/, smoke/) passes ShellCheck at -S warning, enforced by check-shellcheck. Template stubs are lint-governed, since they are runnable shell a consumer copies out, and check-comment-tier governs their comments too. A copied-out template’s spec: line resolves against the vendored kit path, so its comments are directives like any source, thinned to the # graph:/# spec: lines and the placeholder scaffolding the consumer fills in (canon-kit/SPEC.md §check-comment-tier). Only check-spec-pointer skips a template, whose spec: line is a placeholder unresolvable by design (§check-spec-pointer). The template-to-copy relationship is governed by §check-template-copy-parity, whose assertion A is the only mechanism that resolves a template’s target. A template’s post-copy source paths are legitimate. A false positive is silenced inline with # shellcheck disable=SCxxxx plus a justifying comment, never a blanket .shellcheckrc.
Shared cross-gate values
A value or small lookup needed by more than one gate lives in lib/gate.sh as the single source: reference it, never re-literal it per gate. This is deliberately not a base library. It shares values and walk adapters, never gate structure, which is copied from the skeleton and so stays per-gate and legible. Its failure mode is loud: a missing or malformed lib/gate.sh aborts every sourcing gate at the source line, the fail-closed direction.
Calibration lessons (paid for, now design rules)
- Wrap-aware matching. When a gate’s signal token can hard-wrap on a ~80-col prose surface, match over the joined logical unit, not the physical line, and prove it with a wrapped-case
bad/fixture. - A matching window is a calibration. A span over which a gate relates two things it matched (a marker to its claim, a knob name to a nearby value, a lead-in to a token) falls under doctrine-kit’s Policy-as-choice rule. It is a structural bound where the grammar has one, otherwise a knob, never a bare count baked into a module. No roster of windows is kept, because none is derivable: no scan decides membership, for the reason that rule’s Enforced by gives, and Derivation-first forbids a hand-kept list.
--emit knob-rosterlists the knob-backed windows with every other knob; the in-code residue is a consumer audit-roster sweep’s. - Scope a sentinel allowance to its phase. A gate that admits a bootstrap value (
—,TODO,0) with a blanket allowance leaks it past the one phase it was meant for; bind the allowance to the phase. - Exclude the fixture tree from whole-tree discovery. A gate that discovers inputs by a shape its own fixtures also carry descends into the fixture tree and false-reds, and a different gate’s fixtures can break yours. Use
lib/gate.sh’s prune adapters: afindwalk →gate_find <root> <expr…>(parenthesize a multi-term expression, sincegate_findappends-printand an unparenthesized-ochain binds it to the last term only); agrep -rwalk → splice"${GATE_GREP_EXCLUDES[@]}"; a paths-then-filter walk →gate_path_pruned "$f". Compiled, that last adapter iswalk::path_pruned, besidewalk::prune_dirsrather than once per consuming member, because the predicate and the set it applies are one rule. - Couple per surface, not per file. When one artifact holds N representations of the same model, each needs its own coupling edge or an explicit ungated marker; “this file is heavily gated” silently reads as “all of it is gated.”
- Fixtures probe the boundary, not the live tree. A gate passing on the real corpus is not evidence it is correct: write the pair to drive the inputs production doesn’t (last element, empty set). Last-line idiom:
while IFS= read -r e || [[ -n "$e" ]].
When a gate earns its place
A gate is a standing cost (maintenance, false-positive friction, reading load), so the family is sized deliberately. When a consistency property on a driftable governed surface has a cheap, low-false-positive, mechanically-decidable check, build the gate proactively, before any attested miss; discipline is not an acceptable substitute for a check this class can make. A gate that is expensive or higher-FP waits for a real miss to attest it, and the miss is the bad/ fixture. Cheap and low-FP is necessary, not sufficient: the gate must check a real drift axis, never a trivially-true proxy (heading presence while the content drifts) that manufactures false confidence. The dual rule minimizes the standing set subject to the guarantee being preserved: a gate is removable only when its guarantee is delivered another way or was never attested. What stays human is the irreducibly semantic judgment alone: is this prose still true?
This section rules whether a gate is built. What substrate it is built on is ruled at §The port-candidate criteria: new gates are born native by default in a tree carrying the crate, and shell is an exception needing a stated cause.
Enforcement tiers
Three concentric tiers, each an outer backstop for the one inside it:
- pre-commit (opt-in, per-clone, fast) — the generated triggered subset, catching drift in the single commit that perturbs it. Bypassable by design (
git commit --no-verifyis a valve, not a hole, once an outer tier exists). - pre-push / full battery (opt-in, per-clone, fuller) —
run-gates.shwhole-tree before the work leaves the machine. “Whole-tree” means the index: a member walkinggit ls-filescannot see a file that has never beengit added, so a green battery run before staging a new file is vacuous for exactly the members that file most needs. The battery is green and the battery examined my change are different claims, and only staging first makes them the same one. - CI (server-side, authoritative) —
run-gates.sh+ its--run-gate-testsarm on every push, with branch protection making a merge conditional on them. A treeinitinstalled carries a seeded workflow running the battery through the installer’s CI action (installer/SPEC.md §The CI action). The fixture suites for the gates it vendors were executed on the tagged commit of the release its lock names, and it adds its own fixture step when it authors gates. The copy-out for any other tree istemplates/gates-workflow.yml(see there); only this tier is a guarantee, and it stops bypass but not workflow self-edit — the tamper-proof verifier is the deferred hosted-attestation rung. The inner tiers are latency optimizations.--run-gate-testsruns as its own step, not folded into the battery —check-gate-fixture-coverageasserts fixtures exist but never executes them; the execution is the gate-authority backstop, kept separate so a fixture-logic failure is attributed to the gate.
The # graph: manifest
Every registered gate’s declaration path carries a one-line coupling manifest in its header. The path has two spellings: the gate’s own <name>.sh, or the <name>.gate descriptor beside a compiled subcommand (§lib/gate.sh resolves between them). The manifest grammar below is identical in both. Every reader greps it out of the declaration path as text, and none requires that path to be shell.
The .gate descriptor. A gate whose implementation is a binary subcommand keeps a non-executable declaration file <dir>/<name>.gate. It is data, never sourced and never run.
Its existence is the dispatch declaration, and the subcommand name is the gate name. There is no dispatch field, no second registry and no mapping table, since each would be a name that could drift from what it names.
The field roster is closed, and every field on it has a named reader:
# graph:, required — the manifest readers below.# spec:— canon-kit’scheck-spec-pointer.# install:— §check-install-disposition;# armed-by:—doctorand §check-install-disposition;# projection:— §check-projection-roster. All three are ruled at §The install disposition, in a descriptor on the same terms as in a script.# no-fixture:, optional — §check-gate-fixture-coverage.
The descriptor carries no field without a reader and reserves nothing for a future one.
Two fields are minted on shell and stay off the descriptor’s roster. # no-port: <cause> declares that a script never goes to the binary substrate, and names the ruling that makes it so. # port-until: <slug> declares that a script is still owed to the port but not takeable now, and names the live queue entry owning the blocker. Each is one optional header line, beside # graph:, # install: and # spec: in a gate declaration. At most one of the pair sits on a file.
Their domain is any tracked script, not only a gate declaration path. A permanent shell disposition is what an install bootstrap has, and what §port-blockers’ completion predicate asks a script to state. The enabling path is only that the file is tracked. With no registration step, the fields reach a corpus that owns no descriptor. The payloads, the mutual exclusion and the closed descriptor roster stay as stated, so the widening adds no second grammar.
# port-until: covers any temporary hold with named work owed and a live owning entry. That is wider than the born-native exception class §The port-candidate criteria drafted it for. That class describes a gate being authored in shell, while a criterion-7 blocker read over an already-authored gate fits no lettered class. Narrowed to the letters, the field would leave the criteria section’s own worked example unable to declare.
Their readers. # no-port: is read by §port-blockers’ --group arm over the registry and its --tree arm over the tracked shell tree, and by §check-gate-substrate-parity’s assertion G over both corpora. # port-until: is read by those three and, for its slug’s liveness, by §check-gate-exemption-tasks. Both are read by §check-comment-tier, whose built-in directive roster keeps the line from reading as a restatement.
Assertion G widens with the fields in four of its five clauses, and assertion H does not widen. H opens the section a declaration’s # spec: field names. A plain script has no such pointer, so widening H’s walk would red every declaring script. Clause 1 of G is about descriptors and has nothing to range over on a plain script. Clauses 2 through 5 read a header field and need neither a registry membership nor a pointer. Shape is a field’s spelling-domain, so it sits on that gate, while a claim about the queue belongs with §check-gate-exemption-tasks, which already reads the queue.
The widening mints no knob, and no exclusion knob either. Every reader over declarations resolves them through gate_resolve under gate_kit_roots. --tree resolves through git ls-files and the prune-dir set, values gate-sdk’s table already resolves for every pruned walk. §port-blockers rules out an exclusion knob, which is what makes the owed count reaching zero be the completion predicate.
A .gate descriptor never carries either field. A descriptor’s existence is the dispatch declaration, so its member is ported and has no port question left. Such a line would assert the negation of the file it sits in. In practice it lands when a port forward-copies it with the three lines that are copied verbatim, which assertion G’s first clauses red on.
The held field’s payload is a bare slug, where the two grammars part. A temporary hold keeps its ground in the gate’s own SPEC section, and its disposition and cost in the owning queue entry (§The port-candidate criteria owns that split). # no-port: carries free text because permanence is a ruling whose only home is prose, and the field is the pointer to it. # port-until: carries a slug because the slug reaches what a queue reader needs. The ground is one hop away, through the declaration’s own # spec: pointer, and §check-gate-substrate-parity’s assertion H holds that hop. A <cause> half would be a second home for the ground, the one that rots when the entry is re-scoped.
A cause names the ruling it rests on. §port-blockers rules that # no-port: declares the disposition in force under the ruling that stands, never an oracle about future rulings. A cause naming its ruling lets a later reader check whether that ruling still stands. The kit constrains only non-emptiness, so this is an authoring rule the declaring unit holds itself to, not an assertion.
The slug’s anti-rot is coarse. Where several held members point at one umbrella entry, every declaration reds at once when it closes. A member whose own blocker lands earlier keeps a live-but-wrong declaration until then. When a blocker acquires its own designed answer, that answer is a unit with an entry, and the declaration re-points at it. Filing per-blocker entries before then would be speculative.
The # reads:/# needs: refusal below does not reach these fields. Those two were refused because they claim runtime behavior an implementation can contradict. These claim a design ruling and a queue disposition, which no execution could falsify. # no-port: is held to the ruling it cites, which the exception criterion requires in the gate’s own SPEC section, and assertion G holds the pointer’s shape. # port-until: is held more strongly, its slug resolving against a live queue entry. Neither has a value vocabulary: presence is the verdict and absence means takeable.
The kit ships the fields, their readers and the assertions, never a roster of members, a cause text or a slug. A kit literal naming one project’s permanently-shell gates would publish that project’s work queue as everyone’s mechanism. The remainder is derived from declarations in the consumer’s own tree at every read. A cause is free text pointing at the consumer’s own record, and a slug names an entry in the consumer’s own queue. The kit checks only that each is non-empty, and parses neither.
On a plain script only the held field earns a liveness reader. A stale # port-until:, whose slug moved to Done, under-counts the owed set and hides real work. Its slug is therefore held to a live queue entry wherever it sits. A # no-port: cause is held by review at the diff, and for a gate also by assertion H, which a plain script cannot satisfy. The gap is named rather than closed: closing it would demand a # spec: pointer on every declaring script, the obligation the free-text field was chosen not to need.
Two fields are refused, not merely absent. A # reads: line declaring the gate’s walk roots would be the cheap answer to §check-reads-couples, but nothing would hold it to the implementation. It is the removed # reads-couples-exempt: opt-out with more words. A # needs: line declaring the member’s external programs (§The port-candidate criteria, criterion 7) is refused on the same ground. Both sets live in the crate’s registry, where a unit test runs the member and compares the declaration with what it did. §check-gate-substrate-parity assertion D’s manifest-class partition is unchanged for that reason: a roots declaration is implementation data.
The answering interface is substrate-neutral: shell answers by parse, the binary by a top-level flag. --reads answers what a declaration path’s substrate reads, and --needs what it requires, beside --list and --source-stamp. --needs is backed by its own registry-tuple element, and a member whose entry omits it fails to compile. A crate unit test shaped like the --reads one holds it to behavior, each fixture case run in a child whose working directory is the case (§lib/gate.sh). No ordinal is stated for the flag or the element: both rosters churn with every port, and main.rs and GateEntry hold them. Whether a later authoring SDK moves this contract to a substrate-neutral surface is that SDK’s question.
Its report is one line per requirement and nothing else, with no count line and no header, since a transcribed total would be a second source for the lines. Three line kinds, each with a named reader:
<program>— a requirement: a program a host must carry for the member to run, named asPATHnames it. Read by §port-blockers’ default arm at its per-member row, filtered againstGATE_SDK_PROGRAM_FLOORexactly as a shell member’s scanned command word is. The line is a name because the floor is a set of names: a resolved absolute path would never match it and could not be written host-independently. The spawn recorder notes a bare name as passed, or a path-carrying value’s final component minus the host’s executable suffix. A member whose spawn resolved to a path so declaresbashand is covered, and a?<TAB><knob>declaration absorbs its knob’s command word whatever form that word takes.?<TAB><knob-name>— the requirement is the command word of that knob’s resolved value. Read by the same arm at the knob resolution preceding the floor filter, the path a shell member’s command-position expansion takes, so the substrates cannot disagree about a knob’s value. No literal roster is true for every consumer (criterion 7). A program spelled into the registry would be a second copy of the knob’s default, silently wrong for a consumer who repointed it.?— a requirement the registry cannot bound. Read by the trailer’s undecidable counter, as a shell member’s unresolvable expansion is.
The ? in the knob form keeps its --reads meaning while the field order differs. In --reads a root is an answer and the tab field refines it. Here ? says the registry cannot name the literal, as on both arms, and the tab field says where the answer is instead. Dropping the tab field leaves the honest ? the arm would otherwise print, so one covering rule holds both arms.
Unit test A holds the declaration to executed behavior. Every registry member runs over its own gate-tests/<name>/{good,bad}/ cases with the spawn recorder armed, and the observed program set must be a subset of the declared one. Equality would fail a wrapper that correctly declares a program only a branch no case takes reaches. The failure that matters is an undeclared spawn.
A’s reach is bounded by the fixture corpus. A case runs one argv and so selects one mode. A member none of whose cases reaches a spawn is observed spawning nothing, and A is vacuous over it. check-graph is the live instance (§check-graph), a member whose fixture pair is narrowed to one hermetic assertion, held only by a behavioural driver reaching the branch. Test B, no Command construction outside the crate’s one sanctioned spawn wrapper, makes A trustworthy, and proc.rs’s own unit tests enforce it.
The descriptor is a durable surface, not port scaffolding. Shipped members declare this way, check-action-pinning and check-action-gh-repo among them. The hook arm selects a commit’s members by reading every member’s manifest as text (§git-hook). The manifest must therefore stay readable with no execution of the member it declares.
Annotations partition by reader, and the partition is a rule.
- Manifest-class — the
# graph:manifest, and anything else whose reader must work without a build — lives in the descriptor only. It is never emitted into, nor hand-written in, the implementation. Held by §check-gate-substrate-parity assertion D. - Locality-class —
# spec:with its one-line binding, and the comment-tier directives — binds to a line of implementation, so it stays in the implementation. Moved to a companion file it would lose that binding. The corpus primitivecomment_surfacereaches the implementation for this reason (canon-kit/SPEC.md §The shared spec adapters), so those assertions stay live on a ported gate.
The layer above the descriptor is substrate-blind. The graph, hook and meta-gate layers read a gate’s declaration without knowing what implements it. Nothing in this format or the resolution path assumes a language, though the format builds no such generality. A descriptor discloses a gate’s shape without its predicate, which §Consumer payload relies on.
Two shapes were refused, each on a constraint. The binary emitting its own manifest would make hook selection depend on what the consumer’s placed binary compiled in. The hook arm selects from every member’s tracked declaration, a consumer’s own .sh shadow included, so a tracked-text manifest keeps the seam payload-neutral. A shell stub carrying the manifest and exec’ing the binary would stay inside the *.sh corpora of check-shellcheck, check-comment-tier, check-gate-output and the rest. Those would scan a three-line file and report clean, coverage that asserts nothing. A .gate descriptor is honestly outside those corpora, and §Meta-gate conservation for the binary substrate makes that auditable.
The manifest grammar:
# graph: couples=<globs> dir=bi|one valve=none|PROPOSED tier=precommit|align-only|commit-msg [mode=staged|whole-tree] [trigger=<globs>]
couples=— the surfaces the gate binds, as comma-separated globs. Two tokens are special,kit:<glob>andknob:<NAME>, ruled below with the field’s derived couples.dir=—bifor a coupling bijection (both sides must agree),onefor a one-way audit.valve=—PROPOSEDmarks a cycle valve: a coupling where a leading (design) surface may run ahead of a lagging (code) surface via a queue-tracked marker;nonemeans the sides must agree now. It ischeck-graph’s field and nothing else’s. A gate that owns an exemption array still declaresvalve=none, andcheck-gate-exemption-tasksreads the exemption arrays and# port-until:headers, never this field.tier=—precommitgates run in thepre-commithook when a staged path triggers them;align-onlygates run only in the full battery;commit-msggates run unconditionally in thecommit-msghook (the message-file surface, not a tracked path; see §git-hook). Default toprecommit. The discriminator between the first two is whether the invariant is restorable within the single commit that perturbs it, not cost: a settled-corpus audit would false-red on work in progress and belongs to the full battery. Acommit-msggate’scouples=names its config files, the regeneration triggers, never a tree path. It no-ops cleanly under a no-argument full-battery run, since the prospective message exists only at commit time.mode=staged— the hook passes the staged subset of the trigger globs as positional args; default (whole-tree) runs a bare invocation.trigger=— hook guard globs when they diverge fromcouples=;trigger=*runs the gate on every commit.
A manifest carrying gen= reds check-graph, naming the migration: a two-line hook has no region to hand-write, and a trigger=* shell gate that reads the staged set itself does what such a region did.
The closed couples= prefix set is held in one place, registry::COUPLES_PREFIXES. Three independent readers recognise a prefix by exact literal match. A prefix one knows and another does not falls through as an inert literal glob, its trigger silently lost. The three are registry::expand_couples, check-reads-couples’ consumption path, which calls that shared reader, and check-graph’s valid_glob_token, whose prefix set is the same constant. expand_couples feeds hook emission, check-graph, the graph emitter, check-gate-substrate-parity assertion G, port-blockers and run-gates --for.
A new prefix enters the constant in the delta that lands its resolver, never in the amendment that designs it. valid_glob_token runs only inside validate_amend_manifest, over a # graph: manifest embedded in a SPEC-*.md amendment. It admits [A-Za-z0-9._*?/-] after stripping at most one COUPLES_PREFIXES member. An amendment proposing a new colon-delimited token therefore reds check-graph at its own authoring commit.
kit:<glob> expands to <kit-root>/<glob>, repo-root-relative, for every gate_kit_roots member at read time. A whole-tree gate writes kit:*.sh in place of a per-kit hand list. Non-kit couples such as scripts/*.sh, .workflow/*.txt and scripts/gates.list stay literal.
knob:<NAME> expands to the members of the kit knob <NAME>. A gate whose scanned corpus is a knob’s value so declares its trigger without transcribing the value, which would put consumer content in a kit literal. The spelling env: is refused. The value resolves through the owning kit’s table and knob files when a selector expands the token. An env: token would invite a reader to set the value in a shell and expect the trigger to change.
The token sits beside a descriptor’s literal globs, never in their place. A knob’s value and the kit’s default corpus are alternatives: manifest_files() globs with the knob when it is non-empty and derives its default set only when it is empty. A descriptor dropping its literals would lose the trigger for the branch not taken. The union over-triggers by exactly that branch, which the over-approximation rule below sanctions.
A member’s trigger set is knob-derived wherever a walk is, so a knob edit moves its selection and its graph edges. The descriptor-side obligation is a rule over walks, not a roster of members. Wherever a gate performs a walk whose filter argument takes its value from knob <NAME>, that gate’s couples= carries knob:<NAME>. It is keyed on the walk rather than on declared read roots, because a walk can be knob-bounded with no declarable root: check-docs-cmd, check-md-refs and check-spec-fence-balance each scan a knob-bounded corpus through a hardcoded root. The rule keeps two judgements an enumerated roster gets wrong. A knob that narrows a corpus is never a couples token, since coupling it would trigger the gate on paths it deliberately does not scan; that is why the rule reads the knob a walk’s filter argument takes, not any knob the module reads. And a knob whose elements pack several fields is no pattern set and takes no bare token, while a walk filtered by one of its declared fields carries knob:<NAME>.<field>. The set the rule reaches is derived per walk, so a knob added later is reached with no edit here.
Admissibility is checked. knob:<NAME> is admissible only where <NAME> is one of the knobs that gate declares in its registry declaration (§lib/gate.sh). The crate declares only the knobs its own code reads, so an admissible token is provably a corpus the gate reads. check-graph’s live-registry manifest loop asserts it per member, and a token naming an undeclared knob is a finding.
Resolution order is fixed: knob: expands first, and kit: over the result, so a knob member spelled kit:<glob> composes. Each is one pass, so a knob member that is itself a knob: token is a refusal, bounding expansion without a cycle detector. A knob member carrying a comma or whitespace is refused at expansion. couples= is comma-separated and its line splits on unquoted whitespace, so such a member would truncate the trigger silently.
Fail-closed is inherited. knobs::wire refuses a name no static kit owns, so a knob: naming an unowned name is exit 2. A declared knob a consumer set empty expands to nothing, correctly, since the gate then scans nothing either. That is a consumer’s designed state for every kit-empty indexed knob, never a finding.
Only a knob whose members carry no comma and no whitespace is representable. That excludes a Row::scalar holding a whitespace-joined list, in both directions. Unset, it resolves empty and the token is silently inert; set, it becomes one member carrying spaces and the token refuses. Such a row declares .words(), and check-graph reds a knob: token naming one. The shape is static, so the finding reds on every tree rather than on the empty expansion, which would red a stock consumer.
The .words() declaration is held twice. walk::knob_words, the word splitter, refuses a row not declared .words(). A crate unit test holds every scalar whose static default carries whitespace to it, less a named roster of one-value rows, such as a heading or a regex, whose whitespace is content. Honest limit: a word-list scalar with an empty default, split outside knob_words, is held by review.
A packed knob is addressed by a declared field, never whole. Where a knob’s row declares its element packing, knob:<NAME>.<field> names one field’s members across its elements, read through the parser the member’s walk uses. A bare token on a packed knob is refused, since an element is not a pattern. The field is the row’s to declare, so a reordered packing moves no token.
knob:<NAME>/<glob> roots a glob at a directory-valued knob. It expands to the knob’s value, with a trailing / trimmed, a leading ./ dropped and . read as no prefix, then / and <glob>. The result takes the covering conversion below, shares the bare token’s resolution order and one-pass bound, and is expanded at selection the same way. An adopter who relocates the directory gets triggers under the new one at the next commit.
<NAME> in rooted position is a scalar row the member declares, a word list among them, or one of the two locators, GATE_SDK_GATES_DIR and GATE_SDK_ROOT. Every member resolves its gates dir, so declaring a locator would add a declaration no reader could fail. An indexed or packed row is refused there. A .words() row roots the glob at each of its words, one pattern per word, and a list resolving empty expands to none, since it names no root to lose. <glob> is held to the literal-glob character set a prefixed token’s remainder takes. An empty scalar value is a refusal, because a root resolving to nothing would silently re-root the glob at the repository root. The token exists because most consumer paths sit under a directory some knob already configures, and one knob per path would mint many knobs for one fact.
A kit-shipped descriptor names a consumer path through a token, never as a literal. A literal freezes one consumer’s layout into every adopter’s trigger set, and the hook then never fires on the adopter’s real file. A gate may know a document’s shape, never its path. A literal stays in three cases: a path fixed by a tool outside the kits (git’s .gitignore and .gitattributes, GitHub’s .github/ tree and SECURITY.md), a path inside a kit root, and any path in a descriptor the consumer owns.
For any other literal, the first rule yielding a value applies. A file knob the member declares whose value is exactly the literal becomes knob:<NAME>. A directory knob or locator plus a remainder that is exactly the literal becomes knob:<NAME>/<remainder>. A literal neither reaches is a member reading a consumer path no knob configures. That is a code defect beneath the descriptor, so the literal stays and the member is filed as a gap. check-graph’s admissibility loop keeps a mis-mapped member from landing green.
What the token adds is a conversion, not a matcher. A knob member is a pattern in its walker’s discipline, relative to its walk’s root. Each member expands to its covering string pattern, every **/ collapsed to * and a leading * added unless present. That pattern contains every path the member can select under any discipline and root. A member spelled kit:<glob> is field syntax and passes to the kit: pass unconverted. Pre-expanding the token in the shell is refused: check-graph parses the raw manifest line in the crate, so hiding the token would turn the hand-authored descriptor into a generated surface.
Expansion over-approximates by design: an extra trigger runs a green gate, while a missing one would skip a red. check-kit-enum gates the residual hand-lists derivation cannot reach. The corollary is a hard authoring rule: couples= must cover every path the gate reads at runtime, never a subset. check-graph verifies couples→trigger parity, and check-reads-couples (§check-reads-couples) mechanizes reads⊆couples for the statically resolvable walks. The author’s duty narrows to the undecidable remainder that gate skips and counts.
Every expanded token is a bash string pattern over the repo-relative path: the test [[ path == token ]], in which * and ? cross / and ** is no more than *. Every reader asking what a token reaches, trigger= included, asks through that one matcher. So native/src/*.rs couples native/src/emit/mod.rs, as a trigger and as coverage. A recursive walk couples through a pattern whose string reach contains every path it reads, and a sibling glob per depth is never needed. The readers and what each asks: §Reading a couples= field’s reach.
A static kit’s knob file is a derived couple, never a hand-written one. A member reading a static knob reads its kit’s knob file, so registry::knob_files computes the couple from the member’s registry declaration. The set holds <gates-dir>/<stem>-config.knobs for every static kit the declaration reaches, in the static-kit table’s order. A declaration reaches a kit through:
- a declared name the kit owns, a
<KIT>_…*family name included, which also reaches the kits its derivation’s declared inputs name; an environment-only name (§The knob file) reaches none; - the couples-knob sentinel, which reaches each static kit owning a name a
knob:token in the descriptor corpus carries, the namesregistry::couples_knob_namesreturns (§lib/gate.sh); - a derived default’s declared input that another static kit owns, transitively;
- a reference line (§The knob file) in the tracked knob file of a kit the declaration reaches, which reaches the static kit owning the line’s referent. It is read from the file at derivation time, so adding or removing a reference moves the member’s selection at the next commit, and the graph artifact, which
check-graphassertion E reds until it is regenerated.
The gates directory is the locator the static reader uses, env-or-default (§The knob file). The set names the tracked file at its default location only. The gitignored local overlay is never staged, fires no trigger, and its reference lines reach no kit. A tracked file relocated through <KIT>_KNOB_FILE is a per-invocation path the derivation does not read, so a consumer who relocates one couples it by hand.
The derivation stops at a command. A knob whose value is a command hands its reads to the program it names, which the kit cannot see. A member spawning a consumer’s command couples the command knob’s own kit file, and the program’s reads are the consumer’s to couple. A mode=staged member that reaches a static kit is refused at exit 2 by every reader of the derivation. The hook arm passes a staged member its matched paths as arguments, so a derived knob file would reach it as a file to scan.
A compiled member’s crate modules are the second derived couple. registry::module_files returns the member’s cut (§The non-gate arm) as paths under GATE_SDK_NATIVE_SRC. Where that directory is absent, as in every vendored tree, the cut is empty: the payload ships the binary, not the crate.
Every trigger reader appends both sets. A member’s trigger is its expanded trigger=, else its expanded couples=, followed by registry::derived_couples(member): its knob files, then its module files. A trigger=* member is unchanged. The readers are the selector run-gates --for and the hook arm share (§run-gates, §git-hook), check-graph’s manifest loop and the graph emitter (§check-graph), and §projection-witness.
The three readers taking registry::expand_couples for another purpose do not append them. check-reads-couples asks about walk roots, and a knob file is a named-file read while a module file is no read at all. check-gate-substrate-parity assertion C asks about gate declaration paths, which neither set is. port-blockers prints the descriptor’s own field as evidence.
Reading a couples= field’s reach
The field has one matcher, and every reader asking what a token reaches uses it. The matcher is runner::staged_matches, which reads a pattern as POSIX case matching does, in which * spans / because the slash’s special rule belongs to pathname expansion alone (XCU 2.13.3). Do not narrow * to one segment: it is the semantics, and narrowing it would break every trigger in the tree at once. The crate’s readers call that one function rather than each carrying a matcher:
run-gates --forand the hook arm select the members a path triggers (§git-hook).check-reads-couplesasks whether a walk’s tracked reads are covered, so its answer is exactly would an edit to this read fire the gate.check-gate-substrate-parityassertion C asks whether a member’s couples reach a gate declaration path.
Two readers ask something else, and neither is a second semantics. A mode=staged member’s positional arguments are selected by git pathspec, whose default (non-:(glob)) form also lets * cross / (§run-gates). check-graph assertion B asks whether one pattern is contained in another rather than whether a path matches. Its four-branch predicate is sound for this semantics and deliberately incomplete (§check-graph). The graph emitter and port-blockers print the field and ask nothing.
The filter field is not this field. A --reads filter names its walker’s discipline through its mandatory kind (§check-reads-couples), because it stands for a walk the reader cannot see. Couples name what fires a trigger. So a glob: filter’s ** is walk::glob_files’ component globstar and a couples token’s ** is not. A knob serving as both is converted once, at expansion (§The # graph: manifest, the knob: rule).
Read the reach through the oracle, never off the field, because kit: and knob: tokens hide their expansion. run-gates.sh --for <path> answers what a path triggers, and check-reads-couples answers what a gate’s couples cover: one matcher, two questions.
The install disposition
A gate declares its own install disposition: one # install: <disposition> line in its header block beside # graph: and # spec:. The header directives in this section are read from a .sh declaration and a .gate descriptor alike, since a ported gate is still a gate a kit ships and its descriptor rides the payload like any other member (§Consumer payload). The vocabulary is closed, three values:
zero-config— the gate registers in a fresh consumer, because nothing it can red on the treeinitmakes is a rule the adopter has not chosen. It meets one of three conditions. Every surface it reads is oneinitwrites or a kit ships. Or its subject is a construct only the methodology introduces (an amendment’s## What changes, aTODO(task:)tag, a knob a consumer sets). Or its one red on content the adopter authored is a defect by that content’s own terms: a link or path that resolves nowhere, an unclosed fence, a claim the tree falsifies, a pipeline that can lose its match.on-surface— the gate’s subject is one the adopter authors (a glossary, a docs host, a stage attestation, their own workflows). Or it holds adopter-authored content to a house rule that ordinary content breaks without being wrong: a comment must be a directive, no temporal narration, an action ref pinned by SHA. It arms when the adopter opts in rather than at install.initmay seed one member of such a subject, as it seeds a CI workflow. That member is held at the publisher, where the installer’s smoke runs the gate over it green, not in the adopter’s battery (installer/SPEC.md §The consumer smoke).
A house rule reaching content init never wrote is the arming on-surface defers: the adopter’s first red would be a rule they never picked, on files that were not wrong. A defect is not deferred, since the content is wrong on its own terms and the red is the install’s first value. A gate carrying both takes its house-rule arm behind a knob that defaults off, so the defect arm installs alone; one whose house rule is its whole subject is on-surface.
never— the gate is not auto-registered on any tree, because its subject cannot exist in a vendored tree at all, because its input is withheld there so it could only skip, or because it is declared never-registered. §Consumer smoke’s declaration valve recognises this class from the other side.
The vocabulary names install-time reachability, never a path, a surface name or a project’s own governed-file vocabulary. A disposition enumerating consumer paths would be a kit literal carrying one project’s tree layout, which no kit may ship. The path a gate reads stays where it already lives, in the gate’s own configured knob.
The installer’s per-kit roster is derived from these declarations, not maintained. The recipe’s gate derivation (installer/SPEC.md §What init seeds) is every checks/ member of a kit, both declaration spellings, whose disposition is zero-config. It carries no gate name of its own, which check-install-disposition assertion C holds. So a gate cannot reach an adopter undeclared: a kit’s new zero-config gate is registered by construction, and the declaration is enforced present.
The disposition is kit-owned rather than the roster, because two callers register against two trees. A kit’s smoke/install.sh registers against a scratch consumer the smoke script itself builds and seeds, and recipe_gates registers against the tree init makes. The installer’s set is a subset of the smoke’s in every gated kit. lifecycle-kit registers nothing at install, because its gates read a stage attestation only a stage session writes, while its smoke stamps that attestation for real. site-kit’s smoke registers gates its install does not, because the smoke writes docs/CNAME and a workflow template init never writes. One roster could serve neither caller without arming gates on a tree that cannot satisfy them or stripping the smoke’s coverage, so each caller derives its own set from the tree it made.
The arming declaration. A gate’s header may also carry one # armed-by: <KNOB> line beside # install:. It declares that the gate asserts nothing while that knob resolves empty: the gate still runs and prints its clean line, and <KNOB> is one declared static knob of the declaring kit’s own table. The two directives answer independent questions, # install: whether a gate can register on the tree init makes and # armed-by: whether a registered gate is doing anything yet, so the arming declaration does not widen the disposition vocabulary.
- Its reader is
doctor, which names a registered member whose knob is empty (installer/SPEC.md §doctor), so an adopter who never opens a SPEC still meets the knob that arms the gate.doctorcarries no gate or knob name of its own, only the declaration it reads. - Its verifier is
check-install-dispositionassertion D, which keeps the line from being the self-declaration §The# graph:manifest refuses: a misspelled or foreign knob is a red, never a silent no-op. - A content form covers a gate disarmed by what a file holds.
# armed-by: content <KNOB> [<KNOB>…]declares that the gate asserts nothing while no file those knobs name carries a live line. A live line is non-empty after leading blanks and does not open with#, the one filter the pattern-roster gates share. An absent file contributes nothing, anddoctorreads each present file from the repository root. With no named file present it renders nothing, since a required file’s absence is the battery’s exit 2 anddoctoris not a second runner. The bare form cannot say this forGATE_SDK_MSG_PATTERN_FILES, whose row never resolves empty. - The declaration names emptiness, never a predicate over a value.
check-provenance-seam’s disarmed default is a conjunction over a switch and a list, and it is the default the kit intends for an adopter (canon-kit/SPEC.md §check-provenance-seam). So it takes no declaration, and its clean line naming both halves is the bound. - Honest limit: the declaration is taken where a member’s disarmed state has been found, not by census.
The projection declaration. A gate’s header may also carry one # projection: <globs> line. It declares that the gate byte-compares the tracked paths <globs> names against a live emitter, so those paths are a generated projection and this gate is its freshness gate. It is not a # graph: field, so no hook or graph reader reads it.
- The grammar.
<globs>is comma-separated. Each member is a literal glob or a knob-path token,knob:<NAME>orknob:<NAME>/<glob>, resolved to the knob’s value verbatim rather than to a covering pattern, since an output path names a place and not a selection. It is neverkit:, since an output path is the consumer’s tree. A kit-shipped descriptor names its output through the knob that relocates it, on thecouples=field’s own literal rule. A line whose every member resolves empty declares no projection on this tree, a consumer’s designed state (an unset page knob) and not a finding. - The resolution. It is read through
registry::PROJECTIONand resolved throughregistry::knob_paths, the one resolver for a declaration field that names a place. A literal resolves to itself andknob:<NAME>to the knob’s members (knob:<NAME>.<field>on a packed row).knob:<NAME>/<glob>resolves to the rooted token’s path under §The# graph:manifest’s admissibility and.normalization, unconverted. A name no static kit owns and an empty rooted root refuse on the couples token’s grounds. - Every resolved path must be covered by the gate’s own
couples=under the field’s one matcher (§Reading acouples=field’s reach), since the hook never triggers on the staleness of a projection whose output the gate does not couple. - Its verifier and readers.
check-projection-rosterassertion A keeps the line from being the self-declaration §The# graph:manifest refuses. Its readers are that gate, which holds a consumer’s roster of projections to the declaring set, and--projection-witness, which takes its members from it (§projection-witness).
The directive does not reach the installer’s seeding decisions, which stay keyed by kit. They answer what a kit’s install must seed, not what it may register, and a seeded surface is a property of the kit rather than of any one gate, so a per-gate directive has nothing to say about it. Which decisions those are, and how each is made, is the installer’s (installer/SPEC.md §What init seeds).
Meta-gate conservation for the binary substrate
A gate’s shell file carries five things at once: the rule, the # graph: manifest, the output-contract strings, its # spec:/# assertion directives, and the greppable evidence that its reads stay inside its couples. Porting the rule to a compiled subcommand removes the file the other four are read from. A port that records nothing silently ends those readers’ assertions, most of them by finding nothing and printing clean: a false green, not a red. This section is the contract that stops that, and check-gate-substrate-parity assertion C holds it by machine.
The substrate-sensitive set is derived, never maintained. A registry member is substrate-sensitive when its expanded couples= covers the declaration path of a registry member, the derivation check-gate-substrate-parity performs at runtime. The test is against registry members’ declaration paths, not every *.sh under a resolve dir, since a consumer’s claim-oracle script in the gates dir is not a gate. Every derived member takes exactly one recorded disposition below, and a member the section does not name is red.
Where a declaration lives is not a term of the derivation. A member the consumer’s own gates directory declares earns a row on the same terms as a kit-declared one, and its declaration path is an object couples may cover. The table below is all kit gates, and reading that as an exemption would silently end a consumer-declared meta-gate’s assertions. A port changes a declaration path, <gates-dir>/<name>.sh becoming <gates-dir>/<name>.gate, which can move other members into or out of the set. The set is therefore re-derived at each porting cut, never inherited.
No *.sh glob covers a declaration path once the tree holds no shell gate declaration. The derived set is then the members whose couples name the .gate spelling or a spelling-blind directory glob. Which members those are is assertion C’s runtime answer, and rows below whose member it does not select are retained records rather than owed dispositions.
Assertion C is one-directional. It reds on a derived member with no disposition, never on a disposition whose member the derivation stops selecting, so a shrinking set passes green. A later meta-gate reaching declarations through a *.sh token such as kit:*.sh gets no assertion-C coverage, and its author gets no signal. Whether the derivation should widen is not answered here. The table’s length is not the set’s size.
check-crate-arms couples the crate and stays outside the derivation, so its absence from the table is a verdict. native/src/gates/*.rs is where a ported gate’s rule is implemented, never where one is declared, so no disposition row is owed. Its own .gate port changed nothing its couples name.
| Meta-gate | Disposition for a .gate-dispatched member |
|---|---|
check-shellcheck |
Retired with cause, per member rather than for the gate. A .gate-dispatched member has no shell file to lint, so this meta-gate asserts nothing about it; cargo clippy at deny-warnings is the substrate equivalent and runs in CI, not as a gate. The gate itself is on-surface: an adopter cannot author a compiled gate, so a vendoring consumer’s gate family is shell by construction, and what ends when a tree’s last .sh leaves is that tree’s registration, never the shipped gate (§check-shellcheck). This member is itself .gate-dispatched, as criterion 7’s wrapper: its rule invokes shellcheck, a declared dependency the compiled form spawns and refuses at exit 2 without. |
check-gate-output |
Ported and strengthened for the fixtured corpus; source-grep retained for the one member outside it. For fixtured members the assertion runs in the --run-gate-tests arm (§run-gate-tests) against each case’s real output, on shell gates too. check-task-conservation is # no-fixture: (queue-kit/SPEC.md §check-task-conservation: a HEAD-vs-worktree diff has no static-fixture representation), so the source-grep stays its only oracle, and retiring it would zero that member’s output-contract coverage. That member is ported, so corpus and emitter alternation follow the rule to the implementation module, and a tree carrying no crate declares the member out of reach rather than reddening (§check-gate-output owns the resolution). |
check-gate-fail-closed |
Retired with cause. For a member that reads files the defect (branching on a captured value’s emptiness when the subprocess died) is unrepresentable: there is no subprocess, and a fallible read returns a Result that cannot be ignored. It is representable for a member that spawns one: Command::output() returning Ok means the spawn succeeded, never that the program did. The gate’s corpus is check-*.sh and could not scan a Rust module, so the property is held crate-side: the spawn wrapper and its unit tests (§Fail-closed contract) leave a gate module unable to construct a Command, or to reach stdout without the status having been read. |
check-reads-couples |
Retained, with a binary-side equivalent. Its shell parser finds no walks in a binary gate and would print clean, so the substrate answers instead: the binary’s --reads <name> arm prints one line per walk root, a repo-relative path or ? with its ground, and the gate consumes that report into its coverage assertion (§check-reads-couples). The declaration is registry data held to executed behavior. Each gate’s roots are declared beside its dispatch entry in the crate’s registry (an entry without them fails to compile), the crate’s single sanctioned walk implementation records the roots it is invoked with, and three unit tests close the loop. A: every member runs over its own gate-tests/<name>/{good,bad}/ cases with recording on, each case in a child whose working directory is the case (§lib/gate.sh); observed roots must be a subset of declared, and declared prunes a subset of observed. A root declaration widens the demand, so an undeclared root is the fault; a prune narrows it, so an unapplied prune is, and a prune with no reader would be self-certified narrowing. A case relocating a walk through its own positional argument, or not selecting its guarded branch, is held to nothing, and each half carries a global guard so neither passes by holding over nothing. B: no module outside that walk implementation names a filesystem-walk API, since a direct walk would be invisible to the recorder. C: no member answers a statically resolvable root with ?, a property test over the gate modules’ source that sees only the root idioms it enumerates. B’s vendored half is an allowlist over the resolved graph: every crate in the tracked Cargo.lock, transitive included, is admitted by name with the dependency-bar clause it cleared (§The settings cohort, and the crate’s first dependency), and the assertion reds on an unadmitted crate and on an entry absent from the graph. The lock is tracked because reading only [dependencies] would admit whole subtrees unexamined. The refusal survives only where the gate cannot see, a name the substrate does not carry and an unresolvable filter knob, and there is no descriptor-level opt-out (§check-reads-couples). This member is itself .gate-dispatched and reaches the read set in process, so the absent-binary refusal has no input. Declared roots are registry data, not a derivation from Rust source; unit test A holds the link. |
check-gate-assertions |
Retained, corpus extended to the gate’s Rust module; the # assertion marker matches on its token, independent of the comment leader. This member is itself .gate-dispatched, and reads its own implementation module, whose section gained an enumerated contract, so its own heading is in discovery (§check-gate-assertions). Its fixture pair, not the live tree, proves those arms. |
check-gate-exemption-tasks |
Retained, corpus extended the same way. This member is itself .gate-dispatched; it globs both declaration spellings, which a descriptor still is, so its port changed nothing in the rule. |
check-comment-tier |
Retained, corpus extended to the implementation module and the .gate descriptor, whose own lines are directives by construction. The shared primitive comment_surface carries a *.gate arm, and the crate’s language arm through CANON_KIT_COMMENT_SURFACE, widened once for every caller (see the check-spec-pointer row). The implementation arm is load-bearing: locality-class directives stay in the implementation by the reader partition (§The # graph: manifest). This member is itself .gate-dispatched and audits its own declaration, so its trigger names *.gate and the knob, and its fixture pair proves both arms. |
check-spec-pointer |
Retained; its assertion logic is unchanged and its corpus rides the same widening. It calls the same shared primitive, comment_surface in native/src/spec.rs (canon-kit/SPEC.md §The shared spec adapters). Without that shared fix a ported gate’s # spec: line would silently go unchecked in both places it can live, the descriptor and the implementation. |
check-readme-roster |
Retained, glob widened to *.sh + *.gate, so a ported gate stays in its kit README’s roster in both directions. This member is itself .gate-dispatched; it scans a set of basenames across both spellings, so its port changes nothing in the rule. |
check-exec-bit |
Retained, extended: a .gate descriptor must be non-executable. Stated as an assertion so “not executable” cannot read as “not covered”. |
check-todo-task-liveness, check-deprecation-task |
Retained, corpus extended to the Rust module and the descriptor, the check-comment-tier shape: both walk the shared comment surface for TODO(task:)/deprecation markers, so a marker in a ported gate’s Rust source stays tracked. Both are .gate-dispatched. |
check-knob-default-coupling |
Retained unchanged, and deliberately not corpus-extended. Its two default idioms are shell (${KNOB:-v}, the guarded assignment) and its knob prefixes derive from gate_kit_roots members; native/ is not a kit root and a Rust const matches neither idiom, so scanning *.rs would add zero assertions while reading as coverage. A knob has exactly one producer (§lib/gate.sh), its owning kit’s table in the crate, so the crate-versus-lib/gate.sh prune-dir duplication it could not reach is absent rather than test-held. The scalar defaults are coupled off --emit knob-roster, a third idiom (canon-kit/SPEC.md §check-knob-default-coupling). The pre-binary accessors’ six values are the one admitted second holder, held to the table by their own unit test (§lib/gate.sh). This member is itself .gate-dispatched; its idioms are shell whichever substrate reads them. |
check-gate-tamper |
Retained, extended, and partly discharged. The gate-file roster it recognises (DELEGATION_KIT_GATE_FILES) carries the .gate spelling on the kit default, or a consumer on that default would receive a ported gate whose edits escape the isolation rule (delegation-kit/SPEC.md §Layout and configuration). A green battery in a tree whose own config carries both spellings does not show the kit default does. Its meta-layer path roster (DELEGATION_KIT_META_PATHS) is fixed in this repo’s consumer config: native/ is declared there (scripts/delegation-config.knobs), so a commit editing a ported gate’s Rust implementation alongside its descriptor is meta-isolated rather than refused. The kit default still lacks the prefix, since gate_kit_roots’ predicate requires checks/ or smoke/ and the crate ships neither, so the fix is consumer config. Known limit: its exemption reader parses a shell # exception-list: array literal and has no Rust-source equivalent. The limit is unbound while no ported member carries an exemption list, and the tree carries no exemption-list holder at all, which check-gate-exemption-tasks counts on every run. The cohort porting the first holder owes the Rust-source reader. |
check-graph, check-kit-enum, check-gate-fixture-coverage, check-enforcement-fresh |
Survive unchanged: all four read the declaration path as text, directly or through the enforcement-map and footprint emitters, and the descriptor still is text. |
check-value-rollup-fresh |
Survives unchanged in mechanism, .gate-dispatched, and a retained record. It reads declaration paths as text through the footprint emitter, a non-gate arm it calls in process (§The non-gate arm). Its couples= names scripts/*.sh,kit:*.sh, and neither token covers a registry member’s declaration path any more, so the derivation does not select it. The coupling still earns its trigger, since scripts/ and the kits keep many non-gate *.sh, but neither token is what selects this row. |
check-gate-binary-fresh |
Retained by construction. It reads declaration paths as a set, to decide whether the binary is load-bearing, and never reads a gate’s source, so a port is its trigger rather than its blind spot. Its couples name kit:checks/*.gate, so it is substrate-sensitive wherever a descriptor exists (§check-gate-binary-fresh). |
check-gate-substrate-parity |
Retained by construction, and .gate-dispatched. It is substrate-sensitive by the derivation it performs, and it reads declaration paths as text and as a set: the auditor of the dispatch relation auditing itself. Its shell form already read one side of its comparison through --list, so its independence from the binary was only ever the absent-binary case, which compiled has no reachable input. The descriptor set is still globbed off the resolve dirs and the roster is the binary’s, reached in process (§check-gate-substrate-parity). Its row is written out because assertion C is satisfied by any occurrence of a member’s name in this section, and passing by being discussed is not a disposition. Assertion G’s widening to the tracked shell tree adds no member here: that corpus is one this gate walks itself. |
check-install-disposition |
Retained, substrate-blind by construction, and .gate-dispatched. It reads both declaration spellings as text, taking the # install: header line off a .gate descriptor exactly as off a .sh implementation, because a disposition is a property of the gate rather than its substrate (§The install disposition). The declaration travels with the descriptor, the file the installer’s payload already carries. A binary that is absent cannot pass it silently: the battery exits 2 rather than skipping (§Fail-closed contract). |
check-projection-roster |
Born native and substrate-blind: it reads the # projection: header line as text off whichever declaration spelling the registry’s resolve order finds, as check-install-disposition reads # install:. Its couples= names the declaration paths because a new or edited declaration is its subject (§check-projection-roster). |
check-consumer-value-literal |
Born native and substrate-blind. It reads declaration paths as a set, as check-gate-binary-fresh does, to decide which members ship, and reads a shipped member’s crate module rather than its declaration. Its couples= names *.gate because a descriptor leaving or joining a kit root moves its corpus (§check-consumer-value-literal). |
check-docs-cmd, check-install-claim, check-payload-claim, check-queue-slug-liveness |
Survive unchanged: reverse triggers. Each names scripts/*.sh/kit:*.sh in couples= only so a script change re-runs it; each scans the governed-doc set, never a gate script’s content. check-docs-cmd correctly reds on a doc that still fences an invocation of a .sh path a port deleted, and on an inline citation of any retired path outside a history valve (canon-kit/SPEC.md §check-docs-cmd assertion C). A mention outside every code span stays unscanned, and that residue is the close-stage audit roster’s. All four are .gate-declared. |
check-settings-paths |
Survives unchanged: reverse trigger, and a port is its subject. Its couples= names kit:checks/*.sh only so a check-script edit re-runs it; it scans the committed permission allow-list and hook registrations. Replacing checks/<gate>.sh with a descriptor strands every allow entry naming the old path, so the gate reddens because of a port, the check-docs-cmd shape. The glob is not widened to *.gate, since no Bash(…) grant invokes a descriptor path. The trigger is a partial route by construction: the pre-commit hook matches staged ACMR paths, so a deleted .sh never fires it, and the whole-tree battery catches a cohort’s stranded grants (context-kit/SPEC.md §check-settings-paths). This member is itself .gate-dispatched; both limits are properties of its rule. |
check-prose-enum |
Corpus extended to the Rust module: it is not a pure reverse trigger. Its enum derivation (--emit-enum-sets) reads the queue tag vocabulary out of check-tag-lead-line’s own class table, so a rename cannot leave two spellings disagreeing. That table lives in native/src/gates/tag_lead_line.rs (CLASSES), and the in-crate derivation references it rather than reading the module as text, so the read-from-the-owner property holds by construction. The gate is itself a ported member, and its derivation still reads the configured producer’s output as data rather than calling it in process: resolving the bundled producer for a consumer who configured another would void the extension point the knob protects. |
check-measured-claim |
Retained, sensitive through its oracle rather than its corpus, and a retained record. It scans the governed-prose surface, so by corpus it is a reverse trigger. This repo’s CANON_KIT_MEASURED_CLAIMS_CMD emitter counts how much of the registry resolves to a .gate descriptor, reading declaration paths as a set as check-gate-binary-fresh does, and its couples= names that emitter through scripts/*.sh. That token covers no declaration path, so the derivation does not select it, but selection by the derivation and sensitivity in fact are two different questions, and this member is where they part. A port moves its value: a marked sentence about the port reddens when the port advances without it. The coupling stays, since dropping it would leave the oracle’s source outside the trigger set. The row and criterion 4 are independent: the criterion binds on a gate’s assertion target, the governed-prose surface here, while assertion C sees the transitive reach through the emitter (§The port-candidate criteria, criterion 4). |
check-unmarked-claim |
Retained record, sensitive by coupling alone. Its assertion target is the governed-prose surface, and its consumer roster (CANON_KIT_CLAIM_CLASSES_CMD) emits a fixed vocabulary and reads no declaration path, so unlike the row above a port does not move its value. kit:*.sh in its couples= re-triggers it on the kit library that runs its emitter, and that coupling stays; with no kit declaration left in that spelling it is a pure reverse trigger. A consumer class about the port marks its sentence with check-measured-claim’s oracle key, so the moving value lives in that row, and coverage-only (canon-kit/SPEC.md §check-unmarked-claim) costs nothing here. |
check-spec-embedded-source |
Survives unchanged: reverse trigger. Its couples= extension list (*.rs, *.sh, *.toml, …) is the roster of languages it recognizes inside fenced blocks, not a reference to gate declarations; it scans the canonical specs and amendments. It already carries *.rs, so a ported gate’s Rust module is inside its trigger set. This member is itself .gate-dispatched; a port moves only its own candidate index, a property of the corpus (canon-kit/SPEC.md §check-spec-embedded-source). |
check-template-copy-parity, check-template-registry-parity |
Survive unchanged: their corpus is kit templates and the template registry, and neither reads a gate declaration. Both are .gate-dispatched. |
check-task-path-claim |
Survives unchanged: reverse trigger. Its couples= carries * because a ticked task may name any path, a gate declaration among them, and a deleted path is what reds it. It reads its configured task lists and stats the paths they name, never a declaration’s content, so a port that moves a declaration it names reds the task exactly as any deleted path does (canon-kit/SPEC.md §check-task-path-claim). It is .gate-dispatched. |
Every gate ports, an auditor of the dispatch relation included. Holding such an auditor on shell, lest a compiled one pass itself with a broken binary, is refused. A shell auditor already takes --list from the binary, so its independence was only from the binary’s presence. Presence is owned elsewhere: an absent or non-executable binary is exit 2 under §Fail-closed contract, and a stale one is §check-gate-binary-fresh’s red. A gate reading declaration paths as content pays criterion 4’s price for the parity oracle (§The port-candidate criteria).
After such a port, no shell auditor is left to see a corpus-walk regression. It would silence those gates over the crate’s own sources. The widened bad/ fixture cases answer that: they red on exactly that edit, and a consumer receives and runs them without the crate (§Fixture-pair discipline). A selector applying “audits gate source” mechanically would have held the whole spec_comment_surface family, which the rule never covered.
The declared read set’s honest limit. Unit test A’s coverage is the fixture corpus, bounded by contract: every ported gate carries a good/+bad/ pair (§Fixture-pair discipline), enforced by §check-gate-fixture-coverage. A walk reachable only on an input no case exercises is observed by nothing, and its declaration rests on the author. A gate whose author cannot bound a root declares ?, which the reader counts as undecidable rather than trusting as empty. Test A holds a ? to its arity: each one absorbs a single unmatched observed root, so a second undeclared walk still reds. One glob looped over two configured kit roots is two walks, owing two.
Those unit tests run in cargo test, in this repo and in CI, never in a consumer tree. So does the third registry-data test, every_registry_member_declares_the_root_that_carries_its_descriptor, which holds each member’s declared root to the descriptor it carries, over both root shapes (§check-gate-substrate-parity, assertion B’s owner column). It is named root rather than kit so its name matches both shapes it asserts over. For the sentinel it asks gate_sdk_gates_dir for the consumer’s gates directory, and a gate module reads that locator through the crate’s one reader (§The knob file).
Learning a layout inside a cargo test is sound here and nowhere else. native/ ships no checks/ and no smoke/, so it is not a kit root and no consumer receives the crate source. The declaration is held to executed behavior upstream, and the consumer’s own check is the good/+bad/ pair init vendors, which discloses a gate’s shape without its predicate. The consumption path assumes nothing about the binary’s origin: it invokes through GATE_SDK_NATIVE_BIN, and an installed artifact answers --reads as one cargo produced does.
Gates whose corpus is kit directories or smoke scripts (check-kit-registration, check-smoke-entry-guard, check-test-hermetic, check-assertion-strength) are not substrate-sensitive by the derivation above and owe no row. This table is about whose assertion a port could end, never about which substrate a member is on.
Reference-only implementations. The section carries one further disposition, in the other direction: an implementation the binary carries that no descriptor dispatches to. Assertion B reads that as a gate nothing declares, dead code or a half-finished port, and reds it. The exception is an implementation deliberately kept ahead of its port, so the substrate stays exercised by real tests. Naming it here is what separates it from the stranded implementation the assertion exists to catch.
The reference-only roster is empty, and the rule stands. Written as a sentence rather than a missing table, since an invisible table reads as a rule nobody kept. Every implementation is dispatched to, so no such subcommand exists today. Assertion B still reds a subcommand no descriptor dispatches to, and the next implementation carried ahead of its port is named here or reds.
Porting a gate to the binary substrate
A first attempt ported one gate end to end and then reverted the port, keeping everything else it built. This section is the record of what that cost bought, written here rather than left in a commit message because a later reader deciding whether to port will read the spec and would otherwise re-attempt it into the same wall.
cargo fmt is a defect here rather than housekeeping, and the rule, its measurement and the trade it defers are §build-native’s. It is pointed at from this section because a session porting a member has its load trigger here and not there, which is the whole of what the pointer buys.
The build-then-stage order is §check-gate-binary-fresh’s, and it is pointed at from here for that same reason. A port that adds a crate source has to stage it before building, not after; that section owns the rule and why the stamp cannot see an untracked file. A session porting a member reads this one.
One ground a port is argued on has changed since, by ruling rather than by drift. That attempt was built under the constraint that opacity is not claimed, so the case for a port rested on the seam and the conservation contract alone. §Consumer payload now rules withholding a gate’s predicate a goal, which makes opacity a ground a port may argue on — bounded exactly as that section bounds it, raised cost of analysis and never confidentiality. A later session weighing a port weighs that ruling, not the constraint set this record was written under.
A section is a cut’s outer bound, never its minimum. The composer’s the owed files behind one specification section bounds what a cut may bundle. It refuses averaging grounds across sections, and nothing in it obliges a cut to take every owed file the section holds. A section whose own text sequences one member behind a named unit may cut the unblocked remainder behind that section’s amendment and carry a second amendment when the sequenced member frees.
context-kit/SPEC.md is the instance that grounds it, because it stated both shapes in one file. §Layout and configuration sequenced its toolfloor library behind the installer’s behind-invoke relocation and said nothing of the kind about bin/env-probe.sh. §Testing sequenced two named members behind that same relocation and ruled a third — the AGENTS.md smoke — unblocked on its own ground. That relocation has since landed and every sequenced member left by deletion, so what follows is the shape’s record rather than a live sequencing. So the owner doc has already said which of its groups cuts whole and which cuts in part, and spec-over-precedent reads it there.
Four things bind the shape:
- The sequenced member stays owed. It takes no
# port-until:, because a held file leaves §port-blockers’ owed column and the completion predicate admits no contributor-side subtraction; its sequencing is prose in the section, at no cost. - The cut record lands in the section, as every closed cut’s does.
- The
--grouptrailer’s still owed against takeable at this cut is already a per-member reading, and §The port-candidate criteria already has a cohort sequence a member without declaring it held. - A port that must read the sequenced member’s content — env-probe read toolfloor’s roster and floor predicate — keeps one owner where the crate already reads it. It holds a predicate twice only under criterion 6’s unless clause with a parity test, evidence-kit/SPEC.md §The evidence adapters’ shape.
Three alternatives were refused:
- Whole section or nothing is the over-read the packaging ruling refused for one-cut-per-iteration, contradicts the owning SPEC’s own per-member sequencing, and prices unblocked lines at the Windows bootstrap’s clock.
- A
# port-until:on the remainder subtracts from the predicate. - Re-pointing the remainder’s
# spec:at another section to make the cut section a singleton is precedent over spec: the section itself says the roster is that library’s.
No past cut had taken a proper subset of one section’s owed files, so the ruling stands on the owner doc rather than on precedent.
A stated cause reaches only the members it names, and an always-loaded manifest is never a cut boundary. Two corollaries of the outer-bound rule, stated because a composer met both.
A section’s sequencing sentence holds exactly the members whose content it names. A member of the same section that neither sources nor reads the sequenced library is unblocked, whatever the section says of its group as a whole, and the owner doc corrects its own over-declaration where it stands (context-kit/SPEC.md §Testing is the instance).
The composer’s one specification section means a section of a kit SPEC or of the owner doc a # spec: line names for its mechanism. An always-loaded manifest such as a consumer’s CLAUDE.md owns no mechanism, so a file whose # spec: points there is mis-homed rather than sequenced. The correction re-homes the pointer at the doc that owns the file’s mechanism, and the file then cuts as a singleton under that owner. This is not the re-pointing refused above: that one moved a file away from a SPEC section that already owned it, and this one gives a file the owner it never had.
The worked instance is the adoption walkthrough. Its header and its knob line both pointed at a consumer’s CLAUDE.md, a section that described the walkthrough and specified no mechanism of it, not even the scratch-base knob that line told the reader it owned. The file’s own third pointer already named §Consumer smoke for the one decision it had needed a spec for. The pointers re-homed there first and separately, because re-homing is a legal end state on its own and the reverse order leaves no tree in which the mis-homing was ever corrected. The section then ported the member it had just been given.
A consumer’s plugin on a kit seam is owed like any file; the extension-point ruling reaches the seam alone. That ruling protects an extension point’s resolution, direct execution and env contract, and its own cited surfaces bound it. The load-bearing one is §The config-seam port disposition: that class reaches only files whose whole documented purpose is to be edited. Beside it, §The harness-template port disposition reaches a file only where its body carries the marked gap a consumer fills, and drift-kit/SPEC.md §The extensibility contract records the port moving the kit’s own members while touching nothing about how a consumer’s are found.
A file this repo names as a knob’s value is what the seam resolves, not the seam: no adopter edits it, and its whole documented purpose is to run. The instances are scripts/parse-gates-log.sh and scripts/parse-installer-smoke-log.sh on evidence-kit’s EVIDENCE_KIT_PARSER_<suite>, and scripts/enum-sets.sh on canon-kit’s CANON_KIT_ENUM_SETS_CMD. Beside them stand scripts/gate-exec.sh and scripts/producer-liveness-reader.sh on the pre-flight and liveness knobs. Porting one moves its mechanism into the binary and re-points the value. The knob still takes any consumer command, so no extension point narrows, exactly as DRIFT_KIT_KPI_DIRS still resolves a consumer plugin after the bundled KPIs went in-crate.
Some of those worked instances have since been ported, and the names above stay anyway — they are the record of what this ruling was decided against, so deleting one would leave a ruling whose reasoning cites nothing. scripts/parse-gates-log.sh and scripts/parse-installer-smoke-log.sh went in-crate as --emit-parse-gates-log and --emit-parse-smoke-log, each re-pointing its EVIDENCE_KIT_PARSER_<suite> value and narrowing nothing (evidence-kit/SPEC.md §Layout and configuration). scripts/enum-sets.sh went in-crate alongside them as --emit-enum-sets, re-pointing CANON_KIT_ENUM_SETS_CMD on the same terms (canon-kit/SPEC.md §check-prose-enum).
The other two left by a different disposition, and the difference is worth the sentence. scripts/producer-liveness-reader.sh and scripts/gate-exec.sh left with no in-crate arm minted for either. Each was an adapter whose whole body was a resolution and a dispatch, so what replaced it was a surface that already existed: the liveness hook’s default became the running executable itself, and the pre-flight roster re-pointed onto the battery front-end’s --only form. They were deleted and re-pointed rather than ported, and the knobs they were the values of still take any consumer command, so the reading below is untouched either way.
So the literal predicate governs these files unopposed, and each took a per-file port or a per-file declared cause on its own ground — a front end’s need to locate the binary, a reader’s worktree resolution — never on that ruling. Corroboration rather than ground, since it predates it and answers the provenance-seam question instead. The scripts/ declarations read every mechanism row and left each owed, recording of enum-sets.sh that being a knob’s value is half (i) of the exemplar’s cause, and the ruling declares on (b) alone. Two readings were refused. That ruling as exempting a consumer-side plugin file would exempt every file this repo names in a knob value — the contributor-side class the literal predicate refused, under another name, reached by the analogy that ruling itself bars. A # no-port: on the parsers citing the seam over-declares against files holding no seam content class, which mis-sizes the predicate with nothing red to catch it.
The decisions this substrate already closed
Two questions behind the port are settled, and this subsection is where they are recorded so the component that depends on them can be read alone. Reading them does not reopen them, and this section is their only home; the authoring rule that keeps a settled question from being restated from memory is stated where it belongs, queue-kit/SPEC.md §check-queue-entry-budget, rather than here.
The substrate language is Rust. The alternative weighed and refused is Go, on three grounds: larger binaries, poorer memory management under a primitive garbage collector, and a weaker compiler. The first of those is not a matter of taste here, because footprint is a first-class cost of the substrate: an install is tried and reversed without growing the adopter’s dependency set. The payload ships one prebuilt binary per declared target (§Consumer payload), so binary size is paid per target on every adopter’s machine.
A bash portability floor was costed and rejected — closed, not deferred. What was costed: shimming or dropping the GNU-only constructs the battery depends on — the gate library’s nameref and realpath --relative-to, gawk’s three-argument match(), sort -V, date -d, stat -c. The blast radius was about 25 files, mostly single call sites, with two shared libraries carrying most of the leverage, for an estimated two to four small units. It is rejected because it cannot deliver the reach it would be bought for: stock macOS ships bash 3.2, which no shim reaches, and mapfile / declare -A appeared in 57 of the 96 checks the battery held when it was counted. So the floor buys BSD-userland tolerance and never a stock-macOS install.
That rejection stands on the adopter constraints (§The adopter constraints), and deliberately not on this section’s own case for a port. The port’s own case ruled platform reach out as a ground for porting. An argument from reach would therefore contradict the case it sits under. The constraints reinstate reach: every major operating system is a target and the non-technical adopter is a design constraint. Read against those, a floor reaching BSD userlands but not a stock macOS install fails a constraint rather than a preference. The constraints’ own direction, shrinking the interpreter surface to the unavoidable, is the one this costing already pointed, which makes the costing a step toward them rather than a casualty of them. The narrower installer-and-probe trade this does not close has since been settled by removal. The install path is POSIX sh (installer/SPEC.md §The install boundary), the probe is an arm of the gate binary, and docs/requirements.md’s prerequisites block declares any POSIX sh, so nothing about that trade is left open here.
The figures above are a measurement frozen at the rejection, not a live claim about this tree: they record what the floor would have cost when it was rejected. A later session must not refresh them against the current battery — a recount is a step toward re-deciding a closed question.
The non-gate arm
The binary is a multi-call binary whose gate subcommands are dispatched by name out of gates::REGISTRY. Everything else it answers is a non-gate arm, and the roster is the binary’s own, never transcribed: the flags main resolves itself (TOP_LEVEL_FLAGS, the installer’s adopter verbs among them — installer/SPEC.md §The verbs), and the arm table ARMS. The table’s rows are the --emit- family, the harness-integration arms (§The harness-integration arm) and the other Arm::Run members. The binary run with no subcommand prints the whole roster: the adopter verbs, the hardcoded flags’ usage line and every arm-table member. Each arm’s own spec: binding names the section that owns it. The class is stated here because a session with a new non-gate thing to port has no other way to learn that it exists or what it costs.
An unknown --emit <name> gets its own refusal, no_such_arm, which prints the --emit- family off the arm table, prints no gate roster, and cites this section. It is routed where normalize composes --emit-<name>, the one point that still knows the argv opened with --emit, so no state carries the fact forward. The binary holds two rosters, and a reader who typed --emit handed the gate roster is handed the one that cannot contain what they meant. The printed family is the one the --emit <name> operand composes, since naming a bare-flag Arm::Run member there would offer a spelling that cannot work.
A non-gate arm is specified by three properties:
-
It is a top-level
---prefixed flag, resolved inmainbefore the registry lookup, and absent from--list. The flag’s spelling and the member’s variant are one decision, and the correlation has no exception. AnArm::Emitmember spells--emit-<name>, whichbin/run-gates.shcomposes from its--emit <name>operand. AnArm::Runmember spells a bare flag, which the front-end passes through, its argv case falling through on any leading-token. Both families are front-end reachable, so the--emit-prefix records the variant and never what makes a member reachable. The placement is load-bearing: §check-gate-substrate-parity assertion B equates the.gatedescriptor set with the roster--listprints, so an arm inside that roster would read as a subcommand nothing declares.A member’s own subcommand word is an operand, never composed into the flag.
--hookdispatches a member name off its first argument against a fixed table and refuses an unknown one, and--wait-probetakes the same shape for its subcommands (delegation-kit/SPEC.md §bin/wait-probe). TheEVERY_HOOK_KNOBsentinel stands for one member’s knobs where the argv names a member, so a subcommand-bearing arm is an already-solved registration. - It owes no
.gatedescriptor, nogates.listregistration and nogood/+bad/fixture pair. Those are the gate contract, attached to a verdict a battery reads; an arm that returns a document has no pass and no fail to fixture. It owes a unit test: a#[cfg(test)]module in the file implementing the arm, which §check-crate-arms’ test arm runs. It exercises the arm’s own rule on input it builds: its argument grammar, or the document or status it returns. A crate test elsewhere or a kit smoke that also drives the arm adds to that module and never stands in for it. A unit test beside the arm table resolves each row’s function to its file and reds any file with no test module. It resolves each--hookmember offhook::HOOKSthe same way, so a dispatched member’s own file owes that module, never standing onhook/mod.rs’s. It checks presence, never coverage. So a shared file such aswalk.rspasses on tests of its other functions, and a hardcoded top-level flag, which has no row, is outside the check. -
It owes a named caller instead: the caller that reads its output and the transition where it is read, or the arm is dead weight.
--source-stampis read by §check-gate-binary-fresh, and--guard-jsonby a consumer’s rule command, which reads payload fields and views and renders envelopes through it (guard-kit/SPEC.md §Consumer rules). This property is what stops the class becoming a place to park unreachable code.A caller is the requirement, and a stored projection is only one shape of it.
--emit-close-surfaces(lifecycle-kit/SPEC.md §The close-surfaces emit arm) stores nothing. Its roster is recomputed when its reader asks, by a gate calling it in process and a stage step running it through the front-end. A member may be a query tool:--emit-queue-index extent(queue-kit/SPEC.md §The queue-index arm) answers two integers and emits no document. Its only caller is a session, and a session reaching a mode through the front-end counts as a stage step does.A parity arm’s caller is its second holder, so the arm retires with it. One holder cannot be held equal to itself, and an arm whose harness can only skip is the unreachable code this property refuses. Retirement is read off the caller census, never off the deleting edit: a port can empty a helper’s production caller set as a side effect (evidence-kit/SPEC.md §check-producer-liveness), so a session porting a member asks which other helpers’ caller sets its cut empties. Read forward, the rule refuses a parity arm a cut would have to mint when it deletes both holders; a one-time line-for-line comparison in the deleting session stands in its place. A comparison run before a delete is evidence, and an arm that can only skip after it is not.
A flag spelling is minted with its implementation, never reserved ahead of it. The closed-roster rule that a key or field is minted with its reader applies to a flag spelling identically. A shell tool’s callers outside the crate move to the arm in the commit that deletes the tool, since a caller left on the deleted path fails at an adopter’s battery rather than in the crate.
The family follows the exit contract, never the output. Arm::Emit collapses every error to 2 and every success to 0. A member whose status distinctions carry meaning cannot take the --emit- spelling and is an Arm::Run. A member whose whole exit grammar already is that collapse may take it, whether or not it prints a document. The members forced out, each by the distinction it carries:
--lesson-sink(queue-kit/SPEC.md §The lesson-sink arm) returns its consumer-configured command’s status, so a failing sink is a red close step.--queue(queue-kit/SPEC.md §The queue verbs) returns 1 for a move written under a red post-check, the worklist its caller clears before commit, and 2 for a move refused with nothing written.--upgrade-smoke(§upgrade-smoke) returns a three-way verdict of its own: 2 a broken tag or environment, 1 an upgrade finding, 0 clean.--usage-verdict(delegation-kit/SPEC.md §usage-verdict) returns 0 OK/RESET-OK, 1 PAUSE, 2 STALE. The 1 is the whole blocking signal the per-dispatch budget guard reads, and collapsed the guard would block on nothing. Its callers are a session brief, a kit smoke and a gate reaching it in process, so it is not a harness-integration arm.--run-validate(evidence-kit/SPEC.md §bin/run-validate.sh) returns 0 clean, 1 a suite recordednew-failures, 2 the run could not start. That distinction is the member’s whole product, and its callers (a stage template, the lead template, a command shim and a kit smoke) read the status alone.--diff-baseline(evidence-kit/SPEC.md §bin/diff-baseline.sh) prints a report, yet 1 is its verdict and 2 its misuse code, and its one functional caller is a CI leg reading only the status.--install-hooks(§install-hooks) returns 0 wired and verified, 1check-identity’s own finding surfacing through it, 2 no gate binary to place, no repository, an uninterpretable manifest or a failedcore.hooksPathwrite. The collapse would report a wrong identity mapping as a broken install.
The test admits as well as refuses. --emit-usage-trend (delegation-kit/SPEC.md §Trend reporter) declares no 1, since it renders no verdict, so the collapse discards nothing; it sits beside --usage-verdict in one kit under the opposite answer. --emit-stage-rules (doctrine-kit/SPEC.md §stage-rules) emits a document, and both its failures, a missing <stage> and an unreadable doctrine file, are exit 2.
A departed stdout reader changes no status of the process’s own. For every subcommand and arm the binary answers, a write this process makes to stdout that fails because its reader has gone is dropped, and so is every later one. The process runs on, exits with the status it would have returned with the reader present, and prints no panic trace.
- It is the process’s rule, held once where the binary starts, never per arm.
native/src/main.rsshadows the two print macros ahead of every module. The crate’s print sites number in the thousands, and a rule per site is one every new arm has to remember. A site writing through a stdout handle of its own reads the same predicate:--lesson-sinkreturns its sink’s status, and the observation block’s writer (§check-reads-couples) drops the block. - Dying at the first failed write is refused. A fixed status there cannot tell a move that landed from one refused after its first line, and it loses
--queue’s 1 exactly when the post-check line is the write that fails. A gate’s verdict is its status, and a verdict that changes with the reader’s patience is no verdict. Restoring the signal’s default disposition is refused on the same ground, and it has no Windows form. - Only a departed reader is dropped. Any other stdout failure, a full disk under a redirect among them, keeps the behaviour it has: a document cut short by one must not exit clean.
- Stated cost. An arm piped into a filter that exits early runs to its end unread, the battery under
--runbeing the long instance. A caller that needs the whole document reads its own reader’s status. A hook member whose harness has gone drops its envelope and returns its protocol status. - A child handed the inherited stdout answers for its own writes. An arm that spawns one (
Sink::Inheritinnative/src/proc.rs) relays or reads the child’s status as it returns it. A child that is a build of this binary (the running executable, the binaryGATE_SDK_NATIVE_BINnames, the packaged artifact) keeps the rule, since the rule is held where that build starts. Any other child meets the departed reader as its platform delivers it, on Unix a death by the pipe signal, and the arm cannot tell the status that child would have returned. So an arm that relays its child’s status returns the death, and an arm that reads the status as a verdict reads the death as that verdict:--run-validaterefuses on a pre-hook that died printing. - Routing the child through a pipe the process drains is refused. The child’s two streams would stop sharing one description. Its stdout would then reach a merged reader later than the stderr written after it, the reordering §run-gates refuses for a gate’s own report. A descendant holding the pipe open past the child’s exit would hold the arm with it, and a child started from a terminal would lose it.
- Starting the child with the pipe signal ignored is refused. Its failed write then returns an error the child handles its own way, a shell script under
set -eexiting on it, so the status still changes, with a diagnostic added. It has no Windows form. - Out of reach. A closed stderr is outside the rule.
- The holder is a crate test on the binary cargo builds for the test run,
native/tests/closed_reader.rs, never the installed one, since §check-crate-arms answers for the tree’s source. It hands each case a stdout that is a pipe whose reader has already gone, which a closed descriptor is not. A member’s clean and finding verdicts, a document-printing--emit-arm, a--queuemove under a green and a red post-check with its write landed, and a refused move each keep the status they return with a reader, on an empty stderr wherever nothing is refused. A--queuemove’s post-check is the child that is this binary, and on the Unix legs a--scratch-runchild holds the other half: its own exit code where it wrote nothing, the pipe signal’s status where it wrote. It is a crate test because the failure has a Windows form, which only the crate’s Windows legs run.
An --emit- member has no front-end case arm. The --emit <name> operand composes its flag, which is why its spelling is forced, so the member has no named line and no paragraph in the front-end’s --help. Its usage lives at its own shape refusal (§The bin/-tool contract).
The table is named ARMS because its members share a declared knob roster, not emission. It is keyed by each arm’s own flag spelling, and --emit- is a per-arm spelling rather than a family name, so a non-emitting member joins without stretching the word. --run (§run-gates) is such a member: it returns a verdict rather than a document, yet no verdict a battery reads, and its caller is bin/run-gates.sh. It is a table member because it reads the gates dir, the kit roots and the scratch dir.
An arm reads its configuration itself, and the arm table is where it declares what it reads. Every arm resolves its knobs in process through knobs::wire (§lib/gate.sh), so a bare invocation of an arm reads the tree’s knob files as a member does. The arm stays the only entry point into the crate; what the class forbids is a second entry point into the emission path, not a caller. So the family is forced for any tool that needs configuration. Only an arm-table member carries a declared knob roster. A hardcoded top-level flag has no row, so the knob-file derivation, the couples check and the substrate-parity check cannot see what it reads, and a configured tool ported as one reads knobs no declaration holds.
Which environment reads may be declared is decided by the knob’s name, never by the arm. A name a static kit’s prefix owns resolves through that kit’s table, and knobs::wire refuses a name no static kit owns (§lib/gate.sh). A name carrying no kit prefix therefore cannot be declared, and the arm reads it off the process environment as the shell tool did. The declared rosters take these shapes:
- Across kits. Each name resolves through the table its
<KIT>_prefix names, so a roster may span kits (--emit-enforcement-map) or be wholly another kit’s rows.--emit-scan-promptsdeclares three rows of guard-kit’s table, andGUARD_KIT_LOG’s default derives from gate-sdk’sGATE_SDK_WORKFLOW_DIR(guard-kit/SPEC.md §scan-prompts).--emit-enum-setsdeclaresGATE_SDK_KIT_DIRSbesideQUEUE_KIT_LESSON_TAGS, under a constraint no gate enforces: a gate’s command adapter readingCANON_KIT_ENUM_SETS_CMDspawns it, so declaring that knob would nest its own resolution inside the spawn and hang rather than red (canon-kit/SPEC.md §The shared spec adapters). - Empty, because the member reads nothing. Table membership is what makes an arm reachable at all:
normalizecomposes--emit-<name>, andemit::lookupfinds a member only in the table. So a member reading nothing still takes a row.--emit-md-section(context-kit/SPEC.md §Index-first reading) resolves no knob and must not mint one.--emit-parse-gates-logand--emit-parse-smoke-logread their operands off their own argv, and a knob either acquired later would simply be declared. The smoke parser’s driver path is the consumer’s own file, so it is an operand rather than a crate constant, which is how the provenance seam holds on an arm the payload ships (§The provenance seam). - Empty, because nothing it reads may be declared.
--emit-session-id’s two candidate names (lifecycle-kit/SPEC.md §bin/session-id.sh) are owned by no static kit, so declaring either would meetknobs::wire’s refusal and fail-close the arm on every invocation. Defining them to make them declarable would be a behaviour widening, not a port, and the row is the only reachable spelling. - Partial, on the same ground. A member declares what it can and still reads what it may not declare.
--run-demodeclaresGATE_SDK_KIT_DIRSandGATE_SDK_NATIVE_BIN, and its scratch baseDEMO_TMP_DIRis read undeclared (§Consumer smoke).--pack-installerdeclares the kit-root override, the target roster, the artifact path,GATE_SDK_PAYLOAD_WITHHOLD,GATE_SDK_PAYLOAD_LICENSE,GATE_SDK_PAYLOAD_RECIPESandGATE_SDK_SPEC_BASE_URL(§Consumer payload), and its scratch baseINSTALLER_PACK_TMP_DIRis read undeclared. Each scratch base falls back toTMPDIR. A name gate-sdk’s static table defines is declarable whether or not the pre-binary shell library spells it.
A roster whose names are known only at run time declares a sentinel, never a maintained union, which would rot against a churning registry. A sentinel stands for a read set in the member’s own declared roster and never keys on the arm’s variant: Arm is a return shape and a read set is not, and a variant-keyed union would credit every hook with every registered gate’s knobs. Three are declared:
EVERY_REGISTERED_KNOB, every knob the tree’s registry declares, carried by--runbeside the runner’s own knobs and by--emit-port-blockers, which resolves an arbitrary knob’s default discovered at scan time and reports an unowned name?, with default unresolvable evidence (§port-blockers);EVERY_HOOK_KNOB, one hook member’s knobs where the argv names a member and the union over the hook table where it does not;registry::EVERY_COUPLES_KNOB, every knob aknob:token in the descriptor corpus names (§lib/gate.sh).
Each is a declaration only: no front-end resolves a union from it, since every arm reads its own knobs in process.
Where the roster is a namespace, the arm declares a prefix family instead. walk::knob_prefix reads it as the owning kit’s open family in process (§lib/gate.sh, §The knob file). A prefix matching nothing is an empty family that passes, so a consumer may configure one family and not another. drift-kit’s collator declares DRIFT_KIT_* (drift-kit/SPEC.md §The report skeleton): its contract rests on compgen -v DRIFT_KIT_ so no fixed export list drifts, and its registry is a KPI list declaring no knobs, outside the registry sentinel’s scope. --emit-enforcement-map declares EVIDENCE_KIT_RUN_*, and --run-validate declares it beside EVIDENCE_KIT_PARSER_*. --enter-stage declares lifecycle-kit’s whole namespace (lifecycle-kit/SPEC.md §bin/enter-stage.sh): it takes no --gates-dir and dispatches gates it names rather than a registry, so the registry sentinel would expand to nothing.
A dispatching arm declares its callee’s reads beside its own, and passes nothing down. The child gets the invoking environment unchanged and resolves its own knobs from the knob files. The declaration is what lets the knob-file derivation and the couples check see every read one run of the arm makes. --enter-stage’s pre-flight gates and --install-hooks’ dispatch of check-identity are the instances. A dispatched member is resolved through the registry and spawned, never called in process (§run-gates), since a member that panics would take the arm with it. A sibling arm’s knobs are outside --run’s sentinel, because the runner never invokes a sibling arm; a gate reaching an emitter in process declares that emitter’s knobs on its own registry entry. A knob only an unselected member declares does not fail a selector run: a member’s knob refusal is raised at its first read, so it fails a bare run and a --only naming that member.
An arm may have two sanctioned callers. --enter-stage is reached by a session through the front-end, and by a hermetic harness reaching the binary directly, because the front-end refuses outside a git repository and the harness runs in a non-git sandbox. That second caller is a caller, not a second entry point into the emission path.
A default a deleted shell driver held inline moves into its kit’s table in the same cut. The binary resolves a declared knob from its kit’s table (§The knob file). A default left in the deleted driver has no home, and the compiled reader asks for a name its kit does not declare. drift-kit’s report knobs are the worked instance.
A configuration-selecting argument is deleted at the port, never ported. The binary resolves every knob a member reads from its kit’s knob files, located by <KIT>_KNOB_FILE or the gates directory (§The knob file). An argument pointing the kit library at a different config file has nothing left to redirect. A documented flag that silently changes nothing is worse than no flag, so its callers are re-pointed at the config path the resolution already reads, and a sentence documenting it is deleted in the same port. check-docs-cname-parity’s third positional is the worked instance (site-kit/SPEC.md §check-docs-cname-parity): its fixture pair reads a gates-dir knob file of its case instead, SITE_KIT_KNOB_FILE being the selector. Check for this shape before sizing a member, since it is an interface removal on a governed surface.
The test is taken per argument, never off a count. An argument is unportable only when it redirects configuration the knob resolution already selects from the tree. An argument the rule itself consumes arrives as argv, ports unchanged, and may override a knob value. The shapes that port:
- a gates-dir positional, which names the registry and the first resolve dir (
check-kit-enum,check-root-tiering); - a rule input such as
check-gate-binary-fresh’s tree-stamp file, or the file a rule writes into (--install-lifecycle’s[agent-file], lifecycle-kit/SPEC.md §bin/install-lifecycle.sh); - an input-corpus selector no knob resolves:
check-gate-tamper’s--fixture <dir>,check-reads-couples’ gate-source paths,check-gate-exemption-tasks’ scan dirs; - a scan-root positional the rule composes with its knob globs (
check-prose-tells,check-spec-embedded-source,check-template-copy-parity); - a queue-file or state-file positional falling back to a knob (
check-amendment-queue,check-evidence-manifest,check-todo-task-liveness).
The deletions are check-identity’s two arms (§check-identity) and check-gate-fixture-coverage’s positionals, whose narrower ground is stated at that member’s section. An environment variable selecting a knob file is not the unportable shape: <KIT>_KNOB_FILE survives, because the arm resolves its kit’s knobs in process from its own environment, so the redirection happens inside the resolution.
--install is the deliberately unconfigured member. A configured install arm would read knob files the install is still placing, and its bootstrap caller may not be a POSIX shell (installer/SPEC.md §The install boundary). So every value it needs arrives as argv, and it resolves no knob and no kit config. Its callers are the --init arm, in process, and the two bootstraps. Its grammar, channels and exit statuses are installer/SPEC.md §The install boundary’s, and the consumer smoke’s install path asserts it (installer/SPEC.md §The consumer smoke). A later op growing a configured input arrives as a new argv key, never as a knob.
A member may decline the --install <op> family. --install-lifecycle is its own arm-table arm rather than an op of that family, because it renders blocks from resolved kit config. As an op it would take every knob on argv from a caller with no way to resolve them.
A member taking free-text argv keeps its shape refusal and its -- escape across the port (§The bin/-tool contract). The hazard belongs to the argument, not to the tool being a bin/ script. The capture members are --emit-file-survey and --emit-cite-survey (lifecycle-kit/SPEC.md §The survey record), --emit-file-gap (lifecycle-kit/SPEC.md §The committed gap inbox), --emit-kfric (drift-kit/SPEC.md §The knowledge-friction loop) and --emit-file-install (drift-kit/SPEC.md §The install-observation record). A capture member’s usage lives at its shape refusal, which prints the usage block at exit 2, and a per-arm help flag would be a second home for one sentence. So --emit file-survey --help is a refusal rather than a capture.
Seeding a capture surface is the arm’s own obligation, never the shared append’s. A member whose surface may legitimately be absent, such as an inbox truncated at a close or a log no consumer has written yet, creates it and writes its contract header itself. A port taking its shape from a sibling arm must add this, since nothing the members share does it.
--emit-knob-roster publishes the static knob table (§The knob file), one line per statically owned knob: <NAME><TAB><shape><TAB><rendered default>, <shape> being scalar, indexed or keyed. A scalar prints one line, an indexed default one line per element in order, and a keyed default one line per pair, spelled <key>=<value> and sorted by key. An empty indexed or keyed default prints one line with an empty third field, the resolved-empty reading the wire gives. A derived default renders with every sibling at its own default and a locator input as ${NAME} (§The knob file). The arm takes no knob and reads no file. Its readers: check-docs-cmd assertion B unions its first column into the known-knob set, check-knob-default-coupling couples its scalar defaults as a third default idiom (canon-kit/SPEC.md, both sections), and an adopter reads a static kit’s names and defaults off it. Names and defaults are configuration surface, not rule source, so publishing them discloses no gate rule. A gate reader is a member of the binary and reads the table in process.
--emit knob-values <NAME>... prints the resolved value of each named static knob in the invoking tree, in the roster’s line grammar: <NAME><TAB><shape><TAB><element>, one line per element, and one line with an empty third field for an empty collection. A name no static kit owns, or one its kit does not declare, is exit 2. A derived default resolves exactly as it does for a member (§The knob file). It is the only producer of the value a shell reader needs: no bash code parses a knob file or recomputes a default, which a second producer would be (§The port-candidate criteria, criterion 6), the pre-binary accessors aside (§lib/gate.sh). Its reader is lib/gate.sh’s gate_knob_values, through which the prune-set load reaches it; a consumer KPI plugin reading another kit’s knob is the reader it stays open for, drift-kit’s own kpi-deprecated-surface member now reading its two canon-kit knobs in process. It is a second arm rather than an argument form of --emit knob-roster, since one arm answering defaults bare and resolved values with arguments would give one spelling two meanings.
--emit kit-roots prints the kit roots, one per line, relative to the working directory: the crate’s derivation from the GATE_SDK_ROOT locator and the GATE_SDK_KIT_DIRS override (§Layout and configuration), for a shell caller that needs the set. Its declared roster is that override.
The fence-safe arm set is declared beside the arm table. FENCE_SAFE_ARMS names the arms a documented fence may run the binary under when canon-kit/SPEC.md §check-fence-run executes it. An arm belongs when it spawns no program that reaches the network and writes nowhere but its working tree and stdout. It is one declaration of a property of arms, on FAIL_OPEN_ARMS’ precedent, and not a knob, so it joins no knob roster. The declaration spells its hardcoded and Arm::Run members, and every --emit family member and every registered gate name join by derivation off the table and the registry.
The crate’s network spawners are --usage-poll (curl), --pack-installer and --installer-smoke (npm), --with-foreign-shells (docker), --price-coverage (a consumer’s DRIFT_KIT_PRICE_PAGE_CMD, where set), --foreign-run (a consumer’s DELEGATION_KIT_FOREIGN_ADAPTERS) and --foreign-resume (a consumer’s DELEGATION_KIT_FOREIGN_RESUME), each of which also spawns a consumer’s DELEGATION_KIT_FOREIGN_USAGE_CMD where one is set. A crate unit test holds them disjoint from the set, so admitting one reds the build rather than an adopter’s scratch. One gate spawns a consumer’s command too, outside that test, whose roster is arms: check-crate-arms runs whatever GATE_SDK_CRATE_TARGET_LINT_CMD names, where set (§check-crate-arms). A gate name is fence-safe by derivation, and a fence runs in a scratch under a fixed environment, where no host overlay arms the knob. A network spawner therefore takes a bare-flag Arm::Run spelling even where its exit grammar is the --emit- collapse, since that family joins the set by derivation. The set is gate-sdk’s because it is a property of the binary’s arms. A consumer needing a further program in a runnable fence names it in CANON_KIT_FENCE_RUN_PROGRAMS and never widens this set.
The read-only arm set is declared beside it and is a subset of it. READ_ONLY_ARMS names the arms a read may be steered to and an isolated child may run against the main checkout (guard-kit/SPEC.md §The rule roster, rules sed_file and worktree_confinement). An arm belongs when its effect is its command line: it writes nothing but stdout and stderr, spawns no program, and reaches no network. The set holds reads, not every arm that happens to meet the contract. A crate unit test holds three things: each member names an arm-table row, each is fence-safe, and each member’s module rule text carries no filesystem write, removal, rename, copy, permission or link call and no program spawn. Honest limit: the scan reads the member’s own module, so a write or a spawn reached through a helper in another module passes it. An arm therefore joins only after its call graph has been read once, and that reading is recorded where the member is added. --emit md-index and --emit enum-sets stay out on that reading, since each spawns git, the first through the helper that resolves its path column’s root.
What an arm spawns is recorded here, because --needs cannot answer it. --needs takes a gate name and reads the requirement element of that gate’s REGISTRY row, and an ARMS row carries a flag spelling, the arm and a knob roster, with no requirement element. native/src/programs.rs is the machine-held union of every arm’s set and the rest of the binary’s (§The program roster). The per-arm attribution stays prose, since no fixture corpus runs an arm and a per-arm declaration would be one nothing holds.
- Fixed by the implementation.
gitunder several--emit-arms and under the origin-URL lookup the arm table’s module makes.dateoff unix under--emit-queue-index,--emit-file-gap,--emit-file-consult,--emit-kfric,--emit-file-installand--emit-file-survey, which also spawnsgitfor therevit stamps.--emit-stage-economicsspawnsgitanddateoff unix, and no interpreter.bashunder--lesson-sinkand--emit-port-blockers.--emit-compare-settings-allowspawns nothing: it reads the allow lists withserde_json(guard-kit/SPEC.md §compare-settings-allow).--emit-env-probespawnsuname,dateoff unix and every element of the probe roster, which is the crate’s own and carries no knob (context-kit/SPEC.md §bin/env-probe). - Spawned per case or per consumer file.
--run-gate-testsspawnsbashonce per case to readgate_command’s answer out of the library that owns it, once more for the default gate-declaration dir set, and once per bespoke*.test.sh.--emit-pub-indexspawnsbashtwice per language whose extractor resolves to a consumer file and not at all for a built-in grammar, since only a consumer’s public-surface extractor is a sourced bash file (context-kit/SPEC.md §Index-first reading). - The scratch-consumer arms.
--agents-md-smoke,--run-demoand--run-consumer-smokebuild their scratch consumer through the in-crate builder (§Consumer smoke):bash,git,mktempandcp, plus whatever the vendored kits’ own installers spawn, with nocargoand notar.--run-consumer-smokeadds each unregistered gate’s own declaration, run by path or through the probed tree’s binary with its working directory in that tree. Those probes stay spawned, because the binary reads the probed tree’s knob files, and an in-process call would pair the invoking tree’s knob state with the probed tree. - The heaviest.
--upgrade-smokespawnsgit,bash,cargo,tarand floor utilities;cargoandtarare offGATE_SDK_PROGRAM_FLOOR, ruled a requirement on that suite rather than on an adopter (§upgrade-smoke).--pack-installerspawnsgit(archive,rev-parse,describe,ls-files,status),tarto extract the archive stream,npmto pack, andmktemp. Its preflight refuses unless those three are onPATH, since a tool check naming a program nothing runs is the defect a documented flag that does nothing is.git archive | tarandnpm packstay spawns, because reproducing either in-crate is a second implementation of a format.--installer-smokespawnsgit,npm,tar,shorpwshfor the host bootstrap, the running binary for each pack,shasumas the fallback’s control, and whatever the vendored kits’ installers and the scratch consumers’ batteries spawn (installer/SPEC.md §The consumer smoke). - Split between the arm and its subject.
--run-guard-testsspawns the running binary per case as the--hook shell-guardmember,gitto build the sandbox, and, in the consumer lane, the argvGUARD_KIT_CONSUMER_RULES_CMDnames, whose programs are the consumer’s (guard-kit/SPEC.md §Testing). - Fixed by the subject.
--run-index-testsspawnsbashonce per check and nothing else: every check reaches its arm through the--emitfront-end, the path an adopter’s own call takes (context-kit/SPEC.md §Testing), so compiling the spawn away would stop testing the thing.--wait-probespawnsbash, and through itnohup,sleepanddate, which are what the member measures (delegation-kit/SPEC.md §bin/wait-probe). A smaller set would be the defect there, and its two off-floor members are stated as a cost at that section.--measure-commitspawnsgitfor the top level and the tracked-path list, and whatever the selected members spawn, since running them is what it measures. - Changeable by a consumer.
--emit-always-loadedspawnsbash,gitfor the baseline commit and the growth diff, and whatever programCONTEXT_KIT_HOOK_CMDnames, a command seam kept spawned (context-kit/SPEC.md §The always-loaded meter).--usage-verdictspawnsbash -calone, and only whereDELEGATION_KIT_REFRESH_CMDis non-empty, that knob being a command seam too.--run-validatespawnsbash, whatever its configured parser and pre-hook commands name, and each suite’s own run command, so its set is a consumer’s configuration.--emit-drift-reportspawns whatever program a consumer’s KPI plugin names.--price-coveragespawns whateverDRIFT_KIT_PRICE_PAGE_CMDnames andgit hash-objectover its output, both only where that knob is non-empty, and otherwisedateoff unix alone (drift-kit/SPEC.md §The price-coverage arm).--foreign-runspawnsgitfor the clone and whateverDELEGATION_KIT_FOREIGN_ADAPTERSnames, and--foreign-resumegitfor the shape check and whateverDELEGATION_KIT_FOREIGN_RESUMEnames (delegation-kit/SPEC.md §The foreign-vendor run). Each first spawns whateverDELEGATION_KIT_FOREIGN_USAGE_CMDnames for the adapter, where it names one, as the--budgetform does alone (delegation-kit/SPEC.md §The keyed verdict). One gate belongs here beside the arms:check-crate-armsspawns whateverGATE_SDK_CRATE_TARGET_LINT_CMDnames, where set (§check-crate-arms). - Unbounded by construction.
--emit-ruling-staleness(lifecycle-kit/SPEC.md §The ruling-staleness probe) spawnsbash, and through it whatever program adischarge:declaration’s oracle names, which the arm never sees before it runs it. An arm that dispatches a declared command has no set to state, and saying so stops a reader taking the omission for an oversight. The timeout knob’s bound is the mitigation the shape admits.--with-foreign-shellsspawnsdocker, the shells it copies, andbash, which runs whatever command it was handed (§with-foreign-shells).
The harness-integration arm
A harness-integration arm is a non-gate arm whose named caller is the coding harness rather than a battery, a stage step, a regen command or a gate reaching it in process. It satisfies the class’s three properties unchanged. What it adds is that its channels are the harness’s, not the gate output contract’s: stdin carries a harness payload, stdout carries whatever that integration point reads, and the exit status means what the harness says it means rather than what §Output contract says. Every other member of the class answers to a caller this project wrote. The family is forced by the configuration test above: these members read a consumer’s own rosters and paths out of the owning kit’s config, so each is an arm-table member with its knobs declared on its row.
--hook <member> takes the member name as argv, never the hook event name. The harness settings file scopes hooks by matcher inside an event. A PreToolUse carrying several matcher groups need not have interchangeable members: a shell member and a ported member may sit in one group. An event-named arm would have to re-derive the matcher from the payload, would fire on every tool call rather than the matched ones, and could not express that mixture. The member name makes the port a one-for-one substitution of the command value and leaves every matcher untouched.
Two shapes are refused. One arm per member is refused because arms speaking one protocol share one named caller, so the class rule is met once. A per-member spelling would also put a second copy of the crate’s roster into the front-end’s grammar. Hooks stay shell as a class is refused because a per-hook bash file is the duplicate a second-platform port has to write twice.
The channels.
- argv is
<member>, one token from the table’s closed roster; an unknown member is exit 2 with the roster named, theno_such_gateshape. - stdin is the harness payload, read whole and parsed with
serde_json. An absent, empty or unparseable payload takes the member’s own degraded path, never a panic and never a block the member would not otherwise have issued. - stdout is the hook-JSON envelope where the member emits one, with every interpolated value serialized rather than quoted by hand. The envelope’s own braces and key order stay a literal, so the wire shape is the one the shell guard’s advise published. A member emitting no envelope writes nothing on stdout.
- stderr is the member’s block or refusal text, which the harness shows.
- exit status is the harness’s protocol, returned unchanged:
0allow or advise,2block or refuse. A member using the status channel alone is using the protocol, not excepted from it.
The member table is the single roster. native/src/hook/mod.rs owns a HOOKS table keyed by member name, with one module per member beside it. Each row carries the member’s function, its declared knob slice and its owning kit, the kit whose SPEC states the member’s rule. A unit test holds each row’s owner to the kit named by its member module’s leading spec: binding. The table is what the arm dispatches on, what EVERY_HOOK_KNOB stands for, what the unknown-member refusal prints, what check-settings-paths resolves a registration’s member against, and what the enforcement map credits a member row to. The owner is a column rather than a derivation because nothing cheaper names it: knob prefixes do not name a member’s kit, and the module’s comment is unreadable to the shipped binary at run time.
The registration grammar has one parser beside the table. It reads a hook’s tokens and returns the --hook operand where the command token’s file name is one of three spellings:
- A front end’s,
run-gates.shorrun-gates.ps1. - The binary’s own, the crate’s package name with or without an executable suffix. That is the name assertion B of guard-kit’s
check-door-bindingderives, so the two cannot drift. A stated limit: a consumer who renamed the binary gets no member check. - The binary’s placeholder,
@GATE_SDK_NATIVE_BIN@, the spelling a kit template carries before the adopter’s merge. The crate holds the token once, beside the front-end names; guard-kit/SPEC.md §The recommended allowlist owns it.
Three readers call it: check-settings-paths (context-kit/SPEC.md §check-settings-paths), the enforcement map (§enforcement-map) and check-plugin-parity’s rendering (plugin/SPEC.md §The guards). They read a merged settings file and an unmerged template alike, so no reader of a registration parses it a second way.
A shipped registration is the harness’s exec form. command names the binary under the project-directory placeholder and a sibling args array carries --hook and the member, so the harness starts the program with no shell between and the path needs no quoting. A kit template spells the binary as the placeholder, and each template’s note states how its own file resolves it (guard-kit/SPEC.md §The shell guard). The front end keeps the --hook arm for the registrations that stay shell form: a consumer’s already-merged wiring, the plugin’s rendering and the status line.
Started with no front end, the arm resolves the tree it runs in. Before any knob is read it asks git for the toplevel of its working directory, through the crosser (§The crate’s crosser), and enters it. A member therefore reads its knob files from the tree’s root, whatever directory the session’s shell stands in. Without that step a session’s cd would switch a consumer’s configuration off: the knob file is missed from a subdirectory and the member allows the call with no word. Through the front end nothing changes, since the arm is already at the toplevel and asking again answers the same directory. The gate-sdk root locator takes its default on a direct spawn (§Layout and configuration).
- A linked worktree. The project-directory placeholder names the root the session started in. A session that entered a worktree, an isolated child among them, therefore starts the main checkout’s binary with its working directory in the worktree, and the arm reads the worktree’s own tree. That is what
gate_harness_binarranges for a front-end call (§lib/gate.sh). Run on Linux: in a linked worktree the placeholder named the main checkout. - Honest limit: a session launched inside a linked worktree is not run. There the placeholder would name the worktree, where no binary is built, leaving its guards off behind the harness’s one notice.
Two knob shapes do not reach a member’s slice. A knob naming a shell surface the member once sourced or spawned does not, because a compiled member sources and spawns neither. The row declares the rule’s own reads, so the member’s roster and its kit’s defaults stay one change (delegation-kit/SPEC.md §usage-verdict). <KIT>_KNOB_FILE does not either: it selects where configuration comes from, and it keeps working because the binary reads it from the environment when it resolves the owning kit. The same-cut default rule above is load-bearing here: a name the owning kit’s table does not declare is refused at its first read, and on a hook path that refusal is a block.
A harness-integration arm whose exit status gates a user action fails open when it cannot run at all. A status of 2 on a hook path blocks the tool call. A tree with no binary, one deleted or vendored by hand on a host the roster carries no artifact for, would refuse every guarded call. So would a session whose working directory sits in a tree git refuses or skips, the cd that would leave it included, under a valid project directory the plugin’s own lookup accepts. The guard therefore declines rather than wedging the session, and who speaks the decline depends on how the arm was reached.
- Through the front end, the stub exits
0on both causes it can see: an absent or non-executable binary, whereexec_armwould otherwise exit 2, and a tree whose repository git refuses or skips (§run-gates), where the stub runs no binary. For--hookit writes a fixedsystemMessageenvelope on stdout, telling the operator the tree’s hook guards are not running and naming the remedy: the build command, orgit statusandgit --git-dir=<that .git> statusfor a refused or skipped repository. A stub has no JSON reader, so it takes the universal field every hook event accepts rather than choosing the event’s envelope. The envelope is a literal with no interpolated value, so it needs no escaper, and the path-bearing diagnostic stays on stderr. Inside a linked worktree, which carries no build output, a fail-open arm first dispatches to the main checkout’s binary throughgate_harness_bin(§lib/gate.sh), since that binary already guards the parent session’s own calls. A verdict-bearing path takes the main checkout’s binary only throughgate_verdict_bin’s stamp-gated link (§lib/gate.sh), and fails closed otherwise. - By an exec-form registration, the arm’s own decline. Where git refuses the repository, skips a nested one, or answers none, the arm exits
0before it validates its member. It writes the samesystemMessageenvelope under its own name, a literal again, with the crosser’s path-bearing diagnostic on stderr. The front end keeps its own copy for the callers that reach it, since there the stub runs no binary. - By an exec-form registration, the harness’s decline. An absent binary runs nothing of this project’s, and the harness lets each call through (§run-gates).
--statuslinetakes no envelope, because the harness renders its stdout as the status line.
A knob that cannot resolve is seen inside the member, which declines through hook::decline. The decline allows the call, exits 0, and writes the envelope the firing event reads, chosen by the payload’s hook_event_name. On PreToolUse that is the advise envelope (hookSpecificOutput.additionalContext), which reaches the session model and changes no verdict. On any other event, or where the payload names none, it is the universal systemMessage field, which reaches the operator. On a turn-end event no envelope reaches the model without refusing the stop, since that event’s additionalContext keeps the subagent running and its exit 2 and decision: "block" both refuse. The text names the member, the unresolved knob’s reason, and that the rule was not enforced on this call. The same text goes to stderr, which at exit 0 the harness writes to its debug log alone, so stderr is that record and never the channel. The decline keeps a malformed unrelated knob in a kit’s validating table from wedging every guarded tool call.
shell-guard is the one exception: a refused read of its own knob file blocks with the refusal’s text. That file is guard-kit’s own and its repair tool, the Edit tool, is a call the member never sees, so the block wedges nothing it cannot also clear (guard-kit/SPEC.md §The shell guard). An arm whose caller is a refresh command or a session rather than a gate on a tool call keeps exit 2, as --usage-poll does for its timer.
--statusline declines at 0 and neither branch covers it. It gates no tool call, so the fail-open ground is absent, and the harness discards its status, so an exit 2 would be a verdict with no reader. The status is fixed at the front-end. Both fail-open members are listed once, in the crate’s FAIL_OPEN_ARMS beside the arm table; this section states why an arm is in the set, and each front-end stub carries a copy check-front-end-fail-open holds to it (§run-gates). Whether --statusline is a third branch (an arm whose status the harness ignores declines at 0) or the fail-open branch read more widely is not settled here. Both readings produce the same behaviour, and the choice shapes the next member rather than this one.
§Fail-closed contract does not reach this class. That contract attaches to a thing that returns a verdict a battery reads, and a harness-integration arm returns its verdict to the harness. The ground is guard-kit’s own: the shell guard fails open on missing infrastructure, so an unparseable payload, a failed settings read or a view fault decides nothing and every reader declines (guard-kit/SPEC.md §The fail-open postures). Failing closed is refused, because its blast radius is every tool call in the session and its trigger is an adopter’s platform rather than an adopter’s error.
--statusline and --usage-poll are siblings of --hook, not members of it, and neither takes an argument. That arm’s contract is the hook protocol: an envelope on stdout and the exit status as the harness’s allow or block signal. A status-line renderer writes an ANSI line whose status the harness ignores, and a usage-snapshot refresher has no harness event at all, its callers being a refresh-command knob and the session that invokes it. Folding either into --hook would make its output contract “whatever the member’s integration point expects”, which is no contract. Each satisfies the class’s properties, names its own caller, and answers a surplus argument, --help included, on the channel that caller reads:
--usage-pollrefuses it at exit 2 before it reads a file or spawnscurl, naming the argument and the front-end’s--help. Its callers read the status, and 2 stays distinct from the fail-soft cycle’s 1.--statuslinedrops it, names it in one stderr line written before the payload is read, and renders as it would bare. A refusal would speak a status nobody reads and blank the line, the only thing the harness shows. The drop rests on the channel alone, so it leaves the open question above unsettled.
--usage-poll gives the installed binary a path that reads an OAuth credential file and reaches an external endpoint. No adopter constraint governs the installed binary’s capability surface. The disposition is therefore stated: the path is admitted. No HTTP client enters the crate and no dependency is added, since curl stays external, spawned through proc::run. Opacity being a goal (§The adopter constraints) reads toward this: a credential-reading path moving out of a vendored, readable shell file into the binary is that goal working as stated.
A gate that reaches an arm in process acquires a source coupling its descriptor must carry. A shell comparator spawning its emitter is invisible to the # graph: manifest, which names tracked paths. In the compiled form the same relationship is a call across crate modules, and the callee’s module is a tracked file whose edit changes the caller’s verdict. Omitting the coupling leaves the gate registered and green while the projection it holds goes stale, because the hook arm’s selection derives from couples=, so the gate never runs on the edit that broke it. The derivation supplies the modules; the descriptor names the corpus. registry::module_files computes each member’s cut from the crate source, and it joins the member’s effective couples= and trigger= as a derived couple (§The # graph: manifest). A porting session writes no crate-file token the cut already carries. A wider crate token, native/src/*.rs on a gate that reads the crate as text, names a corpus the gate reads and stays hand-declared.
The cut is one hop, plus one hop through an in-process emitter. A member’s cut is its own module, its direct first-party imports, and the direct imports of each emit/ module among them, since an emitter called in process is part of the verdict. A transitive closure does not discriminate, since most gate modules reach one strongly connected core of the crate. The cut is read statically: every crate:: and super:: path outside comments and the test module, braced use groups expanded, and every path through a name a use bound to a module, so use crate::emit; then emit::footprint::measure() reaches emit/footprint.rs. The member-to-module map is read at build time off each REGISTRY row’s <module>::run path, so the registry stays the one table.
The cut stops at the universal layers, named as registry::UNIVERSAL_MODULES: walk.rs, proc.rs and registry.rs (the knob-reading, spawn and registry layers every gate module reaches), and the three table roots gates/mod.rs, emit/mod.rs and knobs/mod.rs. Coupling a universal layer into every member spells one fact once per member and re-runs the whole battery on any edit to it. §check-crate-arms and the binary’s own source stamp hold the universal layers instead, and a universal emitter root is never expanded through. What the cut reaches is the modules whose edit can change this member’s verdict: its own module and the shared rule-carrying modules beside it (fresh.rs, declaration.rs, an emitter it calls in process). Honest limits: a mode=staged member takes no cut, since the hook arm would hand it a crate file as a path to scan. A member whose couples= holds only its own module keeps that token, because couples= may not be empty.
registry.rs is the one implementation of the registry grammar. It owns the gates.list member grammar, gate_resolve’s declaration path (dirs consumer-first, .sh beating .gate within a dir), the # graph: field read and the couples=/trigger= kit expansion. Every reader of them calls it, --run, the graph projection and the enforcement map among them.
The port-candidate criteria
These seven are an engineering roster and an ordering signal, never an eligibility screen. The whole corpus ports over time, so none of them excludes a gate from the port. Each does two jobs instead. It names an engineering problem the port owes for the gates that fail it, and it orders the work: a gate clearing all seven is cheap to port today, and a gate failing one carries exactly the cost the criterion describes.
All seven bear on gates, and the directive’s completion predicate bears on the tree. A criterion asks after a registration, a fixture pair, a tier or a couples=, properties a gate has because it is a gate. A plain tracked script has none of them, so applying a criterion to one yields an unanswerable question, which is why §port-blockers’ --tree arm emits no criterion column over that corpus. The predicate asks a much smaller question of each file: is there a stated disposition. The two rosters are read at different transitions by different sessions, and the arm that answers each is the one whose corpus matches its question.
- Registered in
gates.list: an unregistered gate proves no dispatch. -
Carries a fixture pair: parity between substrates is proved by running both against the same cases, never asserted.
A
# no-fixture:member satisfies this by a constructed scenario, not by an exemption. Where the state under test has no static representation, such as a HEAD-vs-worktree diff whose committed fixture has HEAD == worktree, the pair is unavailable. The criterion’s actual demand still is: the same cases, both substrates, while both implementations exist. The port stands the state up in a throwaway tree, runs both implementations over it, and compares bytes and exit codes. That is a stronger oracle than the pair, because the scenario reaches branches a committed case cannot: the loss case itself, and the no-repository branch most parity harnesses skip. It is bought once, at port time. Nothing machine-held keeps the two agreeing after, which is why the shell original is deleted for a ported member rather than left running beside it.Where a pair can reach the derivation, making it do so is the discharge. A scenario then covers only the residue. A pair that drives a second code path the live arm never takes, such as a
--fixture <dir>arm redirecting paths a knob already redirects, reaches nothing the port changes. The bypass arm is deleted and the pair re-pointed onto the knobs; the scenario covers what no fixture can, such as a default derivation whose corpus is a harness directory underHOME. A scenario may carry an arm the port retires rather than proves equal, and it records that arm as retired, so the run’s verdict never reads as a clean sweep it did not have. - Lands in a hook through a manifest
check-graphholds, so a greencheck-graphafter the port is end-to-end proof the manifest survived the substrate change.tier=precommitlands in the pre-commit hook’s selection (§git-hook). Atier=commit-msgmember satisfies the criterion in full, because thecommit-msghook selects on identical terms (§The second budget batch).tier=align-onlyreaches no hook, so the criterion still names a real cost for it. -
Its assertion target is not gate source. Porting a gate that audits gate sources makes the parity proof self-referential: the corpus the cross-substrate comparison runs over is changed by the very port being compared. The criterion binds where a registry member’s declaration path lies inside the corpus the gate scans as content.
The discharge is the fixture corpus. A member failing this criterion still ports; what it owes is an oracle the port cannot invalidate. The pair is the only corpus with that property, since
gate-testsis pruned from every live-tree walk and no port can add or remove a file inside it (§Fixture-pair discipline owns the widening rule and itsexpect.txtdiscipline). The condition is that the pair carry every arm of the derivation being ported. A gate-source auditor whose cases are all.shproves the.shbranch and leaves the descriptor and implementation branches resting on a live tree that is green because it is clean. Widen first, then port: a port answering only the criterion’s sentence ships the hole the sentence points at. A roster auditor overchecks/basenames, for instance, widens to a kit whosechecks/holds a descriptor and a mixed.sh+.gatekit, which proves the union of two globs rather than either alone.A criterion-4 bind prices a port and never holds one. Widen first, then port is an instruction, not a blocker: a member whose only unmet criterion is 4 is takeable, carrying the widening in its price. A member binding it therefore declares no
# port-until:on that ground (§The# graph:manifest owns the field). The shared-snapshot ordering constraint below is narrowed by none of this: a cohort still sequences a criterion-4 member deliberately, it just does not declare it held.The live-tree arm is demoted from proof to smoke for such a member. Assertion A forbids a descriptor and a script coexisting in one resolve dir. The live-tree comparison so runs on the pre-descriptor tree, a corpus the port then changes with no second implementation left to notice a disagreement. The arm is retained, because it is cheap and finds real disagreements. Its verdict is recorded as no disagreement found on the pre-descriptor tree, never as parity proved. The demotion is escapable. Assertion A binds a resolving name, so restoring the pre-port rule under a name no registry member resolves to lets both implementations run over the post-descriptor corpus. A member taking that route records parity proved on the live tree (§check-install-disposition). Where the port also moves a sibling member’s corpus the demotion stands, because the two implementations then read two different trees.
The edge-root arm keeps its own value. A
..scan root once made the shell’s kit-root prune compare an unnormalised file path against a normalised root and prune nothing, and the compiled side was the correct one. A disagreement is a finding to adjudicate against the rule, never a defect in whichever side moved.This is not the substrate-sensitive set of §Meta-gate conservation for the binary substrate. That set is derived from a member’s expanded
couples=and is deliberately trigger-shaped. Assertion C’s job is anti-vacuity: a member whose re-run trigger reaches a gate declaration must carry a disposition, so a port cannot silently end an assertion. Over-selection there costs one table row, which is why it is wide on purpose (§check-gate-substrate-parity). A conservation row and a criterion-4 bind are independent facts about a member. The trigger field over-selects in two ways and under-selects in one:- A reverse-trigger couple is named only so that an edit re-runs the gate, and is never read as content.
check-install-claimandcheck-payload-claimcouplescripts/*.shso the consumer’s emitters re-run them, while both scan the governed markdown set: no bind. - A content couple wider than the walk covers declaration paths the gate’s own population predicate never reaches.
check-template-registry-parityreads*.shnames only under a<kit>/<name>/directory a sibling<kit>/templates/<name>.listregisters, and no kit ships a template registeringchecks/: no bind. A consumer whose kit shipstemplates/checks.listwould put declaration paths inside that walk, and there criterion 4 would bind. - The couple clears and the walk binds, the case that produces a missed hold rather than an over-selected row.
check-docs-cname-paritycouples one literal file while its walk greps every tracked file’s bytes.check-knob-default-coupling’scouples=is one level deep while its recursive kit-root walk opens its own declaration path as content.check-tree-termswalks the whole tracked set with no narrowing available, the widest instance.
The verdict is therefore taken by reading the walk, never the trigger field, and by running the derivation at cohort-cut time, never by reading this paragraph.
A verdict can flip on which consumer configuration is read. A gate whose corpus is a consumer-configured path set clears under a default that holds no kit declaration and binds under a config widening that set to reach its own declaration. The answer is read against every configuration the gate can run under, never the default alone.
check-gate-tampercloses the class: its reader unions every registered member’s declaration into its gate files whatever the configured globs say, so criterion 4 binds on every tree (delegation-kit/SPEC.md §Verify after every agent commit). A member can also have no clearing configuration:check-gate-exemption-tasksreads its own declaration in an authoring tree and in a vendored consumer alike, andcheck-graphandcheck-gate-assertionsshare that shape.An immunity guarded only by a prohibition the gate itself would have to enforce is circular, so the port takes the binding verdict.
check-gate-assertionsis the instance. It cleared under the gate’s own declaration path only while its SPEC section carried no enumerated contract, and its port made it self-auditing (§check-gate-assertions). Whether the criterion means a registry member’s declaration path or the gate’s own is not settled here: that is a change to this section’s rule with reach across every member, so it is filed rather than taken inside a port. The conservative verdict is the one this criterion prescribes when a reading is uncertain. A member whose walk content-compares every source file as a diff reference rather than as its assertion target takes the binding verdict on the same ground, with that class left open: binding costs a fixture widening and cannot be wrong in the harmful direction.A member can be self-immune, clearing the criterion while the ordering constraint still binds.
check-gate-fixture-coveragereaches a declaration’s bytes only for a member with no resolvable fixture pair, and it must carry a pair itself to pass its own rule. Criterion 4 and the shared-snapshot ordering constraint are independent facts: the criterion protects the parity oracle from the member’s own port, and the ordering protects every comparison from a sibling’s (§The fifth budget batch). - A reverse-trigger couple is named only so that an edit re-runs the gate, and is never read as content.
-
Its vendored form stays runnable. A
.gatedescriptor under a vendoring kit root reaches every consumer; the binary does not, becausenative/ships nochecks/orsmoke/, and that predicate is what makes a root directory a kit. A harness that vendors kits per ref cannot vendor the binary per ref either, so it builds one itself rather than pair one ref’s shell with another’s artifact (§upgrade-smoke).gate_commandis fail-closed on an absent binary, and its exit 2 is a dispatch-harness error that takes down the calling battery, not just its own member.The condition that satisfies this criterion is ruled. The payload carries a prebuilt binary per declared target, built by the release and never from a working tree. The installer resolves the host to a target, verifies the matching artifact against a published digest and copies it; no selection ever builds. Both halves ship: the publish workflow’s roster-derived build matrix emits a binary and a digest sidecar per declared target,
--pack-installerverifies and places them, the Release publishes them (§Consumer payload), andinitresolves, verifies and places. The criterion is satisfied per target, not globally: a host whose triple the roster carries no published artifact for is refused at the bootstrap (installer/SPEC.md §Hosts refused at the bootstrap). That complement is stated rather than counted, since a count moves on a roster join and on every release.An uncovered host receives no install, so no member is dispatched into an absent binary and none is omitted. Every install step sits behind the invoke, so a host without a verified binary has no path an install could proceed through. The bootstrap refuses it, names the platform and writes nothing, and the consumer smoke’s artifact-less refusal leg asserts that refusal (installer/SPEC.md §The consumer smoke). The registry’s
# omitted:line is a consumer’s own reason-agnostic record (§run-gates), never the installer’s.It also bounds what the installer may relocate. A host with no verified binary leaves
initnothing to invoke, so an install step moved behind the invoke is a step such a host cannot run, which is why it is refused rather than half-installed. The rule is installer/SPEC.md §The install boundary’s; this criterion is where a porting session meets it. The consumer smoke packs a real artifact on the main payload, so every profile it installs takes the placement branch.The criterion prices the support roster, never a member or a cohort. A host outside the roster loses the whole install whatever substrate a member is written in. Porting a member, or landing a born-native one, therefore removes nothing such a host would have kept, and no per-cohort quantity exists to measure. The binary substrate’s cost is the roster’s complement, the hosts
native/targets.listcarries no artifact for. That complement is the project’s support commitment, and it shrinks as a target joins on a run that produced and exercised its artifact.check-install-platforms’ lockstep of the platform declaration, the roster and both host detectors holds it at the commit that would break it (§Consumer payload). No member is held back on shell for an uncovered host’s sake, since that host has no battery for a shell member to keep alive.What a cohort owes under this criterion is reachability on a covered host. Each member registers as a dispatch to its descriptor, so the placed binary carries it and §check-gate-binary-fresh holds that binary fresh against the source. The cohort adds no install step outside the invoke, and the consumer smoke’s per-profile battery, run on a payload carrying a real artifact, stays green.
The value arm is the covered-host claim. It plants a real defect in adopter-authored prose and asserts that some profile below the maximum catches it, on a payload that carries an artifact (installer/SPEC.md §The consumer smoke). A cohort that changes what a covered host catches is held there, never by this criterion.
-
Its corpus derivation is self-contained, unless the duplication the port creates is machine-held. The roster is derived by the tool criterion 7 uses:
bash gate-sdk/bin/run-gates.sh --emit port-blockers --grouppartitions the still-shell members by derived corpus derivation (§port-blockers), so which members share a derivation is read off a run at cohort-cut time. The arm emits a decidable partition plus a counted remainder; the unless clause stays a judgment no column reports. A config-driven shell derivation that is notmode=staged, such ascheck-spec-fence-balance’s, fails the criterion: a ported form would re-implement it with nothing holding the two copies together.The qualification turns on whether a machine notices when the two sides diverge, not on the presence of a shared call. That is what admits a shared-library corpus call like
gate_find.For a static knob the criterion is discharged by construction. A knob has exactly one producer, its owning kit’s defaults table in the crate (§lib/gate.sh), which every member reads in process and
--emit knob-valueshands a shell reader. The duplication is not machine-held, it is absent, so no parity test holds a Rust default against a shell default (the conservation table’scheck-knob-default-couplingrow). The pre-binary accessors are the clause’s machine-held instance on the same knobs. Six gate-sdk values a shell caller needs before any binary can answer are held a second time inlib/gate.sh.native/src/knobs/gate_sdk.rs’ unit test spawns the accessors under every layer and compares each answer withknobs::resolve(§lib/gate.sh).Two dispositions satisfy the clause, and the shell caller set decides which. Where the shell caller set empties at the port, the shell original is deleted and the duplication is absent. Where live shell consumers survive the port, the discharge is a standing cross-substrate comparison: one canned corpus fed to both holders, their classification compared byte for byte by a standing test, as
gate_path_rooted’s pair is (§lib/gate.sh). Machine-held means a standing oracle, so a parity proof taken once at port time satisfies criterion 2 and never this one; it expires at the next edit to either side.- The road is open to a duplication a port creates, not only to one it finds.
gate_staged_matchesgained a crate twin when--formoved into the--runarm, while the hook emitter spliced that function’s body. The matcher was compared on the standing lane until the hook became a start of the arm, which deleted the splice and the twin with it (§git-hook). - A discharge ends when its duplication does. When a twin’s last shell caller goes, the lane retires with its second holder (§The non-gate arm), rather than being kept as a comparison with one side left.
- The clause binds on a helper set, not on the one helper an amendment names. Before taking this road, enumerate the shell callers of every helper the ported member touches, the installer’s payload copy of a library included. A grep over the tracked battery misses a payload caller and licenses a deletion that breaks
init. - A comparison lane asserts coverage of its own corpus. Each branch the comparison is bought for is grepped out of the shell side, so an agreement over a corpus that classifies nothing cannot pass for a hold. A twin implementing a subset of its holder owes a proof that the omitted branch is unreachable on the twin’s input.
- A holder’s
no-portgrounds are checked against the twinned predicates, not against the file. A file can be shell for reasons that leave a predicate inside it perfectly portable.
A dead twin is deleted, not held, on the same enforcement-first ordering: where a shell helper has no caller and its compiled counterpart is live and tested, a standing parity obligation gates a duplication that removal disposes of. The bound is undocumented surface, a helper no SPEC section names. A documented member leaves through a stated-contract cut, which restates the section it declared (§lib/declaration.sh). The caller set decides whether the disposition is available, and the bound decides whether the removal owes a section rewrite.
Glob semantics, committed once here rather than re-decided per port. Knob resolution transports strings and interprets nothing. A Rust glob matcher over a knob’s value is
**-capable (globstar semantics), matching bash’sshopt -s globstarreading of a consumer’s glob. The shell side enablesglobstar, so the config surface permits a**, and a plain-glob reader would silently mis-scan the first consumer who writes one. A porting unit inherits the commitment.The commitment governs a glob matcher and not a predicate that matches no glob.
check-graph’s assertion Bcouples ⊆ triggertest is four branches of exact-token and suffix comparison with one bash-pattern branch inside it. Substituting a globstar matcher for the whole predicate flips verdicts on the live registry (§check-graph).A reader that reads a corpus prunes the
**descent; bash’s expansion does not. Such a reader expands a consumer’s glob throughwalk::glob_corpus, whose**never descends into a directory the prune set (§lib/gate.sh) names. A literal component still reaches one a glob names outright. The ground is a race: an unpruned**stats every entry undertarget/, and a build running beside the battery deletes entries there between the listing and the stat, a fail-closed exit 2 on a clean tree. A reader whose corpus lives inside a pruned directory by layout states so at its own section (evidence-kit/SPEC.md §check-evidence-baseline).spec::manifest_files’ two configured branches expand through that same pruning walk (canon-kit/SPEC.md §The shared spec adapters).walk::glob_filesstays bash-faithful for a reader resolving roots rather than reading a corpus, and a single-level glob never descends. - The road is open to a duplication a port creates, not only to one it finds.
-
Its rule invokes no external program the payload does not carry. The programs the payload is entitled to assume are
GATE_SDK_PROGRAM_FLOOR(§lib/gate.sh); git is on it as the one sanctioned exception, because it is the floor. A gate that fails this carries the largest named piece of port work, not a permitted exclusion: the dependency is designed away, embedded, replaced, or the rule itself changed, and that design is the porting session’s.The floor guarantees a program’s presence, never that a bare name resolves to it. What argv[0] a spawn of the bare name reaches is the operating system’s
PATHsearch. On Windows that search is%SystemRoot%\System32-first, where the onlybash.exethe platform ships is the WSL launcher rather than a shell. A port that clears here on an on-floor program has cleared the dependency and not the resolution. Where the host also ships the spawned name under another meaning, the spawn owner resolves the interpreter, never the call site (§check-graph). It is stated as prose rather than gated, since a gate reading every spawn site is a class-wide change.A blocker holds a member only where the program is the rule’s own semantic content. The test is whether removing the program changes the gate’s verdict:
- The program is the rule.
shellcheckdecides what §check-shellcheck and §check-action-run-shell assert,cargowhat §check-crate-arms asserts, and the renderer what site-kit/SPEC.md §check-docs-render-fidelity asserts. Such a member ports as a wrapper that spawns the program, and the program stays a declared dependency, refusing at exit 2 when absent as the shell form did. The dependency is the rule’s content and the port moves the wrapper, not the rule, so the class is takeable and priced and# port-until:has no holder on this ground. A consumer without the program gets the refusal it got before; a compiled wrapper does not move the floor. - The program is incidental spelling. A text utility the rule uses to assemble, split or order a string the port re-expresses in the target language:
paste -sd, -is.join(","), and the verdict is identical either side of the substitution. The blocker is priced by the cut, never a hold: one line of the port’s own work, like a criterion-4 widening.
What a wrapper owes, for the class. Refusing at exit 2 is the cheap half. The wrapper owes message parity at the shell form’s own point in the order, and the two mechanisms that buy it, a PATH presence probe and a merged-stream capture, live in §Fail-closed contract for the whole class. The ordering is per member and read off the shell text:
- A member whose scan root is a positional argument checks that root before probing the program, so an absent root reports the root and not the linter.
- A corpus-presence branch precedes the program probe, so a tree with no crate is clean with no
cargoinstalled. - A shell form that probes its oracle by running it surfaces an absent program as the probe’s own exit status, so the compiled form reproduces bash’s 127 (or 126) for a pipeline element it could not start rather than substituting a presence test.
- A shell form that fires no refusal gives no licence to inherit a false green: fire where the shell form fired it constrains only where a shell refusal exists.
A wrapper’s declared set and its refusal set are two different sets.
check-crate-armsrefuses withoutcargoand merely degrades withoutrustc, whose absence contributes an empty field to a cache key nothing tests. The declared set is what unit test A observes, the refusal set is what the member refuses on, and the parity run that separates them is the constructed scenario run per program and for the set (§Fail-closed contract). A gate runningcargo testover the crate can live inside the crate it tests:cargo testandcargo clippycompile and run the crate’s source afresh, so the verdict is cargo’s over the tree, never the installed binary’s about itself (§check-crate-arms).Where the program decides a measurement, the wrapper preserves the measured shape (delegation-kit/SPEC.md §bin/wait-probe). The wrapper
execs into the program rather than spawning and waiting on it, because the process depth between the caller and the loop is part of what is measured. A shell’s backgrounded child is reaped, sokill -0on its recorded PID fails once it exits. A compiled child whose parent outlives it leaves a zombie thatkill -0reports as alive, inverting the field the member classifies on while every battery stays green. A verdict-preserving port is the test.A wrapper’s program can be a shell builtin. The honest route to it is
kill(2)throughlibcwhere the platform has one, andbash -con the program floor where the pids are a shell’s own. It is never a second off-floor dependency on/bin/kill, nor a narrowing to the fallback program alone.psstays spawned on non-unix builds of--run-validate, because §The producer-liveness lock rules the pid predicate’s second leg the rule’s content: the builtin’s exit status conflates no such process with not yours and must never produce a false free reading (evidence-kit/SPEC.md §bin/run-validate.sh). A member whose shell form spawned only on-floor programs, none of which survives the port, is not a wrapper, and its declared requirement set is empty rather than filtered to empty.The tool cannot make the class call and is not asked to. §port-blockers reports which program a rule invokes; the class is a judgment about what the rule asserts, which no tokenizer sees. Declaring it is presence-shaped on the terms §check-gate-substrate-parity’s assertion G fixes: presence is the verdict, absence means takeable, and an undeclared member is over-counted as takeable rather than lost. A misclassified class-(i) member reads takeable, is selected, and the cut discovers the blocker at composition time. Two tests are refused: the program is not on the floor is the report’s test, not the hold’s, and the port is more work than an ordinary member is pricing, not holding.
The criterion adjudicates exactly one thing: whether the payload carries the program a rule invokes. Whether the target of a sanctioned spawn is itself ported is a cohort-composition question. A rule shelling out to
bash <emitter>clears this criterion, becausebashis on the floor, however unported that emitter is; what holds such a member is its own sequencing (the generated-projection freshness family, §The first cohort, and the rule that selects the next).An undecidable
?is neither a pass nor a blocker. A command word the scanner cannot resolve statically, such as an arraygate_commandpopulates at run time, is an instruction to trace the runtime program set by hand. A?is not a declarable hold either:# port-until:is machine-read, so declaring a member whose only evidence is the tokenizer’s?writes the scanner’s limit into a field later sessions trust. A member declares when a ground is stated in its own section, never when the report shrugs.The roster is derived and lives in no document, including this one.
bash gate-sdk/bin/run-gates.sh --emit port-blockersreports it against the tree at the moment a session sequences a cohort, a correctness requirement rather than a preference for freshness. A gate’s requirement need not be spelled in its source:check-docs-render-fidelity’s is the first element of whichever renderer knob that run resolves to a command, the batch one where non-empty andSITE_KIT_RENDERERotherwise (site-kit/SPEC.md §Knob defaults). A consumer who repoints either changes which program that gate requires, which is why the compiled member declares both knobs. No literal roster is true for every consumer, so none is shipped. A derived roster is still re-derived by a repaired derivation, never trusted because it is derived: a scan that abandons a declaration before reaching a call reports the member clean.The report’s honest bound is its undecidable count, the share of the corpus it cannot speak for. A zero is not a claim that nothing is missed: §port-blockers states its two standing blind spots, a library-mediated spawn and an unregistered member. A registry arm reading zero owed says the battery is ported and nothing about the tree; the completion predicate is bounded by the tree, and §port-blockers’
--treearm evaluates it. - The program is the rule.
New gates are born native by default; shell is the exception and it needs a stated cause. It is the measure aimed at the port’s denominator rather than its remainder: every gate landing in shell adds one the port then owes.
The default’s domain is a tree that carries the crate the gate would compile into (GATE_SDK_NATIVE_CRATE). Where the authoring tree does not, as in every vendoring consumer, shell is not an exception but the only substrate, and the consumer-facing authoring path is unchanged. An adopter receives no gate implementation source (§Consumer payload), and native/ ships no checks/ and no smoke/, so gate_kit_roots never selects it and init never vendors it. An adopter therefore has no supported path to a compiled gate: an install carries no crate to add a subcommand to. A fork that adds the subcommand and ships its own build can, since descriptors resolve consumer-first and so its own .gate descriptor runs, and that fork then owns its own release, digest and upgrades, outside this project’s. templates/check-skeleton.sh, gate-sdk/README.md’s first-gate walkthrough and §Consumer smoke’s kit-landing checklist stay shell-shaped; publisher-facing surfaces (this section, and the publisher’s always-loaded authoring line) carry the default.
A born-native gate is the member these criteria do not describe, and only some of them bind on it. The roster is stated over ports, a gate that exists in shell and is being moved. A gate landed as a Rust module and a .gate descriptor with no shell original reads them as follows:
- 1 and 3 bind unchanged. It registers in
gates.listand it lands in a hook’s selection, socheck-graphproves its manifest the same way. - 2 does not bind. Parity is between substrates, and a gate with one implementation has none to prove against. Its
good/+bad/pair is its whole oracle, a weaker guarantee than a ported member’s parity run, and the accepted price rather than the reason to ship shell. The corpus ports in full, so shipping a new gate in shell does not avoid the compiled implementation. It defers it, adds a parity run that would not otherwise be owed, and grows the denominator. The weaker oracle is paid once; the deferred port is paid again at every cohort cut that has to sequence it. - 4 does not bind where the gate’s assertion target is not gate source, criterion 4’s own predicate applied unchanged. A conservation-table row may still be owed, because
couples=can reach a declaration-path-processing oracle transitively, and that is assertion C’s question.check-measured-claimscans the governed-prose surface and clears criterion 4, while itscouples=namesscripts/*.sh, an emitter that treats declaration paths as a set, so it earns a row (§Meta-gate conservation for the binary substrate). - 5 binds at the roster and no further. A born-native
.gatemember dispatches to the shipped binary, so it runs on every host the installer serves and on none it refuses. A shell form would reach no host the descriptor does not, since a host with no artifact receives no install of any substrate, so the born-native default charges criterion 5 nothing per gate. - 6 and 7 bind unchanged, and a born-native gate is designed to clear them at authoring time rather than owing the work afterwards: the substrate it needs already exists or it is not born native. Under a default that clause would read as a licence to declare any gate un-bornable, which exception class (c) below bounds.
The default is not a discount, and two of the criteria are more load-bearing under it. The good/+bad/ pair is the gate’s entire oracle in the ordinary case. The --reads declaration plus assertion C’s conservation row are owed at authoring time rather than deferred to a port. A born-native gate shipping without them ships less coverage than a shell gate would have, the one outcome the default must not produce.
The exception criterion: one live class with a stated cause, and two retired. Shell is taken only under the live class, (c), and the gate’s own SPEC section states why. A further class is an amendment, not a judgment call.
- (a) RETIRED: no gate is permanently shell. The class held that a gate auditing the dispatch relation stays shell because a compiled form could pass itself with a broken binary. The grounds for retiring it sit below the conservation table: the shell auditor already reads the binary’s
--list, an absent binary is exit 2 under the fail-closed contract, and a stale one is §check-gate-binary-fresh’s red. No# no-port:holder remains among the registry’s members. The field’s live holders sit outside that registry, since it reaches any tracked script (§The# graph:manifest). Only §port-blockers’--treetrailer counts them: a consumer’s measured-claim emitter on a provenance ground, and the consumer-smoke class on the ruling at §Consumer smoke, The port disposition. - (b) RETIRED: the gate’s subject is a platform the target roster does not cover. The class held such a gate in shell because born-native would omit it on exactly the platforms it reads. A host the roster carries no artifact for receives no install of any substrate (installer/SPEC.md §Hosts refused at the bootstrap), so a shell form reaches no host the descriptor does not, and no member holds the class.
- (c) The rule needs substrate the crate does not carry, and building it is not that unit’s work. The cause names the missing substrate and the entry that owns it, and the gate lands shell inside the port corpus with its blocker already declared. Temporary by construction: it expires when the substrate lands.
The held class takes its own field, # port-until: <slug> (§The # graph: manifest owns it). Class (c) is temporary, so # no-port: must not carry it: a marker conflating permanent with temporarily-held would drop members that will port, replacing the remainder’s over-count with an under-count. until is this tree’s word for a temporary disposition, where blocked would collide with the word port-blockers.sh uses for something derived. §check-gate-exemption-tasks pairs # until: <live-slug> with # permanent: <reason> on the same axis, and its second arm holds the slug to a live queue entry, so when the blocker lands and the slug moves to Done the gate reds and the declaration must be dropped. The closed-roster rule (no field without a named reader) admits the field only with holders, so it lands with its declarations in one unit. Its domain is wider than this class, any temporary hold with named work owed and a live owning entry, and §The # graph: manifest states that widening.
The cause is recorded in the gate’s own SPEC section, beside the rule it governs, never in a central roster that derivation-first refuses and that rots at every port. It is reachable there because that section is written in the same unit as the gate (§Consumer smoke’s kit-landing checklist). It has three readers:
- the reviewing session, which finds it where every other ruling for that gate lives;
- the port-track selector, for which a class-(c) cause states a blocker and its owning entry that
--emit port-blockerscan derive but not attribute; - the session landing the missing substrate, for which a class-(c) cause is the list of gates that become portable with it.
A held member’s cause has a machine-read companion, and the two agree. # port-until: names the owning entry, that entry carries the disposition and the cost, and the gate’s own section carries the ground. This is the single statement of that split, which §The # graph: manifest cites. A member may not declare the field until its own section states its cause. A ground inferred from a sibling gate, or from this section’s shared prose, is the one shape the field must not normalise, since its value is that a reader reaches the ground from the declaration in one hop. §check-gate-substrate-parity’s assertion H holds that rule: it opens the section the declaration’s own # spec: field names and reds when it does not state the hold.
What the classes deliberately exclude. The rule is easier to write in shell, the author is faster in bash, the corpus is small, and it is only a temporary gate are none of them causes. Nor is the gate would be lost on an uncovered platform: no gate of any substrate reaches a host the roster does not cover.
No gate enforces the default, and the ground is cost rather than impossibility. Three enforceable shapes carry their costs:
- A per-gate
# substrate: shell — <cause>header asserted over every shell member demands one retrospective declaration per unported member, each deleted again as its member ports. It becomes cheap when the residue is small, the condition to revisit it under.# no-port:does not discharge it: this header would enforce the born-native default (may this gate land in shell at all?) over newly authored gates, while that field reports the port remainder (will this gate ever leave shell?) over existing ones. For the same reason# no-port:mints no value vocabulary and nocandidatedefault, which would reinstate that retrospective sweep. - A baseline roster of today’s shell members that a gate diffs against is a maintained roster, which derivation-first refuses, and it rots at every cohort cut.
- A git-anchored discriminator demands the header only of a member whose declaration file entered the history after the default’s own commit.
git log --diff-filter=Adates every declaration file, this repo checks out at full depth becausecheck-trajectory-freshreads the commit graph, and the scan costs about a second. It needs no retrospective sweep. It has three costs. A rename re-dates a file, so the plain form false-positives, and--followis a heuristic rather than a guarantee. The anchor is a literal commit no derivation produces. And it is publisher-local, since in a vendored tree every declaration file was added by the vendoring commit. Making it consumer-safe is design work it has not had.
The default therefore rests on this section and on the publisher’s always-loaded authoring line, and the enforcement disposition is filed rather than flagged-and-skipped. Enforcement-first is not waived: it ranks a gate above discipline where a gate is available, and the available shape is unbudgeted rather than unavailable.
The first cohort, and the rule that selects the next
Two members: check-action-pinning and check-action-gh-repo. They share one corpus derivation byte for byte (§check-action-gh-repo: “The walk is §check-action-pinning’s”), read no knob between them, and declare the same single unbounded walk root. The substrate work is therefore done once and proved twice. check-action-pinning needed no new implementation: the crate already carried its rule as the reference-only disposition, kept live so the substrate stayed exercised until a port needed it.
What the cohort landed. Both rules ship as compiled subcommands, proved byte-identical against the shell gates on each fixture pair, the live tree and the edge roots. The comparison ran while both implementations still existed, the only order in which parity can be proved, since §check-gate-substrate-parity assertion A forbids a descriptor and a script coexisting in one dir. Both .gate files sit in gate-sdk/checks/, both members are registered, and the shell scripts they replaced are deleted. With a live descriptor the dogfood settlement bites (§What the dispatch seam does not settle) and the stale-binary path is armed (§check-gate-binary-fresh).
A declaration is not a dispatch. A binary meta-gate that derived its trigger by globbing *.gate across the resolve dirs, reading no registry, would red every tree that legitimately has no binary. The sharpest case is a consumer registering fewer members than its vendored kits ship, so a descriptor sits in the tree that nothing dispatches to. No release tag and no registry edit could clear that. The load-bearing predicate is §check-gate-binary-fresh’s, and it is what makes a descriptor landable at all.
That predicate is necessary and not sufficient. It clears every tree that declares without dispatching. A tree that legitimately dispatches and has no binary is a different problem: --run-consumer-smoke vendors kit roots by copy, and gate-sdk’s own smoke/install.sh registers members because it installs the workflow template they lint. A registered dispatch with no binary is exactly the harness error gate_command exists to raise, so that harness receives the built artifact (§Consumer smoke), and check-gate-binary-fresh ships in no kit, because a consumer tree does not verify a build it did not make. A port that lands descriptors checks both halves: which trees declare, and which trees register.
The next cohort is the largest set of criteria-clearing gates sharing one corpus derivation, an ordering rule rather than a bound: every gate ports. The instrument is bash gate-sdk/bin/run-gates.sh --emit port-blockers --group (§port-blockers). It partitions the still-shell members by derived corpus derivation, largest group first, with the mechanically derivable criterion columns beside each member. “Largest”, “criteria-clearing” and “sharing one corpus derivation” are each read off a run rather than off a session’s reading of the tree.
What the tool means by “one corpus derivation” is specified at §port-blockers: a two-factor key, neither of which is couples=. Read it there before reasoning about why two members did or did not group. Its output is advisory, so a group whose members’ corpora visibly diverge is a finding the selecting session adjudicates, never a cohort the tool cut. A non-empty key does not make a shared derivation: a group sharing only the fail-closed guard shares no corpus, since fail_closed derives none. The columns are not the whole screen either, since a member a conservation row holds on shell appears in no column.
Three arms, with a total precedence read off a run.
- The size arm selects first: the largest takeable group.
- The blocker-retiring override outranks it wherever a blocker several members are queued behind can be retired. A cohort retiring such a blocker outranks a larger one that retires none (§The POSIX ERE matcher). A pair of members in separate kits behind one wire-format change is the shape the override is for.
- The budget arm composes N criteria-clearing members, taken as N independent units, sized against the iteration’s own budget. It is reachable only when a
--grouprun reports no takeable group, so its precondition is a verdict from the instrument, never a session’s reading of the tree.
A member that taxes later cuts is not a blocker, and the override does not reach it. A blocker prevents later members: until it is retired they cannot be taken at all, which is what makes retiring it worth more than the size it carries. A file that merely grows with each later cut, such as a front end gaining a dispatch branch per ported arm, prevents nothing: every one of those cuts is takeable and simply costs more. That is a real and often decisive reason to order such a member early, and it is an ordering rationale inside the budget arm, never the override. Recording it as the override would make a precedent that reads “dearer later” as “blocking”, which empties the override of the property it selects on. Where the two are hard to tell apart, the run decides: a member the instrument reports takeable was never queued behind anything.
The size arm is permanently exhausted for this tree. A --group run over the owed members partitions them into groups of exactly one, so no group has a second member to amortize a walk across. The born-native default forecloses a new one forming, because a gate written native was never in the shell partition. What would reopen the arm is a consumer contributing shell gates that share a derivation. The trailer’s figures are oracle reads a later session re-reads, never values this section holds. A trailer that is zero in every field but the scan is the arm reporting it has nothing left to compose, not a broken run.
Shared derivation is the axis while it selects, because it makes a cohort cheap: the walk is ported once and proved N times, and the parity comparison is over one corpus shape. Selecting by kit, by profile, or by whatever is easiest next re-imports work a cohort pays only once. The gates failing criterion 4 or 7 are sequenced last and budgeted for, because each carries a design problem the port owes an answer to rather than a waiver: designed, then ported.
Amortization lowers the price of a cohort the override already selected, and selects none. A first-mover design paid by one tranche (§check-gate-substrate-parity’s assertion-B owner clause) is inherited by every later member, so marginal cost per member sits at its floor. That is no licence to take a large set whose members share nothing.
“Never as one cohort” is the property that makes a budget batch safe. A batch’s members carry no joint proof. Each takes its own .gate descriptor, its own registry entry, its own fixture-pair parity run and its own live-tree and edge-root arms, so no shared walk is claimed and no comparison spans two of them. Dropping a member mid-batch invalidates nothing. A member that turns out dearer than it was sized leaves the batch and the rest land unchanged, where a cohort losing a member loses the amortization it was composed on and is re-planned. A batch is never recorded, argued or merged as one unit of work.
The batch size is a budget, and the budget is deliberately not a knob. Config-via-env exists for configuration that varies by consumer. A batch size varies by iteration, against that iteration’s other work, so it is a judgment the composing session makes with the queue in front of it. A knob would freeze a default that reads as the answer, and the next session would size against the default rather than against its budget. The sizing session weighs the per-member inputs --group prints (lines=, the declaration’s own line count, and the criterion columns), its own reading of each member’s declaration, the iteration’s non-port work and the fixed per-iteration ceremony.
lines= is a floor on a port’s size and never a ranking of it. The column prices a shell file’s text, and several costs sit where it cannot see:
- interfaces: a ranking of logic cannot see six command-line arguments;
- a spawned tool, whose subsystem is a few lines of shell and a real structure in the port (the census finding below);
- composition: a file composed out of a permanently-shell library’s primitives owes a compiled twin of each and criterion 6’s machine-held comparator, none of it in the column. Where two owed files compose over the same library, the order they are taken in is itself a price, and taking the consumer first pays the twins twice;
- another kit’s gate: a header the file carries may be another gate’s entire live enforcement vocabulary, so deleting it moves that gate’s reach (§check-assertion-strength);
- evidence: governed prose in more than one kit may rest on trials only that file produces and can reproduce (delegation-kit/SPEC.md §bin/wait-probe).
So the count is one input beside the criterion columns and the session’s own reading, never the answer. A session that reads the column as an answer stops looking for the cost it cannot see.
The budget’s floor is the build window. The ceremony a cut amortizes, the boundary stages and their supervision, is paid once per iteration whatever the cut carries. Under a rate-windowed harness the throughput cap is the window rather than the iteration count, so a cut sized under what one build stage can carry inside a single window pays full ceremony for less work. The sizing session composes the cut to fill the window and stops at the window, never at a member count, since members differ by an order of magnitude in cost. What bounds a cut from above is unchanged, the section bound and the shared-surface rule the lead batches by, and filling the window widens within them.
A budget batch records only findings here, never a member roster. A cohort section below earns its place by having a shared derivation to explain; a budget batch has none, so its default record is nothing. It adds a section to this SPEC only where it has a finding to record: an adjudication a later selector would otherwise re-make, a primitive it landed, a criterion it discharged. Membership and progress are derived: a ported member leaves a .gate beside its deleted .sh, and the count is the ported-gate-members key of the measured-claim oracle (CANON_KIT_MEASURED_CLAIMS_CMD), which §check-measured-claim holds to the tree.
The budget arm’s findings, kept generic like the arm itself:
- Where a front end’s argument grammar and a binary’s arm table must agree, the alias belongs in the file holding the table. That is the only place every door into the arm table reads one spelling (§run-gates’ stub cut holds the reasoning). A front end that grows by a branch per arm is a member the budget arm orders early.
- A consumer-side adapter is homed at the kit section whose contract it implements. An adapter is an executable that is the value of a kit knob, its whole body a resolution and a dispatch. It falls outside a consumer’s config-and-vocabulary class, since it holds no knob roster or vocabulary an adopter edits, and an always-loaded manifest is never a cut boundary. The owning kit section is also the one a cut rewrites when the adapter goes.
- Where a ruling tells a port to carry a derivation once, look for that derivation in-crate first. Writing it beside the new member because the existing one is private reproduces the refused divergence, relocated from two interpreters into two modules. The discharge is a visibility and placement change: promote the private items to a top-level module and leave the original holder a thin caller.
- A permission-cost blocker is re-derived against the forced family before composing. The family decides the command string, and the command string decides whether any grant is owed. A member forced onto the arm-table family is reached through the battery front end, whose grant a consumer holds once for every arm, so a blocker written against a relocated-path assumption evaporates and the port forces a removal.
- Take a census of what the crate already holds before composing a member that buys subsystems from spawned interpreters. A JSON reader, an aggregation with dedup, a history harvest or a sibling-record walk is a few lines under
jq,awk,gitandsort. Each is a real structure in the port. That decomposition, not the line count, is the batch’s cut, and a member that decomposes into named subsystems can be split along them.
The arm names no remainder, which keeps it kit content. It is stated generically, with no gate names, no member roster and no count of any tree’s remaining corpus. A consumer who ports gates so receives the mechanism and not this project’s work queue (§check-graph’s rule-content split).
The arm lives in this section. lifecycle-kit’s scope template owns the general economic-composition test, and a port-specific arm there would bind every consumer’s iteration, including those who port nothing. A consumer’s port entry names this section as canonical for every increment, so a scope session composing a port iteration reaches the arm on the path it already walks. A ruling record records rulings and never authors one, and registers a ruling at its mechanism’s home, which is this section. This section owns the two arms the third joins, its reach is exactly the port, and it is kit content.
The arm’s producer and consumers, and no field. The producer is the session composing a port iteration: scope when it ranks the queue, and the authoring stage when it writes the batch amendment, triggered by a --group run whose verdict is no takeable group. Its consumers are that same session, by reading this section, and the iteration lead at batch-cut, which needs to know that a batch’s deltas are independent and may be cut apart where a cohort’s may not. There is no new field. The arm is an authoring contract like the two it joins, neither of which has a machine reader, in the house form of §check-queue-entry-budget’s the gate cannot hold this paragraph.
A primitive’s remaining consumers are derived, never recorded here. A ported gate leaves a .gate descriptor beside its former script, so the still-shell consumers of a shared derivation are the */checks/*.sh files naming it that have no sibling .gate. That one-liner answers what is left of one named primitive, and --group answers how the whole remaining registry partitions. A session that has a primitive in hand uses the one-liner; a session choosing which primitive to take next runs the arm.
Exclusions are recorded, because the next selector meets them again. check-memory-off was excluded on criterion 2: its --fixture <dir> arm was a different code path from its live arm, whose corpus is the harness memory directory under HOME. A gate whose fixture arm bypasses the derivation being ported has no parity oracle for the part that matters. §The third budget batch took the discharge that exclusion named.
A cohort taken by kit is sound only where the kit boundary and the corpus boundary coincide. queue-kit is the kit whose corpus derivation is the queue file. Read off the # graph: manifests, its members couple TASK-QUEUE.md alone but for two. check-roadmap-fresh adds the roadmap projection and the queue config its own trigger= names, and check-queue-slug-liveness names kit:*.sh only as a reverse trigger (§Meta-gate conservation for the binary substrate). Every member is dir=one valve=none tier=precommit, so criterion 3 is clear kit-wide. lifecycle-kit is the second worked exception, on a config surface rather than a corpus. Half its members sourced the kit library for its knobs alone, and all the knobs they read had one producer, so the substrate work is discharged by construction rather than by parity tests (criterion 6). The load-time config validation runs once at knob resolution, so a compiled member inherits the malformed-config refusal without a twin. Neither is licence to take a kit whose gates share nothing.
A hold is per member and keyed on what the member executes. A cohort is sized off what its members execute, never off what their fixtures reach. A pair whose good/ and bad/ cases pass pre-baked files steers the assertion off the live emitter, so a ported member’s pair would go green over an arm with no implementation. A hold orders the work and is never an eligibility screen, which would invert the rule §The port-candidate criteria opens with.
- A gate that shells out to an unported emitter is held on is this gate’s emitter ported? Criterion 7 clears it, since
bashis onGATE_SDK_PROGRAM_FLOOR, so the hold is sequencing and never labelled criterion 7. Of the three designs (shell out to the emitter, reimplement the emission format beside the surviving script, or collapse the emitter onto the binary as a non-gate arm, §The non-gate arm), only the third pays. Porting the gate and its emitter in one commit keeps an adapter’s never-disagree guarantee unbroken, where porting exactly one of the two spends it. - A gate that interprets a consumer-configured ERE needs an ERE engine on the span semantics §The POSIX ERE matcher fixes. Sizing a subset to one consumer’s configured regex is foreclosed by criterion 6’s globs argument: the config surface permits what one consumer happens not to write. A pattern baked literally into
awksource is hand-compiled, so it sits outside that engine.
The generated-projection freshness family. Its members byte-compare a tracked projection against a live emitter, and each declares so in its own header’s # projection: line (§The install disposition), which check-projection-roster reads. Every member now compares against an in-process emitter function rather than a spawn, which is where the family’s bash hop is retired. The rules it taught:
- The cheap cohort in this family is the emitters, not the gates. A ported byte-comparator spawning a shell emitter removes no shell, so it buys nothing against dual maintenance. The largest-shared-derivation rule mis-selects this family, because its members share a spawn shape rather than a corpus; that is a worked limit on the rule, not a change to it.
- A member ported ahead of its emitter banks nothing against dual maintenance until the emitter follows. What such a cohort banks is consumer-tranche completion, a gates directory with no check script left in it, never a shrunken interpreter surface.
- Work outside a cohort’s ruled scope is filed, not adopted. An emitter port the cohort did not rule becomes a costed queue entry under scope-gated intake.
- The report cap is one crate constant, landed with its first reader (§The diff renderer); the renderer is uncapped and the cap is the caller’s.
- A pair steered off the live emitter proves no emission. Parity for such a member is bought by a stale live-tree run or a constructed scenario, criterion 2’s
# no-fixture:treatment reached through a bypass arm. A clean comparison exercises neither the diff renderer nor the cap. A member whose single positional is a root, driving the live generator against a synthetic case tree, already proves the emitter-executing arm. Agood/case run on the live emitter’s default branch (§check-reads-couples) executes the emitter, and being clean it still leaves the stale-run half owed.
A freshness gate whose emitter is a library function it sources directly ports in the same motion as that library, so the family’s hold condition is satisfied rather than waived. check-lifecycle-registration byte-compares tracked agent-file text against lifecycle_registration_block, a function of the library its cohort compiled.
A nine-of-ten split on one axis is not a difficulty estimate. A walk axis and a fixture axis each isolated one member, and both landed on members that ported; the members that were held were found down axes nobody had looked along.
A kit that ships bin/ or smoke/ has callers beyond the fixture harness. Its scenario runners, its own tools and its own smoke may name gates by script path, including a stage-entry tool every consumer stage goes through. A selector enumerates those callers before the cohort is cut, since meeting them at implementation time is the failure criterion 7 exists to prevent (lifecycle-kit/SPEC.md §Testing and §bin/enter-stage.sh hold the conversions).
Splitting a blocker-retiring increment is refused where the split undercuts its own selection. An increment selected by the override on a pair is not delivered as one member. Splitting could leave a new wire format with no non-empty live instance in the tree, and a wire whose in-tree exercise is the empty case is one whose first real consumer finds the bugs. A second missing channel of the same defect class, a wire the compiled substrate cannot say what the shell substrate could, rides the same increment: holding a member on a designed-but-unbuilt prerequisite buys delay and retires no risk. A member that sources none of the kit’s library is unsized rather than held, and a later selector owes it a sizing.
A live-tree parity run proves nothing about a branch the tree’s configuration does not execute. A knob this repo sets can disable a branch (LIFECYCLE_KIT_SESSION_BOUNDARY at iteration skips the cross-stage distinctness map). A value carrying no ** never exercises the globstar path criterion 6 commits the reader to. Such branches are proved by constructed scenario. An overridden kit default, such as this repo’s six-stage LIFECYCLE_KIT_STAGES, narrows every parity run without being a branch at all.
A port implements the rule, not the shell’s spelling of it. A comm pipeline over process substitutions becomes the set difference the contract states, which is not locale-dependent where sort’s collation is. A diagnostic the shell produced with diff is built inside the member, since the crate’s one-spawn-site rule (§Fail-closed contract) leaves no way to shell out for one. A shell defect at a site the compiled member does not reach is dissolved rather than repaired, and the port says so, so it is not read as having dropped an assertion.
A criterion-4 cohort widens its pairs before porting, and it is ruled knowing it is design-then-port rather than cheap. The comment-surface primitive spans *.sh, the *.gate descriptor, the crate’s *.rs through its knob and the workflow directory’s tracked tier, while its members’ pairs carried only .sh and .md sources. The parity instrument then covers one arm in four, and the three it misses are the three that make criterion 4 bind (§Fixture-pair discipline). When a primitive’s callers are exactly the cohort’s members, criterion 6 is discharged in its strongest form: the shell forms are deleted rather than duplicated (canon-kit/SPEC.md §The shared spec adapters states the price).
A primitive widened to new arms owes every member’s trigger the same widening. A corpus arm with no matching couples= token leaves those files inside the corpus and outside the trigger, so staging an edit to one re-runs none of the gates that read it. The added tokens are bare globs (*.gate, *.rs): a token naming this repo’s crate directory would publish one consumer’s layout into a kit file (§The provenance seam), and over-selection in a trigger-shaped set is cheap by design.
Shared mechanisms a port lands serve later cohorts. Basename-glob list matching sits inside the crate’s single sanctioned walk implementation, so the recorder still observes it (§Meta-gate conservation for the binary substrate). The crate derives the kit roots in process from the GATE_SDK_ROOT locator, since a binary the installer copies elsewhere cannot recover the kits from its own path (§Layout and configuration).
The first budget batch
The budget arm’s first live selection: --group reported no takeable group, and a criteria audit over the smallest-key groups took six members.
A missing shared primitive is invisible to --group, so the run’s silence is not clearance. Its key is shell libraries and globs. Two members hand-rolling the same heading-bounded section walk share neither, so nothing in the run names the primitive they duplicate. native/src/section.rs lands that walk, and its contract is owned here: the primitive yields a bounded line range and nothing else, so a caller reads no heading level and no heading text. The matching and closing rule that produces the range is stated once, as a directive at the implementation, and never restated here. The crate ships the walker and no section vocabulary; every name arrives as a caller argument.
Two members sharing a parsing primitive are sequenced, never a rejoined cohort. The cohort axis is a shared corpus derivation, compared over one corpus shape. check-brevity and check-doctrine-registration share a primitive and walk different corpora, so neither’s parity run lends the other proof. The sequencing is soft in both directions: dropping either leaves the primitive correct, with one caller or landed by the survivor, so drop-any-member still holds.
Assertion C is re-derived fresh at every cut, never inherited. A port moves a declaration path from <kit>/checks/<name>.sh to <name>.gate, which can move other members into or out of the derived substrate-sensitive set. The derivation compares each member’s expanded couples= against the resolved, absolute declaration paths, so the set is the members whose couples carry an unanchored *.sh or *.gate glob. A port changes such a member’s extension and never its membership, and a re-run is what shows it for the cut in hand.
The second budget batch
Criterion 3’s literal value is a proxy, and it does not bar a tier=commit-msg member. The criterion’s purpose is that the member lands in a hook through a manifest check-graph holds, and the commit-msg hook selects on identical terms. The hook arm dispatches a .gate member through the run arm’s one member dispatch, as the binary’s subcommand, with the message file after the subcommand name, so it reaches the gate module’s argv slice unchanged (§git-hook).
Criterion 4 is discharged by fixture pre-work, and the order is the discharge: widen first, then port. A member scanning every kit’s checks/ holds its own declaration path in its corpus. Its pair must carry every arm of the derivation being ported: a kit whose checks/ holds a descriptor, and a mixed .sh+.gate kit, which proves the union of the two globs rather than either alone.
For such a member the live-tree arm is smoke, not proof. Assertion A forbids a descriptor and a script coexisting in one resolve dir, so the comparison runs on the pre-descriptor tree, a corpus the port then changes. Its verdict is recorded as no disagreement found on the pre-descriptor tree, never as parity proved. The edge-root arm keeps its own value: it caught a path-construction disagreement no relative-root case could reach.
The third budget batch
A member is admitted on the criterion that holds it. check-memory-off was held on criterion 2 (§The first cohort, and the rule that selects the next). An argument that its jq use is a path query the crate already performs answers criterion 7, so it could not retire the hold. A member admitted on the wrong criterion reads, to the next selector, as evidence that the criterion was satisfied.
A fixture arm redirecting only what knobs already redirect is deleted, not ported. The shell form’s --fixture <dir> arm resolved the same three paths its three knobs did. It bought a shorter spelling with the divergent code path that was the whole criterion-2 finding. Deleting it dissolves the hold. It is an interface removal on a governed surface, flagged as one. The pair is kept and re-pointed onto the knobs, and so drives the code path the live battery drives.
A derivation no in-tree case reaches takes a constructed scenario. The default memory dir, which the harness names under HOME, is compared over a throwaway HOME holding that layout, both implementations run over the same state, bytes and exit codes compared. A shell branch with no compiled counterpart is recorded as retired, never as proved equal: the shell failed closed on a local settings file with jq absent, and the ported member reads JSON itself.
Re-using a crate layer is not substitution. check-memory-off re-uses native/src/json.rs’s Path::compile, eval and values_equal, with two consequences:
- Comparison is structural, so
1and1.0compare equal. The manifest declares expected JSON rather than a byte form, andcheck-settings-pinsalready reads it so. - The null disposition is the member’s own.
check-memory-offskips a null actual, since a null means the local file sets no override.settings_pins.rstreats a null as a fail-closed absent pin, which is correct for the tracked file it reads and would red every tree whose local settings omit a pinned key. The constructed scenario carries a case for it.
A port’s reader enumeration covers gates, and a direct shell-out from a non-gate script is outside all of them. Neither check-docs-cmd nor check-md-refs scans a smoke script. One invoking a member by literal path fails with a bare “No such file or directory” the first time it runs. Every port therefore greps the tree for its member’s declaration path before the deletion lands, and re-points each caller onto gate_command, resolved against the vendored tree. The enumeration is a grep, never a gate.
The fourth budget batch
Width buys session overhead and nothing else. This cut composed by declaration size plus criterion cleanliness: members under a line band, with a fixture pair, a generated-hook tier, no criterion-7 blocker and no design fork. Cheapness is not a shared derivation, so the batch amortizes nothing across members. What it spreads is one cut, one criteria audit, one assertion-C re-run and one amendment. A later selector must not cite a wide batch as evidence that width is cheap in itself.
Criterion-4 members share one pre-descriptor snapshot. Assertion A forbids both spellings in one resolve dir, so a member’s shell form vanishes the instant its port lands, and the live corpus of every criterion-4 sibling moves with it. Every binding member’s live-tree comparison is therefore captured against one snapshot taken before the batch’s first descriptor lands. An incremental order compares each later member against a corpus its predecessors changed, and the verdict reads like the earlier ones without being the same claim. The fixture-pair runs carry no such constraint, since gate-tests is pruned from every live-tree walk.
check-docs-cname-parity is the first instance where the couple clears and the walk binds. Its couples= is the single literal docs/CNAME, so assertion C does not select it. Its scan root defaults to the whole tracked tree (SITE_KIT_SCAN_ROOT) and it reads every file’s bytes, so every kit’s checks/*.sh and *.gate is content in its corpus. That is criterion 4’s predicate, reached through the walk rather than the trigger field, and assertion C cannot produce the finding.
check-gate-fail-closed ports. §Meta-gate conservation for the binary substrate dispositions it Retired with cause, but that row prices its assertion about a .gate-dispatched member, never whether the meta-gate itself may port. Its check-*.sh corpus is full in every vendoring consumer, where shell is the only substrate.
Porting a kit’s last shell gate leaves it a kit root. The kit-root predicate is the existence of a checks/ or smoke/ directory, never a *.sh glob, so a kit with no check-*.sh resolves normally, and check-kit-enum stays green.
Every consumer value a member reads is a knob with one producer, never a crate literal. A Rust const holding a message-pattern file, a governed-path manifest or a scan root would publish one project’s configuration as everyone’s mechanism. Each value is its owning kit’s table row, criterion 6’s strongest form, so the binary holds no second default to drift from. §check-consumer-value-literal holds the crate-literal form for every gate, shipped or withheld: a literal naming a consumer’s tracked path or heading.
A gate whose contract moves in the iteration is not ported in it. Porting against a moving contract makes the parity oracle chase a moving target. check-gate-exemption-tasks was excluded on that ground, and its contract then moved twice.
The fifth budget batch
Cut widths are ruled per cut and never inherited. Two batches read together are not a standing envelope. Drop-any-member rides each batch’s envelope, available for cause.
A cut composed off a filed, witnessed cost survey is auditable after the fact, where one read at the cut is not. This cut took the cheapest takeable members as ranked by a survey filed under lifecycle-kit/SPEC.md §The survey record, whose witness the authoring stage re-ran.
A size ranking is silent about interfaces. Six of this cut’s seven members carried a command-line argument, and the deleted-argument test §The non-gate arm states bound twice. A porting session prices interface removals itself.
A caller naming a member’s declaration path is re-pointed in that member’s own port commit. install-hooks’ identity rung (§install-hooks) and the close-stage entry pre-flight (evidence-kit/SPEC.md §check-evidence-manifest) each named one, and no gate caught either. Neither is reachable before its member ports, and neither may land after, because the window in which the descriptor exists and the caller has not moved is where the defect lives. Same-commit makes it zero-width.
A knob whose default no kit table publishes lands as a pre-port unit, once, before the first descriptor. A value defaulted inline at its use site hits the undeclared-knob refusal on the member’s first post-port run. Found per member, it is paid per member.
A cut spanning several sessions takes its snapshot as a rev. “Before the first descriptor lands” is unreachable for a member ported in a later session. The pre-descriptor tree is a named commit, and a later member’s live-tree comparison runs against a worktree of that rev. Reap that worktree off git worktree list, never off git status: a scratch directory is gitignored, so the status reads clean while an unreaped worktree still aborts a clean-tree precondition.
A pair that injects its corpus cannot reach the code that derives it. A single good and a single bad case under-exercise any member with more than two outcomes. Past that, some members need the pair written, not widened: one ran its fixture arm in both cases, so the function reading gate-file bytes through git had no case at all. The discharge is a gate-tests/*.test.sh sibling driving the live arm, never another injected case.
check-graph is ruled out of every budget batch. It emits a self-contained HTML artifact, so it is a non-gate arm designed before it is ported (§The non-gate arm). A batch whose members must be droppable cannot carry one whose design is a prerequisite for the rest of its own work.
A kit literal is admitted where it is kit mechanism. A wire-format version string is one (evidence-kit/SPEC.md §Evidence manifest).
The sixth budget batch
Six members, the whole takeable tier bar check-graph. The budget arm has no further cut to compose until a # port-until: hold releases.
Criterion 4 bound on four of six, and two are new register rows (§The port-candidate criteria, criterion 4). check-gate-exemption-tasks binds in every configuration: in an authoring tree its declaration is scanned, and in a vendored consumer it is in the out-of-scope set the skip count reads. check-knob-default-coupling is the sharpest couple-clears-walk-binds instance: its trigger field is one level shallower than the walk beneath it, so assertion C structurally cannot report it. check-spec-embedded-source’s verdict is taken conservatively without ruling the class, since a conservative verdict costs a fixture widening and cannot err in the harmful direction.
Criterion 5 charges this cut nothing: a host outside the roster never receives the binary the guard moves behind, and a covered host runs the same rule.
A knob’s one spelling is its kit table row. With every default in a kit’s table, the trap is declaring a spelling the table subsumes under a resolved sibling, which hits the undeclared-knob refusal on the first post-port run.
Ordering binds within a batch where one member’s corpus holds another’s declaration as content. Porting the second moves the first’s clean-line counts, so every later comparison runs against the named pre-descriptor rev. check-reads-couples ported last: while its shell form existed it was the one second implementation able to audit its siblings’ new descriptors.
An arm reachable only through a .gate member has no case unless a fixture ships a descriptor. Most registry members are descriptors, so that arm does most of the work. A root default no injected case reaches is discharged by a sibling harness driving the live arm.
A port reproduces a defect rather than repairing it. A refusal the shell form never made is a verdict change across the seam, which the parity run holds invariant; each such defect is filed as its own unit. The exception is a governed sentence drifted from its code: the port takes the code and corrects the sentence.
The seam can be a privacy boundary. check-template-copy-parity’s pattern discard keeps a consumer’s rule vocabulary out of the crate, and no committed case could catch a port losing it. A pair of consumer-extended vocabularies the library unioned before reading the knob is the other trap: declaring the extension names beside them doubles every consumer token, and declaring only them drops the bundled set.
A compiled check-reads-couples resolves a knob whose name is known only at run time through a derived union, computed off the registry and read as data; §check-reads-couples owns the ruling.
A size ranking is silent in both directions. The fifth cut’s cost sat in interface removals; this cut’s five argument-carrying members ported every argument unchanged, and its cost sat in fresh derivation: embedded awk with no Rust counterpart, a hand-rolled surface classifier, a fence tokenizer. A porting session prices both itself.
The canon-kit spec_manifest_files cohort
The shared derivation is compiled. native/src/spec.rs carries all three branches of spec_manifest_files: the kit-root path prune, the manifest-prose walk driver and the count grammar. It was ported once and is proved by every member that calls it.
Parity is bought against the branches this repo does not run. This tree sets CANON_KIT_MANIFEST_FILES, and sets CANON_KIT_SCAN_KIT_ROOTS to the value that disables pruning (canon-kit/SPEC.md §Layout and configuration), so every live run takes the explicit-glob branch. A live-tree comparison, the oracle a session trusts most, therefore proves nothing about the default walk or the prune. The port stood up a differential edge tree with both knobs at their defaults. It carries each of SPEC.md, README.md and CLAUDE.md under templates/ and at a vendored kit root, the ancestor-root case, and a kit root outside the scan root. It caught a prune that never ran: a scan root of . was absolutised to a . component appended to the cwd, so every path-prefix test failed silently. No fixture pair and no live-tree run could surface that.
The Rust walk sorts, where find(1)’s order is a filesystem artifact. A member reaching its corpus through sort -u stays byte-identical. The one printing its corpus unsorted is set-identical with a different line order, asserted as such rather than normalised away.
A member that interprets a consumer-configured ERE owes an engine. check-install-claim, check-payload-claim and check-manifest-temporal each apply a consumer pattern: CANON_KIT_INSTALL_SECTION_RE and one per transport, one per disclosure class, and every CANON_KIT_TEMPORAL_MARKERS entry. Bridging the vocabulary as data removes the interpreter the binary would spawn, not the regex it must then apply. Sizing an engine to this consumer’s patterns is foreclosed by criterion 6’s globs argument. That foreclosure binds the pattern grammar the engine accepts and not its API (§The POSIX ERE matcher, now those members’ contract).
Read the kit default, not only the knob. check-manifest-temporal clears criterion 4, spawns nothing and reads this cohort’s derivation, so every mechanical screen puts it in. Its shipped marker default already carries an alternation, so the engine is load-bearing for a consumer who overrides nothing. CANON_KIT_DERIVABLE_POINTER_REGEX’s shipped default is an alternation too (canon-kit/SPEC.md §Layout and configuration), which puts check-spec-derivable-section in the same class. A knob documented as consumer config is exactly where a mechanical screen stops looking.
The kit-roots gate_kit_roots cohort
Five members sharing one corpus derivation: check-kit-registration, check-smoke-entry-guard, check-test-hermetic, check-assertion-strength and check-template-registry-parity. Each derives its corpus from gate_kit_roots and a fixed sub-path under each root (smoke/install.sh, smoke/violation.sh, gate-tests/*.test.sh, smoke/*.sh, bin/*.sh, templates/*.list), then applies a per-file text test. The crate derives the kit roots itself (§Layout and configuration), so the derivation was paid before the cohort started.
check-template-registry-parity is a content couple wider than its walk, never a reverse-trigger couple. It reads *.sh names as content through git ls-files -- '*.sh', but only those under a <kit>/<name>/ directory that a sibling <kit>/templates/<name>.list registers. No kit ships a template registering checks/, so the port changes nothing the gate reads and its parity proof is not self-referential. Criterion 4’s worked instance owns what its kit:*/*.sh couple once cost.
A default resolved inline is no published default. check-kit-registration resolved GATE_SDK_REGISTRY_DOC and GATE_SDK_RUNNER_DOC inline, which the compiled member’s knob resolution would refuse. Both defaults moved into gate-sdk’s table under the consumer knob names, since a knob that changes no grammar keeps its name (§lib/gate.sh).
Three differences are asserted rather than normalised away:
check-assertion-strength’s token-to-code map leftawkin hash order and leaves the crate sorted. It is set-identical and reaches output only through a multi-finding message.- Set arms are computed by set membership, in byte order.
sort’s collation is locale-dependent and a set difference is not, so the port implements the rule the contract states rather than the mechanism the shell reached for.check-template-registry-parity’s twocommarms are the instance. - A member taking a positional root resolves its relative kit roots against that root, where the shell held absolute roots and ignored the argument. The two agree whenever the positional root is the invoking directory, which is every documented use.
A reader of a ported member no kit roster names still moves with the port. check-gate-assertions looked for # assertion markers in a member’s declaration, which a .gate descriptor cannot carry. It now reads a .gate member’s markers in its implementation module (§check-gate-output), under either comment leader, and with no crate present, as in every vendored consumer, it skips and counts those members. A tree-green repair is not a consumer-green one, and the consumer smoke’s per-profile battery, run on a freshly installed consumer, tells them apart (installer/SPEC.md §The consumer smoke).
The members assert over kit-authored files only, a vendored kit’s own smoke/, gate-tests/, templates/ and registry rows, so no class of adopter-authored content rests on them.
The canonical-spec spec_canonical_specs cohort
Two members, check-spec-dod-singleton and check-spec-derivable-section, selected by running the selection rule. The run reported zero undecidable members, and that count bounds the claim that a group is the largest: at zero, the partition speaks for the whole still-shell registry. The zero rests on the repaired scan (§port-blockers).
A group failing only criterion 3 outranks one failing 4 or 7. Both members are tier=align-only, so no hook selects them, and a green check-graph loses only the hook arm of its end-to-end proof. A criterion-4 or criterion-7 failure carries a design problem rather than a weaker proof, which is why those members are sequenced last.
Criterion 6 is discharged by lifting the derivation out, never by copying it. spec_canonical_specs is the SPEC-name find, templates/-filtered and kit-root pruned, all of which native/src/spec.rs already carried inside manifest_files’ default branch. Lifting it out means the manifest set and the canonical-spec set cannot disagree about which specs exist. The compiled finder is the one holder (canon-kit/SPEC.md §The shared spec adapters).
The edge-root arm disagreed, and the compiled side read the rule correctly. At a .. scan root the shell prune kept twice the canonical specs the compiled form kept. It was a prefix test that normalised neither the scan root nor the file paths, so a .. component made every comparison fail and the corpus silently widened. The rule excludes a file under a kit root that is a strict descendant of the scan root, and at a .. root the kit roots are such descendants. The shell was repaired before the port, so parity proves the repaired behaviour. A disagreement is adjudicated against the requirement, never against whichever side moved.
An oracle for a normalisation defect breaks one side only. check-spec-dod-singleton.test.sh’s ..-root case reds under an asymmetric break of the normalisation and stays green under a symmetric one. Dropping .. from every path alike leaves the prefix test succeeding by coincidence, so a control that transforms both sides proves nothing.
Criterion 5 charges this cohort nothing, and the shell-side rival is refused: a host outside the roster loses the Definition-of-Done singleton and the derivable-section budget with every other member, and restoring the class shell-side would reinstate the second spec_canonical_specs implementation the criterion-6 discharge removed.
The POSIX ERE matcher
Three members, and the engine is what the cohort buys. check-install-claim, check-payload-claim and check-manifest-temporal, held on an ERE engine by §The canon-kit spec_manifest_files cohort, port together. On member count the selection rule picks elsewhere, but a POSIX ERE matcher was owed by every member interpreting a consumer pattern. Paying it against three retires the blocker for all of them, a worked instance of the blocker-retiring override.
Criterion 4 binds on none of the three. Assertion C selects check-install-claim and check-payload-claim through their scripts/*.sh couple, the reverse-trigger over-selection criterion 4 names: that couple re-runs each gate when this repo’s transport and disclosure emitters change. What all three scan is spec_manifest_files, the governed markdown set, which holds no gate declaration.
The owed engine is a matcher, with no substitution engine. Every member but check-deprecation-task applies its consumer pattern only as a match test, and that one extracts a span. Every gsub or sub in the set runs over a pattern baked literally into awk source, which a port hand-compiles. The owed engine is therefore a POSIX ERE matcher with leftmost-longest span reporting. A caller that substitutes composes the spans itself, as --rewrite (guard-kit/SPEC.md §rewrite) does through find_from.
The sizing foreclosure binds the pattern language, not the API surface. The config surface permits what one consumer happens not to write, so a narrow grammar silently mis-scans the first consumer who writes it. Whether a member substitutes is fixed by its own source, not by consumer input, so no future consumer can turn a match test into a substitution. A capture is a separate item with its own reader, not a widening of the foreclosure.
The engine stays hand-written though the crate may take dependencies. Its contract is POSIX leftmost-longest span reporting, the semantics awk’s RSTART/RLENGTH gives. The ecosystem’s ordinary matchers are leftmost-first, which agrees on every is_match and disagrees on spans. A dependency with the wrong span semantics would regress silently exactly where a boolean-only oracle cannot see, so replacing the engine is a costed design question, never a cleanup.
The contract. native/src/ere.rs accepts POSIX ERE in full: alternation, concatenation, * + ?, intervals {n} {n,} {n,m}, grouping, ., anchors ^ $, bracket expressions with ranges, negation and the POSIX character classes, and backslash escaping of every special. Its public surface is five items and no more:
Ere::compile(pattern) -> Result<Ere, EreError>Ere::is_match(&self, hay: &str) -> bool— awk’s$0 ~ pEre::find(&self, hay: &str) -> Option<(usize, usize)>— the leftmost-longest span as byte offsets, theRSTART/RLENGTHpair awk reportsEre::find_from(&self, hay: &str, from: usize) -> Option<(usize, usize)>— the leftmost-longest span starting at or afterfrom, searched in the whole subject so^still holds only at its position 0;findisfind_from(hay, 0). Its reader is--rewrite, a born-native arm rather than a ported member, so it does not fire the promotion trigger below.EreCapture::compile(pattern) -> Result<EreCapture, EreError>,EreCapture::capture(&self, hay: &str) -> Option<(usize, usize)>andEreCapture::capture_from(&self, hay: &str, from: usize) -> Option<((usize, usize), (usize, usize))>— the one-group capture: the group’s byte span within the leftmost-longest whole match, orNonewhere the whole pattern does not match.capture_fromis its from-offset form, asfind_fromisfind’s, and returns the whole match’s span beside the group’s, so a caller steps past each match to find a line’s every one in turn;captureiscapture_from(hay, 0)’s group.
There is no replace and no replace_all; adding one is a design decision with its own reader rather than an omission to fill in.
The one-group capture admits one shape, because one shape needs no submatch arbitration. The pattern holds exactly one unescaped ( outside a bracket expression. The group stands in the pattern’s top-level concatenation, never the operand of a * + ? or interval and never inside a top-level alternation, and its body is any accepted ERE except an alternation. Each other shape is an EreError naming the rule it broke: no group, a second group, a quantified group, a group inside an alternation, an alternation inside the group.
The admitted shape answers the POSIX subpattern rule with the span engine alone. The rule is that each subpattern, from left to right, shall match the longest possible string. The shape splits the pattern into prefix, group and suffix. With [s, e) the whole match (find), the group span [i, j) takes the largest i in [s, e] such that the prefix matches exactly [s, i) and some j lets the group match exactly [i, j) and the suffix exactly [j, e). For that i it then takes the largest such j. An exact match honours ^ and $ at their absolute subject positions, as find_from does, never at the sub-span’s edges.
The refusals are the fail-closed form of not implemented, never an approximation. A quantified group’s POSIX span is its last iteration, and an alternated group may not participate at all; the split answers neither. An alternation inside the group is refused on the differential’s evidence: the host regcomp (glibc) resolves it by first alternative rather than longest, so (a|ab)[b-d]* on ab captures a under bash and ab under the rule. Admitting it would change a consumer’s captured pid silently. Every other admitted shape agrees with bash across the generated cross product. A general submatch engine, per-thread slot vectors or a Pike VM, is not owed, since the one reader’s pattern is specified with exactly one group.
Substitution is a private caller-side loop until a second reader promotes it. check-queue-prose-precondition took its one substitution as a loop over find in its own module (replace_all in native/src/gates/queue_prose_precondition.rs). find reports leftmost-longest, which is awk’s gsub match rule, so the loop reproduces gsub with no engine surface. One rule of that loop is owned here, and its spec: line cites back: an empty match advances one character, since its failure is a silent infinite loop rather than a wrong answer. A second ported member needing substitution promotes the loop into ere.rs as a further item, with the differential oracle widened to awk 'gsub(p,r){…}'.
The engine is byte-wise and bounded. Byte-wise is the C-locale semantics its callers’ span arithmetic assumes: find’s offsets feed byte-indexed slicing, and a char-wise engine shifts every one of them on a multi-byte glyph. Interval bounds are capped at the POSIX RE_DUP_MAX, and the compiled program at a fixed instruction count, because an interval expands by copying its operand. The program is a Thompson construction simulated thread-set by thread-set and never backtracked, so a search costs memory linear in the program and time bounded by the program size times the subject length.
Leftmost-longest is the semantics, not an implementation detail. POSIX ERE alternation is leftmost-longest and a backtracking matcher gives leftmost-first. The two agree on every is_match and disagree on spans, so a boolean-only engine can sit on the wrong semantics unseen, until check-deprecation-task extracts the wrong marker from (deprecated|deprecated-since).
An unsupported construct is a fail-closed refusal, never a silent mis-parse. GNU awk, which the adopter floor admits beside any POSIX awk, carries extensions POSIX does not: \y \< \> \B \w \s and the backreference forms. compile returns EreError for any escape or construct outside the POSIX ERE grammar, and each member turns that into exit 2 naming the pattern, the knob it came from and the extension. Three constructs are refused rather than resolved by picking a dialect: a backslash inside a bracket expression (POSIX reads a literal, GNU an escape), a collating-symbol or equivalence-class bracket, and any escape outside the specials list. The failure guarded against is observable: a marker knob spelled \yformerly\y makes GNU awk warn, read \y as y, and report a clean verdict off a scan that never matched what the consumer meant.
The acceptance oracle is a differential run against the shell’s own awk. Tests and implementation authored from one understanding prove nothing for a hand-written regex engine. ere.rs’s unit arm compares is_match against awk '$0 ~ p' and find against awk 'match($0,p){print RSTART, RLENGTH}', byte for byte, over a generated pattern-and-subject cross product. The pattern crosses through ENVIRON rather than -v, so awk’s string-escape pass never rewrites it, and the oracle runs at LC_ALL=C to hold both sides to byte semantics. The generator covers what this tree’s vocabularies never exercise: intervals, nested alternation under a quantifier, negated bracket ranges, anchors inside groups and the character classes.
The one-group capture has the same oracle against bash. capture is compared, as the whole-match and group strings, with [[ $s =~ $p ]] and BASH_REMATCH[0]/[1] over a generated prefix × group × suffix × subject cross product. It runs in one bash at LC_ALL=C, every pattern and subject crossing on stdin, one per line, and never interpolated into the script; the cross product overflows a Windows command line. Its generator covers prefixes and suffixes competing with the group for the same bytes, an empty group, anchors on either side and bracket ranges. A divergence is a design gap to resolve in this section, never a case to drop. The bash oracle is compiled out on macOS, whose regcomp breaks the leftmost rule: it answers aab for a(ab?)[b-d]* on xaaab, where the match starting one byte earlier, aa, is POSIX’s. The engine is platform-independent code, so the oracle’s Linux runs cover it, and the awk oracle runs on every host. Both oracles are test-scoped arms under check-crate-arms, so a divergence is a commit-time red and the production binary spawns nothing for them.
A consumer-supplied pattern goes through the engine, and so does a kit-owned one. “Hand-compiled” is a claim about substitution, which stays the port’s own work over kit literals. check-manifest-temporal’s prefix strip and inline-code stripper port as direct code, which is why the API owes no replace. It never obliged a port to hand-write a matcher. A kit-owned pattern such as check-queue-prose-precondition’s alternation with a negated-class gap compiles through the engine like any other, and the member owns only the loop around it. check-install-claim’s heading extraction routes through find for the same reason, so a precommit-tier gate exercises the span API on every invocation.
The provenance seam: native/src/ere.rs carries no vocabulary whatever. It holds no transport id, no disclosure class and no temporal marker. An engine sized to a grammar rather than a corpus cannot encode one project’s terms, and shrinking it toward this tree’s patterns is exactly the pressure that would. The transport and disclosure vocabularies and the temporal marker set stay consumer config. The declaration regexes, the heading and fence patterns and the inline-code stripper are grammar the kit owns and carry no project term. The check: a grep for any transport or disclosure id across native/src/ returns nothing.
The diff renderer
The crate’s one rendering of diff’s normal format. native/src/diff.rs carries normal_diff, a pure function over two line slices returning the normal-format hunk sequence. That is the NcM / NdM / NaM range headers and the < / --- / > body lines the external diff prints with no options. It is shared because the generated-projection freshness members each need one at their port.
It carries no vocabulary and no report policy, §The POSIX ERE matcher’s scoping applied to a renderer. It reads no knob, takes no path, and knows nothing about what its two slices represent.
The renderer is uncapped; the cap belongs to the caller. normal_diff returns every hunk. A renderer that truncates is a reporter, and the roles are separate because native/src/gates/lifecycle_registration.rs wants the whole sequence while the freshness family’s stale reports do not.
The cap is STALE_REPORT_CAP in native/src/diff.rs, the shell family’s value carried rather than re-picked. Every shell freshness gate that rendered a diff capped its stale report at one literal, diff <(...) | head -20. Criterion 2 proves a port byte-identical to its original, so re-picking or dropping the cap diverges exactly where a fixture does not reach: a planted bad/ difference is small, and the live tree is where a report first exceeds the cap. The constant landed with its first readers, check-value-rollup-fresh and check-trajectory-fresh (§The consumer remainder cohort), and never ahead of them: check-crate-arms runs clippy at -D warnings, where a constant no code reads is dead_code. A later family member reads the constant, never the literal.
Applying the cap is a reporter’s job. native/src/fresh.rs carries the family’s shared shape: the emitter spawn, the command-substitution read, diff’s own line model, and the one site the cap is applied at. normal_diff stays uncapped and pure, so the module that truncates is not the module that renders.
The renderer never spawns. diff is on GATE_SDK_PROGRAM_FLOOR (§lib/gate.sh), so criterion 7 clears a port reaching for Command::new("diff"). It is refused: native/src/proc.rs is the crate’s one sanctioned spawn site, an LCS walk needs none, and a spawned renderer would reinstate per member the external-program dependency the port exists to collapse.
The settings cohort, and the crate’s first dependency
Two members, check-settings-paths and check-settings-pins (context-kit), and what the cohort buys is a JSON reader. It sets the precedent of the first dependency. It is not §The POSIX ERE matcher’s pattern: that engine had to be built, and a JSON reader was a cargo add. The two members share no --group key; they share the harness settings file as couples= corpus and the criterion-7 jq blocker, and the cohort was selected under the blocker-retiring override.
The dependency bar. native/Cargo.toml takes serde_json, and the cohort implements no JSON parser. The no-dependency constraint is the adopter’s, who needs only git and the prebuilt binaries. It governs what an adopter installs and says nothing about a build graph no consumer receives, resolves or compiles. A candidate clears a bar, never a precedent: it performs no filesystem walk (the condition native/src/walk.rs’s own assertion states), spawns no subprocess and opens no socket. Its MSRV is at or below the crate’s floor, or the floor moves deliberately, and its transitive set is small, enumerable, and admitted under the same clauses. Every package in the resolved graph, activated or carried by the lock as an unactivated optional dependency, is named with its admitting clause in walk.rs’s allowlist, the machine-held form of this bar.
The floor moves by measurement. Taking serde_json raised the crate’s MSRV through its activated set, so no feature trim avoided it. Pinning an older release to hold the floor would freeze a dependency at an end-of-life version, contradicting the maintained-dependency premise the bar rests on.
The second dependency is libc, on unix targets only. It cleared the bar with an empty transitive set and an MSRV below the floor. It lets the pid predicate read EPERM in-process (evidence-kit/SPEC.md §The producer-liveness lock), kills a bounded child’s process group (installer/SPEC.md §The update notice), and serves the local-time conversions. Its unsafe calls are kill(pid, 0) and kill(-pgid, SIGKILL), which touch no memory the crate owns, the group being an unreaped child’s own pid, and localtime_r and mktime, each over a zeroed stack-local tm. The target scoping is the point of the admission, not a size trim: the native Windows build resolves pids in a shell’s namespace, where kill would read the wrong processes. The Windows legs are kernel32 functions declared in-crate in one module, admitting no dependency, since std already links kernel32 there. The pid predicate calls OpenProcess, GetExitCodeProcess and CloseHandle, taking integers, a stack-local exit code and a handle it closes itself. The bounded child’s tree kill calls CreateJobObjectW, AssignProcessToJobObject and TerminateJobObject, taking null attributes, the child’s own live handle and a job handle it closes itself.
An MSRV bump is a check-crate-arms input. Clippy suppresses a lint whose suggested API postdates the declared rust-version, so raising the floor un-suppresses lints against unchanged code. A floor move budgets for a clippy pass over modules it never edited.
The pin-path layer is the only new surface. Path::compile(&str) -> Result<Path, PathError> and its evaluation over a serde_json::Value have exactly two consumers: native/src/gates/settings_pins.rs, and native/src/gates/settings_paths.rs, whose allow-array access is kit-owned and hand-compiled (§The POSIX ERE matcher’s boundary). It has no filter language, no mutation and no second grammar. The grammar, its refusals, the structural comparison and the two preserved shell semantics are context-kit/SPEC.md §check-settings-pins’.
The acceptance oracle is a differential run against the shell gate’s verdict, generated over path shapes rather than document shapes, since a generator varying documents would re-test serde_json. It compares verdicts rather than jq’s rendered stdout, which is version-dependent. It models the gate, not the tool: the shell refuses a path not opening with . before jq sees it. That distinction caught a real divergence, since jq reads a leading ["k"] as an array literal. The arm needs jq at contributor time only, which touches criterion 7 not at all.
The honest claim about jq. The cohort retired jq from the battery but for two members with named owners, both since ported (§The third budget batch, §The consumer remainder cohort). It did not retire jq from the shipped install path; that path stopped reading JSON with it later (installer/SPEC.md §Requirements). The enforcement-map emitter’s jq programs left as a dividend of a port argued on other grounds, the shape §The port-candidate criteria gives an external-program dependency: engineering work the port owes, never an exclusion it may claim.
The provenance seam. The pin-path layer carries no settings key, no pin path and no permission vocabulary; the pins manifest and the settings file are consumer config. The permission-entry grammar check-settings-paths hand-compiles is a public harness format, the class of the # graph: manifest grammar. A general-purpose JSON parser is grammar, not vocabulary, so it can no more carry a project term than ere.rs could. What the seam forbids is a consumer-shaped dependency, selected because it encodes some tree’s terms, and the bar excludes that class by construction.
The declaration cohort
Three members, check-release-bump, check-tightened-gates-grammar and check-release-declaration-parity, and what the cohort buys is the release-note declaration family off the shell substrate. All three are consumer-declared, so this was that tranche’s first cut; the first-mover design every later tranche inherits is §check-gate-substrate-parity’s assertion-B owner clause.
A derivation the tool split is a finding too, and its adjudicated union key is recorded. --group cut the three as singletons on three keys sharing one library and one walk. An override cohort whose members the tool reported as three singletons otherwise reads, to a later selector, as three coincidences. It is the mirror image of the rule that a group whose members’ corpora diverge is a finding the selecting session adjudicates.
The version comparator is defined over a stated grammar, and a token outside it is a loud refusal. The compiled form orders <major>.<minor>.<patch>, each a run of ASCII digits, field-wise numeric, with the row form tying on the path’s byte order. It refuses anything else at exit 2, naming the token, its source and the grammar. A faithful port and a correct one are different programs here: sort -V orders 1.0.0 before 1.0.0-rc1 and semver orders it after. Reproducing sort -V bakes a prerelease ordering into a gate whose subject is a semver line, and implementing semver is a rule change smuggled through a substrate change. Refusing neither invents a rule nor ships a known-wrong one, and installer/SPEC.md §Versioning names where the ordering ruling is owed. The sort -u inside the disposition collector is not version ordering; it is a byte sort with dedupe over whole lines and ports as one.
A rejection case cannot pin a refusal, so the arm is pinned where refusals are. §run-gate-tests makes a bad/ case exit 1 and a refusal is exit 2, so scripts/gate-tests/check-release-bump.test.sh pins it beside that gate’s other exit-2 cases. It covers both producers, a note’s release: key and a disposition data line, because a refusal naming the wrong file sends its reader hunting.
A cohort port is one commit, by construction. §check-gate-binary-fresh’s stamp is computed from git ls-files. A partial-path git commit -- <paths> swaps in a temporary index of HEAD plus the named paths, so it recomputes the crate’s manifest without the sources it leaves out, and no binary built from the whole tree can match. Splitting modules from the registry, the descriptors or the .sh deletions therefore reds.
Both subprocess reads go through the crate’s one spawn site (§Fail-closed contract). The tag probe’s non-zero status is the no such tag branch, a verdict rather than a failure, read through the exit-code accessor that section provides for a child that grades its own outcome by exit code. The historical git log’s added lines are extracted with a pattern the gate owns, so it is hand-compiled.
A port proves parity and does not fix rules, so two conflations are preserved. check-release-bump silences its historical git log’s stderr and returns success regardless, so a tree where git cannot answer derives its deferral floor from the live file alone. The branch is unreachable in the live tree and in the fixture pair, so no case could prove a change correct. The second is the declaration grammar’s: both arms leak already-resolved tokens into the refusal output (§lib/declaration.sh). Both are filed as gaps.
A knob no static kit’s prefix owns is declared on the .gate descriptor of the consumer gate that reads it, on a # knob: line. The text after the token is one knob-file line (§The knob file), read by that file’s own line reader. It is NAME = value for a scalar, or one NAME[] = element line per element of an indexed knob. The name carries the GATE_LOCAL_ prefix, which no static kit’s prefix matches: a static-prefixed name is that kit’s, and a consumer value there would be a kit literal. Each name has one producer. A line naming a static kit’s knob, a name without the prefix, a keyed or reference form, or a name two descriptors declare is refused at exit 2, naming the files.
The resolver reads the descriptors of the resolved gates directory. A name no static kit owns resolves from the environment, then the declaring line’s value. An indexed knob’s environment value is the wire, its elements tab-joined, because the environment is the one consumer-set layer such a name has. An undeclared name is refused whatever the environment carries. So is a name the right kit’s prefix spells and its table does not declare, so a live kit’s prefix is necessary and not sufficient for a knob to resolve. A reader’s precedence is its positional operand, then the knob, and a member declares the names it reads in its registry row’s knob slice. A knob is read on the path that opens what it names and nowhere earlier, so a path that never opens it keeps its verdict in a tree declaring none. A knob-file layer stays unruled: <gates-dir>/*.knobs refuses a GATE_LOCAL_ name, the fail-closed state for an unowned name.
A fixture case declares its own. §run-gate-tests resolves knob values inside the case dir, where no live descriptor sits. A case reaching a declared read without a positional therefore carries the declaring line in its own <gates-dir>/<gate>.gate, as it carries its own knob file. A heading-valued knob has no positional spelling, since a case’s args file is split on whitespace. Exporting the live descriptor’s values onto the case is refused, because the case would then read two oracles. Honest limit: check-docs-cmd assertion B and check-knob-citation derive their vocabularies from kit prefixes, so a GATE_LOCAL_ name in prose is held by neither.
The declaration directory is a declared knob. Every reader of <workflow-dir>/release-declarations.md and release-disposition.txt, these three and check-release-change-declared (born native, not ported), resolves GATE_SDK_WORKFLOW_DIR from gate-sdk’s table, as §upgrade-smoke’s arm does. A knob resolves against the one kit its name’s prefix names, never the declaring gate’s location (§lib/gate.sh).
The readers of a narrowed corpus are enumerated by red condition, not by subject, because a narrower corpus can only remove violations is false for three ordinary shapes (canon-kit/SPEC.md’s causal-completeness point 5):
- Inverse: assertion A reds on a descriptor and a script coexisting, so a half-done port violates it;
check-exec-bit’s unconditional.gatearm reds on a descriptor committed executable. - Reds-on-empty:
check-shellcheckexits 2 on an empty target set, andcheck-gate-fixture-coveragereds on a registered member with no pair, so deleting a script and orphaning its pair are one action apart. - Superset:
check-reads-couplesreds when observed walk roots exceed declared ones. A walked directory the gate’s positional argument relocates is declared with a single?, since declaring the live default reds unit test A the moment a fixture case runs.
Structurally out of reach is measured, never assumed. check-readme-roster and check-install-disposition sweep gate_kit_roots only, so neither names a consumer-declared member. A wildcard permission grant naming a .sh path covers nothing once its member dispatches to the binary, and that silence is not coverage. check-measured-claim’s oracle counts registry members resolving to a .gate, so a marked count moves with the port.
A consumer tranche enumerates its bespoke tests before the cut. A scripts/gate-tests/*.test.sh naming its gate by script path invokes a deleted file after the port. Each reaches its gate through gate_run (§run-gate-tests), which is what lets a behavioral test survive a substrate change.
The consumer remainder cohort
Ten members, the whole remainder of this repo’s gates directory: check-docs-kit-parity, check-docs-mirror-fresh, check-docs-nav-reachable, check-install-toolchain, check-installer-no-deps, check-kit-ref-liveness, check-npm-publish-spec, check-release-channel-parity, check-trajectory-fresh and check-value-rollup-fresh. They dispatch to the binary, their shell scripts are deleted, and scripts/ keeps no check-*.sh. This completes the consumer-declared tranche. Its ground is the blocker-retiring override with the first-mover design already paid, so marginal cost per member sits at its floor.
A criterion-7 ? can be scanner blindness, settled by tracing the runtime program set. port-blockers reported check-docs-kit-parity undecidable because its command word was an array gate_command populated at run time. Traced by hand, the set was awk, bash and the crate’s own binary, the first two on GATE_SDK_PROGRAM_FLOOR, so criterion 7 clears.
A compiled gate whose rule is another gate’s verdict calls that module in-process. Both members live in one binary, so spawning would route a gate’s own process through native/src/proc.rs and make its verdict depend on a path it happens to know. The wrapped module owes a capturing entry point: run(&[String]) -> i32 stays the dispatch signature, and the body moves behind a form writing into caller-supplied sinks. Calling run directly passes a good/ fixture and diverges on every failing case, printing the wrapped report unheaded beside the wrapper’s own. A wrapper that would only re-scope a knob-configured rule onto another doc is owed nowhere: the knob is the re-scope (§check-kit-registration).
A wrapper’s knob cost is the wrapped rule’s. A member declares every knob a shared derivation it calls is computed from, not only those its own script spelled. check-kit-ref-liveness reaches the kit-root derivation and the prune set, so it declares GATE_SDK_KIT_DIRS, GATE_SDK_PRUNE_DIRS, GATE_SDK_PRUNE_EXTRA_DIRS and GATE_SDK_QUEUE_FILE.
Three members port ahead of their emitters, and The generated-projection freshness family (§The first cohort, and the rule that selects the next) carries the accounting. check-docs-mirror-fresh’s fail-closed repair is the cohort’s one designed divergence, at its row in that family.
check-installer-no-deps drops jq for the crate’s JSON reader. Its predicate is does this package declare any of three dependency fields, or any of three install-time lifecycle script keys, a has(key) test serde_json expresses directly. The command -v jq guard goes with it, since a linked parser cannot be absent from PATH. The semantics are preserved exactly: the field’s presence is the finding, not its emptiness. So is a detail a tidier port would “fix”: the shell indents the capture rather than each finding, because printf ' %s\n' "$findings" takes one argument, so only the first line carries the lead.
A pair steered off its live emitter buys parity from a stale live-tree run. check-trajectory-fresh and check-value-rollup-fresh had pairs driving the emit-source positional. Their criterion-2 parity was a live-tree run with both implementations present, deliberately stale, since a clean run exercises neither the diff renderer nor its cap. Every pair is retained, and the two bespoke scripts/gate-tests/*.test.sh reach their gate through gate_run.
The twelfth cohort
Two members, check-close-surfaces and check-queue-prose-precondition, selected by the size arm’s first group and sharing no blocker. Both cut as libs=fail_closed globs=-, cleared criteria 2, 3 and 7, and tripped no criterion 4. The arm selected on shape, so this record has two halves: check-close-surfaces removes a spawned derivation from the gate corpus, and check-queue-prose-precondition spends the ERE matcher on its last held reader.
A spawned tool is invisible to every static sizing signal, bash gate-sdk/bin/run-gates.sh --emit port-blockers --group’s line counts included. A member’s dependency set is measured on the gate file, so a gate whose work sits behind a spawn reads as unsized rather than large. check-close-surfaces sourced only lib/gate.sh while the tool it spawned carried the whole derivation. The class is exhausted: check-graph’s spawned generator is sized in its own section (§check-graph).
A declaration wider than the read is a false claim, not a safe over-approximation. Both of the derivation’s walks hang off a computed base, the scan-root argument or else the toplevel. --reads declares static strings, so each takes the ? marker and is skipped and counted. Declaring the roster tier as . filtered by LIFECYCLE_KIT_ROSTER_BASENAME compiles and reds, because it claims every tracked SPEC.md including the docs mirror’s, which the member never reads.
The spawn becomes a non-gate arm the gate calls in-process (§The non-gate arm, lifecycle-kit/SPEC.md §The close-surfaces emit arm). The emit-arm class took its first member that is not a stored projection, which widened that class’s caller clause openly. The in-process call makes “the derivation and the gate can never disagree” structural, and it obliges the descriptor to name both crate modules under couples=, or the gate goes untriggered on the edit that breaks it.
gsub is a caller-side loop, and the engine’s surface did not move (§The POSIX ERE matcher owns the loop’s promotion trigger). The loop uses to_ascii_lowercase and not to_lowercase, because a Unicode fold can change a string’s byte length and desynchronize the offsets the loop slices with.
A member lands whole in one commit; two members may share it. §check-gate-substrate-parity’s assertions A and B are non-monotone in both directions, so every intermediate state of a staggered landing is red, and a member’s descriptor, module, registry entry and shell deletion cross together. What the rule forbids is splitting one member across commits.
What the reverted port established
Not nothing, and worth separating from what it broke:
- Both substrates were byte-identical on the fixture pair, on the live tree, and on an edge-case tree (uppercase hex, a 39-hex near miss, quoted refs, comment leaders, a non-matching dash). The seam carries a gate’s rule without changing its verdict.
- The manifest survived the substrate change: the pre-commit hook, the graph artifact, and every declaration-reading meta-gate kept working against a descriptor.
- The conservation table was exercised rather than imagined — each disposition ran against a real ported member.
- Two relaxations that a port makes tempting were found only because a live instance existed:
check-reads-couplesacquired an undocumented descriptor-level opt-out (§check-reads-couples), andcheck-gate-substrate-parityassertion B turned out to go dark at zero descriptors (§check-gate-substrate-parity). Both are fixed; neither would have been visible without the port, and neither survives it.
What is retained, and where the second port stands
Retained: the crate at native/, the descriptor spelling and its specification, the gate_resolve / gate_command split, the conservation contract and its disposition table, and check-gate-substrate-parity with every assertion that section enumerates. So are the fixture-runner’s substrate-blind dispatch, the toolchain floor pin, and the CI crate build, clippy and test legs. The crate carries both first-cohort rules as live dispatched implementations (§The first cohort, and the rule that selects the next), so cargo test and the crate’s evidence suite assert against real gate rules rather than an empty crate.
The seam was the deliverable; the live port was the demonstration. A second port had two prerequisites, and both are built:
check-reads-couples’s binary-side equivalent: a--readsarm answering what a member walks, registry-declared and held to executed behavior by two crate unit tests, consumed by the gate in place of its refusal (§check-reads-couples, §Meta-gate conservation for the binary substrate). It was proved without a port, against a reference-only implementation and a hermetic fixture.- Criterion 5’s delivery mechanism: the publish path produces the artifacts and digests, and the install path selects, verifies and places them.
The second port is built and proved: every first-cohort member ships its rule as a compiled subcommand, byte-identical to the shell gate it reproduces. Declaring and dispatching are separate prerequisites (§The first cohort, and the rule that selects the next), and §check-gate-binary-fresh, which landed ahead of the first descriptor, is the freshness oracle a live dispatch carries.
What the dispatch seam does not settle
What the descriptor seam leaves open is recorded here, because an unrecorded deferral cannot be told from a question nobody asked. A question since ruled points at its ruling.
Dogfooding is ruled: this repo runs built artifacts. A registered member dispatching to a compiled subcommand at tier=precommit puts gate_command on the pre-commit path, and gate_command is fail-closed on an absent binary (§lib/gate.sh). So wherever a .gate descriptor is live in this tree, the repo builds and runs the binary at commit time, and cargo is a commit-time requirement here rather than a build-and-CI one. That is a condition on descriptors, not a count of them. Every first-cohort member resolves to a descriptor, so the toolchain floor pin (context-kit/SPEC.md §bin/env-probe) sits at the commit-time tier. A fresh clone cannot commit until it has run bash gate-sdk/bin/build-native.sh once, which cargo test does not do (§check-gate-binary-fresh). CI still builds, lints and tests the crate.
The trade. It costs a compiled toolchain at every commit in this tree. It buys the port’s benefit, measured in the one tree that exercises it daily. The alternative is not available: a descriptor and its shell script cannot coexist in one resolve dir (§check-gate-substrate-parity assertion A), and the kit tree this repo runs its gates from is the tree that vendors to consumers. No arrangement lets a consumer dispatch to the binary while this repo runs the shell script, so ruling no would refuse the port outright.
Ruled elsewhere. Vendoring: a prebuilt per-target binary in the payload, digest-verified before it is written, a host the target roster carries no artifact for refused at the bootstrap (§The port-candidate criteria, criterion 5). The manifest stays tracked text, read consumer-side at commit whichever way the payload is built. The extensibility model: §The extensibility model. Opacity: §Consumer payload, which rules withholding a gate’s predicate a goal.
Visible and not built. The layer above the descriptor is substrate-blind (§The # graph: manifest), which makes a gate-authoring SDK coherent: the descriptor as a gate’s neutral surface, its predicate in any language. It is neither built nor foreclosed.
Not deferred. The manifest-class/implementation SSOT split is enforced by §check-gate-substrate-parity assertion D rather than left to habit, since a rule stated only in prose is the one a tired session breaks.
The adopter constraints
The constraints below bind what this kit ships to an adopter, and every design record in this SPEC that weighs a cost against reach, footprint or the interpreter surface weighs it against them. They are constraints on the payload, never a claim about a tree: a user-facing surface states a floor as reached only once it is, and a platform is declared held with the run that joins it, never left undeclared. docs/requirements.md §Supported systems owns both grammars.
- The floor is git, shelled out rather than embedded. git is the only unconditional requirement of an install. A program a selected kit or arm spawns is that kit’s declared requirement, probed only where the kit is selected, and is not the floor — the audience grammar context-kit/SPEC.md §bin/env-probe owns, rendered by docs/requirements.md §Installing and running the shipped gates. The profiles a first install reaches (starter, prose) resolve to git alone, and no user-facing surface says “git alone” of a profile whose resolved floor is wider. A gate on the binary substrate spawns git (§The program roster) and embeds nothing. Grounds: the dependency floor is the first cost an evaluator pays and the one paid before any value is seen.
- Every major operating system, Windows natively. A bash-only install path fails the constraint; the joined and held platforms are docs/requirements.md §Supported systems’ declaration block and
native/targets.list. Grounds: a bash floor excludes a platform’s evaluators before they reach the front door. - Opacity is a goal. A gate’s implementation is withheld so it is executed rather than analysed by the agents it holds; §Consumer payload states the disclosure class and exactly what it buys.
- Footprint is a first-class cost. An adopter installs, tries and uninstalls without growing their code base or dependency set. The install is a one-shot vendoring that leaves no resolvable reference behind and reverses to the tree it found (installer/SPEC.md §uninstall). The payload is one prebuilt binary per declared target. A crate dependency is a cost paid per target on every adopter’s machine, so the standard library is preferred wherever it covers the need.
- The non-technical adopter is a design constraint. No install step assumes a developer machine or a toolchain: nothing builds at install (installer/SPEC.md §The gate binary), and a host the roster does not carry is refused with its reason rather than handed a build.
- The script-interpreter surface shrinks to the unavoidable, and the unavoidable is dual-implemented. A script standing between an adopter and the install or the battery is a POSIX shell for Linux and macOS and PowerShell for Windows (installer/SPEC.md §The install boundary’s two bootstrap halves). §The port-candidate criteria rules what may stay shell and on what ground. Two caller classes keep a script door permanently, named as classes because a file list goes stale at the next surface. A harness-configuration value the harness can run only through a shell keeps one (§run-gates). Its members are a setting that takes a shell command and no argument array, as the status line does, and a wiring that must test for the kits before it runs, as a plugin’s hook rendering does. So does the prose publishing that value for an adopter to paste. A pre-build door keeps one because naming the binary would name a path that does not exist yet. Such a door carries its precondition at the site, since a reader who cannot see that a build comes first reads a clone-path floor as the install floor. Every other door names the binary through
GATE_SDK_NATIVE_BIN. - The master harness is a binding, never an assumption. Kit prose names a harness surface generically wherever config owns it: the settings file and the agent-skill directory, each a knob, and the project-directory variable, a binding the wiring templates carry (guard-kit/SPEC.md §The shell guard). §check-harness-literal holds the listed literals to their declared sites. The hook members are the exception by construction. They speak one harness’s hook protocol, so the event names, matchers and payload keys their sections state are the shipped Claude Code binding, as the wiring templates and the plugin are. Grounds: an adopter on another harness must tell a contract of the kit from one vendor’s spelling of it.
Consumer payload
What a gate on the binary substrate discloses to the consumer it judges. It rules what a gate ships, not whether this repo runs built artifacts, which §What the dispatch seam does not settle rules. How a compiled gate arrives is ruled at §Porting a gate to the binary substrate (criterion 5) and implemented by the publish and install paths; this section is what arrives with it.
A kit gate ships because it serves an adopter, configurably. Its subject is a surface an adopter holds, not one only the publishing repository has, and every consumer value it reads is a knob whose default is its convention’s value (§The port-candidate criteria). A gate failing the first is withheld: its descriptor moves to the publisher’s gates dir as a consumer-declared member and its rule stays in the crate, so it leaves the payload, init’s rosters and the install smoke. A gate failing only the second is made generic. Whose surface a subject is stays judgement, which no gate holds; the configurability half’s crate-literal form is held by §check-consumer-value-literal over every gate, the withheld ones included. It is asked of a new kit gate when it is authored, and of the shipped set by audit.
The payload withholds the predicate and the engineering record. A gate on the binary substrate reaches a consumer as its .gate descriptor, its # spec: pointer, the one-line invariant that pointer’s directive carries, its good/+bad/ fixture pair, and a prebuilt, digest-verified binary. Its implementation source does not ship, and neither does the SPEC file the pointer names. A consumer receives everything needed to run a gate, act on its verdict, and verify it behaves as specified. The rule’s text is withheld; the specification’s prose is published rather than packed.
That marker is the machine tier of the rule the paragraph above states, and this section is its one owner. canon-kit/SPEC.md §check-payload-claim owns what binds the two, so a governed doc asserting a different disclosure class is a red. The class the marker declares does not move when a member’s transport does. Withholding the SPEC file withholds nothing from a consumer: the section is disclosed at a published location instead of inside the tarball. A change of transport is not a change of disclosure, and editing the marker would assert one.
Opacity serves a stated constraint (§The adopter constraints): withholding a gate’s implementation favours execution of it over analysis of it by the coding agents the gate exists to hold. It is a goal, not a side effect, and any surviving reading that opacity is not claimed is void.
What the constraint buys, stated so it is not oversold. The beneficiary is one failure mode: a coding agent told to make a battery green reads the gate blocking it and edits its way around the predicate instead of fixing the defect. Withholding the source removes the cheapest path to that. It does not make the rule secret: a binary is reverse-engineerable, the fixture pair discloses shape, and the SPEC section states the invariant on purpose. The claim is raised cost of analysis relative to execution, never confidentiality, and no governed surface may state it as the latter.
What opacity does not extend to. A withholding rule with no stated boundary grows until the tool is unusable. Four things ship, each because withholding it would break something the product needs:
- The
.gatedescriptor, because its manifest readers must work with no build and no execution: the installer’sinitgenerates the hooks from them in the consumer tree (§The# graph:manifest). - The
# spec:pointer, resolved rather than accompanied, because a gate that goes red without an explicable invariant is an unactionable block, and an unactionable block becomes a bypassed one. The invariant must be reachable at the block, not present in the tree. The descriptor’s one-line invariant ships and is printed with the failure (§run-gates), and the section behind the pointer is published atGATE_SDK_SPEC_BASE_URL(§Layout and configuration), the manifest’scommitresolving the exact text a tree was vendored from. The SPEC file is most of the packed bytes and no consumer battery reads it, since a vendored kit root is pruned from every finder by default. - The gate’s own output and help text, because the remedy line is the product. A gate that says only no is worse than no gate.
- The
good/+bad/fixture pair, which is the consumer’s whole verification oracle once the source is withheld (§Fixture-pair discipline owns what it is evidence of). Because the pair ships, a fixture is payload content, bound by what the payload’s transport can carry. A fixture that cannot be vendored onto a supported host is a broken fixture however well it proves its arm.tarcannot create a dangling symlink on a native Windows host, so §check-tree-terms builds that case at run time in its shipped.test.sh.
Each packed kit also carries the publisher’s license text, placed by the packer from GATE_SDK_PAYLOAD_LICENSE, because a kit root holds none in the tree and a vendored kit is a redistribution.
Each packed kit also carries its withheld spec’s close-surface: declarations, as close-surfaces.txt in its root, because the close-surface derivation must find a kit-owned capture log declared in a tree the SPEC never reaches (lifecycle-kit/SPEC.md §The close-surface roster). They are generated from the stamped commit at pack time, so the tree keeps one copy, and a root with none carries no file. The packer reads each withheld member that is a file at that commit with the derivation’s own line reader, fenced lines skipped, and writes the lines in source order after a one-line # comment. The basename is one crate constant the packer and the derivation share, never a knob, since the release that packs the file is the release whose binary reads it. A kit root already tracking a file of that name is a refusal at exit 2, since the carry would overwrite a shipped file. An explicitly empty withheld set ships each SPEC.md whole and carries nothing, since the derivation then reads the SPEC itself.
The package may also carry payload recipes, named by GATE_SDK_PAYLOAD_RECIPES: consumer config a publisher ships for init to apply by name. They ride the package root beside the payload, never inside it, because every directory under the payload is a kit to its readers.
A payload file’s executable mode is transport-lost, so the packer sets it. The prebuilt binary is payload content, and the artifact transport between a build leg and the --pack-installer arm does not preserve mode: GitHub’s artifact upload flattens every uploaded file to 0644. The adopter meets that as a Permission denied when init runs the payload artifact to place it. Both readers of the target roster move the binary over that transport.
The mode is restored once, in the packer, and never in a workflow. A per-workflow chmod puts the payload’s shape in two places. It greens one transport while the other keeps shipping a binary the adopter cannot run, spending the signal that the defect exists. The packer is the one seam both transports reach. Restoring a mode is not a content write, so the sidecar the build leg emitted still describes the bytes the packer copied. The mode is set absolutely rather than added, so who packs does not decide what an adopter receives.
The target roster is the surface that asserts platform support. It holds one Rust target triple per live line in the file GATE_SDK_NATIVE_TARGETS_FILE names (§Layout and configuration), with one owner and three readers. The publish workflow’s roster job derives its build matrix from it. The --pack-installer arm packs one artifact directory per line and copies the roster verbatim into the payload as its one publication. The installer reads that payload copy to select the host’s artifact.
The packer takes each target’s artifact name by discovery, the bootstrap’s own select_artifact rule: the directory holds exactly one regular file not named *.sha256, beside its sidecar, and zero or several is a refusal naming the directory. A platform list hand-kept in the workflow would let the build’s supported set and the installer’s drift apart silently. A target in the roster that no build leg produced fails the release.
The installer needs the roster, rather than inferring support from a directory’s presence, to separate two cases that otherwise look identical. This platform was never committed to is a refusal with no adopter action. This platform was committed to and the artifact is missing is a broken payload, also a refusal, with a remedy of its own. A payload assembled with no artifacts carries no artifact/ directory and no roster copy, so it reads as the first case.
A roster line is a support commitment, so it is bounded twice. It may not exceed what the project’s own install documentation already states. A platform’s OS floor is a property of the built artifact rather than of the build image, so where the consumer’s declaration states one, the shared build body measures each artifact against it and refuses a difference. Which floor a platform states, and where, is the consumer’s. A target joins only when a green run has produced and exercised its artifact, not when a platform is reasoned about or a provider offers a runner for it. A build leg written and never run discharges nothing.
The first bound wants a surface a reader can compare against. Held by prose alone, it is discharged by whoever remembers to open the install page. A consumer that wants it mechanized declares its supported platforms on the page that already states them: one marker block per page, each entry a target triple and a join state. The state is joined for a triple the roster carries, and held with a named precondition for one it does not. A second file asserting platform support would put the commitment in two places. The block’s grammar and holder are the consumer’s own, and which platforms it declares is the project’s own commitment.
A declared block makes the bound checkable. A consumer gate such as check-install-platforms reds on a roster line the block does not declare joined, and on the converse, so the bound is discharged at the commit that would break it. A consumer without such a gate still holds the bound, by discipline: it is a rule about the roster, not about any gate.
Removing a blocker is not granting a permission. Work that makes a target possible, such as the crate compiling for it, an installer host-map answering for it or an artifact name deriving correctly for it, makes the target eligible to be measured and measures nothing. The target still joins only on a run that produced and exercised its artifact. An iteration named unblock invites the misreading, and a roster widened on it is widened on a plan. x86_64-pc-windows-msvc compiled long before it joined, and it joined on the run that produced and exercised its artifact.
Widening is cheap on the publish path, and not free even there. The build matrix is roster-derived, so a new platform is one roster line plus one runner mapping. A roster-derived matrix widens the set of hosts the build body runs on, and the body only runs unchanged on a host whose image meets the declared floor. The first platform whose image does not costs a bootstrap step on the publish path, reachable only from a tag. So the release leg and the release-shaped producer the join bound asks for share one build body and one bootstrap. With two copies, the one nothing exercises until a tag is the one that drifts. On a Windows leg the bootstrap starts before the checkout. Git for Windows sets core.autocrlf=true, so a tree that pins no line endings checks out CRLF, and bash folds each carriage return into the last word of a build-body line. Both legs therefore turn it off first.
Widening is not free for the consumer smoke either. A smoke that builds its artifact on its own host cannot satisfy a roster naming a platform that host is not. It is steered at a narrowed roster through GATE_SDK_NATIVE_TARGETS_FILE (§Layout and configuration), or would need a cross-compiling build. installer/SPEC.md §The consumer smoke owns that re-entry: the steering is built and the cross-build is refused, the smoke deriving a one-line host roster unless its caller has set that knob. A cross-built artifact is one no run has executed, the join bound arriving one layer out.
A join needs a producer the smoke is not. The stand-in clause below exempts a host-built smoke from the never-from-a-working-tree rule; it does not make that smoke’s green the produced and exercised the bound wants. A join is discharged by a pair: a release-shaped producer, whose build command, sidecar format and directory layout are the release path’s own, and a platform smoke that consumed that producer’s upload (installer/SPEC.md §The consumer smoke’s artifact hand-off) and reached its artifact-present branch. Neither half alone is the predicate. Which jobs and log line spell the pair is the consumer’s CI, stated beside its roster.
One payload carries every declared target, so the packed artifact name is derived per roster line from that line’s own target: gate_exe_suffix "$target" appended to the stripped binary basename, inside the roster loop (§lib/gate.sh). A single host-derived name is correct only while every roster line shares the host’s platform class, and would break on the commit widening the roster.
pack_tracked is fail-closed on content it cannot vendor. The tracked-set copy is git archive <commit> -- <root> | tar -x, so the packed kit is only as portable as tar’s ability to reproduce it. A tracked symlink fails: on a native Windows host tar refuses a dangling link, whose kind it cannot pick from an absent target. The pipeline’s status is tar’s, so the run fails after writing a partial kit. The helper therefore pre-flights git ls-files -s for mode 120000 over the root it is about to pack. It exits 2 before writing anything, naming each offending path, the platform class it breaks and the remedy.
That enforcement is exact. Every kit root and installer/ go through this one helper, on every path that assembles a payload, the release pack included, with no corpus knob and no roster. The same helper applies the payload’s withheld shape, to the kit roots alone: installer/ is packed whole, its non-shipping content decided by the package roster instead. The symlink pre-flight reads the same pathspec the archive does, so the fail-closed guarantee covers precisely what is packed.
The withheld shape is one value rather than a roster. GATE_SDK_PAYLOAD_WITHHOLD (§Layout and configuration) holds whitespace-separated root-relative members. Each contributes :(exclude)<root>/<member> to the pathspec the archive and the pre-flight share. A member naming a directory on disk also contributes :(exclude)<root>/<member>/*, since excluding a directory entry excludes no file under it. One shape reaching every root keeps the shipped set derived from the governed set, where a roster naming kits would let a kit fall out of the withholding by being forgotten.
The exclusion rides git archive’s pathspec rather than tar --exclude. The pipeline’s fragile half is tar’s reproduction of what it is handed, so the half deciding what is handed over is the one to narrow, and a pathspec needs nothing of tar.
The pre-flight’s verdict is monotone in the packed set, so narrowing it costs nothing. The verdict is refuse iff the packed set contains a 120000 entry. Narrowing can only remove refusals, and every refusal it removes names a path outside the packed set. A wide pre-flight would refuse a pack over a symlink inside a withheld smoke/ tree, a refusal satisfiable only by deleting content the payload never carries.
Neither withheld member is load-bearing for a consumer. A SPEC’s close-surface: declarations are the one exception, and the packer carries them out of it (above), so withholding the SPEC still withholds nothing a consumer’s gate reads. canon-kit’s finders prune a vendored kit root by default, opt-out only through CANON_KIT_SCAN_KIT_ROOTS, so a pointer inside a vendored kit root is never scanned (canon-kit/SPEC.md §check-spec-pointer). A kit’s smoke/install.sh is the copy-vendoring path’s installer, which init replaces. init derives a kit’s consumer config from templates/*-config.sh and templates/*-config.knobs, and its queue seed from templates/TASK-QUEUE.md, reaching smoke/ for nothing. A kit’s SPEC is also the surface most likely to carry its publisher’s own rule content, and withholding it keeps publication from riding on the completeness of a sweep.
The prune covers pointers inside a kit root and nothing else. A pointer into a kit root from a file outside one is not pruned, and init seeds such pointers into a consumer’s tree. The # contract: headers on the workflow directory’s tracked members (§The workflow directory) name the kit SPEC ruling each file’s format. init writes two of them, and the gap, survey and always-loaded arms write more on first use. Those pointers are correct, since the document they name is published rather than packed, and check-spec-pointer reports such a target as withheld rather than dangling (canon-kit/SPEC.md §check-spec-pointer).
Refused: a generated extract carrying only the sections shipped pointers cite. It buys offline reading for a reader the publication already serves, at the standing cost of a projection and a freshness gate. It also leaves the publisher’s prose in the payload for exactly the sections most likely to carry it. The declaration carry is not that extract: its reader is a gate no publication serves, it is written per pack and tracked nowhere, and it holds directive lines, never the SPEC’s prose.
Withholding smoke/ costs the derived kit-root set, and the repair is a declaration. The derived resolver admits a sibling directory as a kit root by testing for checks/ or smoke/ on disk (§Layout and configuration), and not every kit ships a checks/. In a vendored tree those roots would drop out silently, contributing no gate to a battery that stays green. No disk marker repairs it. README.md is the only member every kit still ships, and names nothing in particular at a consumer’s root; templates/ is a directory a consumer’s own root commonly holds.
Inference belongs to the repository that owns the kits, where the markers are present. A tree that received the kits has a better source: the set is a fact the payload carries, and GATE_SDK_KIT_DIRS already overrides the derived set. The installer writes it (installer/SPEC.md §What init seeds), so the declaration mints no name and adds no mechanism.
The published resolution. Where GATE_SDK_SPEC_BASE_URL is set, a pointer <dir>/SPEC.md §<heading> resolves to <base>/<dir>/SPEC#<anchor>, the anchor being the heading’s auto-generated id. The packer writes the resolved base into the payload stamp beside the version and the commit, so init can write it into the consumer’s knob seam (installer/SPEC.md §The manifest). The pointer grammar is unchanged: a directive’s target is a repo-relative path with an optional §<heading> fragment, so a URL in a pointer reads as a missing target. A URL form would mint a second grammar, rewrite every shipped directive line and put the publisher’s host in kit source.
Its honest bound, stated wherever the resolution is: a published mirror serves the current statement of an invariant, not the statement at the commit a tree was vendored from. The manifest’s commit, against the public repository, resolves that exactly. No surface may present the mirror as version-pinned.
A kit README keeps every SPEC link relative in the tree, and the packer resolves each in the payload. A kit’s README.md ships and no SPEC.md does, so a relative link to any kit’s SPEC would dangle in an installed tree. Re-targeting them in the tracked file is refused. The publisher’s host in eleven kit files is the kit literal the <KIT>_<KNOB> convention prevents. The site mirror is also a byte projection of those READMEs, which canon-kit/SPEC.md §The reference-link grammar requires to keep the cross-citation topology one-to-one.
So the resolution happens at pack time and nowhere else. Immediately after a kit’s tracked set is extracted, the packer rewrites the extracted copy of payload/<leaf>/README.md. A markdown link whose target is exactly SPEC.md or ../<leaf>/SPEC.md, either optionally followed by #<fragment>, becomes <base>/<leaf>/SPEC or <base>/<leaf>/SPEC#<fragment>. <leaf> is the linking kit’s own in the first form and the named one in the second. Four bounds make it a contract:
<leaf>is a kit this pack packs. ForSPEC.mdit is the packer’s own directory name for the kit it is packing, and for../<leaf>/SPEC.mdit must be a member of the pack loop’s kit set, resolved once above the loop. A link naming any other directory is not rewritten, nor is a longer path, a./prefix or a link title.- The fragment passes through unchanged. A README link’s fragment is already a markdown heading slug, the value the pointer resolution’s
§<heading>conversion produces, so converting it again would be a second, lossy normalization. - An empty base rewrites nothing, the knob’s documented resolve in the tree meaning. A publisher who sets no base ships the relative link and the sentence beside it, so the rewrite cannot regress an unconfigured packer.
- The tracked README and its docs mirror are never touched: the rewrite reaches only the extracted copy under the assembly scratch, leaving
check-docs-mirror-fresh’s byte-freshness invariant and the mirror topology intact.
The sentence each README carries, naming the withholding and the knob with no host in it, stays. It explains a link that works, and a reader whose publisher set no base still meets the relative link and needs it.
A rewrite no reader observes is a change nobody can catch regressing, so the rewrite and §check-packed-links land together. Nothing else in the battery looks inside a packed README: the sidecar verification digests only the prebuilt artifacts, and the consumer smoke vendors by direct filesystem copy, bypassing the packer.
The artifacts are never produced from a working tree. The pack step takes them from the run artifacts the build legs uploaded and builds none itself, so a locally-built binary can never substitute for a released one. The subject is the payload artifacts, and the rule is no bar on the pack job building a gate binary for its own tooling. The packer copies payload content only out of the artifacts directory its caller names, so a binary built elsewhere in that job has no path in. The packer’s own # spec: line binds this narrowly: the script never builds one.
The consumer smoke’s host-built artifact is a harness stand-in, not this rule relaxing. Having no Release to draw on, it builds from a working tree. It hands the arm a directory it did not produce, as a build leg would (installer/SPEC.md §The consumer smoke).
One payload rather than one per target is ruled on the numbers. The installed footprint is one binary either way, since the installer writes only the matching target, so the difference is download size alone, bounded by the roster. A per-target shape multiplies the publish path, the digest set and the attestation surface by the roster size. The revisit trigger is a measurement: the roster growing past what a CI matrix commits to, or one target’s binary ceasing to be small.
The obligation opacity buys. A consumer who cannot read the gate has only the publisher’s word for it, so the integrity story replaces reading the source. The achievable floor is a published per-target digest verified before the artifact is written. Each build leg writes <binary>.sha256 beside its binary, in sha256sum -c format with a bare filename inside it. That is the shape the release uses for the tarball and the install page documents a reader verifying.
One producer, two publications, and no recomputation anywhere. The bytes the build leg wrote are the bytes the payload carries and the Release attaches, because every later hop moves the file rather than re-deriving it. A second sha256sum on a later job would let a published and an installed digest diverge while both look computed. §check-gate-substrate-parity assertion F holds the rule mechanically, within its reach: the text of each workflow the knob names and the scripts its steps call, one hop deep. A build body factored into a called script stays inside that reach. A body a second hop down, or a hasher reached through an expansion, rests on construction alone.
Per-artifact sidecars are used rather than a combined SHA256SUMS or a JSON manifest, because an attestation’s subject list is {name, digest} pairs. A sidecar maps onto a subject one-to-one, so a build attestation can later land beside these files with no migration and no digest changing. A manifest would mint a schema and a version key for the same information.
A Release carries the tarball, its sidecar, and one archive per roster target, checkwright-gates-<version>-<target>.tar.gz, holding that target’s binary and sidecar under their payload names, because Release assets are flat. Once extracted, the sidecar verifies with sha256sum -c, so no second spelling of the digest’s subject is minted. The digest an installer checks therefore has a source outside the payload it travels in, and a digest shipped only beside its own artifact certifies nothing. A Release also carries the companion archive and its sidecar, which no payload reads. The line below declares the set, and §check-release-assets holds each tag’s set to it before its Release exists. A Release published before the line carries what its own tag’s workflow attached.
What the digest proves, at its honest bound. It is a transfer- and substitution-integrity claim: the artifact in the payload is byte-identical to the one the release built. It is not evidence the build host was uncompromised. What that floor does not provide is a reproducible build: the build attestation on the tarball (installer/SPEC.md §The dependency boundary) names which workflow built it, and a reproducible build stays the outstanding ground. Withholding the sources changes what that attestation is worth: with sources withheld it is the consumer’s only remaining basis for trust.
The bound that puts on prose is exact: a governed surface may say verified against a published digest and may not say reproducible. Those are claims about two different properties, and the weaker is the only one this floor earns.
Building from vendored crate source at install time is void. It adds a Rust toolchain to the dependency floor the prebuilt payload exists to collapse, it is unreachable for a non-technical adopter, and it ships the implementation source the payload withholds.
Both install paths ship, and the second leaves the disclosure boundary untouched. An adopter takes a pre-compiled binary, or builds from source, meaning a developer clones the public repository and builds it there. Source never enters the installer payload, so the second path reopens neither the withholding above nor §check-gate-substrate-parity assertion E, which refuses an implementation source inside a vendoring kit root.
The rule is held by structure, not by discipline. Its violation is structural, a ported gate’s implementation source reaching the vendoring set, which is exactly the kit roots, so §check-gate-substrate-parity assertion E checks it structurally. A NUL-byte scan enforcing no artifact ships is refused: under this ruling shipping a binary is the point.
The extensibility model
How a consumer extends the battery: three shapes, one kept and two refused, each with its ground.
The shell escape hatch stays first-class. A consumer authors their own gates as shell in their own resolve dir. Resolution stays consumer-first and .sh beats .gate within a dir (§lib/gate.sh), so a consumer shadowing a ported kit gate with their own script still wins. Withholding a gate’s predicate does not narrow this: it is a choice made on the consumer’s own machine about the consumer’s own code, and it puts no interpreter into the shipped payload’s dependency floor (§The adopter constraints). Forbidding it would strand every consumer-authored gate to buy nothing.
A declarative check DSL is refused. A DSL is a language carrying none of a language’s tooling, and the gates carrying real judgment are exactly the ones a rule language would not hold. The declarative half a DSL is wanted for already exists as the # graph: manifest, which every reader greps as text with no build (§The # graph: manifest).
Native plugins are refused, and §Consumer payload strengthens the refusal. A dynamically loaded third-party plugin is a stability contract this project would own forever, and an unattested execution path inside the one artifact whose integrity the pre-write digest verification exists to guarantee. Opacity any loaded object can step around is not opacity. The neutral authoring surface a plugin ABI would foreclose is a gate-authoring SDK’s to design, and refusing the ABI keeps it open.
Consumer smoke
The fixture suites prove each gate in isolation on contrived case dirs, and a consumer repo’s battery runs under that consumer’s own config overrides. Two things go untested there: that a fresh consumer reaches green by following the kit READMEs, and that the kit defaults hold on a vendored-kit tree under zero config. The --run-consumer-smoke arm closes that gap with committed evidence.
The harness is run-gates.sh --run-consumer-smoke [--keep] [kit-root...], a non-gate Arm::Run and never a registered gate: it builds a repo and runs the battery repeatedly, so it is pre-commit-unfit by runtime budget. It builds a scratch consumer in a fresh temp dir (git init, seed commit) and vendors each kit root by copy, by default every derived kit root (§Layout and configuration). It runs each kit’s smoke/install.sh, the invoking gate-sdk root first, then argument order, and commits the installed baseline.
It then asserts the full battery is green under zero consumer config, on the positive green token All N gates passed, the defaults-on-a-vendored-tree assertion no fixture suite makes. Next it runs the registration accounting (below) over the union of the vendored kits’ gates. Per kit shipping smoke/violation.sh it fires one crafted violation, re-runs the battery, and asserts a non-zero exit, a FAIL: line naming the expected gate, and a result with that gate’s rule id in the SARIF log the run wrote under GATE_SDK_SARIF_FILE (§run-gates).
It then restores the tree with git reset --hard && git clean -fd before the next kit. A hard reset rather than git checkout unstages a violation that staged its shape, since an index-reading gate like check-gate-tamper sees only the index. It asserts green once more after the last restore. For a run vendoring more than one kit root beyond gate-sdk, it then runs the self-sufficiency phase below.
Exit codes follow the gate convention: 0 all hold, 1 an assertion failed, 2 usage or environment. The success token is CONSUMER-SMOKE: clean (<n> kits installed, <m> violations fired, <r> gates registered, <s> self-declared, <h> hand-declared). --keep retains the temp dir and prints its path, the temp-dir write’s named reclaim path.
Its operands, channels and arm placement. --keep is the one flag. Any other --led word is an unknown option refusal, and so is a kit root that is not a directory or ships no smoke/install.sh; all exit 2. That second refusal names its vendored-tree cause, since the payload withholds smoke/ (§Consumer payload) and so every root of an installed tree meets it. The arm asks whether the kits work when vendored by copy, a publisher’s question for a tree holding the kit sources, and an unexplained refusal would read as a broken install.
Diagnostics go to stderr under a run-consumer-smoke: prefix, the arm’s name without its dashes. The verdict lines, and the union pass’s installer output, go to stdout. It is a bare flag rather than an --emit member because its contract is the exit status an emitting arm collapses (§The non-gate arm). It is a table member because it reads GATE_SDK_KIT_DIRS and GATE_SDK_NATIVE_BIN. bin/run-gates.sh passes it through unedited, and its operands are documented in the front-end’s --help text alone.
This harness does not run the installer, and that bounds what it proves. It vendors kit roots by copy, so no payload and no digest are in play. Its scratch consumer registers whatever each smoke/install.sh registers, under zero consumer config. The installer’s own smoke (installer/SPEC.md §The consumer smoke) proves the install path, packing a payload and running init against it. This harness answers do the kits work when vendored; that one answers does the installer deliver them.
Each kit is also run alone, because the union run cannot see an install that leans on a sibling. A kit’s smoke/install.sh may assume gate-sdk and nothing else, and a tree vendoring every kit satisfies every cross-dependency by accident. So when the run vendors more than gate-sdk and one other root, it repeats the pass for gate-sdk alone and for gate-sdk plus each root. Each repeat builds a fresh scratch tree, torn down whatever --keep says. The phase prints CONSUMER-SMOKE: alone — <n> kit root(s) green alone in <ms>ms before the clean line.
A repeat’s installer output goes to stderr, and a green repeat prints no verdict lines. A red repeat is exit 1 even when its cause is an installer’s exit: the union run already showed the environment works, so a failure alone is the kit’s. The red names the kit, prints that repeat’s own failure lines, and ends with a help line naming the narrowed --keep run that reproduces it. A single-root run skips the phase, being its own repeat, so the narrowed run stays the cheap check and the default run is validate’s.
The repeats also put the harness into subset vendoring on every run: one kit vendored while the shared binary carries every ported kit’s subcommands. check-gate-substrate-parity reaches that subset at commit time (§check-gate-substrate-parity), and this phase reaches it at validate.
The harness places the gate binary. A vendored member that dispatches must be able to run, so the scratch consumer receives an already-built artifact out of a checkout the caller names (the builder’s place_binary, below). A kit root vendoring a .gate descriptor is the ordinary case, and a kit’s smoke/install.sh may register a ported member. gate-sdk’s does: it installs its templates/gates-workflow.yml verbatim, so the four check-action-* members earn scratch-battery slots on its leg, and they lint site-kit’s templates/site-health.yml wherever site-kit is vendored.
The caller names whose artifact it is. The checkout whose native/ was built is the placement’s host argument. A shared builder deciding from its own location whose artifact a caller receives would hand upgrade-smoke the invoking tree’s binary for both refs, running FROM’s shell against TO’s binary (§upgrade-smoke). --run-consumer-smoke and context-kit’s AGENTS.md smoke pass the invoking repo. Whether a binary is wanted at all is the builder’s gate_descriptors, so a caller that must produce one asks the same question a step earlier.
Three constraints keep the placement from becoming a second install path. It is a copy, never a build: the artifact comes from GATE_SDK_NATIVE_BIN in the named checkout, which for the in-repo callers must be current (§check-gate-binary-fresh), so this harness acquires no toolchain. upgrade-smoke builds one binary per ref before calling, a requirement it states itself (§upgrade-smoke).
It lands at the kit default path and writes no consumer config, unlike init, which places the artifact in the gates dir and points the knob at it. And it is derived: the vendored kit roots are scanned for .gate descriptors, so a kit set with no ported gate gets no binary. Descriptors present with no built artifact are an environment failure (exit 2), not a red battery. The placed artifact is gitignored in the scratch tree, because the violation phase’s git clean -fd spares ignored paths and would otherwise delete it between kits.
check-gate-substrate-parity is in the scratch battery: it needs no consumer config, and a vendored tree is where a descriptor with nothing behind it would land. check-gate-binary-fresh and check-crate-arms, whose subject is the crate the binary was built from, ship in no kit: a consumer tree does not verify a build it did not make (§check-gate-binary-fresh). So no leg owes them a registration or a declaration.
§check-gate-substrate-parity assertion I sees every kit vendored from the first leg on. Its kit-vendored scope is a directory scan, and the harness copies every kit’s tree before any smoke/install.sh registers a gate. So gate-sdk’s leg, which runs first with only its own lines in the registry, scopes itself with GATE_SDK_KIT_DIRS for its own process. Every gate-sdk-owned member that leg omits is declared # unregistered: there, and no later leg registers one. A registration split across legs could not be green in the owning kit’s alone run.
The leg’s graph artifact is the one exception to its narrowing: its last act, after its own hook exercise, regenerates it un-narrowed, so the graph describes every vendored kit root the battery reads. That exercise places the hooks with --install-hooks, runs the placed pre-commit and undoes the opt-in, so the commits the driver makes later run no hook. A hand-repro running the scratch consumer’s placed hook outside smoke/install.sh carries that export, because the hook arm reads the kit roots when it selects. walk::roots_at derives kit roots from every sibling holding a checks/ or a smoke/. Without the narrowing, assertion I scopes to every copied kit and buries the one real finding under phantom ones.
A ported gate a smoke/install.sh does not register vendors its descriptor with the kit root, and nothing dispatches to it. The binary meta-gate the scratch battery carries reads the live registry and reports clean. A member a smoke/install.sh does register while no binary exists still reds, through gate_command’s harness error (§lib/gate.sh). That path is why the placement exists rather than a relaxation.
The scratch-consumer build is the in-crate builder native/src/emit/csmoke.rs: vendor_and_install, which returns the scratch directory and the installed-kit count, and place_binary, which it calls. A second harness needing the same green baseline shares the mechanics in process rather than copying them. The placement lives in the builder because every caller builds a tree holding the vendored kits’ descriptors. The caller owns its cleanup and every assertion after the baseline commit.
context-kit’s AGENTS.md smoke is the second caller: it converts the baseline consumer to a nondefault agent file (AGENTS.md) and asserts the agent-file knobs carry it, which --run-consumer-smoke cannot, fixing the kit defaults under zero config (context-kit/SPEC.md §Testing). The upgrade suite is the third caller, diverging from the same FROM baseline into the two-phase upgrade proof (§upgrade-smoke).
The builder’s contract. gate_descriptors(roots) counts the .gate descriptors directly under each root’s checks/. place_binary(consumer, host, roots) does nothing when that count is zero, and otherwise copies <host>/<GATE_SDK_NATIVE_BIN> to <consumer>/<GATE_SDK_NATIVE_BIN>, creating the parent. Its PlaceError is NoHost (the caller named no checkout), ArtifactUnusable (the artifact is absent or not executable, carrying the build-native.sh help line) or Io (the binary path did not resolve or the copy failed). Each is an environment failure, rendered in the caller’s own verdict grammar. vendor_and_install(host, roots, base, out):
- creates
consumer-smoke.XXXXXXunderbase, which is the caller’s (TMPDIR, else/tmp, for the harness and the AGENTS.md smoke); - runs
git init, writes a.gitignoreof.tmp/plus the binary path, and makes the seed commit; - copies each root by name;
- calls
place_binary; - runs each kit’s
smoke/install.shwithbash, in root order, with cwd at the consumer andGATE_SDK_ROOTandSMOKE_KIT_ROOTexported; - commits the installed baseline with
--no-verify.
A step that fails, an installer’s non-zero exit among them, is an Err naming its cause. The Err carries the scratch directory once one exists, so a caller’s teardown and --keep see a tree whose build failed. out routes the installers’ output: --run-consumer-smoke keeps it on stdout, while --upgrade-smoke and --agents-md-smoke route it to stderr, where each arm’s stdout carries its verdict. The counts are populated for every caller, and only --run-consumer-smoke’s clean line reads the installed count.
place_artifact(from, to) is the placement itself — the parent directories, then a mode-preserving copy — which place_binary calls and which a caller placing a binary it did not take from a checkout calls directly.
The tracked-tree scratch sits beside the builder, for a caller needing the tree it stands in rather than a consumer built from kit roots. tracked_scratch(base, label) lists the index’s tracked set under the working directory and creates <label>.<pid>.<n> under base, n counting past a name already taken. It copies each tracked path’s working-tree content in with std::fs, a symlink as a symlink, skipping a tracked path absent from the working tree. It then runs git init and makes the seed commit. It returns the removal guard, so the directory is gone on every exit path, and a failed listing, copy or git init is an Err. canon-kit/SPEC.md §check-fence-run is its first caller.
tracked_history_scratch(source, base, label) is the variant for a caller whose subject reads git history or the origin URL, as the trajectory and self-repo-link emitters do. In place of git init it makes a shared, no-checkout clone of source’s toplevel and copies the source’s origin URL onto it, or removes origin where the source has none. It lays the same working-tree content over the clone and commits the seed on top, so history up to the source’s HEAD is present and the seed carries any uncommitted difference. §projection-witness is its caller.
The adoption walkthrough is this section’s other member. It is the contributor’s walkthrough: it vendors every kit root the source tree resolves and runs the kits’ shell recipes, while the adopter’s is installer/SPEC.md §demo. --run-demo narrates the arc a new adopter walks: vendor the kits and run each installer, commit clean and watch the battery pass, craft one violation and watch the battery name the gate that caught it, drop the change and watch green return. Exit 0 asserts every act. It is registered as the evidence-kit demo validate suite, so a bit-rotted walkthrough is a red validate.
It builds its own scratch consumer instead of calling vendor_and_install, because the vendoring is part of what it narrates, and calls the builder’s place_binary alone, in process. Its scratch base is DEMO_TMP_DIR, falling back to TMPDIR and then /tmp, the builder’s own base, so the walkthrough’s tree and the harness’s stay comparable. Folding it onto GATE_SDK_TMP_DIR (repo-relative .tmp, absolutized at the invoker’s root) would break that parity silently.
DEMO_TMP_DIR is not a kit knob: it carries no <KIT>_ prefix, is defined in no kit library, and is read straight off the process environment, as a caller overriding it on the command line expects. It stays: an override a consumer may set on the command line is an extension point however thinly used, and this paragraph is its owner.
The member is an Arm::Run, because its status is the whole verdict the demo suite reads. The suite declares no EVIDENCE_KIT_PARSER_demo and falls to the EVIDENCE_KIT_PARSER default (evidence-kit/SPEC.md §Layout and configuration owns its value). An emitting arm would fold a broken walkthrough into the dispatch-failure band, reporting a real finding as an environment error. It is a table member on --agents-md-smoke’s test: it resolves GATE_SDK_KIT_DIRS (which kits to vendor) and GATE_SDK_NATIVE_BIN (the placement, and the scratch .gitignore line sparing it).
No front-end edit is owed and none is legal. bin/run-gates.sh passes a leading -* token straight to the crate’s own parser, so the arm is reachable once the table row exists, and a case in the stub would be a second spelling of the arm roster.
Its exit grammar is this section’s 0/1/2. The native binary failing to place in the scratch consumer, and a vendored kit’s installer erroring, are statements that the harness could not be stood up, so both are 2. 1 is left for the five genuine assertions: the battery not green after install, the violation printing no expected-gate name, the violation not turning the battery red, the wrong gate catching it, and green not returning after the fix. Exit 1 carries the DEMO: FAIL — <cause> line on stdout naming which act broke; exit 2 carries DEMO: FAIL(env) — <cause> on stderr.
An operand is a refusal: the arm prints usage: --run-demo and exits 2. Once 2 means the harness could not be stood up, a swallowed operand would let a caller believe it selected a mode and read a verdict about a different run. --agents-md-smoke accepts --keep, and this member accepts nothing, since its scratch is narrated and torn down as part of the arc.
The spawned battery stays spawned, in this arm and in every consumer-smoke caller. The subject under test is the vendored consumer’s own front-end, so calling this binary’s registry in process would run the host’s gates against the scratch tree, the pairing defect the placement’s own record names.
The walkthrough quotes the reddened gate’s block back, through the remedy. The excerpt runs from that gate’s ===== <name> ===== header to its own FAIL: <name> line and the invariant line beneath it. A reader thus sees the finding, the help line and the declared invariant. §run-gates prints that invariant line after the verdict, so an excerpt stopping at the verdict would silently drop the remedy it exists to show. The excerpt and the runner share one spelling of that line’s prefix. A crate unit test pins the shape: the quoted block, a verdict with no invariant line beneath it, and a later gate’s block never joining the quote.
The smoke/ per-kit contract. Every vendored kit ships a smoke/ directory, which joins fixtures, README and SPEC in the kit-landing checklist; a kit root lacking smoke/ is an environment error (exit 2). Every smoke/ script that mutates the invoking tree, install.sh and violation.sh both, opens with the entry-point guard : "${SMOKE_KIT_ROOT:?run via --run-consumer-smoke on the gate binary}" before its first mutating command. A bare invocation outside the harness then refuses instead of writing into the caller’s repo, and check-smoke-entry-guard (§check-smoke-entry-guard) holds the guard across the roster. The message names the arm and no door. A resolved binary path in a kit’s script would publish one consumer’s install location, and the front end is the adopter door the kits retired. It carries no quote character, since bash reads an apostrophe inside that double-quoted expansion as opening a quote.
Every script in that roster stays on the shell substrate permanently and declares # no-port:, per The port disposition below. The README item of that checklist carries the register-the-gates block in <!-- gate-roster:begin --> / <!-- gate-roster:end --> markers, held in name-set parity with the kit’s shipped checks/ by check-readme-roster (§check-readme-roster). Every gate in that roster carries its # install: declaration, held by check-install-disposition (§The install disposition). A new kit’s bespoke gate-tests/*.test.sh sources lib/test-hermetic.sh (§lib/test-hermetic.sh), enforced by check-test-hermetic. A new gate MAY gain a pass row in the held-constant validate baseline, whose absence evidence-kit/SPEC.md §Baseline manifest tolerates at a classification cost only.
-
smoke/install.sh(required) — run with cwd = scratch-consumer root and envSMOKE_KIT_ROOT= the vendored copy of the installing kit. It is the executable form of that kit’s README install steps: register its gates inscripts/gates.list, establish the minimal governed surface its gates need to be green, and regenerate the graph artifact whenever it changes the registry. It regenerates under the environment the battery reads, since the artifact bakes a knob’s spelling, so a knob it overrides for its own later dispatches is set only after the regeneration.Copying the kit’s own templates in is not part of that contract: a smoke script installs a template only where a registered gate reads one, as gate-sdk’s
templates/gates-workflow.ymland site-kit’stemplates/site-health.ymlare. Which gates it registers is not the author’s discretion: The registration accounting below rules on every omission. The installer’s own narrower subset is derived from the same per-gate declarations (§The install disposition), and this roster is its superset, held so bycheck-install-dispositionassertion B. Its registry heredoc is held to the kit’s owned subcommands, each registered or declared# unregistered:inside the heredoc, bycheck-gate-substrate-parityassertion J.It may assume gate-sdk is installed (it runs first) and nothing else. It establishes every surface its registered gates read, never one only a sibling kit’s install writes, and leaves the scratch tree green with only gate-sdk beside it, as the self-sufficiency phase checks on every default run. A non-zero exit aborts the harness with exit 2, a broken installer being an environment failure rather than a gate finding, except in a self-sufficiency repeat, where it is exit 1 on that phase’s ground.
-
smoke/violation.sh(optional) — same cwd/env contract and entry-point guard. It mutates the scratch tree to introduce exactly one violation the harness restore (git reset --hard+git clean) reverses: edit a tracked file, add an untracked one, or stage a shape. It prints the expected gate name as its first stdout line, which the red-phase assertion reads. A kit without one contributes install coverage only, and the harness prints a notice per such kit. The file is rightly absent only where no battery-reddening violation is craftable, as for a kit that registers no gates.
The registration accounting. A gate earns a scratch-battery slot when it reads a surface the install writes, which keeps the smoke proving a vendored kit installs and runs rather than drifting toward a second copy of the host battery. A gate whose subject no install writes would pass vacuously or fail closed on an absent projection, and neither is coverage. The rule binds every kit and is evaluated, not transcribed. On every run the harness accounts for each shipped-but-unregistered gate, so an omission is derived-justified or declared, never silent, since the harness cannot redden on a gate nothing runs.
The accounting is one pass in --run-consumer-smoke, between the green-battery assertion and the violation phase, over the union: every vendored kit’s checks/ basenames against the scratch consumer’s scripts/gates.list. The tree is already built and green, so the pass adds no install and no second tree. Each unregistered gate runs in the scratch consumer, and its exit code is read against §Output contract’s three meanings, the only authority here. check-gate-fail-closed is a static lint over awk/jq captures and asserts nothing about a gate’s behaviour on an absent surface.
The probe covers both declaration spellings, check-*.sh and check-*.gate. Each probe resolves the gate’s declaration in one checks dir, the vendored kit’s in the scratch consumer or the kit root’s own in the invoking repo, and dispatches it. A .sh declaration runs by its path, and a .gate one runs as <tree>/<GATE_SDK_NATIVE_BIN> <gate>. Both run with cwd at the probed tree and GATE_SDK_ROOT at that tree’s gate-sdk, both streams discarded. A gate that dir declares under neither spelling is exit 2, the table’s “could not run” reading.
A ported gate earns or forfeits a scratch-battery slot on the same terms as any other. A shell-only union would let a ported gate leave the pass unprobed and unreported, the silent departure §Meta-gate conservation for the binary substrate prevents, through a harness assertion C cannot reach because it is not a registry member.
A probe that cannot dispatch is a harness failure, never a verdict. A .gate declaration whose probed tree holds no executable binary at that path ends the run at exit 2, naming the tree and the path. Read as a probe exit 2 instead, it would combine with the scratch leg into an unaccounted row blaming the gate on the invoking-repo leg. On the scratch leg it would fake the surface-absent row and grant the permanent exemption the corroborating probe exists to withhold.
Both legs spawn, and neither runs in process. A gate reads its knob files relative to its working directory, once per process, so an in-process call would read the invoking tree’s knob state while pointed at the scratch tree. The invoking-repo leg would pair correctly in process and spawns anyway, so there is one probe shape rather than a leg-specific shortcut. Nor can the probe delegate to run-gates.sh --only, which resolves against the registry, while the accounting’s subject is gates not in it.
One reading is not enough, and the second probe is not redundant. Exit 2 is usage or environment failure generally: a missing binary, a malformed config, a non-repo cwd, an empty roster, or an absent subject surface. Only the last is a justified omission. The exit-2 verdict grants a permanent exemption owing no written reason, the one verdict nobody re-examines, so granting it off that ambiguous signal would be a false-exemption channel. So it is corroborated by re-running the same gate in the invoking repo, where the kit’s own surfaces exist. An absent-surface gate exits 2 only in the scratch consumer, and a broken one exits 2 in both.
| probe (scratch consumer) | probe (invoking repo) | verdict |
|---|---|---|
| exit 2 | exit 0 or 1 | Justified omission, self-declaring: the surface it reads is genuinely absent. No written reason owed, now or ever. |
| exit 2 | exit 2 | Not exempt — broken or environment-dependent, not surface-absent. Declare or red. |
| exit 0 | — | Green here and registering costs nothing: an unexplained omission. Red unless declared. |
| exit 1 | — | Green nowhere: the gate finds real violations and nothing runs it. The hiding shape. Red unless declared. |
Only the exit-2 rows reach the corroborating probe.
A declaration the probe contradicts is reported, not silently exempted. A gate declaring zero-config (§The install disposition) asserts it reads a surface a fresh install writes. A probe finding it surface-absent in the scratch consumer has derived the opposite, and the accounting names that contradiction rather than granting the ordinary self-declared exemption. That keeps a wrong declaration from being as invisible as a missing one, which is check-install-disposition assertion A’s. The verdict is informational, because the scratch consumer is not the tree init makes; it must never be unremarked.
Probe first, reasons second. The exemption is derived, recomputed from the tree every run, so it cannot go stale, be forgotten or be copied wrong. The derivation justifies only a minority of unregistered gates, but that minority is the permanent exemption nobody re-examines, taken out of human hands. The written reason is the valve for the rest, never the mechanism. An implementation that collects reasons for every unregistered gate and consults the probe afterwards has inverted this contract, however green it runs.
The probe set is self-limiting: a gate that probes green gets registered and leaves the probe set for the battery, so the hand-declared count stays small. The accounting line prints registered, self-declared, hand-declared and unaccounted figures on every run, and is their only authority.
Validate holds the accounting. check-install-disposition assertion B holds registration for zero-config gates only, a static read. The full accounting is a probe, running each unregistered gate against a scratch consumer, and no precommit member can hold a probe result without paying the whole smoke at every commit. So the session that adds a kit-shipped gate, or changes one’s # install: disposition, runs --run-consumer-smoke before the commit that lands it and repairs any unaccounted line there. A landing session that skips it is caught at validate, and the unaccounted line names its own remedy (below).
Not derived from the README roster. The kit’s <!-- gate-roster:begin --> block already carries full checks/ parity (check-readme-roster) and per-gate annotations naming each gate’s subject surface. Deriving smoke/install.sh’s registration from it is declined on a boundary, not on merit: it would turn smoke/install.sh from an executable install recipe into a derivation over a doc. The kit-owned fact is the per-gate disposition (§The install disposition), and the installer’s starting roster is derived from it. This script stays the executable recipe for the smoke’s own richer tree. The README block carries no disposition either: an adopter pastes it, and a machine-read annotation there would be a second grammar.
The declaration valve. Where a kit author judges an exit-0 or exit-1 omission legitimate, a vacuous pass that is not real coverage being the honest case, that kit’s smoke/install.sh carries # smoke-unregistered: <gate-name> — <reason> beside its registration block. It is kit-local, where a reader of registration already is, and readable off the vendored copy the harness holds. Its sibling on a second roster is # unregistered: in the consumer’s own gates.list (§Layout and configuration), read by §check-gate-substrate-parity assertions I and J, and the two keep one dialect. Both fields are read: the name to match, and the reason into the harness’s report.
Stale valves are findings, all caught at the same transition. They are a declaration naming a gate that is registered, one naming a gate that kit does not ship, and one naming a gate the probe already exempts. That last enforces the anti-inversion clause, since a reason written where none is owed is the start of a maintained roster. A declaration line missing either field is likewise a finding.
The counts are permanent, and the cost is reported rather than cached. The clean line carries three counts: gates registered, self-declared (the corroborated exit-2 set) and hand-declared. The hand-declared number says whether the derivation is decaying into a maintained roster, so it prints every run. The phase’s wall-clock and verdict split ride a second line, CONSUMER-SMOKE: accounting — <n> unregistered gate(s) probed in <ms>ms (<s> self-declared, <h> hand-declared, <u> unaccounted), printed on red runs as well as green. If the wall-clock is material, the sanctioned response is to report it, never to sample and never to cache a verdict across runs.
Never at pre-commit. --run-consumer-smoke is a non-gate arm, so the accounting costs the pre-commit battery nothing. The two sibling callers of vendor_and_install do not run it: they build the same baseline for a different assertion.
CI tool provisioning. A gate whose oracle shells out beyond the checkout+bash baseline, such as a renderer or a language runtime, is provisioned in the CI backstop (gates.yml), not just locally. A green pre-commit on a dev box carrying the tool is not a green CI. The provisioning step rides the consumer’s filled workflow instance, never the generic template (§templates/gates-workflow.yml), since the dependency joins the toolchain only for a consumer registering the gate.
Starter-template conformance. A kit that ships a starter template (in templates/) ships it battery-clean. Copied verbatim into a combined-kit consumer, the template must pass the full battery, every vendored kit’s gates, not only the shipping kit’s. Where a kit ships such a template, its smoke/install.sh installs it verbatim (no fill-in) as the governed surface, so a template regression against any kit reddens the harness. That holds only as far as the scratch battery’s coverage, which The registration accounting above provides. A template that composes with a downstream kit’s contract carries that kit’s inert scaffold: queue-kit’s starter queue ships lifecycle-kit’s iteration header, and a single-kit adopter deletes it.
Producers and consumers: smoke/ content is produced by the kit author at kit-landing time and consumed by the harness’s install and violation phases. The expected-gate name (violation.sh line 1) is read by the red-phase assertion. SMOKE_KIT_ROOT is produced by the harness per invocation and read by the scripts to copy from their own kit. The harness verdict is consumed by the validate-stage ritual, which gates on the success token, and is the natural CI entry point.
The accounting phase runs on every --run-consumer-smoke invocation with no enabling knob, since the harness is a consumer’s validate suite. It reads the vendored kits’ checks/ directories, the scratch scripts/gates.list and the vendored smoke/install.sh declarations, all present once vendor_and_install has returned. Its findings join the same harness verdict, and its three counts are read on the clean line at the validate transition. The gate exit contract (§Output contract) gains a second reader beside run-gates.sh, which disambiguates exit 2 and puts no new obligation on any gate.
The port disposition
Every smoke/ install and violation recipe carries # no-port:. The ruling reaches every kit gate_kit_roots vendors, on three measured legs, none of them size; the legs are numbered 2 to 4 because every member’s header cites those numbers. The declaration sits on each kit root’s smoke/install.sh and smoke/violation.sh. §port-blockers’ --tree arm reclassifies each one owed → no-port, so the completion predicate over that arm’s owed count subtracts them.
It is not a size judgment. The criteria relaxation is closed at §The port-candidate criteria as an ordering signal, never an eligibility screen, so what decides this class is what its members are. The cut resolves to declarations and writes no Rust, and it stands on that: declarations are a legitimate cut when they are structural.
The class is closed and enumerated, and the harness is not in it. The harness is the in-crate --run-consumer-smoke arm and its builder; what the arm keeps is The registration accounting’s to state. The adoption walkthrough ported as --run-demo, and context-kit’s AGENTS.md smoke as --agents-md-smoke. The live owed count is --emit port-blockers --tree’s.
Leg 2 — a smoke/install.sh is an executable recipe by stated contract, and porting one is non-monotone for a live reader. Not derived from the README roster above keeps the recipe from becoming a derivation over a doc. A crate table would go further still. An oracle also reads the script as text: §check-install-disposition assertion B reds on a kit shipping a zero-config gate and no smoke/install.sh, and its registration arm greps the script body for each gate name. Deleting or de-textualizing these scripts adds violations.
Leg 3 — the class costs an adopter no interpreter dependency, the constraint the port serves (§The adopter constraints). A smoke/ directory is kit-authored content and vendors to an adopter with its kit. It is executed by no adopter path: the entry-point guard refuses a bare invocation, and the only callers are this repo’s own validate suites. It ships inert.
Leg 4 — the envelope, measured class-wide. Roughly three quarters of the in-contract smoke/install.sh line count sits in the four files that drive and assert on their own kit’s bin/ tools: lifecycle-kit’s, gate-sdk’s, doctrine-kit’s and evidence-kit’s. Those tools are owned by seven other stated contracts, each still owed, so porting those four moves a behavioural envelope this contract does not own. The rest are pure recipe and fall to legs 2 and 3. A kit’s smoke/ is its only end-to-end behavioural oracle, so the class is not moved casually whatever a size arm says.
The ruling reaches by ground, not by scope. The smoke/ recipes under context-kit/SPEC.md §Testing, delegation-kit/SPEC.md §Testing and drift-kit/SPEC.md §Testing take the same disposition because legs 2 and 3 hold of them. Each declares in its own header, and its own SPEC section says so. context-kit’s AGENTS.md smoke is not a member: it is a validate-suite driver, owed on that ground until it ported (context-kit/SPEC.md §Testing).
The honest limit. The smoke corpus is a cleartext catalogue of what reddens each gate, and it vendors to every adopter. That is the analysis surface §Consumer payload’s opacity ruling wants raised, and the one live argument for porting this class. This disposition leaves it standing: answering it is a §Consumer payload question, not a §Consumer smoke one.
A standing ruling names this corpus, and it is reconciled here rather than reversed. It reads kit smoke/ suites and kit-resident test runners as riding the installer payload into adopter trees. They are kit mechanism on the claim like any owed file, and the residue shipping to no adopter takes a per-file disposition when reached. It also refuses “a contributor-side # no-port: class” over that corpus. Both objections are answered on the text:
- The “when reached” clause is not scoped to the three named no-adopter examples. The ruling’s construing sentence restates over “each file [the carve-out] covers”, and that carve-out covers contributor-side tooling and the test harness as a whole, where kit
smoke/suites sit. Its “case-by-case residue rule” is the port’s rule that “surviving shell is residue justified case by case, never a protected category”. Three measured legs, each cited on its own file, apply that rule to a class sharing legs, not the categorical “it is a smoke harness” exemption it refuses. - The refused ground is not this ground. “Ships to no adopter” was measured false for 31 of the 33 disputed files, and leg 3 concedes it: vendoring with the kit is leg 3’s opening clause. Leg 3 rests on executed by no adopter path, and legs 2 and 4 do not use the shipping question. That ruling found no mechanism missing, “only a standing ruling that would make one true, and none does”; these three grounds are that ruling.
The counterweight is real, and it was ruled against. That text’s contrast between kit smoke/ “on the claim like any owed file” and a residue that “takes a per-file disposition” reads, alone, as putting this corpus on the must-port side. A seventeen-file sweep is class-shaped even when every line cites its own ground. A reader meeting that passage alone lands here rather than reopening the question.
What reopens it: legs 2 and 4 dissolve together if §check-install-disposition assertion B stops reading the script as text and the bin/ tools the four envelope files drive are themselves ported. Leg 3 dissolves if any adopter path executes a smoke/ script. # no-port: is the permanent tier, so a class ruling owes its reader what would falsify it.
Per-component contracts
lib/gate.sh
The family’s single sourced library — values and adapters, never gate structure. It gives a gate author:
- the fail-closed guard
fail_closed; - the walk adapters
gate_find,GATE_GREP_EXCLUDESandgate_path_pruned, over the dirsGATE_SDK_PRUNE_DIRSnames plus whateverGATE_SDK_PRUNE_EXTRA_DIRSappends; - the registry helpers
gate_resolve,gate_kit_rootsandgate_check_dirs, which resolve a check consumer-first across kit dirs, the multi-kit resolution path other kits’ gates ride.
The library resolves no knob value of its own beyond the pre-binary accessors below; every other read asks the binary. How each helper derives its result lives in the source, and the invariants a reader needs outlive the refactor that renames one:
-
The prune set is one list, read by all three walk adapters, so a member added to it needs no per-gate edit.
gate_prune_loadreads it once per process on first use, in onegate_knob_valuescall overGATE_SDK_PRUNE_DIRSthenGATE_SDK_PRUNE_EXTRA_DIRS, split on whitespace and expanded by nothing.gate_findandgate_path_prunedcall it themselves, and a gate splicingGATE_GREP_EXCLUDEScalls it first, since an array is filled by a call rather than read. The crate’swalk::prune_dirscomposes the same two knobs in the same order, so both substrates read one set.Its members are directories that are never a consumer’s source: third-party build and dependency output (
target,node_modules), git’s own store (.git), this methodology’s scratch (.tmp), fixture corpora a gate must not judge as tree content (gate-tests), andworktrees. That last is the leaf under which an agent harness materializes a second full copy of the repo. That copy makes the member a kit default rather than consumer config. Every tree-walking gate descends into it, so a live isolated dispatch reddens the battery on the session’s own files, for every consumer alike. The match is on the leaf basename, so neither the parent nor its siblings are taken. Prune the leaf, never the parent: pruning the parent also passes, because the shell and Rust walks collect nothing there, but it silently drops the governed markdown under it that explicit globs read. The same silence is the member’s residual risk: a consumer whose own source lives under a directory namedworktrees,targetor.tmploses coverage with no red, and restates the set without it throughGATE_SDK_PRUNE_DIRS.gate_path_prunedand the crate’s prune matcher match a leaf anywhere in the string they are handed, so a caller hands them a path relative to the walk’s root, never an absolute one. canon-kit’s comment surface (canon-kit/SPEC.md §The shared spec adapters) takes the same caller contract. gate_native_bin_spelledis the one owner of how a door is spelled, asgate_native_binis the one owner of where it is. The knob’s value is a path, and a caller that prints or runs it needs a command token. The two differ by an anchor: a relative value carrying no./,../or root segment is aPATHlookup toshand no command at all to PowerShell. So the accessor returns a relative value./-prefixed and an anchored one unchanged — rooted bygate_path_rooted, or already./or../— which makes the prefix idempotent. One owner means a consumer is never told two spellings of one binary. A caller needing the path rather than the token, such as an-xtest or adirname, readsgate_native_bin.gate_path_rootedis the shell’s one owner of absoluteness,walk::path_root’s predicate: a leading/or\, or an ASCII letter,:, then/or\. It prints nothing and converts nothing. Its body is POSIX becauseinstaller/bin/checkwright.shcarries it byte for byte, the bootstrap being unable to source a payload file before it has located the payload. Its letters are spelled out rather than ranged: whereglobasciirangesis off — bash before 5.0, macOS’s/bin/bashand/bin/shincluded — a bracket range reads by locale collation, and[A-Za-z]matchesé. A crate unit test holds both bodies topath_rootover one corpus, under bash’s default range reading and a locale-collated one, and to each other byte for byte: criterion 6’s machine-held duplication.gate_native_bin_spelledandgate_harness_binask it, andcheck-path-dialect’s shell locality arm reds any other spelling.-
gate_harness_binanswers which binary a harness-integration arm runs, and nothing else asks it. It returnsgate_native_bin_spelled’s answer when that is executable. Failing that, inside a linked worktree whose common dir is<main>/.git, it returns the main checkout’s own resolution of the knob, rooted, when that is executable. Otherwise it returns the local answer unchanged, so every caller’s absent-binary test and diagnostic are untouched. Its caller in the kits is the front end’s fail-open branch, and a consumer’s own script reaching the binary from a linked worktree, a consumer rule command among them, calls it the same way. It is neither a knob accessor nor a second default: the knob’s value and precedence aregate_native_bin’s, read in another tree.A linked worktree is a cwd whose
git rev-parse --git-dirdiffers from its--git-common-dir, each crossed bycd … && pwd -P(§The path-dialect contract). The main checkout is the common dir’s parent, and only when the common dir’s basename is.git, so a bare repository and a--separate-git-dircheckout take no fallback. Resolving in the main checkout’s context honours a pin held only in its gitignored.local.knobsoverlay; a rooted answer is returned unchanged and a relative one is joined onto the main checkout. It spawns git only after the local-xtest fails. The worktree test and the main checkout’s resolution are one private helper,_gate_main_checkout_bin, whichgate_verdict_binshares. Honest limits: the main checkout’s binary can be older than the worktree’s tree, so a knob the worktree’s knob files name and that binary does not own refuses at exit 2. That is the skew that already wedges a main checkout whose binary is stale against its own tree, not a new window. And a value set only in the.local.knobsoverlay, which a worktree lacks, reaches a worktree child forGATE_SDK_NATIVE_BINand for nothing else. -
gate_verdict_binanswers which binary a verdict-bearing arm runs. It returns the local door when executable. Failing that, inside a linked worktree, it links the main checkout’s own resolution of the knob into the local door path, so every reader, the shell’s and the crate’s, finds one binary at the path the knob names. It links only when that binary’s source stamp equals the worktree’s crate source stamp and the door path is gitignored; otherwise it returns the door unchanged, so the caller fails closed. A tree carrying no crate source compares no stamp, since its binary is the installed payload of the same checkout. It links and never builds, by a symlink falling back to a copy.gate_harness_binis unconditional because a hook enforces the parent’s policy; this one is stamp-gated because a verdict is about the tree, and a binary built from other source would answer for code the worktree does not hold.check-gate-binary-freshstill catches that case. A declined link prints a second line naming the refused condition: no main binary, a door path git does not ignore, stamp skew, or a link that could not be made. The front end’s absent-binary message then names the remedy run it in the main checkout rather than building here, never Build it, which would build in the worktree. Its caller is the front end’s verdict branch, andbin/run-gates.ps1holds the same resolution, the stamp included, under the front-end parity corpus (§run-gates). gate_skipped_mark <top> <prefix>is the crosser’s nested mark held in shell (§The crate’s crosser), for a caller that changes to git’s toplevel before the crate can see where it started. Given the toplevel and prefix git answered from the working directory, it prints the first<top>/<run>/.gitentry of any type along the prefix’s leading runs, nearest the starting directory first.-eor-Lcounts, so a dangling symlink does, and<top>itself is never tested. It returns 1 emitting nothing where none is found, under a non-emptyGIT_DIR, and where the entry is the repository git selected. That last is the crosser’sGIT_WORK_TREEexception: the entry, or a gitfile’sgitdir:target read as the crosser reads it, crossed bycd … && pwd -P, equals git’s--absolute-git-diranswer, asked only once an entry is found.gate_msg_pattern_filesis the shell reader of the banned-pattern set, a pre-binary accessor documented at its gate (§check-commit-msg). The commit-type roster has no shell reader: it isGATE_SDK_COMMIT_TYPESin gate-sdk’s table (§check-commit-subject).gate_self_repo_prefixis the single identity derivationcheck-md-refs’ resolver (canon-kit/SPEC.md §check-md-refs) and the reference-link producers share, so an emitted link and the pass that validates it cannot derive divergent identities; it ships no repo name — the provenance seam holds.registry::fixture_suites()is the single fixture-suite derivation: one(suite, tests-dir, checks-dir)triple per directory carrying agate-tests/tree. Rows come inwalk::kit_rootsorder, then the gates directory. The suite is the directory’s basename with-turned to_, and the checks directory is the siblingchecks/when one exists, else empty. A directory is tested at the path its row prints, which is the operand--run-gate-testsreceives.--emit fixture-suitesprints one<suite>⇥<tests-dir>⇥<checks-dir>line per triple. The CI workflow (.github/workflows/gates.yml, and the template it fills) captures that output before looping,suites="$(bash gate-sdk/bin/run-gates.sh --emit fixture-suites)" || exit 2, because a loop over a process substitution would lose the arm’s status and run zero suites green. evidence-kit’sEVIDENCE_KIT_FIXTURE_SUITESand itsEVIDENCE_KIT_RUN_family derive from the same function, so adding a kit enrols its fixtures with no hand-list to drift.gate_kit_rootsreads the binary’s--emit kit-roots, one root per line relative to the working directory, the crate’s derivation from theGATE_SDK_ROOTlocator andGATE_SDK_KIT_DIRS(§Layout and configuration).gate_check_dirscomposes over it, andgate_sdk_rootstays the library’s own location, which is whatbin/run-gates.shexports as the locator. With the locator unset,gate_kit_rootshands the binary that location, so a caller sourcing the library directly reads the roots beside it rather than the working directory’s default.GATE_SDK_PROGRAM_FLOORis the one home of the payload’s assumed-program set, read by §port-blockers where a command-position word is classified as a criterion-7 requirement or discarded. It is a kit default rather than a literal in the tool, so a consumer shipping a different floor — a stripped container, a busybox userland — repoints it instead of patching the derivation.gitis a member because §The port-candidate criteria already rules it the sanctioned exception, “because it is the floor”. The set is written once, in gate-sdk’s knob table (native/src/knobs/gate_sdk.rs), rather than in the SPEC prose that cites it. What membership entitles a spawn to assume is criterion 7’s: the set says a program is present, never that a bare name resolves to it.gate_native_targetsis the target roster’s single reader: one Rust target triple per live line,#-comments and blank lines stripped by the samegates_list_membersgrammarscripts/gates.listuses. An absent roster emits nothing and returns 1, so a caller tells no roster declared from a roster declaring nothing, the distinction §Consumer payload’s selection table turns on. Its path accessors,gate_native_targets_file,gate_native_binandgate_native_crate, are pre-binary accessors (below), andgate_native_cratestrips the crate root’s trailing/where it resolves, as the table’s row does.gate_native_runner <target>is the target-to-runner map’s single reader, beside the roster’s and sharing its line grammar. It prints the runner one target is built on, and returns 1 emitting nothing for a target the map does not name, so a caller refuses that target loudly rather than picking a host for it. A silent default would land a build on the wrong platform, invisible until the artifact behaves differently from the one that was exercised. An absent map returns 1 for every target, the same refusal one step earlier.gate_native_runners_fileis its path accessor, on the roster’s pattern. The map’s separation from the roster is §Layout and configuration’s ruling.-
gate_exe_suffix [<triple>]is the executable suffix’s single owner, and no other surface in any kit spells.exe. It prints.exeor nothing. Given a non-empty target triple it answers for that triple (*-windows-*matches). Given no argument, or an empty one, it answers for the host, since an empty triple is the shape a--target-less cargo build emits. The host form matchesuname -sagainstMINGW*,MSYS*,CYGWIN*andWindows_NT, the two-dialect host of §The path-dialect contract. Each reader picks its form from what it names.gate_native_bin’s default forGATE_SDK_NATIVE_BIN(§Layout and configuration) takes the host form, because the knob names a binary on the machine resolving it.bin/build-native.sh’sBN_ARTandscripts/ci-build-artifact.shtake the target form, because each names an artifact built for a triple that need not be the host’s (§build-native, §Consumer payload).bin/build-native.sh’s body is the binary’s build, so an arm for it would predate itself, and the shell holder is permanent by construction.The bootstrap’s
select_artifactand the payload assembler,--pack-installer, deliberately take neither form. Each discovers the artifact as the one regular file not named*.sha256in the target’s artifact directory, and refuses zero or several, so a.exesatisfies them unchanged; adding a reader would replace a working derivation with a spelling. The crate, which is not a kit, holds the other suffix question — what an installed program may be named, fromPATHEXT— under its own single owner (§Fail-closed contract). Nothing there names a built artifact, and nothing here readsPATHEXT. The host has a compiled answer and a target has none.GATE_SDK_NATIVE_BIN’s row in gate-sdk’s table readsstd::env::consts::EXE_SUFFIX, the constant for the platform the binary was built for, which is the platform it runs on.gate_exe_suffixstays the shell owner of the target form, and the pre-binary accessor test below covers its host form through the default. gate_native_source_stampis the tree side of the source stamp (§check-gate-binary-fresh) and its only shell spelling: the same three git invocationsnative/build.rsbakes into the binary, so the comparison is one algorithm rather than two implementations of one. It emits the stamp, and returns 1 emitting nothing when git cannot answer, so a caller fails closed rather than comparing against an empty string. An edit here that is not the same edit inbuild.rsis the canonicalization drift git-as-hasher exists to prevent, which is why a crate unit test holds the two to agreement.- The pre-binary accessors read their knob in shell, and a crate test holds them to the table. A shell caller that must reach or build the binary needs a few gate-sdk values before any binary can answer: the binary’s path, the crate root and its two platform rosters, and the banned-pattern files
bin/build-native.shchecks a fresh artifact against. These are the pre-binary knobs:GATE_SDK_NATIVE_BIN,GATE_SDK_NATIVE_CRATE,GATE_SDK_NATIVE_TARGETS_FILE,GATE_SDK_NATIVE_RUNNERS_FILE,GATE_SDK_MSG_PATTERN_FILESandGATE_SDK_MSG_PATTERN_FILES_LOCAL.gate_native_bin,gate_native_crate,gate_native_targets_file,gate_native_runners_fileandgate_msg_pattern_filesresolve them through_gate_prebinary_knob. It reads the scalar form alone,NAME = value, over the static precedence: the environment, the local overlay, the tracked file, each file at its locator or its gates-directory default (§The knob file), then the default, derived as the table derives it. Every other shell read of a gate-sdk knob goes throughgate_knob_values, which asks the binary. The accessors are a second holder of these values, admitted on criterion 6’s unless clause and held bynative/src/knobs/gate_sdk.rs’ unit test. That test spawns the five accessors under an environment value, an overlay value, a tracked value, an empty value and nothing, and compares each answer withknobs::resolve.- The accessor refuses nothing. It skips a malformed line, and a file that is absent whether a locator names it or not. The binary refuses the line, and a named file that is missing, at its first gate-sdk read, which every arm reaches.
gate_commandbakes no knob into the argv it emits. A dispatched member resolves every knob itself, against the tree at its own working directory, so a caller running a member in a tree whose knob file lacks a value it needs passes that value in the child’s environment.- One name means one grammar, and a distinct spelling is what a grammar change buys. A whitespace-list knob (
GATE_SDK_PRUNE_DIRS,GATE_SDK_KIT_DIRS,GATE_SDK_EXEC_GLOBSand their siblings in §Layout and configuration) keeps its consumer name and its scalar grammar, so an exported override still works. Its reader splits it on whitespace:walk::knob_wordsfor one knob,walk::prune_dirsfor the prune pair. The split expands nothing, so a glob in the value reaches its reader as written. An indexed or keyed knob takes no environment override, since one exported name would then carry a second grammar (§The knob file), and<KIT>_CONFIG_FILE→<KIT>_KNOB_FILEis the rename a changed grammar bought.
Resolution splits into a declaration path and an invocation argv, because a gate’s implementation may live in a compiled subcommand while its declaration stays a tracked text file:
gate_resolvereturns the declaration path — the file whose text carries the# graph:manifest and the# spec:/# assertiondirectives. Within each search dir it tries<name>.shfirst, then<name>.gate(§The# graph:manifest names the descriptor). Dir order stays consumer-first and.shbeats.gatewithin a dir, which preserves registry-plus-shadowing: a consumer shadowing a kit’s ported gate with its own shell script still wins. Every text reader is unchanged by the second spelling, becauseregistry::manifest_linereads the manifest out of whatever path it is handed and never requires the file to be shell. A dir carrying both spellings for one name is ambiguous dispatch and is red (§check-gate-substrate-parity assertion A), never resolved by ordering.-
gate_commandreturns the invocation argv, one element per line: the one-element<dir>/<name>.sh, or the two-element<binary> <name>, never with anenvprefix, since the binary reads its knobs itself. Its callers are the execution sites that parse its stdout as argv, §run-gate-tests’ per-case resolution and a kit’s ownbin/andsmoke/among them; §run-gates names the set. A caller that resolves and executes from different directories must pinGATE_SDK_NATIVE_BINabsolute, because the default is repo-relative (§Layout and configuration).It emits two failure signals, told apart only by its status.
return 1means the member resolves in no check dir, the caller’s own diagnosis to name. Any other non-zero status is a harness error — an absent or non-executable binary for a.gate-dispatched member — raised as anexitfrom insidegate_commandafter it writes a naming reason to stderr. Both leave the caller an empty argv on stdout. So a caller must capture the status, with a command substitution and never a process substitution: anexitinside< <(…)kills only the subshell and reaches the caller as emptiness alone. A caller reading emptiness as “resolves in no check dir” then re-diagnoses a build problem as a missing gate. §Fail-closed contract states the trap and the required branch for the class.
A member’s declared knobs are the crate’s registry data, off which a member cannot compile: a .gate member’s registry row, a non-gate arm’s row in the arm table, a hook member’s row in the hook table. The binary reads them in process, so the knob-file derivation, check-reads-couples and check-gate-substrate-parity hold what a member reads against what it declares, the same registry-data-held-to-executed-behavior shape as --reads.
One declared name is a sentinel, registry::EVERY_COUPLES_KNOB. Some members expand another member’s couples= or resolve its # projection: line: check-graph, check-reads-couples, check-gate-substrate-parity, check-projection-roster, the graph emitter and the selector. Each reads the knobs those descriptors’ knob: tokens name (§The # graph: manifest) without declaring them one by one; check-graph does not declare CANON_KIT_MANIFEST_FILES. Such a member declares the sentinel instead, and it stays a declaration, read by the knob-file derivation (§The # graph: manifest). registry::couples_knob_names returns the name of every knob: token the descriptor corpus carries on a couples=, trigger= or # projection: line. It derives them from the surface the names are written on, so a newly written token cannot be forgotten, and a unit test holds each name to a static kit’s ownership.
The derivation, registry::knob_files, answers for one member over the resolve dirs its caller already holds, gates directory first, because the sentinel’s expansion and the staged refusal read the descriptor corpus. A member deriving none gets nothing, an environment-only name (§The knob file) reaches no knob file, and a mode=staged member that reaches a static kit is refused. Its one caller is registry::derived_couples, which the trigger readers §The # graph: manifest names each call in process.
So a knob has exactly one producer: its owning kit’s defaults table in the crate, under that kit’s knob files (§The knob file). Every kit is static, so criterion 6’s second producer cannot arise. Every knob read resolves through one function, knobs::wire. It answers the two locators, GATE_SDK_GATES_DIR and GATE_SDK_ROOT, from the environment or their default, and every other name from its owning kit, which owns every name its <KIT>_ prefix spells. It refuses a name no static kit owns with X is not a statically owned knob, so no reader bypasses it. A shell caller reads through gate_knob_values, which runs the binary’s --emit-knob-values arm directly, so no bash recomputes a default; the pre-binary accessors above are the one admitted second holder. A knob: token takes its members from knobs::wire too: registry::expand_couples is the one expander, and the hook arm’s selection (§git-hook) and run-gates --for both ride it, so a commit and a targeted run read one value. Criterion 6 (§The port-candidate criteria), the check-knob-default-coupling row of §Meta-gate conservation for the binary substrate, §check-core-files’ root-set paragraph and §The # graph: manifest’s knob-derived trigger rule each turn on this rule and cite it here.
Reading a knob’s value, per shape:
- A scalar reads as its value through
walk::knob_scalar, and a whitespace-list scalar as its words (above). - An indexed knob reads as its elements through
walk::knob_array, and a resolved-empty knob as an empty set. The one serialization every in-process reader parses tab-joins the elements, so whitespace inside an element is kept:CANON_KIT_TEMPORAL_EXEMPT_SECTIONScontainsOut of scope. - A keyed knob reads as its pairs, sorted by key, through
walk::knob_map, each pair split on its first=, so a value may carry=freely and only the key is constrained. An element carrying no=is refused, naming the knob.EVIDENCE_KIT_SCENARIO_GLOBSandLIFECYCLE_KIT_PREDECESSORare each read by key by a registered gate,check-evidence-baselineandcheck-stage-entryrespectively, andQUEUE_KIT_LESSON_SINKSby the--lesson-sinkarm (queue-kit/SPEC.md §The lesson-sink arm). A knob file refuses a line whose form disagrees with the declared shape (§The knob file), so no reader meets a map where it expects elements. -
A declared name ending in
*is a prefix, andwalk::knob_prefixreads the owning kit’s declared family under it in process (§The knob file), keyed by the suffix the prefix leaves. This lets a member read a family whose key set is another knob’s value:EVIDENCE_KIT_RUN_<suite>is the instance, one member per suite, with the suite set coming fromEVIDENCE_KIT_SUITES. A family’s computed members are its derivation’s, never a loop in consumer config.A family and a keyed knob answer different rosters. A family member’s name carries the key, which lets the key set come from another knob’s value; a keyed knob carries its own keys, which lets its key set be the roster. A member whose roster is external takes the prefix; a member that iterates the map’s own key set takes the keyed shape.
A prefix matching nothing resolves to an empty family and passes, and the fail-closed obligation that seems to drop is relocated, not removed: it belongs to the reader, the only party holding the roster. A reader that looks up a name its roster named and does not find it refuses, naming the member. The family read cannot make that call, because a prefix carries no expectation of its own, which is what the resolution-set rule below says.
Refusing on an empty match would collapse adopted-but-broken into not-adopted’s arm. A roster naming a member with no entry is adopted but broken and refuses; an empty roster is not adopted, performs no lookups, and drops its section.
check-enforcement-freshis the live case: a consumer that has not adopted evidence-kit has noEVIDENCE_KIT_RUN_family at all, and its Validate-suites section drops rather than failing.A prefix is a resolution set, never a roster. It says resolve values for these names, not these are the members. The roster comes from the roster knob, and a reader must look each name up rather than enumerate what matched:
EVIDENCE_KIT_RUN_IDmatchesEVIDENCE_KIT_RUN_and is evidence-kit’s run identifier, not a suite. A reader treating the matched set as the roster would publish it as one.
A configurable document is a path, never a value. A knob value cannot carry a newline (§The knob file), and a stylesheet or an HTML fragment is newline-bearing by construction, so a knob holds the relative path to a directory or file and the member reads the bytes itself. check-graph’s theme directory and its vocabulary file are the two instances (§check-graph).
A member’s knob resolution can be aimed at a config the caller chooses. The binary reads each kit’s <KIT>_KNOB_FILE and <KIT>_LOCAL_KNOB_FILE from the environment it inherits, so a harness (a bespoke scenario runner, a fixture driver) that exports either before calling gate_command points a compiled member’s knobs at a synthetic config or overlay. A ported member therefore still takes a config file, through the locator rather than a positional. §lib/test-hermetic.sh uses exactly this to pin both layers of every kit at one empty file.
A compiled member or arm that must reach a file inside gate-sdk has no BASH_SOURCE to find it by, and reads walk::sdk_root(), the GATE_SDK_ROOT locator (§Layout and configuration). --run-gate-tests is the worked instance, reading lib/gate.sh there to resolve each case’s dispatch (§run-gate-tests). The kit-root set is no substitute, because GATE_SDK_KIT_DIRS may narrow it to a consumer’s own tree, which is exactly the configuration a sandboxed fixture runs under.
The environment is process-global, and the crate’s own tests are serialized against it rather than disciplined around it. Every #[test] in one test binary shares one environment, so two cases setting the same variable — a knob’s scalar name, a <KIT>_KNOB_FILE, GATE_SDK_GATES_DIR — to different values race, and the loser asserts against the other’s render. The symptom, a test green alone and red in the suite, reads as flakiness rather than as this contract, which is why it is mechanized.
The crate carries one serialization point, native/src/knobenv.rs, and the guard it hands back is the write API: knobenv::lock() returns a KnobEnv, whose set and remove are the crate’s only spellings of an environment write. A case holds that guard across its writes and across the assertions that read them. Holding it across the reads is the load-bearing half, since a lock released at the write leaves the render it was taken for unprotected. The rule binds every environment write a case makes, whatever the variable names.
Its machine side is a unit test in that module, in the roster shape §check-reads-couples’ unit test B uses: no crate source outside knobenv.rs names set_var or remove_var. Without it the guard is advice, and a later case calling the std API directly restores the race silently.
Threading a knob into the code under test as a parameter is refused, because it deletes the race by deleting the coverage. Resolution from the environment and the knob files is the production path, so a test passing the value in as an argument stops exercising the only resolution the binary ever performs. Serialization is therefore the deliverable rather than a caveat on it, and it applies to every environment-writing case in the crate.
The working directory is the second process-global a case can write, and it is ruled the opposite way: eliminated, not serialized. The environment’s readers are named — a case reads the variable it wrote — so the writing case can hold the guard across the reads that matter. The working directory’s readers are implicit: every relative path the kernel resolves reads it — a relative knob default, .tmp, .workflow, a git spawned with no -C over a relative pathspec — and a reader cannot take a guard it does not know it needs. A lock over the working directory would serialize its writers and leave every reader exposed. So §run-gate-tests’ invariant applies to the crate’s own tests: a case directory is set on a spawn and never entered. Its machine side is the working-directory pin beside the spawn roster (§Fail-closed contract). No other process-global writer is in scope: environment writes live in knobenv.rs alone, and the crate spells no umask and no signal handler.
The registry coverage tests are where the rule bites, because they run members over fixture cases. Each — §check-reads-couples’ unit test A over walked roots, §The # graph: manifest’s over spawned programs — keeps its loop and its knobenv::lock(). Per case it spawns the running test binary through proc::run_merged_in, with the case as the child’s working directory, filtered --exact to one ignored observer case. The member’s name, a marker, an absolute GATE_SDK_ROOT and the absolute host binary as GATE_SDK_NATIVE_BIN ride that call’s child-scoped environment slice. The child reads the case’s own knob files from its working directory and finds the kits from the locator, as §run-gate-tests hands them to a case, with one difference. The binary overrides a case’s own pin, where that runner keeps it. A pin exists to steer a member’s verdict, and the observer asserts reach, which no pin moves. A stub pinned for the verdict need not start on every host, and the Windows legs cannot start a shebang script.
The observer runs the member with both recorders on and prints sentinel-prefixed lines: one per walked root and per spawned program, one per else:<SELECTOR>: guard whose selector knob resolves non-empty in the case (§check-reads-couples), then one carrying the member’s exit status. The block opens on a newline, so a member’s unterminated last line cannot absorb a sentinel. The parent reads the else-guard selectors back from the child, since a selector resolves in the case and not in the parent, and skips each guarded declaration the child reported. It asserts observed ⊆ declared, no exit 2, a non-empty case set and a non-empty observation. It also asserts that the child passed and printed exactly one exit line, because a filter matching no test exits 0 and prints nothing. Recording happens only inside the child: run_merged_in notes its own spawn into the calling thread’s recorder, so a parent with one started would record its harness re-exec as a member’s spawn. The observer is #[ignore]d, so an ordinary cargo test skips it, and it panics without the marker, so a run including ignored cases cannot execute it in-process. The cost is one filtered re-exec of the test binary per case.
Three alternatives are refused. Widening knobenv into “process-global test state” serializes writers only, and stretches the module’s charter past what its assertion can hold. Running the two tests single-threaded through a split check-crate-arms argv changes a gate’s contract and CI’s invocation, and a contributor’s plain cargo test still races. Threading a root argument into the members deletes the coverage the tests exist for, on the ground given above for refusing a knob parameter.
The binary’s path is the knob GATE_SDK_NATIVE_BIN (§Layout and configuration), never a literal. An absent or non-executable binary when a registry member dispatches to it is a harness error — exit 2, never a skip and never a pass. This is §Fail-closed contract applied to dispatch: a skip would give a battery that silently stops running a gate whenever a build is missing, the worst available outcome. The install model makes the case unreachable in a correctly installed tree: a host receives its verified artifact or is refused before anything is written (§Porting a gate to the binary substrate, criterion 5). So exit 2 is the backstop for a tree whose binary was deleted or replaced, or one vendored by hand on a host the roster does not cover, never the path an unsupported platform takes. A knob refusal the binary raises at its first read (a malformed knob file, an undeclared name) is the same harness error for the same reason, exit 2 from the member itself.
fail_closed must be passed only a status that genuinely means the check could not execute (an awk/jq/parser crash) — never grep’s exit 1, which is the expected “no match”; the caller draws that line at the capture site.
Its port disposition is a declaration, on two grounds, and neither is size. §port-blockers’ --tree arm reads the # no-port: line in its header.
- Its API is shell functions sourced into bash callers: CI steps, the installer smoke, the author template (§templates/check-skeleton.sh), the front-ends and the test harness. A binary arm cannot be sourced into bash, which is §lib/test-hermetic.sh’s ground met by the library that harness composes.
- Its pre-binary accessors answer before any binary exists. A caller that must locate or build the binary reads the binary’s path, the crate and its two rosters here, which is the bootstrap cause §build-native declares on.
A kit library that only computed its kit’s knob defaults leaves with its kit’s migration rather than porting: the defaults live in the crate’s table (§The knob file), so the library has nothing left to compute.
What reopens it: the first ground dissolves if no bash caller sources the library, and the second if the pre-binary accessors move to a surface that runs before the binary, such as a native front-end.
lib/inject.sh
The marker-bounded span mechanics every kit’s agent-file injector shares — four functions over one notion of a well-formed block, held in native/src/marker.rs. This section’s name is a path the tree does not carry, and that is deliberate, on §lib/declaration.sh’s own ground: the citations that name it are the mechanism’s, not the file’s, and renaming the heading would strand every one of them. The shell library was deleted once its last sourcer moved in-crate, rather than ported, which is the road §The port-candidate criteria prefers.
The module has a read half and three writers, and each writer’s disposition on a miss is its whole difference. marker::write_block is the generator writer: it matches markers whole-line, replaces the content between them, leaves the file otherwise byte-untouched, and refuses an absent, unbalanced or reversed pair. That refusal prevents the expensive failure: a generator that appends when it cannot find its markers corrupts a hand-authored page, and the freshness gate then reports the corruption as staleness rather than as damage. marker::install_block is the installer writer and appends a fresh block on an absent begin marker, because a seeding installer legitimately writes into a file that has never carried one. Each writer has its own caller class, so a session picks by what the caller is rather than by which function it met first. Its marker-presence test is whole-line, the module’s own rule, so a marker occurring inside prose never sends it down a replace path that matches nothing and still reports replaced. marker::remove_block takes the removal’s own disposition: an absent begin marker is a no-op, because a removal has nothing to guess at.
marker::install_block(file, begin, end, body) writes <begin> + the body + <end> into the target: replacing the span between an existing marker pair (inclusive) in place, or appending a fresh block when the markers are absent, so a re-run never duplicates. A begin marker without its end is a malformed target — it refuses rather than guess the bounds. On success it returns the action taken (appended |
replaced) for the caller to report. |
marker::read_block(text, begin, end) is the retrieval half: it returns the existing block’s inner content (markers exclusive), and the empty string when the marker pair is absent.
marker::remove_block(file, begin, end) deletes the marker pair and everything between it (inclusive), leaves the rest of the file byte-identical, and returns whether it removed one. An absent begin marker is a no-op reporting false, not a refusal: the caller is a path reversing an installation, which cannot know whether the block was ever written there, so a second removal must be as quiet as the first. It refuses on the same malformed target its siblings refuse on.
All of them agreeing on what a malformed target is, in one module, is the point of keeping them together. A caller that reads a block before rewriting it, or removes one it may never have written, must not meet three different answers to the same question.
The module owns placement, retrieval and removal, and nothing above them. Block content generation stays with the caller (the lifecycle roster, the doctrine digest), and so does any rule about what to preserve out of a block that was read. doctrine-kit’s declared-trim round-trip (doctrine-kit/SPEC.md §install-doctrine) is read-compute-emit in that installer, not a preserve rule pushed down here. A preservation parameter on the installer writer would ship one kit’s marker vocabulary to every consumer of a generic injector, which is the provenance seam this split keeps intact. So a second injector adds no second copy of the replace logic: every marker-bounded projection in the tree rides this module, the --install-doctrine and --install-lifecycle arms among them.
It has no gate surface of its own and is exercised end-to-end wherever an installer that rides it runs. doctrine-kit’s smoke/install.sh covers the read half through the trim round-trip its acceptor drives, and the removal half through a --remove/reinstall round trip beside it. lifecycle-kit’s own smoke/install.sh exercises the compiled half at the same transitions.
lib/declaration.sh
The release declaration grammar — one container arm over a token rule per declaration-bearing section, held once, in native/src/declaration.rs. This section’s name is a path the tree does not carry, and that is deliberate: the citations throughout this SPEC and in installer/SPEC.md resolve against §lib/declaration.sh, and renaming the heading strands every one of them. What the section owns is the grammar and its compiled holder. A release note and the release declaration surface (§upgrade-smoke) are both markdown in one grammar, so they share the container and each section’s token rule, which keeps the two surfaces from reading different token sets from the same bullets.
- The container arm — a
## <section>container’s lines and bullets (section_linesandsection_bulletsfor the container alone,section_tokensfor the verdict, which takes the section’s token rule as an argument). A section arrives as its name set, its heading and its aliases, and a container heading opens it when it names any member; every container so opened contributes. A bullet’s lead token is read directly after the bullet marker; a bullet shaped any other way yields no token rather than a stripped one, which makes a wrong spelling visible instead of silent. The holder is pure over text and reaches no filesystem, so a missing file is the empty set at the caller that reads it rather than an error: a tree that has never declared one is not thereby malformed. TokenRule::GateName, thegatesrole — a backticked, unbolded bare gate name directly after the marker, which makes the bolded and bold-and-backticked spellings unreadable rather than stripped.TokenRule::Backticked, theknobsandplatformsroles — the backticked, unbolded span directly after the marker, with any non-empty content: a knob’s old or new name, or a target triple. A knob name carries underscores and a tag carries brackets, and the gate-name predicate refuses both, which is why the rule is per role.TokenRule::Bolded, theauthoringandbehaviorroles — the bolded span directly after the marker, verbatim and trimmed, with any non-empty content.
The holder carries no section name. Names arrive as a set from release_sections::roster(), which resolves GATE_SDK_RELEASE_SECTIONS and GATE_SDK_RELEASE_SECTION_ALIASES into (role, heading, aliases) rows and fixes each role’s token rule (installer/SPEC.md §The upgrade contract owns the roles). The holder names a section only in its tests.
The verdict a caller branches on is a type, so a caller cannot reach a token list without having matched the resolved-empty arm. Absent: the named section is not there. ExplicitNone: a None body, the resolved empty set. Tokens: a declared set, non-empty by construction. Unparsed: unparsed while not None, carrying the offending lines, which is the empty list when the container held no bullet at all. The trichotomy, and its refusal of a non-None section yielding no token, hold for every section rule. A reader of the declaration surface applies the surface verdict: Absent and a heading with no bullets (Unparsed with an empty list) are the empty set, and Unparsed carrying offending lines is a finding, exactly as on a note. ExplicitNone cannot arise on a conforming surface; a reader meeting one takes it as the empty set rather than refusing, since “None” means the same thing on both surfaces.
Every arm of the trichotomy has a named reader at a named transition, and one with none would be removed.
- Absent is read by
check-release-bumpat its roster presence assertions, bycheck-tightened-gates-grammarat itsAbsentarm, by the declaration parity gate’s note refusal, and as the empty set by both surface readers. - Explicit
Noneis read by the grammar gate’snonecounter, which its clean line prints, and by the parity gate as the resolved empty set it compares. - Tokens are read by every cohort gate, as a bullet count in the bump gate and as a token set in the other two, and by the smoke’s arm as the allowed-red set.
- Unparsed is read by the grammar gate’s finding list, by the parity gate’s refusal on either surface, and by the smoke’s finding.
The third arm is the one that earns the helper. A non-none section yielding zero tokens must not become “no allowed reds”. On a green battery that passes silently over a note naming several gates; on a red one it fails loudly with a false message, accusing the note of an omission it did not make. The assertion would then be severed from the artifact it claims to read. So the helper refuses, which closes the class permanently: no future markup variant can disarm the assertion, only red it.
Its port disposition is closed by deletion. No shell holder remains, since §upgrade-smoke’s port moved the last non-test caller in-crate, so criterion 6 is discharged by the duplication being absent rather than machine-held. No parity arm survives it, because one holder cannot be held equal to itself.
One conflation the holder carries, recorded rather than repaired. On the refusal path the arm emits the tokens it had already resolved before the offending lines, so a container mixing a readable and an unreadable bullet reports the readable one to its caller’s finding list as unreadable. The verdict is unaffected and only the diagnostic list is wrong; the defect is filed as a gap. The repair is a one-holder edit with no parity test to red: a cheaper repair and a weaker guard, both stated so the missing oracle is not read as an oversight.
Its callers are compiled members calling the holder directly, so the relation is a call rather than a source. The container and the token rules are separable rather than one pass because a bump derivation needs the bullets without any token rule. One container also guarantees that the section a bump is derived from and the section an allowed-red set is parsed from are the same section. Not a gate, so it owes no good/+bad/ pair. The surface read is exercised a second way, through scripts/gate-tests/check-release-declaration-parity’s own good/+bad/ pair, whose release-declarations.md fixture carries every surface-declared section and drives the holder via the gate’s declaration-file argument.
The compiled holder’s public surface is bounded and is exactly these entry points — the container’s lines, its bullets, and the verdict under a section’s token rule. There is no writer, no renderer and no section-discovery API, and adding one is a design decision with its own reader rather than an omission to fill in.
The holder takes no gate name and no configuration either: the parsing is kit mechanism, the parsed content is the consumer’s. The seam is checkable: native/src/declaration.rs takes its section names as an argument the roster reader resolves, so a grep of that module outside its tests for any configured heading returns nothing.
lib/test-hermetic.sh
The bespoke-test hermeticity bootstrap, sourced as the first act of every gate-tests/*.test.sh (§run-gate-tests, enforced by §check-test-hermetic). It derives the kit roster from its own location: the gate-sdk and *-kit subdirectories of gate-sdk’s parent. That name-glob needs no config to bootstrap, and it reaches the kits whose loader lives in bin/ (context-kit, drift-kit) as well as the lib/ loaders. For each kit, gate-sdk included, it exports <KIT>_KNOB_FILE and <KIT>_LOCAL_KNOB_FILE (name uppercased, -→_) pointing at one shared empty file, ${TMPDIR:-/tmp}/gate-sdk-hermetic-empty.knobs. An empty file is valid in the grammar, so the reader never reads <gates-dir>/<stem>-config.knobs or the overlay beside it under the suite’s cwd. It exports the retired <KIT>_CONFIG_FILE beside it, at a second shared empty file, ${TMPDIR:-/tmp}/gate-sdk-hermetic-empty.sh, where an empty file only disarms the legacy refusal (§The knob file).
Both are created idempotently with : >: fixed path, always empty, so no trap (a test’s own trap … EXIT stays unclobbered) and no growth. It is knob-free by design, since a config-pinning tool cannot itself be configured by the surface it pins. A test that must exercise real config overrides the pin after the source, by a later assignment of <KIT>_KNOB_FILE or an env -u <KIT>_KNOB_FILE prefix so the reader falls back to its cwd-relative default; ordering wins, and no opt-in flag is needed. Each kit’s <KIT>_LOCAL_KNOB_FILE is exported at the same empty file, after the binary pin below is computed, so a suite reads no layer of the tree it runs from and a seat’s overlay still places the binary the suite runs. A suite that reads a real overlay un-pins <KIT>_LOCAL_KNOB_FILE after the source by the same two forms. A suite that un-pins the tracked file alone reads the tracked config with no overlay, which is what a seat without one reads.
The library also unsets GATE_SDK_TMP_DIR and GATE_SDK_WORKFLOW_DIR, so a sourcing test resolves both to their relative kit defaults against its own sandbox cwd. An inherited absolute value would point a test that isolates itself under a relative scratch or workflow directory at the invoker’s live state: a real run-validate.lock, a foreign validate-evidence.txt. The unset runs before every bespoke test’s own explicit pin of either knob, since a pin sits after that test’s source line by construction, so it overrides no deliberate choice and neutralizes only the ambient case. The honest limit: a knob derived from either root and exported directly (EVIDENCE_KIT_LOCK_FILE, for one) still reaches a test that does not also unset it.
The library heads GIT_CEILING_DIRECTORIES with the system temp dir, ${TMPDIR:-/tmp} resolved physically, so a stray .git there, which any process can create, never marks a suite’s mktemp sandbox as inside a repository. A repository a test builds in its sandbox sits below the ceiling and is still found.
The binary pin is an absolutization of the accessor’s answer, never a second default. A bespoke test runs its gate from a sandbox cwd, where the knob’s deliberately repo-relative default resolves to nothing (§Layout and configuration), so the value has to arrive absolute. Spelling the default here again would leave two spellings of one value agreeing by hand, and the second would omit the executable suffix §lib/gate.sh’s own default appends, pinning every bespoke test on a Windows host to a path that cannot exist. So the library sources lib/gate.sh, reads gate_native_bin, and joins a relative answer to its own anchor: the gate-sdk parent it already derives the kit roster from, not the invoker’s cwd. One producer computes the value, the suffix rides along, and this file adds only the join.
The already-set guard around that block keeps a value the invoker pinned. The block runs only when GATE_SDK_NATIVE_BIN is unset, the :- semantics the pin has always had: an exported value outranks every file, and an unconditional absolutization would silently rewrite it against the repo root. So a test run against a binary it pins itself (a scratch-built pre-fix crate, for a red-before-green probe) passes the path absolute, since a set value is never joined. That scratch crate builds only inside a git checkout that tracks its sources (§upgrade-smoke, on native/build.rs).
The gate-sdk root locator is exported absolute, unconditionally, from the same anchor. A suite drives its subject from a sandbox cwd, where the locator’s default, gate-sdk relative to the working directory, names nothing, and the crate derives every kit root from it (§Layout and configuration). A suite steering the roots for one case sets GATE_SDK_ROOT or GATE_SDK_KIT_DIRS through gate_env after this library has run.
Its port disposition is a declaration, on one ground: its API is three shell functions, and a binary arm cannot be sourced into bash. gate_env, gate_run and gate_arm_run are called inside the caller’s own shell, which is why gate_env exists rather than an env prefix: env cannot invoke a shell function. There is no in-crate arm a source line can name. It resolves no knob of its own and computes no default.
Three alternatives are refused, each on its own ground.
- An
--emit-test-hermeticarm emitting export lines for the library toevalwould make the bootstrap depend on the binary being present. A tree vendoring the shell library with no gate binary would lose hermeticity silently, while the bootstrap’s role is to be the first act of every bespoke test, before anything has established that a binary exists. - Moving the environment pinning into the
--run-gate-testsarm narrows the contract. §check-test-hermetic requires the source line as each test’s first act precisely so a standalonebash <name>.test.shis hermetic too, and a runner-side pin reaches only the tests the runner spawns. - Appending
gate_exe_suffixbeside a hand-spelled default leaves two spellings agreeing by hand, the duplication criterion 6 refuses.
What reopens it, as a reopening condition rather than a permanence claim: the ground dissolves if the bespoke *.test.sh corpus stops being bash, since a library whose callers are compiled is not held by it. Its disposition would then be whatever this section could state for it.
run-gates
Aggregate runner: executes every gates.list member in one shot, timing each (<tmp-dir>/gate-timings.txt, <gate> <elapsed-ms> per line + TOTAL — uncommitted by design: a measurement, not state. Only an unfiltered run of the configured registry writes it. A --only or --for selection, or a gates-dir positional spelled other than the configured GATE_SDK_GATES_DIR, leaves the file as the last full battery wrote it, so its one reader never sums a subset). A member that resolves nowhere is a failure, not a skip, and this result is its one report (§Layout and configuration). Exit 0 only when every member passed, and 1 on any member failure, whatever code the member returned. Even under --only, a member’s own code shows only on its FAIL: <gate> (exit N) line, so a caller branching on a gate’s exit codes invokes the binary directly.
The runner is two halves, and which half owns what is the first thing to know here. The arm is --run, a non-gate arm of the binary (§The non-gate arm). It owns the registry walk, both selectors, the dispatch, the worker pool, the timings, the omission accounting, the output contract, the usage text, and the argument grammar below with every refusal decidable from it. The front-end is bin/run-gates.sh and its PowerShell twin bin/run-gates.ps1, each a stub. It resolves the repo root and locates the binary over GATE_SDK_NATIVE_BIN’s pre-binary precedence, through gate_harness_bin for a fail-open arm. It then resolves the gates-dir positional, exports the GATE_SDK_ROOT locator (§Layout and configuration), and runs the binary with no knob environment. The bash stub sources lib/gate.sh for gate_native_bin, gate_harness_bin and gate_sdk_gates_dir and execs the binary. The twin runs it as a child and exits with its status.
The twin exists for a native-Windows host with no bash on PATH, whose bare bash reaches the WSL launcher. It runs under Windows PowerShell 5.1 and PowerShell 7, and pins the installer bootstrap’s two argument-passing settings on that bootstrap’s ground (installer/SPEC.md §The install boundary). It sources nothing, so it re-holds the §lib/gate.sh accessors it needs, each tagged where the twin holds it, and writes the stub’s lines as UTF-8 bytes with a bare LF. That makes it a second holder of the stub’s contract. Each half names the binary in its absence diagnostic by the spelled form, which holds gate_native_bin_spelled’s twin: without that caller it would be a second implementation held by nothing. It rides the precedence corpus below, each tier’s expected text the ./-prefixed value, so a twin dropping the prefix diverges. It compares git directories physically, each symbolic link and junction resolved before a .. past it, since Resolve-Path follows none and folds .. lexically. Its argv is PowerShell’s own $args, so the binder’s rewrites happen before the twin sees a token: a -name:value token arrives split at its colon, and a --% token is consumed.
The twin is held by --run-front-end-parity, an executed comparison of both halves’ transcripts over a fixed corpus. The corpus covers each exit path, each precedence tier of GATE_SDK_NATIVE_BIN, a backslash-rooted value taking no ./ prefix, each residual-grammar form, forwarded stdin, the linked-worktree resolution, and a relative GIT_CEILING_DIRECTORIES entry above a refused repository. It covers each refusal under --hook, and the nested repository git skips, run from sub/deep: an empty sub/.git, one holding a garbage HEAD and, on unix, a dangling .git symlink. Its controls are a GIT_DIR, a valid sub repository, and a repository a GIT_WORK_TREE above it selects. It is reached as sub/.git or through a CRLF gitfile, and on unix through a .git symlink, a gitfile naming a symlinked directory or a symlink’s physical parent, also where that folds to a directory, or a symlinked starting directory.
Each case runs in a fresh scratch repository vendoring this tree’s own gate-sdk/bin/ and gate-sdk/lib/. Both halves run by relative path, or by absolute path from a case’s starting subdirectory. They share one environment stripped of every static kit’s knobs, with GATE_SDK_NATIVE_BIN naming the running binary where a case needs one. Its GIT_CEILING_DIRECTORIES is the scratch root, after any entry the case sets.
The linked-worktree cases run from a worktree whose main checkout carries the running binary at the default path, or carries none. The verdict branch’s pair runs once with a worktree .gitignore covering the door path, which links and runs, and once without, which refuses. The layout is built by hand — the worktree’s .git file naming <main>/.git/worktrees/<wt>, which carries HEAD, commondir and gitdir — because the crate’s own proc unit test refuses any crate source that adds a worktree. Honest limit: these cases rest on git’s documented on-disk worktree format (gitrepository-layout).
Transcripts — exit status, stdout and stderr — must be byte-identical after CRLF becomes LF, since PowerShell’s console writer ending lines with CRLF on Windows is no contract difference. The arm runs the twin under every PowerShell host the platform ships: pwsh and Windows PowerShell 5.1 (powershell) on a Windows host, and pwsh alone elsewhere. It exits 0 when every case is identical under every host, naming the hosts on its clean line. It exits 1 when one diverges, printing the case, the host that diverged, both transcripts and the first differing line. It exits 2 when the check could not run: bash or a host does not resolve, the sandbox cannot be built, or the bash stub itself does not do what a case names, so two halves failing the same way cannot pass as parity.
The parity arm runs on crate-tests-windows’ x64 entry, the one site that measures 5.1, and on the gates job, never in the battery, whose host may carry no PowerShell. A contributor on a Linux host with Docker runs its pwsh half before the push under §with-foreign-shells. Without that, a divergence reds at push, not at commit, the trade the macOS remedy block and the installer’s PowerShell half already accept.
The positional is the whole of what the front-end knows about argv, and the reason is a genuine ambiguity. The binary is dispatched as checkwright-gates <gate-name> by its own battery and by every .gate member, so a bare leading token that names a gate cannot also mean a gates dir at that door. Only the front-end, which is never invoked to run one member, can read it unambiguously, so it resolves the positional and spells it --gates-dir. A form that runs no member, -h and --help, carries none.
The front-end requires a checkout and the arm does not, which decides who may call what. Resolving the repo root is the front-end’s first act. Outside a checkout it refuses with its own run-gates: not inside a git repository line, with no shell diagnostic before it, a line the twin can reproduce. Where the crosser’s mark covers the working directory (§The crate’s crosser), it names git’s refusal instead, since the crate runs only after this cd. Where git answers a toplevel whose prefix crosses a .git entry, the nested mark the crosser refuses, it names that instead, through gate_skipped_mark (§lib/gate.sh). That check reads the one lookup’s --show-toplevel --show-prefix answer, split as the crate splits it, before the cd. Each half holds both marks itself, and neither line carries a path, so the twin reproduces it byte for byte. Every refusal here exits $ARM_UNAVAILABLE_STATUS, so a fail-open arm declines at 0 (§The harness-integration arm).
Honest limit: the twin ascends the logical path rather than the physical one. A failed lookup hands cd a path that is never a directory rather than an empty string, because bash before 5.3 takes cd "" as a silent success and would run on into the binary’s refusal. So an arm whose work happens outside a checkout, such as a hermetic mktemp sandbox, is unreachable through bin/run-gates.sh however correct its argv. Such a caller reaches the binary through gate_native_bin, the path a substrate-parity harness takes (§check-gate-substrate-parity).
Resolving the root is a cd, so a tree-argument arm has two independent selectors. The front-end changes directory to the git toplevel of the current directory before it locates the binary. An arm taking a tree operand such as --root has that operand selecting the tree it works on, while the invoking cwd selects whose checkout supplies the tooling; run from a second checkout the two diverge silently. A caller that needs them to be one tree pins both, which is why a smoke asserting over the very tree it packs passes the tree operand and a cd together.
The front-end serves a harness value only a shell can run, and a pre-build clone, rather than an adopter door. Every kit README, template, knob header and stage procedure names the binary GATE_SDK_NATIVE_BIN names, the hook registrations among them, in the harness’s exec form (§The harness-integration arm). Every compiled gate, hook member and emitter prints its remedy through one crate helper spelling that same door, and guard-kit/SPEC.md §check-door-binding holds both there, the remedies through its Rust reading. The stub keeps the two harness callers §The adopter constraints names, the status line and the plugin’s wiring. Beside them the stub serves a contributor clone before its first build, which has no binary to name.
An exec-form registration declines through the harness when its binary is absent. An absent program does not run, so nothing of this project’s speaks for it: the harness reports a hook that could not start once and lets each call through. The front end’s decline carried the remedy on every call, and that is given up for a wiring that needs no shell. A consumer who relocates the binary re-points the registration, which follows no knob, and check-settings-paths reds one that does not resolve.
The front-end’s port disposition is the stub. It holds the residue this section names: resolve the repo root, locate the binary, resolve the gates-dir positional, export the gate-sdk root locator, and exec the binary. It carries no dispatch loop and no per-arm branch; per-arm dispatch lives in the binary’s own argv parsing. A registry with no live member is refused rather than passing vacuously. Both halves declare # no-port: on a per-file bootstrap cause: the front-end locates the binary it executes, which the binary cannot do for itself. They are the whole of what stays script on the front-end’s path to the binary. Two readings are refused: porting the file whole with a dispatch loop inside, a duplication serving no branch; and declaring the whole file, a subtraction that grows with every arm.
The residual argv grammar: the crate’s parser takes the front-end’s spelling as one normalization step, and the fused arm names stay the arm table’s one spelling. --emit <name> normalizes to --emit-<name>; a token that already names an arm, a gate or a top-level flag passes untouched. Every other leading-dash token is the battery’s own grammar, so it reaches the --run arm that owns the selectors, the help request and every argv-decidable refusal. The step runs above both doors, so every reader of the arm name reads one normalized spelling.
Two alternatives are refused. A second arm table keyed on the front-end spelling is a second source: --emit-<name> is what .gate dispatch and every hermetic gate_native_bin caller already use (§check-gate-substrate-parity), and a table edited twice per arm is per-arm growth relocated rather than removed. Composing the name in the front-end is refused because a case arm per arm grows the front-end by a branch every time an arm is added. One asymmetry is admitted: the crate accepts --emit graph and --emit-graph as the same arm, and an alias is a cost. It is taken because the front-end spelling is what every human caller, workflow file and settings grant in every consumer tree types, and the alias lives in one function beside the table.
The stub keeps one piece of per-arm knowledge, the one that cannot be asked of an absent binary. ARM_UNAVAILABLE_STATUS is read on precisely the paths where no binary runs: where it is absent or not executable, the branch that prints the build remedy naming bash gate-sdk/bin/build-native.sh, and where git refuses or skips the tree’s repository. Each half therefore reads the set before its lookup. So a property the binary would have to be running to report is unavailable exactly when it is needed.
Each half holds the fail-open set, the arms taking the fail-open 0, as one declaration line tested against the leading token: FAIL_OPEN_ARMS='<arm> <arm>' in the shell stub, $FailOpenArms = @('<arm>', '<arm>') with a case-sensitive -ccontains in the PowerShell twin. Each line is a copy, and the copy is held rather than trusted. The shell copy cannot be deleted, because it is read exactly when the binary it would ask is absent. So the crate declares the set authoritatively as FAIL_OPEN_ARMS beside the arm table (§The harness-integration arm), and check-front-end-fail-open holds both stubs’ lines to that declaration. The declaration is one assignment, so the gate reads it by name instead of parsing two dialects’ control flow. --run-front-end-parity holds the two stubs’ transcripts equal; the gate holds their names to the crate.
What the port-complete predicate covers, and the one bash process it does not. Port complete means the battery runs from the hook to the binary with no bash in between, and resolves no knob in bash to launch. It governs the hook-to-binary path only. A battery launched through bin/run-gates.sh still pays that stub’s one bash process, which locates the binary before any binary can run and resolves no knob (§lib/gate.sh). That is a scope reading of the predicate, not an unmet condition.
A member dispatches as a child process, and the threads are the concurrency. A compiled member is a function in the same binary, so calling it in-process looks free. It is refused on two grounds, and what the refusal forgoes is measured at well under one percent of the saving. The arm hands each child the invoking environment unchanged, and the child resolves its own knobs from the knob files.
- It would lose fault isolation. A member that panics or aborts must red that member; in one process it takes the battery with it.
- It would fork the runner in two. Members resolving to
.shsurvive, and consumer shadowing of a ported member with a shell script is a permanent contract (§lib/gate.sh), so a dispatch path reaching only compiled members needs the exec path anyway and buys a second implementation.
No dispatch path carries a knob environment. The arm hands each child the invoking environment as it stands, and the hook arm adds only its work-tree pin (§git-hook).
A child’s two streams merge into one capture file rather than two pipes, as two handles on one file description, so they share an offset and interleave as a shell 2>&1 does. Reading them as two pipes would reorder a member’s own report against its own diagnostics. Its stdin is /dev/null: under a worker pool an inherited terminal is a shared resource two concurrent members could both read from, the class of interference the per-gate scratch isolation below exists against.
A child killed by a signal reports exit 128+n, the spelling bash’s $? gives, so the tail grammar --emit-parse-gates-log reads keeps one shape. The tails are (exit N), (dispatch harness error, exit 2) and (unresolved) — cited rather than counted, because a later dispatch shape may add one.
The output contract is quiet green, loud red. A passing gate prints nothing: its captured output is discarded and the run ends with the summary line alone, whose executed-gate count (All N gates passed.) is the roster-collapse tripwire — a battery that silently shrank shows a smaller N. A failing or erroring member prints its ===== <name> ===== banner and its captured output verbatim, always: the red path is the feedback channel and never quiets.
A red member’s own declared invariant prints beneath its verdict, on every red, in every tree. The runner reads the failing member’s descriptor # spec: line and prints it on a line of its own: four spaces, spec:, then the pointer’s location and the one-line statement after its em-dash. A member whose descriptor carries none contributes nothing rather than a blank line, which is already a red under the self-lint contract for a shipped gate. The read happens only on a red, so a green run pays nothing for it. Where GATE_SDK_SPEC_BASE_URL is set, the location is the published one (§Consumer payload), which makes the invariant reachable in a tree whose kit SPEC the payload withholds.
The gap this closes is presentation, not reach. The invariant is always reachable: the .gate descriptor ships, checks/ is not withheld, and the descriptor’s # spec: line carries the pointer and the one-line statement. What is missing without this line is anything presenting it at the moment a gate blocks a commit. The withholding does not create that gap; it raises the cost of the detour, the file at the end of it having left the tree. Printing only where the SPEC is withheld is refused: it would condition an output line on a payload property, and make the publisher’s own battery, the one surface that proves the behaviour, the one that never exercises it. An unactionable block is how a blocking gate turns into a bypassed one, and a remedy arriving with the block keeps fix it, never bypass it cheap to follow.
The verdict line’s two-space prefix is reserved, and three readers are why. Each parses this log on a different property, and reading them as one rule is how two get missed. --emit parse-gates-log keys on the exact prefixes " PASS: " and " FAIL: " and takes the gate name as the line’s second whitespace-delimited token. Its condition is on the prefix: a separate line beginning with those two spaces would emit a scenario for a gate that never ran. The overhead meter classifies a transcript line as gate output by contains, so its condition is on content, not position: an extra line carrying a verdict substring would inflate a measurement wherever it appeared. §Consumer smoke’s demo excerpt quotes a reddened gate’s block and terminates on the verdict, so its condition is on order. The invariant line’s four-space spec: prefix satisfies the first by construction. The second reader counts the line as gate output on purpose, by a marker on the prefix constant itself (drift-kit/SPEC.md §The overhead meter), since it is gate output. The third is satisfied by its terminator running past the invariant line, which §Consumer smoke owns.
A declared omission is what keeps that tripwire honest. A member a consumer omits from their own registry is recorded there as # omitted: <name> <reason>, a comment line, so gates_list_members strips it and N shrinks legitimately. The class is reason-agnostic and belongs to any consumer who omits a member for any cause of their own. The installer writes none (installer/SPEC.md §The gate binary). The runner counts those lines and prints the count and its remedy beside the summary, one line per reason token present, so a declared omission stays distinguishable from the regression the tripwire exists to catch. A registry whose live set is empty, whatever emptied it, takes the arm’s names no gates refusal at exit 2 (below), so an empty registry never reads green.
The omission line is separate from the summary and carries none of its text. --run-consumer-smoke and the installer’s own smoke both match the green phrase against this output, so a remedy folded into the summary line would either break their assertion or make the phrase match on a run that omitted half the battery. A registry with no omission lines prints nothing extra. GATE_SDK_VERBOSE (any non-empty value) restores the full banner roll, the on-demand reading for the vacuous-pass tripwire (a “0 files scanned” clean line is visible only in the gate’s own banner). It is env over flag by the kit convention: one mechanism serves the interactive run, the placed hooks, and any CI wrapper without an argv contract change. Gates themselves are untouched: each still prints its single clean line per the output contract (§Output contract), and the runner captures it.
That convention governs configuration, not selection. GATE_SDK_VERBOSE is how the battery reports, a value every caller of the battery should carry uniformly, and env is right for it. Which members run must not be ambient. A selector spelled as a GATE_SDK_* knob would be inherited by the pre-commit hook, by --run-consumer-smoke and by any CI wrapper running under it. It would silently narrow a battery to one member while the summary line still reported a pass, the green-with-nothing-behind-it the declared-omission accounting exists to make impossible. A selector spelled in argv dies with the process that typed it. So reporting is env, and selection is argv.
GATE_SDK_JOBS sits on the reporting side of that line. It is the worker count: unset resolves to std::thread::available_parallelism(), and 1 restores a serial run, which makes a suspected interference reproducible without a rebuild. Worker count changes no member’s membership: it is execution configuration, the same class as GATE_SDK_VERBOSE, and an ambient one narrows nothing. It is deliberately environment-only, a name a knob file is refused for (§The knob file): gate-sdk’s own execution config, read once from the invoking environment before the first member is dispatched.
GATE_SDK_SARIF_FILE is the third reporting knob. Where the invoking environment sets it, the --run arm writes a SARIF 2.1.0 log of its verdict to that path once after the join, in registry order, and the exit status is unchanged. It is environment-only, like the worker count. One run holds one tool, checkwright-gates, with one rule per red member. The rule’s id is the member name, its short description is the invariant the red path prints beneath the verdict, and its help URI is the published location where GATE_SDK_SPEC_BASE_URL is set.
A red member’s results are placed by its own finding lines. Each line of a red member’s captured output that opens <path>:<line>: (or <path>:<line>:<col>:), where <path> is repo-relative and names a regular file, is one error result at that region, its message the line. A red or unresolved member with no such line is one result at the registry line that names it, its message the captured block; a sole --only name the registry does not hold lands on the registry file with no line. A green run writes the log with an empty result set, so a code-scanning reader closes what the previous run opened. A path the arm cannot write is exit 2, naming it, after the verdict lines print. Honest limit: a result’s location is only as exact as the member’s finding line. A finding naming its file in another shape lands at the registry line.
The concurrency contract. Members run on a worker pool built from std::thread and std::sync alone, with no new crate dependency: §The adopter constraints makes footprint a cost paid per target on every adopter’s machine, and the standard library already covers a scheduler. Four properties bind:
- Deterministic output ordering. Each member’s captured output is buffered and flushed in registry order, never completion order, so a red reads the same way twice and a transcript diffs against itself. The summary’s failed-set is likewise registry-ordered.
- The timings file is not a contended writer. Per-member elapsed times are collected in memory and written once after the join, in registry order,
TOTALlast, the grammar drift-kit’skpi-gate-runtimemember and its collator read.TOTALis the sum of per-member times and so does not approximate wall-clock: under a pool the two part company, and per-member times rise with CPU contention while the run gets shorter. Those two readers must not read a risingTOTALas a regression. - Per-gate scratch isolation, split by what the directory is for. Each child gets a private
TMPDIRunder the run’s own scratch, where a member’s anonymous temporaries land. That scratch sits under the system temp dir, and it is absolute, because a child’s working directory is its own and a relativeTMPDIRsilently resolves somewhere else inside one.GATE_SDK_TMP_DIRstays shared, because it is the home of a declared, content-keyed cache whose whole value is surviving the run:check-crate-armswrites itscrate-arms-<hash>.greenthere and reads it on the next battery. The rule a later member is measured against: an anonymous temporary is private; a declared cache is shared and its filename must carry its key. The run’s scratch is removed on the way out, so a battery leaves the tmp dir’s file set as it found it. - The projection-ordering constraint is discharged by an invariant, not by the scheduler. A full run over this tree leaves
git status --porcelainbyte-identical and the tmp dir with no new entry. No registered member regenerates anything another reads, because the freshness family compares against an in-memory render rather than writing one. A member that wrote a projection another member read would be a defect against this contract, not a scheduling input.
A shared tree walk is not needed, and is priced so a later session does not re-reach for it. The standing counter-proposal to a pool is one shared walk feeding many readers. Measured, listing and reading the whole tracked corpus costs a few milliseconds of CPU, while the members that walk spend their time on their own per-file matching, which a shared walk cannot share since each wants a different predicate over the same bytes. The battery’s wall clock is floored by its longest single member, check-shellcheck, which the timings file’s slowest rows name. Splitting that member’s corpus is what would move the bound, not the scheduler and not the walk.
A short-circuiting consumer may not be fed by a producer inside the same gate. A member that abandons an in-process pipe producer — printf '%s\n' "${set[@]}" | grep -q …, the membership idiom — takes the producer’s SIGPIPE as the pipeline’s status under set -o pipefail, and the verdict flips. Serially the producer’s single write almost always completes before the consumer short-circuits; under a pool’s contention it does not. Membership is therefore a for loop, never a pipeline. A pool does not create such a defect; it converts a silent one into a visible one, which is what a battery should do. The rule is held by check-pipe-membership (§check-pipe-membership).
The dispatch capture holds the two streams apart, and it is gate_command’s contract rather than the battery’s. gate_command’s stdout is the invocation argv, one element per line; its stderr is diagnostic text. Merging them makes any stderr a successful call emits the first argv element, which the caller then execs: a 127 naming the diagnostic instead of running the gate, with a status of 0 throughout, so nothing upstream reads as an error. So a caller captures stdout into the argv value and stderr apart from it.
Neither half of the runner is such a caller. The arm builds argv as data and never parses a stream into it, so it keeps no capture file under the tmp dir, and the stub locates the binary and execs. The rule binds every caller that parses gate_command’s stdout as argv — the fixture runner, the hook installer, the consumer smoke, the hermetic-test library and the stage-entry step among them — and each must hold the two streams apart.
The capture is structural, never left to a producer’s discipline, because a caller execs whatever stdout carries. A helper abandoning a pipe producer mid-write emits a broken-pipe diagnostic on every run where SIGPIPE is ignored, as on a CI runner inheriting SIG_IGN from its supervisor, and nothing where the disposition is the default. A green local battery cannot see that environment-determined defect. Reproducing it locally means restoring the signal environment (trap '' PIPE), which is what makes the lib-gate.test.sh arm deterministic.
run-gates.sh --hook <member> dispatches a harness-integration arm (§The non-gate arm), the one branch whose caller is not this project’s. The member name is an operand in both halves, unlike --emit <name>, which the crate’s normalization fuses into --emit-<name>, because --hook-<member> would be one arm per member, the refused shape. exec_arm locates the binary and execs, and exec preserves stdin, so the harness payload reaches the arm untouched and the front-end writes nothing on stdout when the binary runs. A dedicated bin/run-hook.sh is refused: it would add an owed shell member and duplicate exec_arm.
exec_arm’s one variant is a status rather than a second function, which keeps the binary check and the exec single-sourced. A dispatch failing before the arm runs, on an absent or non-executable binary or in a tree whose repository git refuses or skips, exits $ARM_UNAVAILABLE_STATUS. That is 2 for every arm whose verdict a battery or a session reads. It is 0 for the two harness-integration arms whose status the harness itself interprets: --hook, where it is the allow/block signal, and --statusline, where the harness ignores it, so an exit 2 would be a verdict nothing reads. --usage-poll keeps 2, since its caller is a timer, not the harness.
run-gates.sh --lesson-sink <tag> keeps 2 on the same discriminator: it gates no tool call, and its caller is a close-stage step whose failure must be visible (queue-kit/SPEC.md §The lesson-sink arm). Like --hook, it takes its operand as argv and relies on exec preserving stdin, so the lesson body reaches the arm untouched; unlike --emit, its flag is the whole spelling. The diagnostic goes to stderr on both settings, and the fail-open --hook setting adds the systemMessage envelope (§The harness-integration arm), since at exit 0 the harness sends stderr to its debug log alone. That is §The non-gate arm’s fail-open rule spelled at the one place the front-end can see the cause.
run-gates.sh --for <path> [<path>...] is the path-scoped selector, the agent-callable half of the oracle-first rule. It resolves the registry exactly as a bare run, then runs only the members whose effective trigger glob-matches at least one given repo-relative path. The effective trigger is trigger= else couples=, expanded through registry::expand_couples, followed by the member’s derived couples (§The # graph: manifest). Registry order and per-gate output are unchanged, and a bare run-gates.sh keeps its behavior. The loop this buys — edit, run the coupled gates, read the verdict and help — is cheaper than reading gate source to predict a verdict. Its producer is a mid-edit session or a delegated agent’s gate-driven worklist, and it needs no new config, since every gate already carries the # graph: manifest the selection reads.
The match is one matcher, runner::staged_matches, and the hook arm calls this selector (§git-hook), so what the hook runs for a staged path and what --for runs for it cannot diverge. The selector draws the # graph: fields through registry::manifest_fields and registry::expand_couples, the single expander §The # graph: manifest names. A trigger=* gate is selected for every path. A mode=staged gate matches by git pathspec (exact path or subtree prefix) and receives its matching paths as positional args. In both the case pattern and the default (non-:(glob)) pathspec, * crosses /. The selector expands kit: against one spelling of the kit roots, relative to the repository root, so a subdirectory vendoring selects what its paths trigger (§Layout and configuration).
When no gate couples to a given path the selector prints no registered gate couples to <path> and exits 0, since an ungoverned path is a fact, not a failure. The selector is a bin/ tool, never a registered gate, and its own plumbing stays fail-closed: an unreadable registry, an unresolvable member or a resolved member whose declaration cannot be read exits non-zero.
run-gates.sh --only <name> [<name>...] is the name-keyed selector, the ergonomic form of the targeted run that §Layout and configuration’s gates.list bullet composes out of two knobs. It resolves the registry as a bare run, then runs the named members. --only is recognized as the first argument on the same terms as --emit and --for, consuming every remaining argument as a gate name, so the [gates-dir] positional is unavailable in this form. A caller selecting within another registry composes it through GATE_SDK_GATES_DIR, the knob that positional shadows anyway. An empty name list is a refusal, run-gates: --only needs at least one gate name, exit 2, matching --for’s message shape.
Selection is set-shaped and registry-ordered: the named set is intersected with the registry and run in registry order rather than argv order, so two names give the same transcript whichever way they were typed. Duplicates collapse silently, because the argument is a set. A name resolving in neither the registry nor a check dir is a refusal, exit 2, naming both the name and the registry path. That is where --only deliberately diverges from --for. An ungoverned path is a fact about the tree, so the path-keyed selector notes it and exits 0; a name is a claim about a declared gate, and a wrong one is a typo or a stale memory. Exiting 0 there would print All 0 gates passed., the vacuous green the summary line exists to make impossible.
A sole name the registry does not hold is resolved against the check dirs. A --only naming exactly one member the registry omits is selected if any check dir declares it, and runs identically: same resolution order, same knob resolution, same dispatch. The vacuous green stays unreachable through this door, since a declared member runs with a count of one and a typo still takes exit 2. It serves the class a registry deliberately excludes: a member wired at a consumer’s entry hook rather than into a battery, unregistered precisely so a bare run never reaches it (evidence-kit/SPEC.md §check-producer-liveness states one).
The widening is sole-name-only. With two or more names the registry intersection stands and an unregistered name among them refuses. Set-shaped and registry-ordered has no meaning for a member with no registry position, so a mixed selection would need an ordering rule nothing asks for. Every pre-flight entry names one gate, which is the case that exists.
It reaches an install: never member in a vendored tree, by intent. The disposition governs auto-registration (§The install disposition), while the descriptor rides the payload like any other member, so in a consumer tree such a gate is declared and unregistered. A never member is one an adopter wires deliberately, and a front end that could not reach it would ship a capability that works only in the tree that wrote it. never promises absence from a bare run, and a sole explicit name is the opposite of one.
A mode=staged member receives no positional arguments under --only unless the caller passes some through the channel below, which is the bare-run behavior rather than the hook’s. --for hands such a member its matching paths because it has paths, and --only names gates, so a member it selects runs over its full corpus by default.
--only carries a bounded argv channel, and it is single-member-or-refuse. A -- separator ends the name list, and every remaining argument is forwarded to the selected gate — only when the selection resolves to exactly one member. A -- with two or more selected members is a refusal, exit 2, naming the cardinality and the members. Handing one argument vector to N gates is what the bound forecloses, and a refusal rather than a silent first-member rule means a caller never gets a narrower run than it asked for unknowingly. The cardinality is resolved after the registry intersection, so duplicate names still collapse to one member and still forward. The stage-entry pre-flight is the caller: it resolves a gate’s name and invokes it with arguments. --enter-stage appends <queue> <state> to every pre-flight entry’s argv, and without the -- each appended token would resolve as a gate the registry does not name. So the -- is mandatory even on an entry whose knob default matches its positional. This is the one place the grammar is stated; evidence-kit’s pre-flight roster cites it.
Everything downstream is untouched, which is the value of siting this on the runner rather than in a second tool. The knob resolution, the consumer-first resolve-dir order, the worker pool, the per-gate timing, GATE_SDK_VERBOSE, the declared-omission line and the output contract all behave as in a bare run. The summary line’s N is the selected count, as under --for; the roster-collapse tripwire is a property of a bare run, and neither selector claims it.
Every refusal is the arm’s, and the shapes hold whichever process prints them. The refusals decidable from argv alone travel with the usage text, because the usage text is the unrecognized-option refusal’s body: the two empty-list refusals, the unrecognized-option refusal, --emit with no arm name, and a leading-dash name inside --only. The arm also owns every refusal that reads the registry: no registry at <list>, the --only steer beside it, <list> names no gates, an unregistered --only name, the -- channel’s multi-member refusal, and a member --for cannot resolve or whose resolved declaration it cannot read. Each keeps the run-gates: prefix, because the message shape is this tool’s documented surface, and a caller cannot tell which process printed it.
The runner honours §The bin/-tool contract, which binds because its positional is free text — a path — and an arity check is not a shape check. -h or --help as the first argument prints the usage on stdout at exit 0. A first argument beginning with - that is none of --emit, --hook, --for, --only, -h, --help or -- is a refusal, usage on stderr at exit 2, naming the unrecognized option. -- ends option processing, so a gates-dir legitimately spelled with a leading dash stays reachable. A name beginning with - is an unrecognized option wherever it stands, so --only --for refuses at the name rather than taking it for a gate and calling it unregistered.
The usage text is the arm’s. It is the largest block a front-end would carry and grows by a paragraph per configured arm, so it belongs beside the table that mints them; a second short usage in the stub is refused as an SSOT breach. So -h and --help reach the binary like every other arm: on a binary-less tree they get the absent-binary diagnostic, which names the build command, at the unavailable status. That trade is deliberate, since a usage text listing arms none of which can run is a worse answer than one line naming the command that makes them run. Where the binary is present, --help exits 0. The text names each form by its arm and no program, the binary door’s spelling, and the front end takes every form it lists.
A gates-dir that is really a gate name steers to --only. When the positional holds no gates.list and names a member of the default registry, the refusal gains a line naming the gate and the command that runs it, spelled on the binary door by the remedy helper a gate’s own remedy takes. Where that door does not resolve, the line is left off. The default registry resolves through GATE_SDK_GATES_DIR precisely because the failing argument was never a registry path, which is why the positional crosses to the arm as an explicit argument rather than as an override of that knob. The steer is an addition to a refusal and never a fallback: the positional keeps one meaning, and a caller who typed a name gets a remedy rather than a run it did not ask for. Where the default registry is itself unresolvable the plain message stands, so the steer adds no failure path of its own.
run-gate-tests
Golden-fixture runner, the --run-gate-tests arm (§The non-gate arm). Each <tests-dir>/<gate>/ holds good/ + bad/ case dirs. The runner runs the gate with the case dir as the child’s working directory, with the args in the case’s args file: # lines are stripped and the surviving text is word-split on whitespace into argv, not taken one argument per line. So an argument containing a space is unexpressible in this file, and any second implementation of the runner (the crate’s own parity test among them) must reproduce the splitting rule, not guess it. The split performs no pathname expansion. good/ must exit 0 (and, when good/expect.txt exists, satisfy it); bad/ must exit 1 and satisfy bad/expect.txt — a rejection expectation is required, so the right finding fired.
The member is an Arm::Run and an arm table row, and both are forced rather than chosen. Its contract is a three-valued exit — 0 clean, 1 a logic failure in a gate or a unit test, 2 a harness or fixture error — which every caller reads. An Arm::Emit collapses that to 0-or-2, so it could carry the report but not the verdict the evidence-kit suite and the per-kit roster line consume. It is a table member rather than a hardcoded top-level flag because it is configured: GATE_SDK_TESTS_DIR, GATE_SDK_TMP_DIR and GATE_SDK_NATIVE_BIN are its declared knobs, and a top-level flag receives no row a declaration reader can see (§The non-gate arm). A compiled arm has no BASH_SOURCE anchor, so it finds gate-sdk’s own shell library through the GATE_SDK_ROOT locator. It absolutizes the locator against the invoker’s root, as it does the binary, and exports it into every case and unit test it spawns, because each runs in another working directory. bin/run-gates.sh passes the arm straight to exec_arm, and ARM_UNAVAILABLE_STATUS stays 2 for it: a test runner whose binary is absent has not passed, and 0 is reserved for a harness-integration arm gating a user action.
The dispatch resolution lib/gate.sh owns stays there, reached by a bash spawn. The runner needs gate_command once per case — the consumer-first dir order, .sh beating .gate within a dir, the binary check — and a crate-side copy would be a second implementation of what the library answers for its shell callers. The discriminator is whether the crate re-derives what the library computes, and this runner only consumes gate_command’s answer. So per case the arm spawns a bash that exports the absolutized binary, the case scratch and the absolutized GATE_SDK_ROOT, sources lib/gate.sh, and cds into the case dir. That shell prints the case’s own GATE_SDK_NATIVE_BIN pin, read through _gate_prebinary_knob with the exported value unset, then runs gate_command. The arm reads the pin and the resolved argv off stdout, one element per line, and resolves the default gate-declaration dir set the same way, out of gate_check_dirs.
Three properties bind that spawn, and each is why a cheaper shape is refused. The cd into the case dir happens on the shell side, before gate_command runs, which keeps the invoker-root/case-dir split below exact. A refusal is a status, not a parse: gate_command names it on stderr, which the spawn inherits, and its status becomes the HARNESS: line. And nothing is re-implemented: the crate learns the argv and nothing about how it was derived, so the duplication is absent rather than machine-held. Whether the spawn is per case or batched is a calibration under one constraint: each case’s resolution runs with the process cwd inside that case’s own directory, and batching changes no resolved value. The shipped form spawns per case.
The case runs whatever the member dispatches to. The invocation resolves through gate_command (§lib/gate.sh), so a case runs <binary> <name> for a .gate-declared member exactly where it runs the script for a shell one, and the executable guard applies to the resolved argv’s first element. That makes the fixture pair an executed parity oracle across both substrates: check-gate-fixture-coverage asserts a pair exists and never runs it. The ordering is binding: a ported gate’s pair passes against the subcommand before the script it replaces is deleted, never after.
Both argument positions are fail-closed. Each holds on §The non-gate arm’s distinguishing test: the first is a scan-root positional the rule itself composes, the second an input-corpus positional selecting what the rule analyses, and neither redirects configuration the knob resolution already selects. $1 is the tests dir, and an absent tree exits 2 saying so. ${@:2} is the gate-declaration dir set and replaces the resolved default rather than extending it. A named member that is not a directory exits 2 naming it; dropped silently, it would empty the search path, and every gate would report HARNESS: <gate> resolves in none of: with no line naming the argument. Both are paths, so a dash-led one is a refusal printing the usage block at exit 2, and -- admits one (§The bin/-tool contract). This repo’s own consumer remainder keeps no checks/ dir, so its roster line (README.md §This repo, governed) passes the tests dir alone.
Every subdirectory of a tests dir is read as a case pair. The runner globs <tests-dir>/*/ and demands good/ and bad/ under each, so a directory that is not a gate’s fixture pair reds as a missing case dir. A bespoke *.test.sh therefore keeps its corpus inline (or under the scratch dir) rather than in a sibling directory beside itself.
A bespoke *.test.sh reaches its gate the same way, through gate_run (lib/test-hermetic.sh): it names a gate and a checks dir, never a script path, so a member’s port leaves its behavioral tests untouched. The binary is pinned absolutized rather than defaulted — the accessor’s own answer joined to the bootstrap’s anchor (§lib/test-hermetic.sh) — because these tests run their gate from a sandbox cwd where the knob’s repo-relative default resolves to nothing. A case’s environment is applied with gate_env inside the command substitution, because env cannot invoke a shell function. A bespoke test’s knob files are pinned away: lib/test-hermetic.sh points every <KIT>_KNOB_FILE and <KIT>_LOCAL_KNOB_FILE at one empty file, so a gate it drives reads no case’s <gates-dir>/gate-sdk-config.knobs and no overlay beside it. Such a test sets a knob with gate_env, since an exported scalar outranks every file, or points the kit’s <KIT>_KNOB_FILE at a file of its own. The --run-gate-tests arm, by contrast, does read the case’s knob files: it runs each case in the case dir and pins neither locator. The asymmetry is stated here because it is invisible at the call site that suffers from it.
A *.test.sh may also be a scenario runner that compares substrates, and it asserts exactly where a dispatch exists. A bespoke test whose subject is a shared derivation held on both substrates reaches the compiled side through gate_native_bin, since the arm it interrogates is a binary-level flag and dispatches no gate (§check-gate-substrate-parity). Its skip predicate is check-gate-binary-fresh’s a declaration is not a dispatch, applied one layer up. Where the binary is absent or non-executable — a tree vendoring the shell library with no binary behind it — the comparison has nothing to compare and skips, saying so on its clean line. So a reader can tell no binary here from parity holds; a silent skip would be the vacuity the fixture pair exists to end.
Two boundaries keep the skip narrow. A binary that is present and refuses the arm is stale, not absent, so it fails rather than skips, and §check-gate-binary-fresh names it. And the runner asserts that its corpus still reaches the branches the comparison was bought for, since an agreement over a corpus that classifies nothing is a pass with no content.
The runner is the one caller that needs the dispatch executable rather than the whole command (§lib/gate.sh), and it reaches it through the spawned resolution above. It applies both the executable guard and the absolutization to the argv’s first element: the binary knob’s default is deliberately a repo-relative path, so an element not absolutized before the cd is resolved against the case dir and vanishes.
Knob values are resolved on the other side of the same split. The dispatch executable and the gate dirs are resolved at the invoker’s root; the knob values are resolved inside the case dir, because the binary reads <gates-dir>/<stem>-config.knobs cwd-relative and the case’s own knob file is what the member reads. Resolving them at the invoker’s root would hand the binary this repo’s consumer config while the fixture holds its own, two oracles instead of one, contradicting the hermeticity clause below. So the case runs in the case dir, and the binary is absolutized before it. A case pinning GATE_SDK_NATIVE_BIN in its own knob file keeps its pin, read inside the case dir by the resolving shell; every other case is handed the absolutized binary, since the exported value outranks the file.
The output contract is asserted here, at runtime (§Output contract). A good/ case must emit the canonical ^<NAME>: clean (<parenthetical>)$ line and a bad/ case must emit a help: remedy line, on top of exit code and expect.txt. This applies to every fixtured member on either substrate. An expect.txt is a conjunction: every non-blank line must appear literally in the case’s combined output, and the case fails when any one does not. So a case pinning two findings writes two lines; the semantics are shared by both files, good/’s being optional to supply. Matching is order-independent, because several gates do not fix a report ordering and none contracts one, and a fixture pinning two findings cares that both fired. A blank or whitespace-only line asserts nothing: it is a separator, which grep -F would match against any output. A failing case names every missing line, so one re-run resolves the case. Exit 2 from a gate marks the fixture malformed (harness error, distinct from logic failure).
Fixture-pair hermeticity is the case dir as the child’s working directory: a gate resolving its knob files under the cwd finds only the case’s own files, and a fixture may ship its own cwd-relative config deliberately. The runner sets that directory on the spawn and never enters it itself, so no code path can leave the process in a case dir.
That working directory buys the read side and costs the write side, so the runner pays the write side back: GATE_SDK_TMP_DIR is absolutized at the invoker’s root and handed to the case invocation, the mirror of the dispatch executable’s reason. A member’s runtime scratch — check-crate-arms’s source-stamp cache is the live instance — resolves that knob’s deliberately repo-relative default against its cwd. Under the case dir that cwd is a tracked fixture corpus, so the gate would deposit state inside the very directory it is the oracle for, .gitignored, surviving the run, and riding cp -R into anything that vendors the kit tree verbatim. The fix belongs here and not in the member: the member’s cwd-relative spelling is what §Layout and configuration ratifies for every inline reader, and the anomaly is a cwd pointed at tracked content, which only the case invocation does. Pinning the knob per fixture instead would be one hand-remembered copy per scratch-writing gate, audited by nothing.
The pin is one exported value, and it serves both substrates. It is exported into the resolving shell and onto the case invocation, so a .sh member reads it in its own process and a .gate member through the crate’s knob resolution, where a scalar’s environment value outranks every file (§The knob file). A case’s knob file naming its own scratch therefore cannot override it, which a unit test holds. Because the correction sits at the knob’s resolution, any member that declares GATE_SDK_TMP_DIR inherits it with no edit. Two shapes are foreclosed: computing the pin at the member, a second producer of one value, and a per-gate opt-out knob, a second source for one pin. The consumer gains no config, and the knob’s value stays the consumer’s for ordinary runs.
Per-case sandbox isolation is a deliberate non-target. The pin resolves to the invoker’s root, so every case shares one scratch directory. A per-case one would also stop one case’s cache greening another, a different property from the one the pin closes, and a question nothing asks.
The pin’s scope is the pair loop and the shell that resolves each case’s argv, never the runner’s own process. A bespoke *.test.sh runs at the invoker’s cwd and builds its own sandbox trees off this knob’s relative default. A process-wide export would replace every one of those sandboxes with the invoker’s live scratch: evidence-kit/gate-tests/producer-lock.test.sh, whose scratch trees each own a .tmp/run-validate.lock, would then contend with a real --run-validate producer in the same tree. Within the pair loop the pin stays one-directional: an already-absolute value passes through.
<tests-dir>/*.test.sh unit tests run after the pairs, and each must exit 0. Each runs with the invoker’s cwd (repo root in this repo’s battery), so absent a pin a gate it drives silently inherits this repo’s consumer config. The hermeticity contract is therefore explicit: every bespoke test sources lib/test-hermetic.sh (§lib/test-hermetic.sh) as its first act, pinning every kit’s <KIT>_KNOB_FILE and <KIT>_LOCAL_KNOB_FILE to one shared empty file so the gate runs on kit defaults, and check-test-hermetic (§check-test-hermetic) enforces the pairing. A case that deliberately exercises config overrides the pin after the source, by a later assignment or an env -u <KIT>_KNOB_FILE / per-invocation prefix.
A tests dir may hold fixture pairs, bespoke unit tests, or both, and the runner exits 2 only when it holds neither, since the invocation then named nothing to test. So a gateless kit ships bespoke tests with no fixture pair to give: it registers no gate to build a pair around, yet its lib/ and bin/ still need testing. The discovery rule recognizes that kind of kit (gate_kit_roots keys on checks/ or smoke/ — §lib/gate.sh). A unit-test-only dir prints 0 pairs beside its unit count, so the shape reads as deliberate. This widens what a tests dir may contain, never what a gate may omit: a registered gate still owes its good/+bad/ pair (§check-gate-fixture-coverage). Rehoming such a test in the owning kit’s own runner is ruled out: check-test-hermetic’s assertion A enumerates <kit-root>/gate-tests/*.test.sh only, so a test outside that directory would escape the bootstrap-or-marker obligation.
The runner is a test layer parallel to the gates, never a gates.list member. Like §upgrade-smoke’s tool it owes no good/+bad/ pair of its own, and a pair would sit outside check-gate-fixture-coverage’s registry authority set, audited by nothing. Its own coverage is the bespoke gate-tests/run-gate-tests.test.sh, which drives it over scratch fixture trees to pin the expect-line conjunction above. The inner invocation is bounded by a tests dir holding fixture dirs and no *.test.sh, so it runs pairs and returns. That test reaches its subject through gate_arm_run (§lib/test-hermetic.sh), the arm counterpart of gate_run, rather than through bin/run-gates.sh, because the front-end refuses outside a repository and the write-side row drives the runner from a non-git sandbox cwd. Its scratch-pin override is an unset inside that row’s subshell rather than an env -u prefix, because env cannot invoke a shell function.
run-consumer-smoke
The scratch-consumer install+violation harness, the binary’s --run-consumer-smoke arm (§Consumer smoke). It vendors the kit roots into a fresh temp repo, drives each smoke/install.sh, and asserts the full battery is green under zero config. It then runs the registration accounting over every shipped-but-unregistered gate, fires each smoke/violation.sh, and asserts the battery reddens at the named gate before restoring. A non-gate arm (§The non-gate arm), never a gates.list member: it is pre-commit-unfit by runtime budget, and it is the proof that the kit defaults hold on a vendored-kit tree.
upgrade-smoke
The two-phase upgrade proof, the binary’s --upgrade-smoke arm and a caller of the scratch-consumer builder’s vendor_and_install (§Consumer smoke). run-consumer-smoke proves one release’s defaults hold; this proves the transition between two. It vendors every kit at a FROM ref into a scratch consumer, installs, seeds the config seam init derives, and asserts the baseline is green; a red FROM baseline is a broken tag, exit 2, not an upgrade finding. It then replaces the vendored kit directories wholesale at a TO ref, retires the shell configs TO’s kits replaced with knob files, and regenerates the generated artifacts: the contract’s consumer phase-A steps (installer/SPEC.md §The upgrade contract). It asserts determinism (the scratch consumer’s git status shows changes only under the kit roots), and then, over the phase-B battery, that the red set is a subset of TO’s gates-section declaration.
A new N+1 gate is not in this consumer’s gates.list, since phase A never re-runs the installer, so it does not run in phase B. The smoke asserts the declaration’s sufficiency for the gates that do run, and the upgrade skill (lifecycle-kit/SPEC.md §templates/upgrade.md) registers the new ones. Its absence still reds phase B: §check-gate-substrate-parity assertion I reds on a vendored kit’s member the registration neither registers nor declares, so a release adding a kit-owned gate owes a gates-section bullet naming check-gate-substrate-parity, reconciled by that registration. A red gate absent from the declaration, or a declaration that does not parse while reds exist, is exit 1: a malformed declaration is a contract violation, not a broken environment. Usage or environment failure is exit 2. It is a non-gate arm (§The non-gate arm), so no fixture pair is owed. Its evidence is the upgrade validate suite (scripts/evidence-config.knobs), at about twice run-consumer-smoke’s cost since it runs the battery twice, accepted as validate-stage cost and never pre-commit.
The spelling is --upgrade-smoke, and the family is forced. Its contract is the exit status. Arm::Emit collapses every error to 2 and every success to 0, so §The non-gate arm’s rule that “a member whose status distinctions carry meaning cannot take the --emit- spelling” binds. It is an arm table member rather than a hardcoded flag because it reads six knobs, and a hardcoded flag has no row to declare them on. Its usage lives in the runner’s --help. The arm takes no positional and no flag.
Its callers are two. The upgrade validate suite writes the exit status into the validate session’s evidence file against the upgrade suite’s baseline row (evidence-kit/SPEC.md §Baseline manifest). A session reads it at the standing pre-release assertion, with TO at its HEAD default, and at the release procedure’s go/no-go; a session reaching the arm through the front-end counts as a stage step does (§The non-gate arm).
The FROM consumer is seeded with the config seam init derives. After vendoring and each kit’s smoke/install.sh, the arm copies every destination recipe::config_seam_plan derives over each FROM kit root that is still absent, and commits the result into the baseline. So the baseline is the tree an init-seeded consumer holds, and a smoke that wrote its own config keeps it, since a copy lands only where nothing is. The step lives in this arm alone, never in the shared builder, so the consumer smoke’s baseline does not move with it. A seeded copy that reds the FROM baseline is the tag’s own defect, reported as FAIL(env). Without the step, a config-shape change to a kit whose smoke seeds no config ships a consumer break this suite cannot see.
Phase A retires the shell configs TO’s kits replaced with knob files, before it regenerates. Such a kit ships templates/<stem>-config.knobs and no <stem>-config.sh, and its loader refuses a <gates-dir>/<stem>-config.sh or <stem>-config.local.sh beside it (§The knob file). The regen expands every couples=knob: token, so it cannot run while such a file stands. Phase A is therefore three steps (installer/SPEC.md §The upgrade contract): sync, retire each such config, regenerate. The arm retires by deleting, the set derived through recipe::config_seam_plan over TO’s kit roots; deleting is a declared remedy, and the scratch consumer holds nothing an adopter set. The step runs after the determinism check, so that check measures the sync alone, and the deletions ride the phase-A commit.
A contract-following consumer therefore meets no red from a left-behind config, and the suite requires no gates-section bullet for one. A gate that reds because a deleted file held a value its kit smoke set still reaches phase B, where TO’s declaration must contain it.
The honest limit. Both derivations are the host’s, over FROM’s and TO’s templates, exact while the rule is unchanged since FROM. The placement seam init writes beside an artifact (installer/SPEC.md §The gate binary) is not seeded, since writing it faithfully means running FROM’s installer. The suite deletes rather than rewrites, so it never proves an adopter’s rewrite of a config they edited.
The determinism assertion is measured between phase A’s sync and its regen. The claim under test is the sync’s: replacing kit directories wholesale loses nothing a consumer owns. Read after the regen, git status would mix two authors. Unmixing them would take an allow-set naming every generated artifact, which reddens whenever an emitter grows an output and cannot tell a sync that clobbered the agent file from install-doctrine writing it. The suite claims nothing about the regen’s write set: each emitter’s is held by its own freshness gate (check-graph assertion E covers the graph artifact) and fixtures.
The regen reads the artifact’s path in the consumer’s tree. The graph emitter is a binary arm reached through the emit front-end (§The non-gate arm), and its destination is the scratch consumer’s own GATE_SDK_GRAPH_ARTIFACT, never the host’s. A host that republishes its artifact would otherwise write to a path the consumer’s own gate never reads. That read is a bash -c running the consumer’s own front-end. The descriptor count, the FROM vendoring and each placement are in-process calls to the scratch-consumer builder (§Consumer smoke), whose installers’ output goes to stderr, since stdout carries the one clean line. Both refs’ kits come from the extracted refs, so the arm resolves no kit root of the invoking tree.
The declaration resolves in-crate, through native/src/declaration.rs, the grammar’s only holder (§lib/declaration.sh), and the arm sources no shell library.
Each phase runs against its own ref’s gate binary. A .gate member dispatches to a binary that does not travel with the kits: native/ ships no checks/ or smoke/, so the phase-A swap never reaches it. One binary placed once would run FROM’s shell against it too, and a FROM tag whose shell predates a widened binary interface would red under a pairing that never shipped, with phase 1 calling the tag broken. So the suite builds one binary per ref: FROM’s before phase 1, and TO’s in the same motion that swaps the kit directories, since that swap is the upgrade transition. Phase B’s claim, TO’s shell against TO’s binary, then holds by construction.
A ref’s binary comes from a git checkout of that ref, never from the archive its kits come from. native/build.rs stamps the crate’s source by running git ls-files and panics when that fails. An archive build would die there and read as a broken tag. The checkout is a shared local clone under the scratch base (git clone --shared --no-checkout, then a detached checkout of the commit the ref resolves to in the host). It is never a linked worktree, which registers itself in the host’s .git/worktrees/. Rust runs no destructor on a signal, so a killed run would leave that registration behind, and the iteration-boundary entry refuses on any linked worktree (lifecycle-kit/SPEC.md §bin/enter-stage.sh). A clone registers nothing, so a killed run leaves only scratch, which the scratch reclaimers already own. Its native/target/ is scratch too, so the host’s build output, which check-gate-binary-fresh judges, is untouched.
Neither a signal handler nor a boundary reaper replaces the clone. A handler covers SIGTERM and SIGINT but never SIGKILL, would be the crate’s first process-global signal writer (§lib/gate.sh’s process-global rule), and on Windows has only a console control handler. A reaper at the boundary would reverse lifecycle-kit’s ruling that the reap is a session act.
The per-ref build is a cargo build --release in the clone’s crate directory. Its wall clock goes to compiling the crate’s modules, so it moves with the module count and with any dependency the crate takes: re-measure rather than re-derive. The build needs the registry: an empty cargo home plus --offline fails outright on the crate’s serde_json dependency. The clone shares the host’s cargo home, so a contributor who has built the crate once is warm. A fresh machine and every CI runner are cold, and neither .github/workflows/publish.yml nor gates.yml provisions a cargo cache; whether to provision one, vendor the graph, or accept the fetch is open.
A ref carrying no crate is a branch, not a special case. Whether a binary is needed comes from the builder’s gate_descriptors (§Consumer smoke), the derivation the placement itself uses. A ref whose vendored kits carry no *.gate needs none, so no clone is made and no build runs: that is every tag before the first one shipping native/, any of which a consumer may name in GATE_SDK_UPGRADE_FROM. A ref that does dispatch and carries no crate, or whose crate will not compile under the current toolchain, is exit 2 under phase 1’s rule, reported with the ref named and the build’s own stderr. It never falls back to the host’s binary.
Both sides of the comparison are committed at their ref. Phase A archives TO, so the kits under test are TO’s committed content, and TO’s binary is built from the same commit rather than from the host’s working tree, even though TO is usually HEAD. Committed is not landed on master: GATE_SDK_UPGRADE_TO takes any git-resolvable ref, so a throwaway commit on a scratch branch is a legitimate TO. Only the HEAD default makes a working-tree measurement invalid, so sizing a gates-section declaration before the push is one commit away.
What it does not cover. It proves the vendored kit directories moving FROM→TO and never re-runs an installer, so a consumer’s cross-version init path is outside its reach: a green upgrade suite is evidence about kit contents, not about the activation surface that delivers them. The gate binary is covered for the phases the suite pairs, each running its own ref’s artifact.
The gate binary is placed by the harness, and sits outside the determinism assertion. That assertion covers changes under the kit roots, so an installed binary is neither a finding nor an exemption. Re-placing it at phase A does not disturb the assertion, because the scratch consumer’s .gitignore carries GATE_SDK_NATIVE_BIN’s path (§Consumer smoke). The install path’s idempotence proof is the installer’s smoke (installer/SPEC.md §The consumer smoke), which does re-run init. The rule it holds is the manifest’s: an on-disk artifact that still verifies against the recorded digest is not rewritten (installer/SPEC.md §The manifest).
The declaration resolves on two arms, both over §lib/declaration.sh’s one token predicate. A tagged TO resolves its version from the v* tag pointing at it. Its declaration is the docs/posts/ note whose front-matter release: names that version: the gates section’s lead tokens, in the grammar installer/SPEC.md §The upgrade contract owns. An untagged TO, the HEAD default and so every run of the standing pre-release assertion, reads the gates section of <workflow-dir>/release-declarations.md out of TO’s tree instead. Both arms find the section under the gates role’s heading or an alias, resolved from GATE_SDK_RELEASE_SECTIONS and GATE_SDK_RELEASE_SECTION_ALIASES in the invoking tree, so an archived note is read under today’s names; a roster without the role declares the empty set. Without that arm an untagged TO would resolve no note, so an iteration that tightens anything could not pass the assertion. An empty declared set still forces an empty red set.
The release declaration surface, <workflow-dir>/release-declarations.md, is a tracked checked projection (§The workflow directory). Its path derives from GATE_SDK_WORKFLOW_DIR and it has no knob of its own, since such a knob would add a way to configure the assertion away without a way to satisfy it. Its first line is the # contract: header. Below it sits a ## section per declared role, under its roster heading or an alias: every declaration-bearing role but platforms, which composition derives (installer/SPEC.md §The upgrade contract). Each holds bullets in the grammar that contract gives the section in a note. A section that is absent, or present with no bullet, is the empty set. There is no None body, so a drained surface is its header line alone. Order carries no meaning, and a section is written in note order when first created.
The surface holds exactly what the note will declare, written in the note’s grammar, rather than bare names. A red a vendored tree meets on upgrade can need a one-line remedy only the landing session knows, and a changed surface that is not a gate has no name to record. The smoke reads only the gates section’s lead tokens, through the holder the tagged arm uses; the prose is read by the close that transcribes it into the note.
Only surfaces that ship inside a kit are declared, because only those reach a consumer’s vendored tree. Being tracked is load-bearing: the smoke reads the file out of a git archive of TO, which carries tracked content only.
Its producer is the session that lands the change, in the same commit: the build stage for every section (lifecycle-kit/templates/stages/build.md), and any other session whose commit ships such a change. It declares from knowledge rather than reconstructing from commits or from a red. Where that session has ended, the session that discovers the omission is the producer, whatever its stage, in the commit that discovers it; appending records a shipped fact, so it writes no queue entry. For a removed kit bin/ tool and a data change to a template init claims, discovery is mechanical: check-release-change-declared reds the committing session until the surface names the path. A gate that lands new or gets stricter takes a gates bullet, and an assertion that discovers its allowed-red set from the gate it was meant to check is its own trigger. A knob renamed, removed or added takes a knobs bullet. A change to the gate-authoring contract (the gate library, the descriptor grammar, the skeleton or the runner’s member contract) takes an authoring bullet. Any other change to what the kits do takes a behavior bullet. Where a consumer must act, the bullet’s prose carries the remedy.
A gate whose input moved is declared on the same ground. A build minting a new template binding slot reds the gate grading slot binding in every vendoring consumer until that consumer binds it, while the gate’s rule stands still. What owes a declaration is a red a vendored tree meets on upgrade, not a diff inside the gate, so “lands or tightens” names the common producers rather than the boundary. The bullet carries the one-line remedy. A template init claims declares in both sections when its change tightens a gate: the gates section for each gate it can red, and Behavior changes on the template path. init rewrites an unmodified seeded copy on upgrade, so it reds, and keeps an edited copy, which then diverges; two populations take two sections every time. A template no init claims reaches an adopter only as a copy they took out, and takes Behavior changes alone (installer/SPEC.md §The upgrade contract).
It accumulates, by choice: each section is a release-level aggregate, not an iteration-level one. Several internal iterations batching into one release is a shape this repo wants, and a build stage cannot write a note whose version it cannot know. A buffer composed once at the release boundary is correct under batching and degenerates to one iteration per release. RELEASING.md §The procedure composes the note’s three declaration-bearing sections from it in Author the release-note post — in-iteration and drains it at the tag in Tag the stamp commit, then drain the release declaration surface, truncating to the header and never clearing the file. An iteration closing on release none or a deferral carries every section forward, so a deferral’s disposition line carries the earned level and no criteria.
Knobs, each read exactly once at the resolve step:
GATE_SDK_UPGRADE_REPO— the kit-source git repository; default empty, which the knob’s one reader expands to the enclosing repo’s toplevel. A consumer points it at their checkwright clone; the smoke never touches the network.GATE_SDK_UPGRADE_FROM— the FROM ref; default empty, which the reader expands to the source repo’s newestv*tag (none resolvable is exit 2, not a skip).GATE_SDK_UPGRADE_TO— the TO ref (default:HEAD).- Scratch base is the existing
GATE_SDK_TMP_DIR: the extracted trees, the per-ref clones and the consumer live under it and are removed on every ordinary exit, and a killed run leaves nothing outside it. - The declaration path derives from
GATE_SDK_WORKFLOW_DIR, resolved at the same step.GATE_SDK_NATIVE_BINis declared beside it and read by the in-crate builder, which places each ref’s binary at that path and names it in the scratch.gitignore(§Consumer smoke).
All three GATE_SDK_UPGRADE_* defaults live in gate-sdk’s table. The binary exits 2 on a knob its kit’s table does not declare, so a default held anywhere else would be read by nothing. _TO’s row carries HEAD verbatim and takes it for an empty value, with check-knob-default-coupling as the oracle. The other two cannot be verbatim: each is a derivation over a git repository, so each defaults empty and its one reader expands it. That is the CONTEXT_KIT_MEMORY_DIRS / CONTEXT_KIT_PUB_LANGS shape, since transcribing a derivation into the table would be the second producer criterion 6 refuses. check-docs-cmd assertion B and check-kit-ref-liveness both red a kit-prefixed knob token the table does not carry.
cargo and tar on PATH are requirements on this suite, not on an adopter, and cargo binds only for a ref that dispatches a member to the binary. tar extracts a git archive of a ref; the archive’s committed-content-only property is what the untagged-TO arm reads against, so a per-ref checkout does not replace it. upgrade-smoke is a validate-stage tool in the kit-source repo, whose contributors already need the toolchain for check-crate-arms and bin/build-native.sh. So both stay off GATE_SDK_PROGRAM_FLOOR, which bounds what a gate rule may invoke, and §The port-candidate criteria’s criterion 7 does not bind here.
The battery-summary parse is a recorded cross-ref coupling. The suite reads the scratch consumer’s battery through two literals: All [0-9]+ gates passed for a green run and ^[0-9]+ of [0-9]+ gates FAILED: for the red set. Their holders are bin/run-gates.sh and native/src/runner.rs, and the arm is a third reader. Running that battery in process is refused: it is the scratch consumer’s, vendored at another ref and dispatched to that ref’s binary, and a host binary running it would destroy the one-binary-per-ref pairing. The two literals are the contract between two implementations at two refs.
An arm’s own spawned set is recorded in prose; §The program roster holds the union. This member’s is the heaviest in the class, git, bash, cargo, tar and floor utilities, stated here because --needs answers for registry members only (§The non-gate arm).
The repo predicate is git’s own answer. GATE_SDK_UPGRADE_REPO names a repository when git -C <repo> rev-parse --show-cdup succeeds and prints nothing, which is a work tree’s toplevel. So a linked worktree runs the smoke as a main checkout does, and a subdirectory of either is refused; a crate unit test holds both halves.
Criteria 2 and 5, in this member’s terms. It owes no fixture pair, so criterion 2 is discharged on the # no-fixture: road, whose limit stands, and no shell original runs beside the arm. Criterion 5 costs it nothing: a consumer on a host the roster does not cover is refused at install and has no such suite to lose. The suite runs in the kit-source repo, where cargo, already required for any ref that dispatches to the binary, builds the binary the arm needs. It is a validate-stage, pre-release instrument rather than an adopter-facing one.
Producers and consumers:
- The smoke’s verdict (exit code and assertion output) is produced by the
upgradesuite each validate stage, or by a consumer invoking the arm pre-upgrade. It is consumed by the validate session’s evidence file (this repo) or an adopter’s go/no-go on a consumer tree. - The
GATE_SDK_UPGRADE_*knobs come from the invoking environment or gate-sdk’s knob files, defaulted in gate-sdk’s table so the zero-config run works here, and are read only at the resolve step, by the arm itself. - The release declaration surface is produced under the producer clause above, and close composes it into the note at the release boundary.
The surface is read at four named transitions. At the resolve step, the untagged arm reads the gates section’s lead tokens as the allowed-red set. Close composes every surface-declared note section by transcription, and Platforms from the platforms-table diff, and drains the surface at the tag. check-release-declaration-parity compares every section against the note composed from it at pre-commit, while that note is still untagged, so compose-then-drain is held equal at its one comparable moment. check-release-change-declared reads the behavior and gate-authoring bullets at pre-commit for every kit tool removed and every claimed template changed since the last tag (§check-release-change-declared). The upgrade skill reads the note as the consumer’s registration checklist. The grammar’s owner is installer/SPEC.md §The upgrade contract and its implementation §lib/declaration.sh; check-tightened-gates-grammar holds the published corpus’s gates sections to it.
with-foreign-shells
--with-foreign-shells <command> [<arg>...] runs a command with pwsh, dash and sh on PATH, copied out of pinned container images and run natively. A Linux contributor without those shells runs a suite that needs them before a push, not first on a CI leg. --run-front-end-parity runs under pwsh, and the installer smoke under a sh that is dash, as it is on the Linux CI legs. A session changing a PowerShell twin runs the parity check under it before committing. A session changing a POSIX sh surface runs the installer smoke under it before the push, since the smoke packs only a clean worktree.
The shells are copied out because a container cannot run the suites. Both suites spawn the host’s gate binary, which links a newer C library than an image’s and so cannot start inside the image. The image’s own binaries do run on the host’s newer library, so a copied pwsh or dash reaches the host’s binary, git and bash unchanged.
- Provisioning. The arm provisions each shell whose image knob is non-empty. It runs
docker createon the image, which pulls it if absent, copies the shell out withdocker cp, and removes the container.pwshtakes its install directory,/opt/microsoft/powershell/7, anddashtakes/usr/bin/dash. The copies land under<GATE_SDK_TMP_DIR>/foreign-shells/<key>/, where<key>is a digest of the two image references together. Abin/there holdspwsh,dashandshas relative links, the last two to the one dash. - Reuse. A marker written last lets a later run reuse the directory, so a pull happens only on a first run or a changed reference, and a failed build removes what it copied. Each copy is probed once, with
pwsh -NoProfile -Command exitanddash -c :, because an image built against a newer C library than the host’s yields a copy that cannot start. - The run. The command runs through
bash -c 'exec "$@"'withbin/first onPATH. The working directory and the rest of the environment stay as invoked, and the arm exits with the command’s status. - The skip. With no
dockeronPATH, a daemon that failsdocker info, or a host that is not Linux, the arm runs nothing, writeswith-foreign-shells: skipped — <reason>; <command> not runon stderr, and exits 0. The copies are Linux binaries, so only a Linux host runs them natively. The provisioning code compiles on Linux alone, and every other build compiles only the skip. - Exit 2. No command given, an image that cannot be created or copied from, or a copy that fails its probe. Each names the step and the image.
Two knobs name the images, and scratch is the existing GATE_SDK_TMP_DIR:
GATE_SDK_FOREIGN_PWSH_IMAGE— the imagepwshis copied from, defaultmcr.microsoft.com/powershell:latest@sha256:810c4f1e0c9d23022c3ec18c50a6205ee4b60766f1739d329b2948df1fd7d5b0. Empty provisions nopwsh.GATE_SDK_FOREIGN_DASH_IMAGE— the imagedashis copied from, defaultdebian:stable-slim@sha256:5bc3287b25407c965a30f38e32603dc253a3869e1b12a21ac09bfc27fd8b13ce. Empty provisions neitherdashnorsh.
Each default pins a digest, so a moved tag never changes what a run copies. A bump is one deliberate change to the knob table and this statement, which check-knob-default-coupling holds equal. The in-image paths are the arm’s constants, bound to the images the defaults name. A knob pointing at an image with another layout, or with no variant for the host’s architecture, meets the copy step’s exit 2.
It is an arm, not a recipe or a scripts/ tool. A recipe is prose nothing executes, so it rots silently. A new tracked shell file needs a no-port cause from a live exception class (§The port-candidate criteria), and a contributor convenience has none. An arm is executable, unit-tested, and listed by the binary’s own roster. No validate suite wraps it, because the skip exits 0 and the evidence record would read a run that did nothing as a pass.
Family and spawns. It is an Arm::Run, because the command’s status passes through and exit 2 is the arm’s own. It spawns docker, each copy once for its probe, and bash to run the command, so what else it runs is unbounded by construction. When an image is absent docker reaches the network, so the arm is outside FENCE_SAFE_ARMS. Its unit tests hold the argument grammar, the skip on a PATH with no docker, the per-pair directory key, and the bin/ layout built over a constructed copy directory.
Honest limits. Windows PowerShell 5.1 and every Windows host behaviour stay on the Windows legs. A shebang naming /bin/sh still runs the host’s own /bin/sh, since PATH does not reach it. The CI legs remain the oracle, and a local green shortens the path to a green push without replacing it.
Producers and consumers: the arm’s caller is the session changing a PowerShell twin or a POSIX sh surface, and it needs no enabling configuration, both knobs defaulting non-empty. The provisioning step produces the copies and bin/; the wrapped command reads them through PATH, and a later run reads the marker’s presence at the reuse check. The skip line is read by the invoking contributor or session, the only reader of the arm’s stderr.
check-release-change-declared
A removed kit tool and a changed claimed template must be named on the release declaration surface. Two kit-shipped changes surface only in an adopter’s tree. A deleted kit bin/ tool breaks a script of theirs that still calls it. A template init claims, changed as pure data, tightens gates with no gate code moving. This gate is the mechanical discovery route §upgrade-smoke’s producer clause names for both. It is consumer-declared, in this repo’s scripts/, at tier=precommit, and compiled like the declaration cohort’s members.
- Base. The nearest
v*tag reachable fromHEAD, the taggit describe --tags --abbrev=0 --match 'v*'names. Whether one exists is decided before describe runs, byHEADresolving and by afor-each-ref --merged HEADlisting ofrefs/tags/v*, so no verdict rests on describe’s localized message. No commit, or no such tag, is dormant: the clean line says nothing was checked and never reports “checked”. - Changed set.
git diff --cached --no-renames --name-status <base>, the index against the base. Reading the index is what lets the commit that deletes a tool red at its own pre-commit; in the battery the index equalsHEAD, so the two readings agree.--no-renamesmakes a rename a deletion of its old path. - Class R, a removed tool. A path with status
Dlying under<root>/bin/for a resolved kit root (GATE_SDK_KIT_DIRS, the kit-root resolver). Every file there counts, not only*.sh, because the packer ships the whole kit root. - Class T, a changed claimed template. A path present both at the base and in the index, claimed by
init, whose non-comment, non-blank lines differ as a multiset between the two. The claimed set is the installer recipe’s own derivation,config_seam_planplus everySeeded::Plansourcerecipe::seedreturns, never a list. A seam added or deleted is outside class T, and so is a comment-only or reordering edit. - Declared. A top-level bullet in the
behaviororauthoringsection of<workflow-dir>/release-declarations.md, read from the index, contains the path verbatim, or contains both the path’s directory with a trailing/and its basename. The second form admits a directory-level bullet naming its files. - Red. One finding per undeclared path, naming the path, its class and the remedy: for class R a bullet naming the path, for class T that bullet plus a gates-section bullet for each gate the change can red (§upgrade-smoke, the both-sections rule).
- Exit 2. A surface absent from the index or lacking its
# contract:header, a failedgitcall, or a kit-root set that resolves empty.
Argument mode (fixture capability): check-release-change-declared [<surface> [<change-dump> <base-dir>]]. With a dump, the surface is read as a file, the dump’s first record is base <tag> or base - (dormant), each further record is one tab-separated --name-status line, the base tree is <base-dir> and the working directory stands in for the index. The fixture pair holds the canned readings — the bad side one case per class plus the dir-and-basename form, the good side a comment-only template edit, a seam add and a removal declared under the gate-authoring section. A case can hold only one verdict, so the dormant case, the live index read, the rename reading and every refusal are pinned against real tags and a real index in scripts/gate-tests/check-release-change-declared.test.sh.
Why classes R and T and nothing wider. Each is decidable from a path and the diff, and each has a measured instance. A wider class would sweep in every stage-template edit: installer/SPEC.md §The upgrade contract’s folding makes each of those a Behavior change in principle, but no release has declared them, so a gate over them would red on a rule nobody applies. Widening it is a scope question, not a calibration.
Consumer-declared, and why. Only the repository that authors kits deletes a kit bin/ tool or edits a kit template; an adopter’s tree receives those changes at upgrade and never makes them. So no kit ships the gate, and it reads the surface path the way §The declaration cohort’s members do, through GATE_SDK_WORKFLOW_DIR.
Honest limits. The gate checks that a path is named, not that the bullet’s prose is right. It cannot check the gates-section half of the both-sections rule, because no derivation maps a template to the gates that read it; that half is held by the producer and by the upgrade smoke, and only for gates that actually red. A copied-out template outside the claimed set, a template edit anywhere else, and any semantic change stay on §upgrade-smoke’s producer clause.
check-release-assets
What a Release carries is declared once and held at the tag, before the Release exists. §Consumer payload carries the declaration, a full-line <!-- release-assets: <template>... --> comment. Each whitespace-separated template names one asset. {version} stands for the tag’s version and is required. A template also carrying {target} names one asset per target-roster line (GATE_SDK_NATIVE_TARGETS_FILE). No other placeholder, and no /, is admitted. The gate is consumer-declared in this repo’s scripts/ at tier=precommit, compiled like the release gates beside it.
- Battery half. Exactly one declaration line in the declaring doc, every template well-formed, no two templates alike. The publish workflow (
GATE_SDK_NATIVE_PUBLISH_WORKFLOW) carries a line invoking this gate with--dist. Each is a finding naming the doc or workflow and the remedy. A doc with no declaration is a finding, not a refusal, because nothing owning the claim is the defect this gate exists for. - The wiring read. The knob is a word list, so the call may sit in any workflow it names, and every named workflow must be readable. A
#-led line is not a call, since a commented step never runs. - Publish half,
--dist <dir> <version>. The directory’s entries, read non-recursively, equal the declaration expanded over<version>and the roster. It reports one finding per declared asset absent and one per entry declared by nothing, a subdirectory included. It runs inpublish.yml’spackjob after the per-target archives are written. A red there failspack, so thereleasejob attaches nothing. - What the publish half expands. Every well-formed template on every declaration line, so a doc carrying a stray second line is still compared rather than skipped. The roster is read only under
--dist. - Exit 2. An absent or unreadable declaring doc or workflow, an unreadable
<dir>, an empty<version>or one carrying whitespace or/, and a roster that resolves empty.
Argument mode (fixture capability): check-release-assets [<doc> <workflow>] [--dist <dir> <version>]. With no positional, the doc is gate-sdk/SPEC.md and the workflow is the knob’s. The fixture pair runs both halves in one case, with the roster pinned by the case’s knob file. good/ holds a declaration with a {target} template and one without, a workflow carrying the call, and a matching directory. bad/ holds a second declaration line, a template missing {version}, a workflow with no call, a directory missing one target’s archive, and an undeclared file. A declaring doc with no declaration, and each exit-2 path, are pinned by the module’s unit tests, since a case can hold one verdict.
Its own class, not a second axis of check-payload-claim. That gate matches sentences against a vocabulary of disclosure classes (canon-kit/SPEC.md §check-payload-claim). An asset set is an enumeration whose truth is a build product, and no sentence pattern can hold it. So it is not a second claim axis, and the claim-registry trigger that section records does not fire.
Consumer-declared, and why. Only the repository that publishes the payload has a publish workflow and a Release; an adopter receives both and publishes neither. So no kit ships the gate. It reads two existing gate-sdk knobs and mints none.
Refused: a monitor over published Releases. Releases published before the archive scheme attach their binary flat, so a monitor would need a roster of exempt Releases, a maintained copy that holds nothing. The publish half holds every Release the current workflow creates, the set the prose claims.
Honest limits. The publish half runs only at a tag, so a declaration that drifts from publish.yml’s naming is red at that tag’s pack job, not at the commit that moved it. That still stops before any Release exists, and the cost is a failed release run. The wiring check reads the call’s text, not whether its step runs. The surfaces that spell an asset name to fetch it, the install page’s recipes and the hosted install scripts, are held to the pinned release’s declaration by installer/SPEC.md §The hosted install pin, invariant C.
check-consumer-value-literal
Invariant: every string literal in a gate’s crate modules that names a consumer’s tracked path or heading is read from a knob, or its site is declared. It is the crate-literal form of §The port-candidate criteria’s rule that a consumer value is a knob with one producer.
The corpus is derived, never listed. It is the union of every registry member’s registry::module_files cuts, whichever directory its descriptor resolves to, less every file under <GATE_SDK_NATIVE_SRC>/knobs/. Those files leave because the knob tables, with the descriptor knob lines, are the sanctioned producers of a consumer value (§The knob file, §The declaration cohort). A member resolves when registry::resolve, over registry::resolve_dirs, finds its descriptor under a kit root or in the gates dir, so a shipped and a withheld gate are held alike, and a member resolving nowhere is left out. Each file is read up to its test module, the cut check-door-binding’s Rust reading makes (guard-kit/SPEC.md §check-door-binding).
The literal reader takes "…" with backslash escapes, raw strings r"…" and r#"…"#, and the b-prefixed form of each, as written, attributed to the opening line. It skips // and /* … */ comments, char literals and lifetimes.
A literal is a consumer value when either arm holds:
- The path arm. Its whole content is a repo-relative path
git ls-fileslists, or a directory holding one, whose first component is no kit root: a kit’s own files are kit vocabulary. - The heading arm. It matches a heading, a
#-led line outside a fence, in a tracked.mdfile outside every kit root and the shared prune set (§lib/gate.sh). A literal carrying the heading’s marker (## Done) matches whatever the heading’s length; a bare literal matches only a heading of two or more words. That cut keeps gate and kit names, which the docs mirrors carry as one-word headings, out of the arm.
The valve is // consumer-value-exempt: <reason> on the literal’s opening line or the line above, the reason mandatory. An empty reason is malformed and reds as its own finding, the line printed, on the door-contributor: convention. A literal a // spec: ground already rules a kit constant takes the valve; the ground stays where it is, and the valve is the gate’s discharge. The token joins this repo’s CANON_KIT_COMMENT_MACHINE extras, since a publisher’s own gate’s valve is consumer vocabulary (canon-kit/SPEC.md §check-comment-tier).
Verdicts. A red names the file, the line, the literal and the arm: the path arm the tracked path, the heading arm the heading and the file it was found in. Its help: gives the two repairs: read the value from a knob with the literal as its default, in the owning kit’s table or on the reading gate’s descriptor, or declare the site. The clean line prints the members swept, the files read, the literals read and the sites valved, a report with no floor. It exits 2 when GATE_SDK_NATIVE_SRC is empty or names no directory, when git ls-files fails, when a corpus file cannot be read, or when no member resolves: a gate that reads no crate cannot say the crate is clean.
Consumer-declared, and why. Its subject is the publisher’s crate source, which no adopter holds (§Consumer payload), so its descriptor sits in this repo’s gates dir and the rule stays in the crate. Its couples= is native/src/*.rs,*.gate,*.md: a path joining or leaving the tracked set can flip a verdict and no trigger reaches that, so the full battery is the catch. The fixture pair builds a crate source and a kit root of its own, the case’s knob file pointing GATE_SDK_NATIVE_CRATE at it, and its modules carry real member names, since the member-to-module map is compiled. One module’s member is withheld to the case’s own gates dir, so the pair holds the withheld half as it holds the shipped one.
Honest limits. A consumer value that is neither a tracked path nor a heading is out of reach: a release channel’s label, a product string, a single-word bare heading, a path the tree does not hold yet. So is a templated literal such as "{}/release-declarations.md", and any literal in a universal layer (§The non-gate arm), gates/mod.rs’ walk-root declarations among them.
git-hook
The binary’s --git-hook arm is what each placed hook starts: <bin> --git-hook pre-commit, and <bin> --git-hook commit-msg <message-file>. It is an Arm::Run: 0 every selected member passed, 1 a member failed, 2 it could not run (outside a repository, an unreadable registry, a registered member whose declaration resolves and cannot be read, a commit-msg with no message-file operand). No operand, an unknown hook name or an extra operand prints usage: --git-hook pre-commit | commit-msg <message-file> at exit 2. The name is git-hook because a bare hook is the harness family (§The harness-integration arm).
The binary, started under a file name whose stem is a git hook it serves, is that hook’s launcher. The served set is closed: pre-commit and commit-msg, the two names this arm takes as its operand. The stem is read from the name the process was started under, with the host’s executable suffix removed, ahead of the argument parse, so git’s operands are never read as arms. The launcher resolves the binary GATE_SDK_NATIVE_BIN names and starts it as <bin> --git-hook <hook> <the arguments git passed>, waits, and returns its status. It always starts it, even when the two are one file. Git starts a hook at the work-tree top, so a relative knob value resolves there, in a linked worktree as in the main checkout. The launcher reads no knob beyond GATE_SDK_NATIVE_BIN, and the work-tree pin, the selection and the output strings below are the arm’s.
A placed hook therefore never judges, a line of the knob files included. A hook is a hard link, so a writer that rewrites the binary’s file in place carries the hook with it, as the install placement does (installer/SPEC.md §Placement). A writer that replaces the file leaves the hook on the previous build, acting only as a launcher. The crate build does, which is why it refreshes the hooks itself (§build-native). A checkout that replaces a tracked binary does, and so does a hook placed as a copy.
The read keeps a scalar’s precedence (§The knob file): the environment, the gitignored .local overlay or the one GATE_SDK_LOCAL_KNOB_FILE names, the tracked knob file or the one GATE_SDK_KNOB_FILE names, then the default. An empty value takes the default. It reads no file below the one that answers, and parses a file only on the lines whose head names the knob. Every other line is skipped unread, whether or not this build’s table holds its name or the grammar admits it, and the legacy shell-config refusals with them. So a launcher older than the installed binary refuses no commit over a name the newer table added, the commit minting it included. Nothing is left unvalidated. The binary the launcher starts resolves its knobs through the general read, so each refusal the launcher skips is printed by the installed build, against its own table and with its own remedy line.
- A binary that cannot be started refuses the commit at exit 2, naming the path the launcher resolved and the two remedies: place or build the binary, or
git commit --no-verify. - A
GATE_SDK_NATIVE_BINwhose own stem is a served hook name is refused at exit 2, since the launcher would start itself. - A line that sets
GATE_SDK_NATIVE_BINand is no scalar refuses the commit at exit 2, naming the file and line, since the launcher then has no binary to start. The knob given twice in one file refuses the same way, and so does a knob file that cannot be read, aGATE_SDK_KNOB_FILEorGATE_SDK_LOCAL_KNOB_FILEnaming no file among them.
The hook is a native executable so that git starts it with no interpreter, one shape on every OS. A script hook needs a shell to start it. On native Windows that shell is the sh Git for Windows bundles, an x86-64 build under emulation on Arm. Git for Windows looks a hook up under its name with the executable suffix appended, which is why a placed file carries the host’s suffix (§install-hooks). Every OS leg of the gates workflow that installs a consumer asserts the start in a scratch repository (installer/SPEC.md §The consumer smoke). The Windows leg asserts it a second time with every bundled sh.exe renamed away. Honest limit: the launcher is a second start of the binary where a shell was the first, so what it saves is the shell’s start and nothing else. Each leg’s probe step prints both costs with its OS and architecture.
Before any member runs, it pins the work tree git implied. Git leaves GIT_DIR set and GIT_WORK_TREE unset for a hook in a linked worktree. There the arm adds GIT_WORK_TREE to each member’s environment, set to its working directory, which git has made the work-tree top. That is the value git’s own rule already gives, so a member running git -C <subdir> resolves the repository as it does in the main checkout. It removes no variable: a partial-path commit’s temporary index reaches every member through GIT_INDEX_FILE (§check-gate-binary-fresh). The pin is written into the child’s environment, never the arm’s own.
pre-commit runs the triggered subset. The staged set is git diff --cached --name-only -z --diff-filter=ACMR, and an empty set exits 0 printing nothing. The selection is read from the # graph: manifests at commit time, so a manifest, registry or knob edit needs no re-install. The members are --for’s selection over that set (§run-gates) restricted to tier=precommit, in registry order, a registry naming one member twice running it once. A mode=staged member receives the staged paths its triggers match that are regular files, as operands, and a member left with no regular-file operand is not run. A registered member resolving nowhere is selected at its registry position and fails there, as in a battery. --for’s uncovered-path note is a report for that arm and the hook arm prints none: its stdout is the summary line, a failing member’s reprint and the verbose lines below. commit-msg runs every tier=commit-msg member, each passed the message file. It selects by the tier a member’s manifest declares, so a member resolving nowhere is left to the pre-commit run git makes first.
A member that resolves and cannot be read refuses either hook at exit 2, naming the declaration: its tier is unknown, and reading it as tierless would drop it from both selections and pass the commit unjudged.
With no tier=commit-msg member registered, commit-msg exits 0 printing nothing, the summary line included. Both hooks are always placed (§install-hooks), so a gate registered at that tier later is reached with no re-install, and a consumer with none meets a silent hook rather than a line on every commit.
Each selected member runs through the run arm’s one member dispatch (§run-gates): consumer-first resolution, a .gate re-exec of this binary as a child and a .sh declaration spawned. Members run serially, stopping at the first failure. Output is captured per member. A failing member’s output is reprinted, then a blank line, <hook>: <gate> failed (see above). and Bypass once (use sparingly): git commit --no-verify indented two spaces, exit 1. With GATE_SDK_VERBOSE set, each passing member’s output is reprinted with PASS: <gate> indented two spaces. After the last member it prints <hook>: <N> gate(s) passed. and exits 0. The consumer smoke’s bash-less arm, the PowerShell leg’s hook steps and gate-sdk’s kit smoke parse these strings.
The members read their knobs from the knob files when they run, and the arm passes them no knob environment. Its own knob roster is GATE_SDK_GATES_DIR and GATE_SDK_KIT_DIRS, held to what the module reads by the crate’s arm-knob test. Its caller is git, through the placed hooks, so it is no harness-integration arm and does not fail open: an absent binary is the launcher’s refusal above, and git commit --no-verify is the bypass.
The staged set picks the members; the working tree is what they judge. A member reads its corpus from the working tree as a whole-tree run does, and an index-reading member such as check-gate-tamper says so in its own section. So an unstaged edit is judged as though committed: a commit can be refused on content it does not carry, or pass over staged content the tree has since changed. Honest limit: bring the working tree to the staged state before committing, by staging the rest or stashing it with --keep-index.
Regeneration follows staging, never merely the build, because a derived roster reads git ls-files rather than the worktree. A generator whose input set comes from the tracked list cannot see an untracked file. A unit adding one gets a green whole-tree run while the file is invisible, then a red at commit once git add makes it visible and the baked artifact is a generation behind. So stage first and regenerate second, for every generated artifact whose derivation walks the tracked list.
The launcher’s and the placement’s cases are their modules’ unit tests and gate-tests/native-git-hooks.test.sh, which commits through the placed hooks in a scratch repository and from a linked worktree. Its case for the one-name read points GATE_SDK_NATIVE_BIN at a stub exiting 0 beside a name no table holds, and the commit lands. A stub is the subject because one build cannot be older than itself: the case shows the launcher did not refuse, and the stub stands for a build that knows the name.
On a host with an executable suffix the suite skips its two stub cases, since each stub is a sh script copied under the binary’s name. The native Windows leg holds those legs in a step of its own (installer/SPEC.md §The consumer smoke), over native members.
measure-commit
The binary’s --measure-commit arm times what the pre-commit hook would run if every tracked file were staged. It lists the tracked paths with git ls-files -z and passes them to the hook arm’s own selection and serial dispatch (§git-hook), three times. Nothing is staged, and the tree and the index are left as they were. It prints two lines:
measure-commit: <median>ms median of 3 (<a>ms, <b>ms, <c>ms) — <m> pre-commit member(s) over <p> tracked path(s), every path selected
host: <os> <arch>, <n> logical CPU(s)
The samples print in run order. The host line comes from the build’s target constants and the available parallelism, with no spawn. A member that goes red ends the run at exit 1 with the hook’s own failure report and no figure, because a red battery measures a failure path rather than a commit. The arm exits 2 on any operand, outside a repository, off the repository’s top level, or on an unreadable registry or member declaration. The top-level refusal is because git runs a hook there, and ls-files paths are relative to the working directory. It is an Arm::Run, and its knob roster is the hook arm’s. The figure is the hook’s work: it excludes git’s own and the hook’s process start. Honest limit: the path list reaches the selection alone, so a member that reads the staged set from the index itself sees nothing staged.
Named caller and transition: an adopter asking what a commit costs in their own repository; and the release step that re-measures the Requirements page’s figure (RELEASING.md §The procedure, *Tag the stamp commit, then drain the release declaration surface), whose transition is the pin-moving commit.*
Its cases are the module’s unit test and the kit smoke’s run in its scratch consumer, over a stub registry of one passing member and then a failing one, since that consumer is uncommitted until every kit has installed.
install-hooks
The binary’s --install-hooks arm is the per-clone opt-in, run once to opt in and again only to take a newer launcher. It is reached directly by the path GATE_SDK_NATIVE_BIN names, the spelling init’s follow-up block prints (installer/SPEC.md §init), or through either front-end. It writes the hook files itself, points core.hooksPath at their directory, and sets blame.ignoreRevsFile when .git-blame-ignore-revs exists. It refuses at exit 2, placing nothing, when the binary it would link is absent. Then, as the apply-and-verify rung, it runs check-identity once, resolved through the registry so a consumer shadow wins. A fresh clone learns of a wrong-identity or wrong-remote mapping before its first commit, when the push-identity half is cheapest to fix, and the gate’s exit status surfaces through this arm’s.
The hooks directory is gate-hooks under the repository’s common git directory, the path git rev-parse --git-common-dir answers, and core.hooksPath is set to its absolute path. Opting in therefore changes no tracked file.
- It is under the git directory because a hook file is a per-platform binary, which a tracked text hook cannot be, and because a directory in the working tree is absent from every linked worktree, where git then runs no hook and says nothing.
- It is its own directory, never git’s default
hooks, so an adopter’s existing hooks are not overwritten andgit config --unset core.hooksPathstays the per-clone off switch. - The path is absolute because a relative
core.hooksPathresolves against each worktree’s top.
Both served hooks are always placed (§git-hook), whatever the registry holds. Each is a hard link to the binary GATE_SDK_NATIVE_BIN names, under the hook’s name with the host’s executable suffix, and a copy where the link cannot be made. On a host with an executable bit the file carries it. A re-run replaces both files and is otherwise idempotent. Each replacement is written under a sibling name in the hooks directory and renamed over the hook. So a placement whose link and copy both fail, or whose copy is cut short, exits 2 with the hook already there still in place, and the sibling is removed on every path. A hook is never absent between the two: git runs no hook it cannot find, which would turn the commit gates off silently.
- The sibling’s name carries the placing process’s id and a value drawn at random for the run, so two placements running at once never remove or rename each other’s. The id alone repeats across process-id namespaces sharing one hooks directory, as containers over a bind-mounted checkout do.
- The sibling is created, and neither the link nor the copy takes a name already there. A name already there is another placement’s. It may be a hard link to the file the placed hooks serve, and a copy opening it would write through to them. The placement refuses at exit 2 instead and leaves that file as it found it. A copy cut short removes the file it created.
- A sibling that cannot be removed is reported on stderr with its path. After a rename that landed the status stays 0, since both hooks are placed and the sibling serves none. After one that failed, or a copy cut short, the line joins the refusal.
- A hook that is running when it is re-placed is served throughout, by the earlier file or the new one. A commit in flight while the arm or a build’s refresh runs is the case. Where the host lets the rename land over a running executable, the arm exits 0 and the next start is the new file. Where it refuses, the arm exits 2 naming the hook it could not replace, the earlier file is still served, and a re-run after the hook has exited lands. The Windows leg’s step a placed hook is re-placed while it runs holds a placed
pre-commitrunning, re-places both hooks from a second binary, and asserts that each hook is one binary or the other and that the status says which. On both Windows triples the rename lands at exit 0 with the hook still running, the hook being a hard link beside others to its file. No leg holds running a hook the placement had to copy. - Honest limit: a run killed between the write and the rename leaves its sibling in the hooks directory, and no later run removes it, since a run cannot tell a dead run’s sibling from a live one’s. It serves no hook and the receipt lists it; delete it by hand. The drawn value makes a repeated name improbable and never impossible. Creation is what bounds a repeat’s cost at one placement’s refusal, with every hook still served.
--refresh re-places the files of a clone already opted in and changes nothing else: no config write, no identity rung, no receipt. It prints install-hooks: refreshed <hooks-dir>. A clone not opted in, or a working directory that is not the work-tree top, is a silent exit 0, so a build script may call it unconditionally.
- Opted in is
core.hooksPathnaming the hooks directory, so the option’s caller never opts a clone in. None of the three things it skips changes when a file is re-placed. - Off the work-tree top it places nothing, outside a repository included. The binary’s path and the hooks directory both resolve from the working directory, so a build run from a scratch directory inside a checkout would otherwise link that checkout’s hooks to the scratch tree’s binary.
- It refuses at exit 2, with the bare run’s message, when the binary it would link is absent or a file cannot be placed. Both no-op tests come first, so a clone not opted in stays silent whatever its knob files hold. The
1of the bare arm’s exit grammar is unreachable, since no rung runs.
Its cases are the placement module’s unit tests and two in gate-tests/native-git-hooks.test.sh. After the binary’s file is replaced, --refresh leaves both hooks byte-identical to the new file, compared by digest so the copy fallback passes too. A unit test places from a source that does not exist and finds both hooks as they were and no sibling left, the cut-short copy being held by construction and not run. Another forces a second placement onto a sibling name already there, and finds it refused with that sibling and both hooks untouched. A third draws two run names from one process and reads the report of a removal that fails. With core.hooksPath unset it places nothing and leaves the key unset.
It is an Arm::Run with its own front-end case arm, because every code of its three-state contract is load-bearing: 0 placed and verified, 1 check-identity’s own finding, 2 no gate binary, no repository, an uninterpretable manifest or a core.hooksPath write that failed. Arm::Emit can never return 1, so the family is forced (§The non-gate arm), and it emits no document besides. It declines the --install <op> family despite the name. That family’s caller is the installer bootstrap, which may not be assumed to be a POSIX shell, so every value it needs arrives as argv and it reads no config. This member resolves the binary’s path and its check-dir roster from kit config, so it cannot live there.
The rung reaches the gate through the registry, never by interpreting the resolved declaration path, because a descriptor interprets to success. Running bash on a .gate file, a data file of comment lines, exits 0, so a rung spelled that way would pass once its member ported, retiring the verification with no diagnostic.
The resolution is a directory walk at run time, which a port must not simplify away. The arm builds the check-dir roster, the gates dir first and then each kit root’s checks/, and resolves check-identity against it consumer-first, .sh beating .gate within a dir (§lib/gate.sh). Calling the crate’s own compiled member by name would silently stop honouring a consumer’s shadow, a narrowing of an extension point. What the walk found decides what happens next, and the three branches are the contract:
- a
.gatedeclaration — this binary is re-exec’d as a child on that member’s name, inheriting the invoking environment, and the child resolves its own knobs. The child is not an optimisation to remove: §run-gates refuses in-process dispatch of a member, on fault isolation among its grounds. This arm declares its callee’s knobs beside its own, so a declaration reader sees every read one run makes, and a crate unit test holds that tail against the callee’s own entry. - a
.shdeclaration — a consumer shadow, spawned with its two streams in the caller’s terminal, because that file is the consumer’s rule and this arm may not substitute its own. - no declaration in any dir — a silent skip at the wiring’s own status, which a consumer without this gate is entitled to; anything else that cannot be dispatched fails the opt-in.
--enter-stage’s pre-flight has the same shape: it resolves another member through the registry and re-execs the binary for a .gate.
The arm runs at the toplevel because the front-end puts it there. run-gates.sh refuses outside a repository and cds to the toplevel before the exec, so a relative binary path always resolves. Reached some other way outside a repository, it refuses at exit 2 with nothing placed, there being no git directory to place into. A core.hooksPath write that fails refuses at exit 2 ahead of the identity rung and the receipt, the hook files placed and git not pointed at them: 0 placed and verified cannot hold for a clone in which no hook runs. Its stderr report is the one the sibling per-clone installer prints for its own driver step. Its status departs from that sibling’s printed skip, whose driver is auxiliary to the two file writes that are its contract. A failed blame.ignoreRevsFile write keeps the sibling’s shape whole, reported on stderr with the status unmoved, since no hook depends on it.
The Active hooks: listing is the opt-in’s receipt, the only place a session sees which hooks were placed, so its shape and sorted order are kept. It lists the placed files, so its names carry the host’s executable suffix.
The member takes no positional, so of the argv-shape split only the option half is live. --refresh is its one option. Any other flag, or a token after --refresh, is a refusal that prints usage at exit 2, and the -- escape and free-text refusal have nothing to bind on. Its -h/--help retires to the front-end, where its own case arm earns it a named line and a paragraph in run-gates.sh --help, which an --emit- member does not get (§The bin/-tool contract).
build-native
The one spelling of the crate build. Invoked as bash gate-sdk/bin/build-native.sh [cargo-arg…] from the repo root, it runs cargo build --release --manifest-path <crate>/Cargo.toml with any trailing arguments appended. A per-target build supplies --target <triple> and still uses the one spelling. The crate comes from the existing gate_native_crate accessor (§Layout and configuration), with no new knob: a knob default gains readers without gaining spellings.
It resolves the crate relative to cwd, never off its own location. A caller running from a scratch tree, such as the consumer smoke’s build leg, sets cwd and gets that tree’s crate, which is why the contract says “from the repo root”.
When to run it relative to git add is §check-gate-binary-fresh’s rule: a session adding a new crate source stages it before running this, since a build preceding the git add bakes a stamp the tree side cannot match. The pointer is here because this is the step whose order goes wrong, and a session investigating a stamp red reaches this script first.
The crate is deliberately not rustfmt-clean, and running cargo fmt over it is a defect rather than housekeeping. The coupling is §check-gate-output, which reads a gate’s help: remedy as a single-line string literal. rustfmt wraps a long println! argument onto its own line and reds that gate across the battery. A session formatting after a small edit reformats the tree and buys a red battery it did not cause. Making the crate fmt-clean is a real option, a skip_macro_invocations configuration plus a whole-tree reformat, and belongs to a unit that takes it on deliberately. Until then no session runs cargo fmt here.
cargo’s exit code and both streams pass through unmodified, so a caller capturing build output keeps working. A successful cargo build can still exit 2 here, on the artifact verification below, the one place this script substitutes its own verdict for cargo’s.
The released artifact carries no builder path, and the remap is this script’s because every shipped build passes through it. rustc records a panic location per crate it compiles, and for a dependency that location is absolute under the cargo home. An unremapped binary would carry the builder’s home directory into every consumer that installs it. Cargo already spells the local crate’s paths relative, even under an absolute --manifest-path, so the leak is the resolved dependencies’ paths under $CARGO_HOME/registry/src/. The build therefore exports RUSTFLAGS carrying --remap-path-prefix for two prefixes. CARGO_HOME (defaulted to $HOME/.cargo) is the measured leak, and HOME covers a cargo home this tree cannot predict and any other path under the builder’s home. A prefix that is empty or / contributes no flag, because remapping / would rewrite every absolute path in the binary. The flags are appended to a caller’s RUSTFLAGS rather than replacing them.
--remap-path-prefix rather than cargo’s trim-paths, because of the toolchain floor. trim-paths stabilised in 1.81 and this crate pins rust-version = "1.71", so taking it would raise the floor the adopter constraints exist to collapse (§The adopter constraints). The accepted cost is that the setting lives in a build path rather than in the manifest, which the verification below answers.
The artifact is verified rather than the flag trusted. After a successful build, the emitted binary is matched against the consumer’s own resolved banned-pattern set, gate_msg_pattern_files. That is the roster §check-tree-terms and §check-commit-msg read, so the artifact is held to the tree’s leak ban rather than a second vocabulary. A hit is exit 2 naming the artifact and the matched text. A build path losing the flags, or a prefix outside CARGO_HOME and HOME reaching the binary, is caught here rather than in an adopter’s first battery.
The artifact path derives from the crate dir, any --target <triple>, and that target’s executable suffix. The --target value is named BN_TARGET because two derivations read it, the output directory and the suffix. The suffix is gate_exe_suffix’s target form, never the host’s (§lib/gate.sh). A cross build from Linux for a Windows triple emits <name>.exe under target/<triple>/release/. A host-derived name would look for <name> there and report no artifact, right about the path and wrong about the cause. An empty BN_TARGET is the host build and takes the host suffix by the accessor’s empty-argument rule, which keeps a native Windows build correct too. An absent artifact after a successful build is itself exit 2, never a skipped verification.
A host build refreshes the placed hooks. After the verification passes, the script runs the built artifact’s --install-hooks --refresh (§install-hooks), because cargo replaces the binary’s file on every build and a hard-linked hook would stay on the previous one (§git-hook). A build with a --target operand skips the step, since its artifact may not run on this host. The step’s output passes through. A refresh that exits non-zero leaves the script’s status cargo’s and prints one line on stderr naming --install-hooks as the remedy, so a hook that could not be refreshed is named rather than silently left behind. The script calls the arm rather than linking the files itself: placement, the executable suffix and the opted-in predicate have one implementation, and it is the binary’s. No fixture builds the crate, so the step’s case is the script run in an opted-in tree with each placed hook’s digest compared against the built binary’s; what the fixtures hold is the arm it calls. A consumer tree carries no crate and never reaches the step.
Fail-closed (§Fail-closed contract), each exit 2 with cause rather than a silent success or a skip:
cargonot onPATH: the message names the contributor-side toolchain floor (context-kit/SPEC.md §bin/env-probe) rather than leaving a bare command-not-found.- The resolved crate directory carrying no
Cargo.toml. The message names the consumer case: a consumer receives a prebuilt, digest-verified binary per declared target and never the crate source (§Consumer payload). So a consumer tree reaching this script has found a misuse, not a missing build, and a vendored tool does not read as a broken one. - The banned-pattern set not resolving, a required tracked pattern file missing or unreadable. The resolver’s status is read from the command substitution and not from
mapfile, whose status reports the array assignment and would turn a fail-closed resolver into a skipped verification. - The built artifact matching a banned pattern, or being absent after a successful build, both stated as assertions so “not shipped” cannot read as “not checked”.
It is a tool, not a gate: no # graph: manifest, no # install: header, no fixture pair, like the kits’ remaining bin/ tools. It is 100755 in the index like every other */bin/*.sh (§check-exec-bit).
The port disposition: declared, on a per-file bootstrap cause. A fresh clone carries no binary, so nothing compiled exists to run the first build, the irreducible the install bootstrap records (installer/SPEC.md §The install boundary) met on the contributor side. A ported build arm would also be the crate’s rebuild step for its own staleness check (§check-gate-binary-fresh names this script as the remedy from inside the crate), which cannot hold. So this file takes # no-port: citing this paragraph. The cause is per-file, not a class: nothing else in bin/ may cite it, and a second builder is a new file with its own disposition.
It is generic kit mechanism and vendors with the rest of gate-sdk. It builds the configured crate and carries no repo-specific path, product constant or private vocabulary; native/ reaches it only as GATE_SDK_NATIVE_CRATE’s default.
The residual, stated rather than gated. Nothing stops a later session writing a fresh longhand cargo build --release --manifest-path … into a new file, and a gate for it would be a banned-literal gate whose pattern list is one repo’s build command. §upgrade-smoke builds with cargo build --release inside a checked-out ref’s crate directory. That is not a leak path: it builds a historical ref’s crate this script could not govern, placed into a scratch consumer that gitignores it (§Consumer smoke).
That asymmetry is the sufficiency argument. The scratch-consumer builder ignores the placed binary, while the installer’s init tracks and commits it. Only the tracked half is a leak path, and every build reaching it goes through this script: the publish workflow’s per-target build, CI’s, and the consumer smoke’s host build. So two independent holds cover it: this script refuses to leave a leaking artifact behind, and the installer smoke runs a real init and, over the committed result, §check-tree-terms by name, since no profile registers it. What stays uncovered is a new build path that both bypasses this script and publishes without an init, a costed filing if it is ever built.
port-blockers
The derived roster for the port’s remaining work, at each invocation. Run bash gate-sdk/bin/run-gates.sh --emit port-blockers [--gates-dir <dir>] [--group | --tree] from the repo root. The default arm answers criterion 7 and --group answers criterion 6, both over the registry through the one walk registry.rs owns; --tree answers over the tracked shell tree. The arms are exclusive: each replaces the others’ report rather than appending to it. It is an arm table member of the binary, not a bin/ tool (§The non-gate arm owns the route, the forced family and the spelling), and the front-end locates the binary and execs it. Why each roster is derived rather than written down is §The port-candidate criteria’s, criteria 7 and 6; this section owns how.
Two corpora, and confusing them is the failure this tool is shaped against. The registry arms speak for the battery, what gates.list names, and for nothing else. --tree speaks for the project, the corpus the completion rule bounds when it requires every remaining non-test .sh to carry a stated cause or be deleted. A registry arm reading zero owed means the battery is ported and says nothing about the tree.
--tree resolves its corpus from the process cwd, and no path argument overrides it. A measurement of a particular tree state, such as a HEAD reading while the main checkout carries uncommitted work, runs with its cwd inside that tree, typically a linked worktree. Run from a modified checkout it reports the modified tree’s numbers, and nothing in the trailer says which tree was scanned. The port’s completion predicate is this count, so a wrong-tree reading misreports whether the port is over.
The default arm answers criterion 7. For every gates.list member it resolves the declaration path and reports the external programs the rule requires beyond GATE_SDK_PROGRAM_FLOOR, one <member><TAB><program><TAB><evidence> row each. A trailing line counts members scanned and members carrying a requirement it could not decide. The evidence column is a <file>:<line> for a shell rule the arm tokenized, or the declaration path and (--needs) for a compiled member that answered for itself.
Three derivation inputs, in descending confidence.
- The
command -v <prog>guard, this tree’s convention for announcing exactly this dependency: a guarded program is a requirement with no inference. - Command position, a word at the head of a simple command. An array-literal element, a word inside a string and an awk-internal token are not in command position, so positional analysis removes those false positives as a class, with no per-case exception list.
- Knob resolution for a command-position
"${KNOB[@]}"or"$KNOB", through the crate’s one knob reader (§lib/gate.sh), so this report cannot disagree with the value a dispatched member reads.
A compiled member is read from its substrate, and its row takes the same shape. A .gate member has no rule text to tokenize, so the arm calls the registry’s requirement lookup in process, the one --needs prints. It maps §The # graph: manifest’s three line kinds onto the existing rows. A program name takes the same floor filter a scanned command word takes. A ?<TAB><knob> takes the same knob resolution a command-position expansion takes, so one knob never has two resolved values in one report. A bare ? reaches the same undecidable counter. No fourth row shape is minted, since there is no fourth reader. --group does not consume --needs: it excludes ported members from the partition, so it has no row to fill.
A member the registry cannot answer for is reported undecidable, never as an empty requirement set. An absent member keeps the undecidable row, because reading a failed question as declares nothing is the captured-emptiness false green of §Fail-closed contract in report form.
Two negative inputs keep the positive ones honest, both derived rather than listed. Keyword and builtin status is asked of the interpreter (type -t) in one batched query per run. A roster baked into the crate would drift against the host’s bash, and a property of the interpreter is no consumer config; bash is already on GATE_SDK_PROGRAM_FLOOR, so the query adds no floor. And declare -F <name>, this tree’s convention for dispatching an optional shell hook, marks a name a function however the scan would otherwise classify it, the mirror of the command -v guard; that harvest is a text scan.
The --group arm answers criterion 6. It emits a corpus-derivation partition over the still-shell members, groups ordered by size descending. Each member’s row carries lines=, the criterion columns below and its expanded couples=. The trailing line counts members scanned, groups formed, members undecidable, members already ported, members permanently shell and members temporarily held. It closes with the two totals a reader could not subtract out alone: still owed and takeable at this cut. Its consumer is a human session cutting the next port cohort (§The first cohort, and the rule that selects the next). It reads the configuration the default arm already resolves, gate_sdk_gates_dir and gate_kit_roots, so it adds no knob. It rides this tool because criterion 6 asks the same kind of question of the same corpus, and needs the command-position tokenizer only this tool has.
The grouping key is set-equality over two derived factors, the pair (kit-library call set, content-glob set), each sorted and de-duplicated. A single shared call is never evidence of a shared derivation, because each single-factor key over-selects and neither under-selects. couples= is trigger-shaped and wide on purpose, by criterion 4’s own words. A bare primitive-call key is worse: gate_kit_roots alone feeds gates whose corpora share nothing, one composing fixed subdirectories and a *.sh glob on it, another reading one smoke/ path.
- Kit-library call set: the command-position words the scan emits that the gate, or a kit library it can source, defines as a shell function. It is the default arm’s filter inverted: what that arm discards as no external program is what the key is made of. So no list of corpus primitives is maintained, and the tool compares whole sets rather than classifying which calls yield a corpus.
- Content-glob set: the literal extension-glob tokens (
*.sh,*.md,*.rs,*.gate,*.list, …) on the declaration’s non-comment lines. It separates members composing one root source differently, thecheck-shellcheckcase. Reading non-comment lines alone keeps the# graph:manifest out of the key.
couples= is a printed column, never a key factor. It is read through the registry readers check-graph and the hook arm read, so the report cannot disagree with them about a manifest. Fusing a trigger-shaped field into a content key is the over-selection criterion 4 names, and would hide the disagreement a reader most needs. Members sharing a key and diverging in couples= are a finding for the cohort session to adjudicate.
Excluded members leave the partition and are counted, never reported undecidable. The grouping orders the corpus this arm can take, so three classes leave it:
- A member resolving to a
.gatedescriptor is ported. The default arm prints?for it only where its requirement cannot be asked; here there is no open question. - A member whose declaration carries
# no-port:(§The# graph:manifest) is permanently shell. The remainder therefore means still owed rather than still shell, derived from the declarations with no hardcoded name. - A member whose declaration carries
# port-until:is temporarily held, and is still owed. A fourth exclusion class would falsify the subtraction a reader would do, so the trailer prints still owed (held included) and takeable at this cut (held excluded) as separate fields.
An exclusion counter reading zero over an empty corpus is not evidence. Once every registered member has ported, both exclusion counters read zero by construction; a session asking whether anything declares a disposition asks --tree. A counter over an empty corpus reports the emptiness, never the predicate, the distinction §check-gate-substrate-parity’s assertion G draws.
A held declaration fails in the opposite direction to # no-port:. An undeclared hold is counted takeable, which a selector’s own audit catches. A stale hold, whose blocker landed and whose slug moved to Done, under-counts takeable members and hides work. A shape assertion cannot see that direction, so §check-gate-exemption-tasks holds the slug to a live queue entry.
The default arm keeps a permanent member’s row, by ruling. That criterion-7 row can be the evidence grounding the declaration, and excluding it would leave a verdict with no oracle behind it. The two arms answer different questions: which programs a rule requires, whatever its port future, and what the next cohort should take, the only one permanence bears on.
Cheap criterion columns make the selection rule applicable end to end. The rule wants the largest set of criteria-clearing gates sharing one derivation, so each row carries the three mechanically derivable criteria:
- criterion 2 as
c2=pair,c2=no-fixtureorc2=none, read in that order through the fixture dirs §check-gate-fixture-coverage resolves. The resolution is shared with that gate, taking its kit roots as a parameter because the gate resolves absolute roots and this evidence column stays repo-relative; - criterion 3 as
c3=<tier>from the manifest; - criterion 7 as
c7=, the default arm’s verdict for that member:clean, the programs it requires, or?.
Criterion 1 is true by construction, the walk being the registry. Criteria 4, 5 and 6 are not emitted: each needs a judgment the tool cannot take, and a guess would invite a cohort cut on the tool’s authority. No field is emitted without a named reader at a named transition, and a column its only honest reader would have to disregard has none.
lines= clears that rule because it is exact. It is wc -l over the resolved declaration path the row’s other columns read. It sits in the fixed-width run ahead of c2=, because c7= is variable-width and a cost column read down a list must align. Its reader is the session composing a budget-arm cut, at the cut; §The first cohort, and the rule that selects the next bounds it as a floor on a port’s size and never a ranking. That reader is a vendoring adopter with a still-shell battery as much as this tree, which is what makes the column kit mechanism.
Undecidable is reported, never guessed, on §check-reads-couples’ precedent, ruled once for the registry arms.
- In the default arm, a command-position expansion whose default cannot be resolved prints
?, and so does a.gatemember with no--needsto ask (§The# graph:manifest). Reporting nothing there would reproduce the false negative the derivation exists to close. - In
--group, a still-shell member empty in both factors prints?with itslines=count and declaration path and is counted. It is never grouped, not even with another empty-keyed member, since a shared absence of evidence is no shared derivation. It carries no criterion column, because the arm decides nothing about it. A line count decides nothing either, being a property of the file, so it stays: an unkeyed member is still owed and is adjudicated by hand.
So the arm emits a decidable partition plus a counted remainder, never a complete partition claimed as one. --tree is outside the ruling, since a path, a disposition and a line count are answers every plain file has; an undeclared file is owed, the same fail-safe over-count direction ? serves.
The --tree arm answers the completion predicate, and it is the only arm whose corpus is the tree. It reports every tracked non-test shell file as <path><TAB><disposition><TAB>lines=<n>, <disposition> being owed, no-port or port-until:<slug>. Its trailer counts files scanned, declared no-port, temporarily held and owed. Owed reaching zero is the completion predicate made decidable. It dispatches ahead of the registry resolution, so a tree with no gates.list still answers for its own shell.
The corpus is derived and its exclusions are rules, not lists. It is git ls-files over tracked *.sh, minus the *.test.sh suffix the predicate’s non-test names. It also drops the prune-dir set GATE_SDK_PRUNE_DIRS and GATE_SDK_PRUNE_EXTRA_DIRS resolve, which removes gate-tests/ without naming it. That is the crate’s one tracked-shell-tree rule, which §check-gate-exemption-tasks’ tree arm also reads. It diverges in one place, and the arm absorbs the divergence. Where git cannot answer the shared rule degrades to an empty corpus, on a ground its other reader states. This arm probes for a repository first and refuses at exit 2, since an empty corpus would print 0 owed, the completion predicate. Enumeration rather than a walk is right here because the subject is tracked files: an untracked script ships nothing and cannot carry a reviewable declaration. The arm adds no knob.
No exclusion knob is minted for the battery runner and the bootstrap. A knob defaulted to one project’s paths is a kit literal carrying its layout (§The install disposition), edited at every move. The predicate supplies the mechanism instead: a file that stays shell by a closed ruling, as the runner and the bootstrap do, declares its # no-port: cause. So owed reaching zero is the completion predicate rather than an approximation of it.
One row per file, with the columns a plain script can answer. lines= is read as in --group. No criterion column is emitted, on the ground that removed criteria 4, 5 and 6 from --group: a plain script has no fixture pair, tier or couples=. A registered gate’s row carries its disposition like any file’s, and the arm does not partition gates from scripts, because the predicate does not.
A disposition is read only from a well-formed declaration, and everything else is owed. Well-formed is exactly one line carrying one of the pair with its payload present. A # no-port: with an empty cause, a # port-until: naming no slug, a doubled field and a file carrying both are one rule, not four: no reviewable declaration was made. The read sits on the crate’s universal layer beside the corpus rule with three readers. This arm prints it, §check-gate-exemption-tasks’ tree half reads the held slug for liveness, and §check-gate-substrate-parity’s assertion G reads the shape fault. The reader yields the fault it found (empty cause, empty slug, doubled field, both fields). The disposition triple is a projection of it mapping every fault to owed, so the arms agree by construction rather than by a parity lane. No fourth fault, disposition or count exists. Held is separated from no-port because a temporary hold is not a permanent disposition.
A # no-port: cause asserts the disposition in force under the ruling that stands, never an oracle about future rulings. Every closed ruling is reopenable by the authority that closed it. If an open reopening path defeated the field, no file could ever declare it. When a ruling is reopened and reversed, the unit that reversed it edits the declaration, like every other consequence of a reversal. The field is weaker than its name suggests, so a cause names the ruling it rests on (§The # graph: manifest). Two rival fixes are refused. Widening owed to no ruling could be reopened is unreachable for every file, so it would retire the completion predicate: owed means no reviewable disposition is declared here and nothing else. Minting a queue entry for a declined option so a # port-until: can name it puts that option in the pickable set, a scope act taken by the wrong stage.
The declaration is read only from the file’s header block, the leading run of shebang, comment and blank lines. The corpus holds scripts that write shell, and a line-anywhere scan cannot tell a declaration from a heredoc literal. The fields are header fields, so confining the read removes that false-positive class by construction, for every reader of the corpus.
Its consumer is a human session, and only the --tree trailer is machine-read. Every arm’s rows are read beside a diff by the session asking the arm’s question. A consumer’s measured-claim emitter may read the trailer’s owed count, as this repo’s does, so that line’s grammar is an interface and is specified above; an emitter re-deriving the count would be a second definition of the corpus. No freshness gate accompanies the tool, on enforcement-first. It would compare the derivation against a stored expectation, the maintained roster back by another door, wrong for every consumer whose tree differs.
What that leaves uncovered. Where a consumer’s emitter reads the trailer, its resolved values are baked into the graph artifact, so the tracked shell tree becomes an input to a byte-gated artifact. check-graph holds that artifact fresh, but its couples= is a trigger over paths. No kit-shipped trigger can name a corpus a consumer’s emitter defines without publishing that consumer’s layout. Nor may a canon-kit descriptor name this project’s crate directory to re-run the claim gate on an edit to this arm, for the same reason. The gap is covered by the full-battery-before-every-commit rule, which runs check-graph unconditionally where the hook’s trigger does not.
Two standing blind spots, limits of the derivation.
- Unregistered members. A gate a kit ships but the consumer never registers is outside
gates.list, so neither registry arm speaks for it.--treecloses this for the disposition, since its declaration is a tracked file. It does not close it for the requirement: an unregistered member’s external-program requirement is reported by nothing. - Library-mediated requirements, the one that generalises. The scan reads a member’s own declaration text and does not follow a call into a kit library. So a requirement reached through a shared helper (
gate_authoring_tree’sgit,ek_pid_alive’sps) is invisible even for a registered member, and the default arm can reportcleanfor a gate needing an off-floor program. It is a limit of the scan, so no arm’s walk closes it. Following library calls is a scanner widening with its own cost and false-positive surface, filed as work.
The tokenizer rules below are stated because the source alone misleads on one of them. The tokenizer is a character state machine over quoting, command-substitution frames, heredocs, here-strings, double-bracket state, case levels, arithmetic and array-literal skipping. Breaking either rule below makes the scan abandon a declaration part-way and report the unread remainder clean, a false green in the criterion-7 roster.
- A here-string is consumed whole as one redirection operator, ahead of the heredoc branch. The heredoc branch’s guard declining
<<when a third<follows does not suffice alone. A generic redirect branch consuming the first<would let the scan re-enter at the second, where the heredoc branch fires on an operand that never recurs as a delimiter. An operator consumed whole cannot be re-entered part-way. - Inside
[[ … ]]only, a)pops a pushed substitution frame ahead of any case-pattern reading. The in-case predicate was true whenever the double-bracket state was set, so a)closing a substitution inside a conditional read as a case-pattern terminator. The scoping is load-bearing: popping whenever a frame is open lets thea)of a genuine$( case "$x" in a) … esac )steal the frame.
An end-of-file balance check cannot see that difference. An early pop drives the depth to zero, the real ) lands on an empty stack and is absorbed, and the file balances by coincidence while everything after the case is lost. So the pair is covered behaviourally in smoke/ by a declaration carrying both shapes, on the behavioural ground §The bin/-tool contract states. Crate tests over planted declarations cover it too, run by §check-crate-arms’ test arm. A scan reaching more lines raises the undecidable count, since those lines carry expansions it cannot resolve and each is reported ?: a lower count under a blind scan is never the better number.
The arm’s argv contract. The three arms are --group, --tree and no argument, and there is no positional. --gates-dir <dir> names the registry the two registry arms walk. It is the rule’s own input rather than a redirect of resolved config, the gates-dir shape §The non-gate arm rules portable. --tree needs no registry and takes none. Of §The bin/-tool contract the arm adopts the refusal: an unrecognized argument, a bare word included, prints usage on stderr at exit 2. -h/--help is no per-arm help flag (§The non-gate arm), so it is an unrecognized argument in any position, and the usage, which carries every arm, reaches its reader at that refusal. -- is not adopted, since it ends option processing in favour of free-text positionals the arm does not have.
Its channels are Arm::Emit’s: a document on stdout at exit 0 and a message on stderr at exit 2 is all it needs. It dispatches nothing, so it takes none of Arm::Run’s dispatch union.
It is an arm, not a gate, so it carries no # graph: manifest and no fixture pair. Behavioural coverage in smoke/ stands in for a pair for all three arms, reached through the front-end, the invocation a caller makes and no in-crate test covers. The --tree case plants a corpus with each disposition, each ill-formed declaration and one file of each excluded class. It asserts the trailer’s counts as exact deltas, since the surrounding tree’s own shell is not its subject. The non-repository refusal is the one leg the front-end cannot reach, because bin/run-gates.sh refuses a non-repository before it execs. It is asserted by invoking the binary directly, a caller rather than a second entry point, so it cannot be rewritten into a leg passing on git’s own message.
check-shellcheck
checks/check-shellcheck.gate (precommit, binary-dispatched).
Invariant: every *.sh directly under the consumer gates dir, each vendored kit’s lib/, bin/, checks/, templates/ and smoke/, and each directory named in GATE_SDK_LINT_EXTRA_DIRS passes ShellCheck at -S warning (the self-lint contract). A missing shellcheck binary is exit 2 — a gate that cannot run is not clean — and so is an empty target set, because a derivation that selected nothing has verified nothing. The two are ordered: the linter is probed before the targets are globbed, so a tree with neither reports the missing linter.
The parse is ShellCheck’s, so the gate spawns no bash. ShellCheck parses a script before linting it and reports a parse failure as an error, which -S warning keeps, so a smoke script bash cannot parse reds here at commit. A host without bash runs the gate unchanged, and a host without ShellCheck is refused as above. Honest limit: ShellCheck’s parser is not bash’s, so a construct only one of them rejects is caught by the other tier, the consumer smoke running each script under bash.
The verdict is a function of the analyser’s release as well as the tree, because ShellCheck’s rule set moves between releases, so a local run on another release predicts a CI verdict in neither direction. The CI verdict’s reference is therefore the release its workflow pins, installed from the upstream release asset and refused unless its digest matches the pinned one, never the runner’s stock analyser. A consumer pins it in its own filled instance, never the template (§Consumer smoke, CI tool provisioning). In this tree .github/workflows/gates.yml owns that pin: one version in the workflow’s env, read by each job that installs the analyser, each verifying its own asset’s digest. The gates job runs the verdict, and crate-tests-windows and crate-tests-unix install it because the crate’s registry-coverage tests observe check-action-run-shell’s cases. A local verdict is read against the version the workflow names. check-action-run-shell spawns the same analyser and reads against the same pin.
A .gate-dispatched member is outside this corpus with cause — there is no shell to lint, and cargo clippy at deny-warnings is the substrate equivalent (§Meta-gate conservation for the binary substrate, which owns the reasoning).
Its rule is an invocation of shellcheck, so it is the wrapper §The port-candidate criteria’s criterion 7 prescribes for a program-is-the-rule member. The compiled form spawns the program, which stays a declared dependency the payload does not carry. An absent one is exit 2 carrying this member’s own refusal text rather than the generic spawn-failure string; §Fail-closed contract owns both mechanisms and why a wrapper owes message parity, not only the exit code. The dependency floor is not widened: a consumer without ShellCheck is refused at the point, and in the order, stated above.
Criterion 4 binds, with a bound. Its corpus is every *.sh under a resolve dir, so it reads gate source as content. The criterion-4 probe, the pre-port rule restored under a non-resolving name (§The port-candidate criteria, criterion 4), sits inside the corpus it probes. Both implementations counted the probe over the same tree at the same moment, so the comparison is over the post-descriptor corpus, but not over a corpus byte-identical to the committed one.
The gate ships to adopters for their own shell. It is on-surface: it ships to every adopter who vendors gate-sdk and arms when their own shell exists. An adopter’s subject is the shell they author under their gates directory, since the vendored kit shell is the publisher’s and the publisher’s battery lints it. So init registers it in no profile, and an adopter who authors a gate script registers it, at which point doctor owes shellcheck through the registered audience (context-kit/SPEC.md §bin/env-probe). An adopter cannot author a compiled gate: native/ ships no checks/ and no smoke/, so gate_kit_roots never selects it and init never vendors it (§The port-candidate criteria, the default’s domain). A vendoring consumer’s gate family is shell by construction, the corpus this gate exists for. When a tree’s last .sh leaves, its corpus is empty and that tree drops the registration while the kit keeps shipping the gate: deregistration, not retirement.
A narrowing of its corpus is safe because the red condition is monotone. The red condition is a .sh under a resolve dir that fails ShellCheck at -S warning, so shrinking the corpus can only remove findings. “A narrower corpus can only remove violations” is false in general: the reds-on-empty arm above is this member’s own counter-case, which is why an emptied target set is exit 2 rather than a clean line.
That derivation does not reach workflows: .github/workflows/*.yml sits under no kit root and under no gates dir. §check-action-run-shell is the sibling that reaches it, extracting the shell out of run: block scalars and linting each at this gate’s severity.
The knob appends to the derived set and never replaces it, so a consumer that sets nothing keeps the shipped coverage and one that sets it can only widen. It is a whitespace-list scalar, a row of gate-sdk’s table defaulting empty, split with walk::knob_words like every whitespace-list knob (§lib/gate.sh). The split does not pathname-expand, a directory set being named rather than globbed. The knob exists because the kit-root predicate (§lib/gate.sh) is what puts a directory in scope, so a shipped script under no kit root, such as a consumer’s own tooling, a runnable walkthrough or an installer, is lintable but not linted until its directory is named. Positional arguments remain a full scope override (the hermetic fixture affordance), not an addition.
The knob widens when the hook fires it too. The gate’s # graph: couples knob:GATE_SDK_GATES_DIR/*.sh,kit:*.sh,knob:GATE_SDK_LINT_EXTRA_DIRS/*.sh, the last a rooted token over a word list, one trigger per named directory (§The # graph: manifest). The kit family is covered through the case-pattern matcher, where * spans /, so <kit>/*.sh matches every script in every subdirectory of that kit.
check-gate-output
checks/check-gate-output.gate (precommit, binary-dispatched).
Invariant: every gates.list member’s source contains both a : clean success emission and a help: remedy line (the static half of the output contract). Presence is checked, not correctness: whether the clean line actually fires is the good/-fixture job, and whether the remedy text is accurate is human review.
The optional [gates-dir] positional is consumed by the rule. It names the registry to read and the first resolve dir, rather than redirecting config gate_command has already resolved before the exec, which is the distinguishing test §The non-gate arm states. No other knob this member reads is derived from it, so a caller passing it gets one resolution rather than a mixed one.
A .gate-dispatched # no-fixture: member resolves both its corpus and its emitter alternation off the declaration’s substrate. The static half runs only for a member no fixture case can reach (§Output contract). A descriptor cannot hold the strings: its field roster is closed and the rule lives elsewhere (§The # graph: manifest). So the corpus is the implementation module, <GATE_SDK_NATIVE_CRATE>/src/gates/<name>.rs, its <name> the gate name less its check- prefix with -→_. lib/gate.sh’s gate_native_module derives the mapping from the gate’s name, because a dispatch field would be a second registry that can drift from the thing it names.
The alternation moves with the corpus: echo/printf in shell, println!/eprintln!/write! in Rust. printf is not a substring of println, so a moved corpus read under the shell alternation would match nothing and report the member clean — a false green, not a missed check.
Where the crate is absent the member is declared out of reach, and that is the consumer’s ordinary case. native/ is not a kit root, and the payload vendors kit roots. So a consumer receives the descriptor and never the module (§Meta-gate conservation for the binary substrate, Where that verification runs, and where it does not). The gate branches on the crate manifest, <GATE_SDK_NATIVE_CRATE>/Cargo.toml. With no manifest the member is counted and named on the success line as out of reach, never dropped from the accounting, since a count that quietly shrank reads like a member that stopped being checked. A crate that is present with no module for a dispatching member is a half-landed port and reds.
The probe is the manifest, never a directory. The crate path is also where build artifacts land: GATE_SDK_NATIVE_BIN defaults inside it. So anything placing the binary there creates the directory with no crate, such as the consumer-smoke builder’s place_binary (§Consumer smoke) or an adopter copying the artifact to that stable relative path. The gates module directory, <crate>/src/gates/, fails the other way: it conflates crate present with ports landed, so a tree whose gates module was deleted would go out of reach silently rather than red. A crate whose modules all vanished still has its Cargo.toml.
The fixture pair proves the module-grep arm in both directions. The out-of-reach branch has its own unit test (gate-tests/check-gate-output.test.sh), because both fixture cases must ship a crate to prove the arm they exist for. That test carries the regression case: a tree holding the binary at its default path and no manifest is out of reach.
check-gate-fail-closed
checks/check-gate-fail-closed.gate (precommit, binary-dispatched). Invariant: every awk/jq command-substitution capture in a check-*.sh gate handles its subprocess exit status — fail_closed, an inline || { … } guard, a captured =$?, or an explicit # fail-closed-exempt: opt-out. Only awk/jq captures are checked: grep’s exit 1 on no-match is expected, here-string captures read data already in memory, and arithmetic $((…)) is never matched. A parser wrapped inside a shell function is not visible to this static scan; the opt-out covers residual false positives.
The corpus is <dir>/check-*.sh expanded per resolve dir and ordered by bytes, never by the invoking locale’s collation. The order reaches the report and no verdict.
A .gate-dispatched member is outside this corpus with cause: the defect — branching on a captured value’s emptiness when the subprocess died — is unrepresentable once a fallible call returns a Result that cannot be ignored (§Meta-gate conservation for the binary substrate).
The empty corpus is two different states, and this member tells them apart. An empty shell corpus beside a non-empty descriptor set is a finished port, so it is green with a counted zero, the verdict §check-gate-substrate-parity assertion G takes for the same shape. The invariant is vacuously true there: with no check-*.sh there is no awk/jq capture that could go unchecked. No declaration of either spelling under the resolved dirs is a tree that resolved no gates directory at all, and stays exit 2 with a message naming both spellings.
The discriminator is the check-*.gate count over the same resolved dirs the shell glob reads, so the refusal fires exactly where the dir set resolves to nothing. The counted zero names the descriptor count, so a reader can tell the port finished from nothing was scanned without running anything else.
Neither empty state has a fixture representation, since a committed case cannot be a tree whose gates directory resolves to nothing. Both are held by a crate unit test that drives the member over two constructed dirs, one carrying a descriptor and one carrying neither spelling.
check-gate-fixture-coverage
checks/check-gate-fixture-coverage.gate (precommit, binary-dispatched).
Invariant: every gates.list member either ships a {good,bad}/ fixture pair (searched across the consumer tests dir, then each vendored kit’s gate-tests/) or carries a # no-fixture: <reason> header annotation. The authority set is the registry — the gates that gate the tree — not every check-*.sh file. A half-built pair is a defect regardless of any opt-out. The gate cannot mechanically distinguish “infeasible” from “stopgap”; honesty is upheld by the reason text.
The member takes no positional. A gates-dir positional would also move the tests dir its default derives from, but that default resolves onto GATE_SDK_TESTS_DIR, a derived row of gate-sdk’s table (§Layout and configuration). A caller passing it would therefore move the registry and the resolve dirs while the tests dirs stayed where the knob put them: one argument, two resolutions, disagreeing silently. That is the config-redirecting shape §The non-gate arm deletes rather than reimplements, and why this member’s verdict differs from check-gate-output’s, whose positional feeds nothing else. A tests-dir positional would redirect that knob directly. The fixture pair reaches the rule through the default layout, so it is a parity oracle for the live arm rather than for a fixture-only second path.
check-gate-assertions
checks/check-gate-assertions.gate (align-only, binary-dispatched). Invariant: every ### <gate> subsection in the family SPEC whose contract enumerates its assertions is coupled to a matching # assertion <label>: marker set in the gate’s code. It holds four assertions. (A) The count-word equals the size of the label span it introduces. (B) The heading resolves to gate code through the registry. (C) A resolved file carrying no marker at all is the retrofit obligation. (D) The marker label set equals the contract’s label span, reported through its missing and extra sub-branches. This catches the prose-vs-code drift an internal count⟺span check cannot: a contract can be internally consistent while the code grew a sixth assertion.
Discovery is first-paragraph-scoped, under four filters. A lone descriptor line opening the section (a backticked .gate or .sh path and its parenthetical, one line ending ).) is not that paragraph, so the one after it is read. The filters exclude sibling-gate mentions, follow-on sentences, hierarchical axis/sub-rule contracts, and count-words with non-enumeration nouns:
-
the enumeration noun ( assertion(s)axesaxischecks) must sit adjacent to a count-word; - the count-word is a spelled cardinal of two or more, in canon-kit’s cardinal grammar (canon-kit/SPEC.md §check-manifest-count), since a hand list stopping at nine silently drops a tenth assertion;
- the first following parenthetical must be a single-char
(X)label.
With no spec argument the gate scans <gates-dir>/SPEC.md when present plus each vendored kit’s own SPEC.md; each matched heading resolves to its gate source through the registry path.
Its disposition is on-surface, because the payload withholds its corpus. An installed tree carries no kit SPEC.md and no gate source (§Consumer payload). Its corpus there is the adopter’s own <gates-dir>/SPEC.md and gates, a surface they author later, which is §The install disposition’s definition of that value. The empty corpus stays exit 2: it is the wrong-directory guard, and demoting it to a clean line would trade a fail-closed arm for an arming decision the disposition vocabulary already expresses. A publisher’s tree registers the gate in its own gates.list regardless of disposition. The internal skip sentinel is no failure path: a .gate-declared member with no crate manifest present is counted onto the clean line as declared out of reach, and that segment is part of the output contract.
Its manifest is dir=bi valve=none tier=align-only, with no trigger=. No hook selects the member, which is why criterion 3 names a real cost for it: the fixture pair, run-gates.sh and the align stage are its only executed callers. Among its couples, kit:checks/*.sh is the shell declarations’ content trigger. kit:checks/*.gate is a reverse trigger: creating or deleting a descriptor changes which file the gate resolves and greps, though the descriptor’s own bytes are never read. A couple glob that expands to nothing against this tree’s layout stays, since it is live against a consumer’s. The couples already carry native/src/gates/*.rs, so the member is substrate-sensitive and carries its conservation row.
A .gate-declared member’s markers live with its rule, in the implementation module. That is the follow-the-rule-to-the-module resolution §check-gate-output owns, as one implementation shared with it, since two copies could disagree about which file a member resolves to. The marker grammar accepts either comment leader (# or //): the leader is the substrate’s, and the marker is a code marker either way. A tree with no crate skips those members and counts them on the clean line, exactly as §check-gate-output declares them out of reach. A vendored consumer receives the descriptor and never the crate, so reding on a marker set not in the tree would red every adopter. Crate presence is the manifest, never the directory, for the reason §check-gate-output states. The skip’s executed oracle is the installer smoke, which runs the battery on a freshly vendored consumer.
Four implementation hazards are pinned here, each a place a natural reimplementation diverges silently rather than loudly.
- The lowercasing is ASCII and byte-length preserving. The rule matches on a lowercased copy of the paragraph and slices the original-case paragraph at that copy’s offsets, sound only while lowercasing keeps the string’s length. Unicode lowercase does not (
İlowercases to two code points), so the slice would land in the wrong place past the first non-ASCII character and nothing would red. The offsets are bytes, and one landing inside a multi-byte character is stepped back to the boundary below it. - The slice keeps one boundary character. The count-word pattern consumes one boundary character on each side, and the slice starts one position early so the trailing one survives. Slicing from the match end loses the
(of a noun followed immediately by a parenthesis, which drops the span’s first label and can silently demote the contract below the two-label arity filter. - The sort is byte order, which is C-locale order. A locale-dependent sort could differ only on a mixed-case label span under a UTF-8 locale.
- The two marker grammars differ on purpose. The extraction pattern accepts a multi-character label while the contract-span pattern accepts exactly one, so a multi-character marker surfaces as an extra marker finding. Unifying the two widths would turn a reported drift into an unreported one.
Criterion 4 binds, in every configuration (§The port-candidate criteria). A registry member’s declaration path lies inside the corpus the gate scans as content: the live enumerated contracts resolve their markers out of native/src/gates/*.rs, and no consumer config stops the SPEC corpus naming gates. The member therefore sits in the no-clearing-configuration row with check-graph and check-gate-exemption-tasks, the conservative verdict the criteria take when one is uncertain.
The member is self-auditing. This section carries its own enumerated contract and gate_assertions.rs the matching markers, so the gate reads its own module. The precedent is §check-comment-tier, which audits its own declaration and whose fixture pair, not the live tree, is what proves those arms. The contract and the markers are two copies of one fact held by the gate itself, the intended coupling rather than a duplication defect: the set-equality arm reads every label and the count arm reads the span’s size, so no label exists without a reader. The module is the resolution target of its own contract, so a marker shape in its unit tests is composed and never spelled — a literal would join the module’s marker set and red the gate against itself, as native/src/gates/tree_terms.rs avoids for its banned-shape set.
Its coverage is the fixture pair plus a bespoke test. The args file carries positionals only and cannot set a knob, so check-gate-assertions.test.sh stands up a throwaway mini-consumer, on §check-graph’s tree-test precedent. It reaches the registry walk, the descriptor-to-module redirection and the no-crate skip. The last needs a tree with no crate manifest, which a case dir inside this repository cannot be. good/ carries a //-led contract in both its plain and indented spelling, and three headings discovery must exclude: a non-enumeration noun, a first parenthetical that is not a single-char label, and a span of fewer than two distinct labels. None resolves to any gate file, so each filter is proved by greenness rather than by absence. bad/ carries the finding arms: an extra-marker set, a count-word disagreeing with its own span, an enumerated contract resolving to nothing, and a resolved file carrying no marker at all.
Honest residual: the marker catches editing one side without the other, but not adding an assertion while forgetting both its marker and the contract. A first paragraph that embeds the literal pattern in example prose self-matches — the failure is loud (a false positive forcing a reword), never a silent miss, so it is accepted.
check-gate-substrate-parity
checks/check-gate-substrate-parity.gate (precommit, binary-dispatched).
Holds the dispatch seam honest: a gate’s implementation may move to a compiled subcommand, but not by quietly deleting the declaration other gates read or the record of what that move costs. Usage <dispatch> [gates-dir] [conservation-doc]. Each positional is consumed by the rule rather than redirecting config the knob resolution selects: the gates dir names the registry and the first resolve dir, and the conservation doc is the rule’s own second corpus. The two-arg form steers the fixture pair onto hermetic copies of each surface. Ten assertions: (A) declaration uniqueness; (B) subcommand parity; (C) disposition coverage; (D) one manifest home; (E) no vendored implementation source; (F) one target-roster owner; (G) port-declaration placement; (H) held-ground reachability; (I) dispatch roster joined to registration; and (J) smoke registries joined to owned subcommands.
- assertion A — declaration uniqueness. Each
gates.listmember resolves to exactly one declaration. A dir carrying both<name>.shand<name>.gateis ambiguous dispatch and is red, rather than being silently settled bygate_resolve’s within-dir precedence. That precedence exists so a consumer can shadow a kit’s gate, and using it to paper over a half-finished port would hide the state a port passes through. -
assertion B — subcommand parity, both directions, over the kits this tree vendored. The set of
.gatedescriptors across the resolve dirs equals the subcommand roster the binary carries. A descriptor naming no subcommand is a gate that cannot run. A subcommand with no descriptor is a stranded implementation — dead code or the residue of a half-finished port — unless the conservation section dispositions itreference-only, the one allowance, recorded there rather than in the crate (§Meta-gate conservation for the binary substrate). This equality is why a non-gate arm sits outside--list: an arm returning a document instead of a verdict would otherwise present here as a subcommand nothing declares, and §The non-gate arm owns that class and its three properties.The roster half is scoped to what this tree declared, because one binary serves every consumer and the unscoped equality is unsatisfiable in any consumer vendoring a subset of the kits it carries. One clause per value the owner column can take:
- for a kit-owned subcommand, a descriptor must exist iff its owning kit is present in
gate_kit_roots; - for a consumer-declared subcommand — owner
-, the sentinel meaning the consumer’s own gates directory declares it and no kit ships it — a descriptor must exist iff the tree is a publishing tree.
The publishing predicate is assertion F’s, reused rather than spelled twice, and deliberately source rather than directory presence for the reason stated there. It is
gate_authoring_tree(§lib/gate.sh), which also scopes both arms of §check-gate-exemption-tasks: whether this tree authored the declaration, or vendored it. A subcommand out of scope under either clause is counted and declared on the clean line.Both directions of the consumer clause carry weight. In the publishing tree the member must be in scope. The tree carrying the crate source is the one whose registry decides which subcommands exist, so it is the only tree where a stranded implementation can be created: a module and a registry entry landed while the descriptor is forgotten. Nothing else catches that, since assertion A stays quiet while a half-finished port leaves the member resolving to its surviving
.sh. In an adopter the member must be out of scope. A gate living solely in the consumer’s own gates directory cannot appear in a vendored tree (§upgrade-smoke), while the payload carries the prebuilt binary (§Consumer payload). So an adopter holds the subcommand and can never hold its descriptor. Unioning the gates-directory basename into the vendored-kit name set is refused:GATE_SDK_GATES_DIRdefaults toscriptsfor every consumer (§Layout and configuration), so every adopter would be in scope for every consumer-declared member and red on a finding it cannot discharge.Three properties are held deliberately:
- The other direction stays unrestricted. A descriptor the resolve dirs carry with no subcommand behind it is red whatever kit owns it, because a vendored descriptor is in scope by definition and that direction catches a gate that cannot run.
- The
reference-onlyallowance composes. An in-scope subcommand with no descriptor is still checked against the conservation section. - The roster half never goes dark. It is never gated on descriptor count or on the registry: a tree with zero descriptors is exactly the state a stranded implementation hides in. Out-of-scope subcommands are counted and printed, so an emptied scope is visible rather than silent.
The owner is registry data held to executed behavior, not a self-declaration.
--listprints two tab-separated columns:<subcommand>and the declaring root, the owning kit’s directory basename as it appears undergate_kit_roots, or-. The descriptor that would otherwise answer this is what a subset vendoring lacks. The declaration lives in the crate’s dispatch registry beside each member’s walk roots and knobs — an entry a member cannot compile without — the same shape--readsand--needshave. A crate unit test holds it to the tree: for every registered subcommand the declared root carries its descriptor,<owner>/checks/<name>.gatefor a kit and<gates-dir>/<name>.gatefor the sentinel. The owner is a column rather than a further flag because a column is roster data on a line the roster already carries, and a whole-roster read degrades where an unrecognised flag refuses.A sentinel rather than the gates directory’s basename, because a basename is not an identity here.
GATE_SDK_GATES_DIRdefaults toscriptsfor every consumer, so the same string would name this repo’s declaring root and every adopter’s. It could not distinguish the tree owning the subcommand from a tree that merely received the binary. A crate literal spelling one project’s gates-directory name would also ship that project’s layout to everyone (§Layout and configuration, §The provenance seam). The reading side wants no name: the gate resolves a consumer-declared descriptor throughgate_sdk_gates_dir, already globbed into its descriptor set.-rather than?, because--readshas spent?on a root the gate’s author cannot bound, and its reader counts that as undecidable. A consumer-declared member’s owning kit is decided, and there is none.An introspection arm is a flag arm, and this assertion is why. An arm asking the binary a question directly, such as a cross-substrate parity harness reaching a primitive no gate exposes, cannot land as a new subcommand. This assertion reds a subcommand no descriptor dispatches to, and
reference-onlyis for an implementation held ahead of its port. So the arm joins--list,--reads,--needsand--source-stampat the binary level. The roster is over subcommands alone: those top-level flags are handled in the top-level dispatch and never enter the gate registry. A flag that leaked into--listwould read here as a stranded implementation.Building the binary per vendoring is refused. Criterion 5’s install model is closed (§The port-candidate criteria): the payload carries a prebuilt binary per declared target, built by the release and never from a working tree. A per-vendoring binary makes the artifact set the product of targets and kit subsets, which no release can enumerate and no digest roster can own.
The predicate this shares with the registration-accounting assertion is stated once here, so the sibling cites it: an assertion over a whole-roster fact states its scope in terms of what the tree vendored. The two are deliberately not unified. They derive scope from different inputs — this one from a subcommand’s declaring root as the binary reports it, that one from the vendored kits’
checks/directories — so a shared rule would be parameterised over both and become a third thing to keep true. - for a kit-owned subcommand, a descriptor must exist iff its owning kit is present in
-
assertion C — disposition coverage. Every substrate-sensitive member carries a disposition line in §Meta-gate conservation for the binary substrate. The set is derived at runtime — a member whose expanded
couples=covers the declaration path of a registry member — so it cannot drift from a maintained roster. This is the anti-vacuity assertion: a new meta-gate over gate source, added by a session that never read the conservation section, reds until its disposition is recorded.The conservation doc is owed by the tree that authors the dispositions. Assertion C and assertion B’s
reference-onlyallowance read the conservation section, whose SPEC the payload withholds (§Consumer payload). A doc given as a positional is a steer, and its absence is exit 2. With no positional the doc is<gate-sdk root>/SPEC.md. Absent in a publishing tree (assertion F’s predicate) it is exit 2, so a deleted doc cannot turn C green. Absent in any other tree, C and the allowance are not asserted and the clean line counts the members left undispositioned. A doc present with no conservation section is exit 2 in every tree. - assertion D — one writable home for the manifest. No file under the implementation tree (
GATE_SDK_NATIVE_SRC, defaultnative/src) carries a manifest-class annotation. The comment leader is matched as#,//or/*, so the assertion is language-agnostic: it holds for whatever language sits behind a descriptor, not for Rust alone. It enforces the reader partition as a live assertion rather than a convention, since two writable sources of one truth drift silently and every build-free reader depends on this one. -
assertion E — no implementation source inside the vendoring set. §Consumer payload withholds a gate’s predicate, and a consumer receives exactly the kit roots (
gate_kit_roots), so the ruling is checked structurally in two halves. No implementation sibling: for every<name>.gatethe resolve dirs declare, no file named<name>.<ext>exists anywhere under any kit root — the natural mistake of putting a ported gate’s implementation beside its descriptor, where it vendors. The crate root is outside every kit root:GATE_SDK_NATIVE_CRATEresolves to no path under anygate_kit_rootsmember. That half is the non-vacuous one in an unported tree, and it reds on the single edit that would silently undo the ruling: relocating the crate to give it a ride into the payload. It folds into this gate because the descriptor set it reads is the one this gate already derives.Three calibrations, each drawing a boundary. The extension test is extension-agnostic: any extension but the two spoken for reds, since a roster of implementation languages would rot and contradict assertion D’s language-agnosticism.
.gateis spoken for by the descriptor itself and.shby assertion A, which owns within-dir ambiguity and by §Layout and configuration sanctions a consumer’s shell shadow of a ported gate. An extensionless name is out of reach: a built artifact named after its gate is an artifact-placement question this section does not own. The sibling half’s corpus is the kit roots, so it does not reach a file beside a consumer-declared descriptor, which is its scope rather than a hole: a file in this repo’s own gates directory vendors nowhere. With no descriptors declared the sibling half scans nothing and says so in its clean line, while the crate half stays live.Its couples are
kit:checks/*, the one location the sibling mistake lands, andnative/*, the crate root a relocation stages away from. The recursive sweep of every kit root stays uncoupled: it reads file names, never content, and a recursive kit-root couple would fire this gate on nearly every commit for a case the full battery already reaches. -
assertion F — one owner for the target roster. §Consumer payload rules the roster the single surface asserting platform support, and prose cannot hold no second spelling, so the three ways that ownership breaks are checked here.
- The roster is well formed: every live line of
GATE_SDK_NATIVE_TARGETS_FILEis a<arch>-<vendor>-<os>[-<env>]triple and the file is non-empty, because a roster asserting no platform support cannot be the surface that asserts it. - The build matrix is roster-derived: every value in a
matrix:declaration of each workflowGATE_SDK_NATIVE_PUBLISH_WORKFLOWnames is a GitHub expression, never a literal, since a hand-written platform there is a second spelling of the support commitment. The runner mapping is a runner selection, not a support declaration, and the one place a platform name may appear in the workflow. The check asks only that a matrix value is an expression and does not follow it to its source; a matrix derived from an install page’s platform block reads roster-derived, and that page’s relation to the roster is §Consumer payload’s to hold. - Each digest has one producer: no job may compute more than one digest, and a job that downloads a run artifact and uploads none may compute none. A step computes a digest when its
run:body — the inline value, or the block underrun: |orrun: >— invokessha256sumorshasumin command position with no-cor--checkoperand. Command position is the crate’s one shell-word reader’s (§port-blockers), so a word naming the hasher as an argument, a string or a comment is a mention, not an invocation.
Both hashers count, because a portable body resolves one at run time (stock macOS carries
shasumand nosha256sum). So the alternatives of oneif/elif/elsechain count as one emission, the arm computing most, while two emissions in one arm, or one beside the chain, are two. Arun:body calling a script asbash <path>orsh <path>, the path a file in the tree, has that script’s text counted at the call with the same reader. So a step re-adding an emission beside a shared build body is a second producer. Every finding names the workflow it read. Verification (sha256sum -c) is unrestricted: it is what the installer does, and what the--pack-installerarm does in-crate. The honest limit: the follow is one hop, so a script the called script calls is unread. A script invoked by its path rather than through a shell word is not followed. A hasher reached through an expansion ("${HASHER[@]}") is a knob’s value and is not counted. Acasestatement’s alternatives are summed rather than taken as one.Two absences are reported rather than red. A missing publish workflow is not a publish path to audit: a consumer whose release rides elsewhere points the knob at it, and each absent member is reported by name beside the members read. A missing roster is red only in a publishing tree that also dispatches, each condition removing a different false red. Dispatch means a registered member resolving to a descriptor, the predicate §check-gate-binary-fresh states. It is required because a declaration needs no prebuilt binary, and a descriptor-count trigger would red every vendored tree once descriptors ship. Publishing is required because declaring platform support is the act of the tree that builds and publishes the artifact. A consumer receives the kit roots and never the crate, so its roster is absent by construction (§Consumer payload) while it may dispatch to a binary it was given. The publishing test is the crate’s tracked source is here, step 1 of §check-gate-binary-fresh’s source stamp. It is deliberately source rather than directory presence, so build output or a placed artifact cannot make a consumer look like a publisher.
- The roster is well formed: every live line of
-
assertion G — port-declaration placement. Five clauses over the union of two corpora: the declaration set assertion A derives, which knows which spelling each member resolved to, and the tracked shell tree §port-blockers derives for its
--treearm. It folds in here on assertion E’s precedent, since the declaration set is the entire input to clause 1.- A
.gatedescriptor carries no# no-port:line and no# port-until:line — §The# graph:manifest’s placement rule for both fields, and the failure a port can actually produce by forward-copying the field with the# graph:/# install:/# spec:lines that are copied verbatim. - A
# no-port:line on a shell declaration carries a non-empty cause, the shape §check-install-disposition holds for# install:, - and a declaration carries at most one, since the field is optional.
- A
# port-until:line carries a non-empty slug on those same terms. - No declaration carries both, because permanent and temporarily-held are contradictory verdicts about one member, and a declaration asserting both makes §port-blockers’ two exclusion counts overlap.
Where a declaration field may live by spelling is the dispatch seam’s own partition, the question assertion D answers for the implementation-versus-declaration axis. Clauses 2 through 5 reach the tree corpus, reading a header field and nothing else. Clause 1 stays on the declaration set, since a plain script is not a descriptor. The corpus is a union and never a replacement, since a
.gatedescriptor is no*.shand a replacing corpus would lose clause 1 entirely. The tree half is de-duplicated against the declaration set, the whole of assertion A’s output; §check-gate-exemption-tasks de-duplicates against both halves of its own walk because its scope rule splits one.Over the tree corpus a disposition is read from the header block alone; the declaration corpus keeps its whole-file scan. The tree corpus contains scripts that write shell, such as smoke recipes, installers and template authors. A line-anywhere scan cannot tell a declaration from a heredoc literal there. The restriction is the fields’ own nature as header fields, and §port-blockers owns it for the corpus rather than for one reader. Leaving the declaration corpus’s scan whole keeps the widening monotone, since narrowing a shipped scan would retire findings.
The tree half is scoped to the tree that authored the declaration, with assertion F’s publishing predicate: a vendored kit’s malformed cause is the kit author’s, and an adopter cannot satisfy it. Where there is no tracked set the tree half is empty and the declaration half still asserts, the opposite disposition from §port-blockers’
--treearm, whose whole subject is the tracked tree and which therefore refuses. Its honest limit: the predicate is the publishing test alone, where §check-gate-exemption-tasks adds a clause for the consumer’s own gates directory. So in a non-authoring tree a malformed declaration on a script that tree wrote itself, outside a kit root, is not caught here.The clean line counts the two corpora separately, so the counted-zero tell survives: the declaration corpus is empty in this tree, and folding the tree count into it would make an empty declaration half indistinguishable from a populated one.
One derivation, two readers. The clauses do not re-derive what §port-blockers’ classifier computes. The disposition reader on the crate’s universal layer yields the shape fault it found — empty cause, empty slug, doubled field, both fields — and the three-valued disposition is a projection of that verdict, mapping every fault to
owed. So the classifier and this assertion read one function and cannot disagree about whether a declaration is well formed. That is criterion 6’s unless clause in its strongest form: the duplication is absent, not machine-held. The two corpora differ only in the scan scope handed to that function, a whole file or aheader_block. The slug clause therefore reads a leading run of[a-z0-9-], the classifier’s own test, so# port-until: NotASlugfails here as §port-blockers counts itowed.Presence is deliberately not asserted for either field. No clause demands that a permanently-shell member or script declare. Permanence is a ruling in prose, and a gate deriving which members hold one would have to parse SPEC argument text; the same holds for a blocker. An undeclared permanent or held member is counted as takeable, the status-quo over-count. Both fields can only shrink the reported takeable set, so the mechanism fails toward today’s state rather than toward an under-count. Enforcement-first ranks a gate above discipline where a gate is available, and the available gate is this shape assertion.
The held field’s stale direction is not covered here: a blocker that landed with its slug moved to Done is slug liveness, not shape, and lives in §check-gate-exemption-tasks. A claim about the queue belongs with the gate that reads the queue, and this gate, the dispatch seam’s auditor, has no queue-file coupling.
An empty declaration corpus is green with a counted zero. Within the declaration set both fields reach only the
<name>.shspelling, so once no registered member declares in that spelling the half has nothing to range over. That is a corpus narrowing, one of the three non-monotone shapes §The causal-completeness check point 5 names. The red condition stays on the right side of it: a declaration whose field is malformed or whose# spec:section does not state the hold — never no declaration found. The counted zero is on the clean line for the reason assertion H’s grounded count is, and the empty case is carried explicitly in the bespoke test. No reader over this corpus reds on finding none, asserts an exact count, or holds a coverage floor.Its couples carry
knob:GATE_SDK_GATES_DIR/*.shandkit:*.shfor the tree half, the tokens §check-gate-exemption-tasks carries for the same corpus. Without them a script gaining a malformed declaration touches no file the manifest names, and the red arrives a tier late. The tree corpus is one this gate walks itself, so it adds no conservation row. - A
-
assertion H — a held declaration’s ground is reachable in one hop. For every declaration carrying
# port-until:, the SPEC section its own# spec:header field points at names the field. §The port-candidate criteria rules that a held member’s ground lives in its own section, and §The# graph:manifest rests the slug-only payload on it; this assertion makes that rule a gate.Resolution is the declaration’s own pointer, not a derivation. Every declaration carries a
# spec: <path> §<section>header field, and canon-kit/SPEC.md §check-spec-pointer holds that pointer to a tracked file and an existing heading. So this assertion resolves nothing: it opens the section that field names and reads it, a reader reaching the ground from the declaration in one hop with no second heading-matching implementation. The extraction runs to the next heading at the same or shallower level, so both heading levels in play (### <gate>here,## <gate>in site-kit/SPEC.md) need no special case.Red conditions. A declaration carrying
# port-until:and no# spec:field naming a section — absent, or a path with no§<heading>fragment — is red naming the declaration, since the ground is unreachable from it. That is notcheck-spec-pointer’s job, which holds a present pointer’s resolution and asserts no pointer’s presence. A declaration whose named section body contains noport-untiltoken is red naming the section; a heading the file lacks arrives here too, its body being empty. An unreadable target file is fail-closed on §Fail-closed contract’s ordinary terms. Not asserted: that a member without the field has no hold, which is assertion G’s presence question.The corpus and the coupling. The subject set is the declarations assertion A resolves; the second corpus is the SPEC files their pointers name. So the manifest couples
kit:SPEC.md, since deleting a paragraph is exactly how this assertion goes red. Thegate-sdk/SPEC.mdliteral is kept beside it as assertion C’s conservation-doc coupling, which a consumer whoseGATE_SDK_KIT_DIRSdoes not name gate-sdk would otherwise lose. It folds in here on assertion C’s precedent: C also asserts that a SPEC document records something for a declaration set, and a SPEC coupling is one this gate already has.Its honest limit. The assertion holds reachability, not truth: a section could name the field and say nothing useful, and whether a ground is a good one stays human judgment. It clears §When a gate earns its place’s trivially-true-proxy bar because live declarations failed it when it landed. The count of held declarations whose ground was verified is on the clean line: a zero there in a tree that declares holds is the vacuous-pass tell, read by the session choosing the next cohort cut. It must not acquire a non-zero floor: a tree with no held member has nothing to ground, and a floor would red every consumer that never declared a hold.
-
assertion I — the crate’s dispatch roster joined to the battery’s registration. Assertion B equates the descriptor set with the subcommand roster, and this gate derives both in process: descriptors by globbing the resolve dirs, the roster from the crate’s own registry. So neither path reads the registration file, and assertion I is the join, in both directions.
The forward direction carries new information. An in-scope subcommand the registration file does not name is red unless that file declares it, by the grammar §Layout and configuration gives
# unregistered:. The disposition is required rather than merely reported, which moves the cause onto the surface the assertion reads. The count of declared residue members is on the clean line: how many members a consumer declines to register is its business, and only the undeclared ones are the gate’s. Its scope clause is assertion B’s, for B’s reason: a kit-owned subcommand is in scope iff this tree vendored that kit, and a consumer-declared one iff this is the tree that declared it.The reverse direction is scoped to registrations that dispatch, and it overlaps assertion B. A registered name the binary does not carry is a fault only where the registration dispatches to the binary: a consumer’s own
check-foo.shis registered, carried by no subcommand, and lawful. Neither assertion subsumes the other, because B’s corpus is descriptors on disk whether registered or not, while this one’s is registrations whatever sits beside them. A stray descriptor beside a shell shadow is B’s alone.Two stale shapes are findings: a declaration naming a member the file does register, and a declaration missing either field. Both fields are read — the name joins the rosters, the reason must be non-empty. It costs no new configuration:
registry::list_pathderives the registration file’s path from the gates dir this gate resolves, and the manifest already couples the gates dir’sgates.list. -
assertion J — each kit’s smoke registry joined to the kit’s owned subcommands. The per-kit registries that assemble a scratch consumer’s file are heredocs in each kit’s
smoke/install.sh, which assertion I reads only second-hand, inside the scratch consumer. J reads each kit’s heredoc directly — the one opened at column 0 by acatwhose>/>>target’s basename isgates.list— and runs assertion I’s comparison against that kit’s owned members. So an owned subcommand the heredoc neither registers nor declares# unregistered:reds at commit. A heredoc member the kit does not own reds too, since no leg registers another kit’s member (§Consumer smoke), unless it is one of the kit’s own shell declarations; so do I’s two stale-declaration shapes. A kit owning subcommands with no registry heredoc, or with two, is a finding; a kit owning none needs none, and a kit root shipping no smoke script is counted on the clean line.J is scoped to the publishing tree, on assertion G’s ground: the payload withholds
smoke/, and a vendored copy would hold the kit author’s file against a binary it may be skewed from. Outside it the clean line says J is out of scope. It costs no manifest edit, sincekit:*.shcovers the smoke scripts.check-install-dispositionassertion B reads the same scripts forzero-configmembers only, and stays.
Its implementation is a compiled subcommand that reaches the registry in process. A stale binary — the only state where an in-process and a spawned answer could differ — is §check-gate-binary-fresh’s red, and an absent one cannot run this subcommand at all. It spawns git, for assertion F’s publishing test and for the tracked-set read assertion G’s tree half rides, and declares it (§The # graph: manifest, --needs). port-blockers does not see that spawn, because the call sits in gate_authoring_tree rather than in the gate’s own text — the shared-library blind spot §port-blockers records.
Its coverage is split across three oracles, because a pair is one invocation each and the configurations cannot all live in one.
- The
good/+bad/pair covers the declaration set: assertion A’s ambiguous dispatch, the descriptor→subcommand direction, and every clause of assertion G. G’s cases are a descriptor carrying either field, a bare one with no payload, a declaration carrying two of one field, and one carrying one of each. For assertion H, a held declaration whose pointed-at section names the field clears, one whose section does not reds, and one with no# spec:field reds. The SPEC surface lives inside the case tree with case-relative pointers, so the pair proves the resolution rather than the live tree’s accident of being green. It covers assertions C, D, E and F end to end over the case’s own hermetic surfaces. Of assertion I it covers a stale declaration naming a member the case’sgates.listregisters, and one missing its reason. It also covers I’s reverse arm, on a registration whose declaration path is a descriptor no subcommand answers. - The bespoke
gate-tests/check-gate-substrate-parity.test.shholds the configurations one pair cannot reach, each a sandbox:- no descriptors at all, where a descriptor-count guard would blank out the roster half;
- descriptors present, none dispatching, and no roster, where assertion F’s missing-roster arm must stay quiet;
- a consumer dispatching to a placed binary with no crate, also assertion G’s empty shell-declaration corpus reported as a counted zero;
- the publishing counterpart, where the same absent roster reds. This and the case above are the publishing predicate’s boundary, which a too-loose predicate passes in
good/.
Three more stand up a throwaway repository for assertion G’s tree half. A publishing sandbox’s tracked shell files drive each clause to a finding, beside a well-formed declaration and a disposition token below the header block that is correctly not read. The same tree with no tracked crate source is out of scope on the clean line. The empty corpus outside any repository fails the tracked-set read and degrades to nothing. Assertion J lives here and in the unit tests.
- The crate’s unit tests hold assertion B’s roster matrix and assertion I’s forward arm, because the configurations that matter are whole rosters, which no case can name. The comparison is a pure function over the descriptor set, the roster, the vendored kit names, the publishing flag and the conservation section. B’s configurations are a subset vendoring and its near miss, where the in-scope kit is the one missing a descriptor. They add the consumer sentinel, out of scope in an adopter and in scope in the publishing tree, the unrestricted descriptor→subcommand direction under the scoped path, and the reference-only allowance. I’s are an in-scope subcommand the registration omits with and without a declaration, the unvendored-kit member that owes none, and both stale-declaration shapes. They add the reverse arm’s lawful shell gate beside its dispatching twin, proving the silence beside a finding, which a case that only reds cannot. The crate’s tests run at commit through §check-crate-arms.
Assertion G’s tree half is out of the pair’s reach because neither case publishes. §run-gate-tests runs each case in place inside this repository, so a tracked-set read from a case directory answers with the case’s own shell files, relative to it. The prune set cannot see a gate-tests component those paths lack. Each case points GATE_SDK_NATIVE_CRATE at a directory that does not exist, so the tree half is out of scope in both, and good/’s clean line asserts exactly that.
The configurations live in fixtures rather than in named live trees, because a coverage claim naming a tree is only as durable as that tree’s configuration, and a landing cohort changes configurations.
It is registered in gate-sdk’s consumer-smoke install: it needs no consumer config, and a vendored tree is exactly where a descriptor with nothing behind it would land.
check-gate-binary-fresh
It is the publisher’s own gate and does not ship. Its subject is the crate the binary was built from, which no adopter’s tree holds (§Consumer payload). The descriptor and its fixture pair sit in the publisher’s gates dir; the rule stays in the crate. precommit, binary-dispatched.
Invariant: whenever a .gate descriptor makes the binary load-bearing, the binary was built from the source now in the tree. Usage <dispatch> [gates-dir] [tree-stamp-file]. Both positionals are consumed by the rule rather than redirecting config the knob resolution selects: the gates dir names the registry and the first resolve dir, and the stamp file is the tree side of the comparison itself.
Why the gate exists. §check-gate-substrate-parity assertion B diffs the descriptor set against the binary’s --list roster — set membership only, never the binary’s content against the source it was built from. gate_command dispatches a .gate-declared member straight to the prebuilt binary with no rebuild and no freshness check. So editing a gate’s Rust source, skipping the rebuild, and committing runs the gate against the stale binary, where it passes on the old implementation. A battery reading taken while this gate is red is rebuilt and re-read, never interpreted: every other ported member in that run executed the same stale binary.
What makes the binary load-bearing — the predicate both binary meta-gates share. A .gate file on disk is a declaration. A registered member resolving to a .gate file is a dispatch, and only a dispatch makes the binary load-bearing or can run stale. A member gates.list does not carry dispatches to nothing, whether never registered or omitted by a consumer’s own # omitted: record, which the runner strips from the live set. So the predicate needs no new field, no marker file and no new knob: one predicate covers the omitted case, the unregistered-in-a-vendored-tree case and the fully-registered case.
The registry is read at one decision — is any live member’s declaration a .gate — and both counts are named in every report, because no descriptors and descriptors nothing dispatches to are different tree states. An absent registry is exit 2 rather than a clean report, since the live member set is what the verdict turns on. Getting the predicate wrong fails silently in both directions. Too loose, and a stale binary passes unnoticed. Too tight, and every tree vendoring a descriptor it does not register reds — including a consumer that omitted a member by its own record, whose re-registration must convert the member back into a live one with no hand edit.
Nothing dispatching is a clean report, not a skipped assertion. With no dispatching member nothing can run stale, the coupling shape assertion F uses for the target roster, reading the same predicate. With one or more dispatching members an absent or non-executable binary is exit 2, not a violation: “cannot verify” and “verified fresh” must not share an exit code (§Fail-closed contract), the verdict gate_command already gives. Executability is proc::is_executable, the crate’s one spelling: it requires a file, so a directory at GATE_SDK_NATIVE_BIN is no runnable binary. On non-unix the twin is is_file() alone, and executability as an extension question belongs to gate_exe_suffix (§lib/gate.sh).
The descriptor-count guard is right here, unlike assertion B’s. Assertion B runs its roster half whenever the binary is merely readable, since a stranded subcommand is a defect whatever the descriptor count. A stale binary nothing dispatches to is no defect at all — it is an unbuilt artifact. And comparing whenever a binary happens to be readable would make the verdict depend on whether a given clone ever ran cargo build: green on a machine that never built, red on one that did.
This gate is not a consumer gate: a consumer tree does not verify a build it did not make. Its subject is the crate the binary was built from, and the tree-side stamp is git -C <crate> ls-files over that crate’s tracked source. §Consumer payload keeps the crate outside every kit root, so no consumer tree has one, and the gate exits 2 (“cannot verify”) there however the binary arrived. §Fixture-pair discipline already rules that a consumer’s question “is not did my build come out right — that question does not exist for a consumer”; its oracle is the digest (which artifact arrived) plus the fixture pair (what it does). So the gate runs in a building tree, this repo and CI, and is withheld from the payload by the rule that section states.
git is the hasher, and that is the ruling the design turns on. Hashing is the classic place two implementations drift: a Rust digest and a shell digest over “the same” input set are one canonicalization disagreement away from a permanent false red. git is already the sole runtime dependency this design commits to, shelled out rather than embedded, and content-addressed hashing is its native operation. So both sides call the same tool with the same arguments — one algorithm, rather than two implementations of one algorithm.
The source stamp is git’s content identity for the crate’s tracked source set, computed by three invocations identical on both sides. The crate computes it in one runtime helper, fresh::source_stamp, which §check-crate-arms also reads to key its cache, so a second reader cannot become a second algorithm:
git -C <crate> ls-files— the input set is derived, never maintained, so neither side can carry a stale roster of which files matter. The honest cost: an edit to a non-code tracked file, such as the target roster, changes the stamp and asks for a rebuild although it changes no code. The ordering isls-files’ own index order, byte-sorted by path, since asorton either side would be a locale-sensitive second canonicalization.git -C <crate> hash-object -- <path>…— each input’s blob hash over the worktree content, not the index, because the worktree is whatcargocompiled and an index-derived stamp would go green on a build made from unstaged edits.- Those hashes joined with their paths into one listing, hashed again by
git -C <crate> hash-object --stdin.
The two axes read different tree states, so git add is part of the trigger. The path roster is the index and the content is the worktree. Editing a tracked crate file moves the stamp with no staging, while a new crate file is outside the stamp until it is staged — and the git add alone moves it. The producer reads ls-files too, so a build run while a new source file is untracked bakes a stamp without it, and staging the file then reds the commit. Stage the new source, then run bash gate-sdk/bin/build-native.sh: the gate reads the source set the commit will carry, and re-running the battery without a rebuild reproduces the red. In a linked worktree git runs the hook with GIT_DIR set alone, and the stamp’s git -C <crate> calls resolve only because the hook arm pins the work tree (§git-hook); a caller reaching fresh::source_stamp under a hook some other way inherits the same obligation.
A green verdict here does not discharge the build. The tree side is the tracked set, so this gate is green on a tree carrying an untracked crate source the binary was never built from. Its freshness claim is the binary matches what git knows about, weaker than the binary matches the source on disk. An untracked file is not in the commit either, so the state the gate governs is exactly the state the stamp describes. A commit-time obligation naming the battery and the build carries no redundancy, and neither discharges the other: only the build reaches the untracked source, and only the gate reaches a binary nobody rebuilt.
A partial-path commit cannot split a port. git commit -- <paths> commits against a temporary index of HEAD plus the named paths, so the input set step 1 derives shrinks to that set. A commit naming only some of a cohort’s crate files recomputes the tree-side stamp without the rest, and no binary built from the whole worktree can match it. Porting a cohort is therefore atomic by construction: the crate modules, the registry edit, the descriptors and the deleted shell gates land in one commit or this gate reds.
The two sides. The producer is native/build.rs, which bakes the stamp in as a compile-time constant. cargo:rerun-if-changed= re-runs it on exactly the two events that can change the stamp: each tracked input’s own path, for a content edit, and the git index, the only way a file enters or leaves git ls-files. There is no directory sweep, which would drag target/ in and rebuild the crate against its own output. A build that cannot compute the stamp fails the build rather than emitting a sentinel, since a binary with no stamp is the artifact no freshness oracle can hold; the crate builds inside its own git checkout by construction. The tree side is fresh::source_stamp in the crate and gate_native_source_stamp (§lib/gate.sh), the one shell home of the same three invocations.
The comparison. With a dispatching descriptor the gate runs <binary> --source-stamp (§Porting a gate to the binary substrate), computes the tree-side stamp, and reds on a mismatch. The failure text names both stamps, the descriptors that dispatch to the binary, and the rebuild command.
Running the crate’s tests does not discharge the rebuild. cargo test compiles its own harness, a different artifact from the binary GATE_SDK_NATIVE_BIN names. While the crate carries an integration test, cargo test --release rewrites that binary too, unremapped: this gate reads it current and the installer smoke reds on its builder path (installer/SPEC.md §The consumer smoke). So the obligation is bash gate-sdk/bin/build-native.sh (§build-native) as its own named step, after the tests where both run: a contributor routine stating a cargo test line has not stated it.
It is its own gate rather than an assertion on check-gate-substrate-parity, because the subjects differ: parity audits what a port declares, this audits whether a build is current. Their placements differ to match: parity ships on-surface to a consumer, while this gate does not ship, running only where the build happened.
Knobs: GATE_SDK_NATIVE_BIN and GATE_SDK_NATIVE_CRATE, both existing (§Layout and configuration); a knob selecting the registry would be a second spelling of GATE_SDK_GATES_DIR. The registry is an input, <gates-dir>/gates.list, the path the positional argument already determines, so the fixture pair stays hermetic. # graph: manifest: tier=precommit, coupling the crate root (native/*, where * crosses /, §run-gates), kit:checks/*.gate (the descriptor set) and the gates dir’s gates.list, since a registration edit alone can flip the verdict.
Its coverage is split across three oracles:
- The
good/+bad/pair covers descriptors present and registered with the binary readable: the comparison reached and agreeing, and the mismatch red, with the descriptor set derived across both a gates dir and a kit resolve dir. It is hermetic through the second positional, which supplies the tree-side stamp from a file, the shape §check-exec-bit uses: a fixture tree cannot carry a git index.run-gate-testsresolves the dispatch binary once at the invoker’s root, outside any case’s config. - The bespoke
scripts/gate-tests/check-gate-binary-fresh.test.shcovers the predicate’s boundary: zero descriptors; descriptors present, none dispatching; the near miss, a registered member resolving to a descriptor with no binary, which must still exit 2; and an absent registry. Its subject isGATE_SDK_NATIVE_BIN, whichgate_commandalso resolves the dispatch through, so a missing binary there makes the dispatcher refuse before the rule runs. The test therefore resolves the argv throughgate_command, never a declaration path, and substitutes the one value the case varies. - Crate unit tests hold the git computation as an executed assertion: one compares the baked constant against the runtime derivation, the other against
gate_native_source_stamp’s output over the real crate. Both fixture cases pass an explicit stamp file, so these tests are where the git-based derivation is exercised before any binary is placed; the battery reaches it only once a build exists to compare against. That is the precedent thecheck-knob-default-couplingdisposition sets (§Meta-gate conservation for the binary substrate).
check-crate-arms
The crate’s lint and test arms run at commit time, as a registered member of the battery:
cargo clippy --release --manifest-path "$CRATE/Cargo.toml" --target-dir "$TARGET_DIR" --all-targets -- -D warnings
cargo test --release --manifest-path "$CRATE/Cargo.toml" --target-dir "$TARGET_DIR"
$CRATE is gate_native_crate (§Layout and configuration) and $TARGET_DIR is GATE_SDK_CARGO_TARGET_DIR. Both arms run even when the first fails, so one commit-time report carries what a CI run would have said in two. The second spawn is unconditional on the first’s verdict, the fixture arm on both, and the target-lint arm, where a host arms it, on all three.
Every spawn, each arm and each fixture suite below, runs with git’s repository locators (proc::GIT_REPO_LOCATORS) stripped: git exports them to a hook, and a sandbox running git would otherwise read the hook’s index, red only from the hook.
This gate is the one spelling of the arms. bin/build-native.sh only builds, so a battery without this gate let a commit pass that CI then failed. A near-identical command elsewhere is the same hole one flag narrower, so CI’s own lint-and-test step is deleted rather than kept in step: two copies held equal by nobody is the defect, and removing the duplication outranks gating it. CI’s build step stays, since §check-gate-binary-fresh needs the artifact.
A third arm runs every fixture suite, because a rule narrowed in a shared module discharges §Fixture-pair discipline only once every <tests-dir>/<gate>/ that module backs has been re-run, and the pre-commit battery runs gates rather than suites. Under the same predicate and after the first two, it runs --run-gate-tests over each row of registry::fixture_suites(), the one fixture-suite derivation and the rows --emit fixture-suites prints. So a consumer-owned pair behind a shared module is in the roster by derivation.
- A suite’s failure is the gate’s failure: the report prints that suite’s runner output, which names the case, and the one-suite re-run command.
- The runner is the binary
GATE_SDK_NATIVE_BINnames, so the arm is meaningful only with §check-gate-binary-fresh green, which the same commit’s battery requires. It never rebuilds. - A suite whose tests dir holds the working directory is skipped, since re-entering it would recurse. This gate’s own fixture cases pin
GATE_SDK_KIT_DIRSto a root with nogate-tests/, so they resolve no suite. - The stamp cache is unchanged, so a fixture-only edit is not re-run here: its author edited that case knowingly, and the per-case run covers it. The cost falls only on a commit missing the stamp, one touching the crate, the only commits that can move a shared module’s verdict.
It is an arm rather than a command to run by hand because the failure it closes is remembering, not typing.
A fourth arm, opt-in, runs a lint command the host names, so a host that can lint the crate for a target it does not build catches a target-only lint before the push. GATE_SDK_CRATE_TARGET_LINT_CMD is a command knob (§The knob file), empty by default, and empty adds no arm. Where it is set the arm runs last, under the same predicate and whatever the other three said. The command takes the gate’s working directory, the repository toplevel at every door, and git’s repository locators are stripped as for every spawn here.
- A non-zero exit is the gate’s failure, the command’s merged output relayed as each arm’s is. The command is a lint at deny level, and its red is a finding.
- A command that cannot be spawned is exit 2, naming the knob. A host that armed a lint it cannot start cannot answer, and a skip there would read as a clean target. The opt-in is the knob, set once, never a probe on each run, so the rule below that no gate branches on a missing program at run time holds.
- In a linked worktree the arm is never spawned: the cache-or-refuse rule below covers it with the other three.
The kit names no target, no container and no toolchain manager. What a host can lint beyond its own target is a fact about that host. One carrying a toolchain manager and the target’s library names cargo directly, and one carrying neither names a container that does. Both are one argv, so the mechanism is a command and the choice is config. The fact is per machine, so the value usually lives in the gitignored knob overlay (§Layout and configuration), which CI and a fresh clone do not read. CI needs no copy: its own target legs are the oracle this arm runs ahead of.
Two shapes are refused. A built-in cross-target clippy that runs when the target is found installed is a run-time presence probe, and it does nothing on a host with no toolchain manager. A pre-push hook has no tier to ride: the battery’s tiers are commit-time and audit-time, and a third hook is a second enforcement surface for one member.
There is no formatter arm, and that is a contract. A green here is no evidence that a formatter would leave the corpus alone, and a session reaching for one to satisfy this gate rewrites a corpus other gates read as text. §build-native owns why no session runs cargo fmt over the crate. Match the surrounding formatting by hand. A consumer wanting the arm adds a formatter’s check-mode command to its own battery.
Each half has one CI spelling, on a target the battery’s host does not build, so neither is the deleted duplicate. Every native-artifacts leg runs cargo clippy --release --all-targets at -D warnings on its own --target, after the artifact build. A warning reds the leg and never changes what a release publishes. Only a Windows leg compiles the crate’s cfg(not(unix)) code, so a dead item there is invisible to this gate on every contributor host that has not armed the target-lint arm.
One root file names the toolchain every leg lints and builds on. rust-toolchain.toml at the repository root carries one channel, the minimal profile and the clippy component. rustup resolves it from the working directory upward and every cargo call in this tree runs from the root with --manifest-path, so every job that runs cargo takes it. The build and the lint of one leg cannot disagree, nor one leg lint ahead of another. This gate lints on the same toolchain wherever the contributor’s host runs rustup, whose first cargo call in a fresh clone downloads it. Nothing else names a version: an exported RUSTUP_TOOLCHAIN or a version in a workflow would override the file for its own steps alone, and a contributor’s host reads neither.
Each job’s first cargo call is cargo --version, which prints the toolchain it resolved and is where a push is read. It stays ahead of the job’s first rustup subcommand: rustup installs a missing toolchain on a cargo call and deprecates doing so on its own subcommands. A runner image carries no toolchain under the pinned name, so that call is the fetch. It has one spelling, the repo-local composite action .github/actions/toolchain, which retries a failed call within a bound, and every job that calls the toolchain takes it as a step ahead of its first such call. check-action-step-order holds that over this tree’s action and program set (§check-action-step-order). A compiler proxy call counts, a version read included, since it fetches as a cargo call does. It is an action because the call precedes any binary that could carry it, and a step is the one form both workflows share with no further shell file.
- The pin is not the floor.
rust-versionin the crate manifest stays the floor an adopter-facing build must hold, andincompatible_msrvkeeps reading it. The pin is the version of the tools that check that floor. - A move is one commit that edits the file alone. The lint and the build of every leg run on runners only, so the move’s push is its witness. A red there is attributable to the toolchain and never to a crate edit sharing the commit. The move is due at release prep, by the
toolchain-pinclass of the consumer’s audit roster (lifecycle-kit/SPEC.md §The audit roster). Its sweep compares the channel with the newest stable and either moves it or records why it holds. A defect a newer toolchain fixes moves it outside that cadence, by the same one-file commit. - A moved pin misses this gate’s cache on a
rustuphost, since the cache keys on the toolchain.
Honest limit. A host without rustup reads no toolchain file and lints at the version it carries, so a lint only the pinned clippy raises still arrives with the push from such a host. On a host that has not armed the target-lint arm, code under cfg(not(unix)) is linted on a Windows leg alone, whatever the host’s toolchain, so a lint that fires only there arrives with the push too. The fetch’s retry is bounded, so a registry outage outlasting it still reds that leg with no cause in the tree: re-run the leg. The retry branch runs only when a fetch fails, so a green push shows the spelling and not the retry. A toolchain call a step reaches through a script is outside check-action-step-order’s read, so a job whose first call sits in a script still owes the step by review.
cargo test --release runs on every other published triple, where a test pinning a platform’s filesystem or process behaviour runs beside it: crate-tests-windows on each Windows triple, and crate-tests-unix on each remaining triple except the battery’s own, which the roster job reads off the gates image’s rustc. A roster leaving no such triple skips the job rather than handing Actions an empty matrix, which it refuses. Each builds the host binary first, because the crate’s registry-coverage tests run every member over its fixture cases and a member that spawns GATE_SDK_NATIVE_BIN exits 2 without it. Every leg also installs the ShellCheck and gems the gates job installs, whose absence exits the same members 2. Every leg takes its triple’s platform-derived posture, the one the other legs of its platform read, and a failure also reaches the run’s annotations as one warning per triple naming the failing tests.
The gates job adds a compile-only Windows check. It runs cargo check for x86_64-pc-windows-msvc on its Linux runner, since check does not link and so needs no MSVC linker, and the next unguarded std::os::unix use reds on the commit that adds it. It proves compilation, not correctness, and names one error batch rather than the full set. It is not a gate, because it needs rustup and network on every host, which the contributor floor does not carry; the target-lint arm asks them of the one host that opted in.
The test arm holds one consumer-shaped leg. A unit test in native/src/emit/git_hook.rs runs both hooks’ selection under every profile in installer/profiles.list and the payload-derived full, and asserts each selects without a refusal. Each kit set is written as GATE_SDK_KIT_DIRS into a knob file in a scratch gates dir, not the process environment; a tree without the profile roster has nothing to hold. Selection costs milliseconds, so the leg mints no gate. It covers a consumer-shaped kit set over this tree’s kits only. A defect shaped as one release’s kits over another’s tree has no build-time form, and §upgrade-smoke at validate is its earliest oracle.
The predicate is the crate’s presence, not cargo’s. Its corpus is the crate cargo is handed by manifest path, so it takes no walk root:
- No
$CRATE/Cargo.toml: no corpus, so clean at exit 0, the parenthetical naming the absent crate. §Consumer payload keeps the crate outside every kit root, so a consumer tree lacks the subject, not the toolchain. It is §check-gate-binary-fresh’s zero-dispatch branch applied to a corpus. - Crate present,
cargoabsent: exit 2 naming the floor, the shapebin/build-native.shuses (§Fail-closed contract). No adopter reaches it.
No gate here branches on a missing program at run time. Criterion 5’s refusal (§The port-candidate criteria) is a vendor-time decision the bootstrap makes before any gate runs, never a runtime command -v skip.
It is the publisher’s own gate and does not ship, for §check-gate-binary-fresh’s reason: the subject cannot exist in a vendored tree. The descriptor and its fixture pair sit in the publisher’s gates dir; the rule stays in the crate.
Tier is precommit, by §The # graph: manifest’s discriminator: a clippy finding or a failing unit test is introduced and repaired inside the commit that perturbs it. The counter-pressure is wall clock, since --all-targets compiles the test targets and clippy keeps a cache separate from the one bin/build-native.sh warms. The answer is a source-stamp cache, not align-only and not a narrower arm set. The arms, three or the four of a host that armed the target lint, re-run only when the crate’s source stamp (§lib/gate.sh, gate_native_source_stamp) or the toolchain differs from the last green run recorded under .tmp/, and a crate with untracked files never hits the cache. A cached green is the same arms’ verdict over the same source and toolchain.
While the target-lint knob is set, the key also takes its argv and the bytes of the root toolchain file. A record written before the host armed the arm then misses, so arming is never answered by an older green. A moved pin misses on a host whose own cargo does not read the file, where the command’s toolchain may. With the knob empty the key is the three arms’ own, so arming elsewhere invalidates no record here. Honest limit: the key cannot see inside the command. A container image that moved under an unchanged argv, or a toolchain the command fetched anew, changes the lint with no miss, and the next crate edit re-runs it. CI and a fresh clone carry no record and run every arm, which keeps true the claim that a passing commit cannot coexist with a failing CI.
In a linked worktree the cache is also read from the main checkout, and a miss refuses rather than builds. A worktree is where a delegated read-only session runs, and a build is the mutation isolation prevents (delegation-kit/templates/agent-execution.md, isolation cost (4)). A local miss is looked up under the same record name in the main checkout’s scratch dir, the main checkout found as _gate_main_checkout_bin finds it (§lib/gate.sh). The record is keyed on the source stamp and the toolchain, so it answers for a worktree holding the same source. A miss in both is exit 2 naming the rule, and no arm of the four is spawned: a moved stamp, an untracked file under the crate, or a toolchain the main checkout did not record.
That refusal is the contract for a crate-source commit made in a linked worktree, which lands from the main checkout. The main checkout’s record answers only for source it built, and delegation-kit’s isolation cost (4) forbids the build that would answer for anything else. So the refusal’s help names that remedy, and names no re-run that cannot turn it green. The registry coverage tests take this member’s fixture cases in a linked worktree as the refusal: each must exit 2 on its first line and is reported as pinned rather than observed, and any other outcome fails them.
The source stamp is one implementation, shared. §check-gate-binary-fresh holds its tree-side stamp to one algorithm, and this member caches on the same value, so both call the helper in native/src/fresh.rs. The shell spelling keeps one live reader, the cross-substrate comparison in main.rs that holds the two derivations equal.
The fixture pair and the build products it must not leave behind. The pair is two minimal crates, good/ clean and bad/ carrying one clippy finding and one failing test, each args-free and pointed at its own crate through its case-dir gate-sdk-config.knobs. They vendor nothing, so they build with no network. Cargo writes a target/ beside the manifest it is given, so each case points GATE_SDK_CARGO_TARGET_DIR at the repo’s scratch directory, .tmp, spelled relative to the case, since the knob grammar has no expansion. The Cargo.lock cargo writes beside the manifest is gitignored like the real crate’s. The bad/ expectations match substrings rather than pinning the report, since the relayed cargo output carries a process id and timings that differ run to run.
It is .gate-dispatched, declared by the publisher’s check-crate-arms.gate with its rule in native/src/gates/crate_arms.rs. It consumes the wrapper contract (§Fail-closed contract): proc::on_path for the presence probe and proc::run_merged for each arm’s 2>&1 capture, since the failing run is the one whose report prints. The one face it adds is Merged::reported_code(), for a wrapper that prints an exit code rather than branching on it.
It declares three programs where criterion 7’s report counts two. cargo and rustc are the off-floor pair. git, observed through gate_native_source_stamp’s compiled counterpart, is on GATE_SDK_PROGRAM_FLOOR, so it earns no criterion-7 residual. All three are declared because unit test A asserts observed ⊆ declared. The fixture arm’s runner is declared beside them as the program GATE_SDK_NATIVE_BIN names, as §check-gate-binary-fresh declares that binary, and the target-lint arm’s command as the program GATE_SDK_CRATE_TARGET_LINT_CMD names, so the test holds while the knob is set.
rustc is read for the cache key and nothing else, so its absence is not a refusal. The key is composed from each program’s version output with its stderr discarded and its emptiness untested. An absent rustc contributes an empty field, a cache miss against any key written while it was present, so the arms re-run and cargo reports what it reports. Only cargo has a refusal arm, and it fires after the crate-presence branch, so a tree with no crate is clean whether or not cargo is installed.
check-install-disposition
Four assertions over every kit root in gate_kit_roots, holding §The install disposition.
- (A) Declared — every
checks/member,check-*.shandcheck-*.gatealike, carries exactly one# install:line and its value is in the closed vocabulary. A gate that ships without one is red, so a newly added zero-config gate cannot reach an adopter undeclared. Two lines are a finding for the same reason one is required: a declaration surface’s failure mode is disagreeing with itself. - (B) Smoke superset — every
zero-configmember of a kit appears in that kit’ssmoke/install.shroster. The smoke’s tree is a superset of the treeinitmakes, so a gate the installer registers must be registrable there too. The converse is not asserted: a kit’s smoke legitimately registers more than the installer does, and asserting equality would force one of the two trees to lie about itself. A kit shipping nosmoke/install.shis skipped, counted on the clean line and never a finding, since there is no roster there to be a superset of. That is every vendored consumer’s case, the payload withholdingsmoke/(§Consumer payload); in a tree that authors kits the skip count is zero. The non-zero-confighalf is held at landing and at validate, because its verdict is a probe result (§Consumer smoke, the registration accounting) rather than a static read. - (C) No second copy — the installer’s recipe,
installer/recipe.rsunderGATE_SDK_NATIVE_SRC, carries no literal gate name, so the de-literalization holds going forward. A§-prefixed occurrence is a spec-section citation and is stripped before the match: a section reference registers nothing, and registering is the only thing this assertion is about. The file is absent in a vendored consumer, which has no installer; that absence is a skip, reported on the clean line, never a finding. - (D) Arming declaration placed — every
checks/member carries at most one# armed-by:line. Its value is a declared static knob thatknobs::ownerplaces in the kit whose root the gate sits under (the root’s basename is the kit’s name), orcontentfollowed by one or more such knobs. A second line, an empty value, acontentwith no knob, a name no static kit declares, or another kit’s knob is a finding. The line’s one reader isdoctor, and a knob it can never resolve empty would make its disarmed line a silent no-op. The clean line counts the declarations verified.
Fail-closed on a non-repo cwd with no root argument, an unreadable gate header, an empty kit roster, or kit roots that enumerate no gate at all. Configuration reuses GATE_SDK_KIT_DIRS and GATE_SDK_NATIVE_SRC; the gate adds no knob of its own. precommit tier.
Its implementation is a compiled subcommand — declaration path check-install-disposition.gate, rule out of the gate binary. The crate spells the kit roots against the invoking directory (§Layout and configuration), so the compiled form anchors them there. The root argument, with the git toplevel it falls back to (this member’s one declared external program), anchors the installer recipe alone. Every invocation in this tree runs at the directory the roots were spelled against, so the two anchors coincide; a caller passing a root that is not its cwd reads the recipe there and the kit roots where it stands.
Criterion 4 binds, and the fixture pair discharges it (§The port-candidate criteria, criterion 4). Every registry member’s declaration path lies inside the corpus this gate scans as content, its own included, in both spellings. The pair carries the four arms of that derivation: a .sh-declared member, a .gate-declared one, a mixed kit holding both, and assertion C’s absent-recipe skip. good/ ships no installer at all, the vendored-consumer shape, while bad/ carries the literal-roster finding beside a §-prefixed citation that registers nothing. The .gate arm is a finding on purpose, since a descriptor the sweep counts but never reports would leave the second glob asserted by nothing. The pair also carries assertion D. good/’s kit root is named for a static kit, one member declaring a knob that kit owns and another the content form over two of them. bad/ names a knob no kit declares, a content with no knob, and a content naming another kit’s knob. What a pair cannot assert is an absence, so the §-stripping and the # install:-with-no-value reading are held by crate unit tests beside the rule.
Its live-tree parity was proved, not demoted to smoke. The member took the escape criterion 4 records: the pre-port rule ran under a name no registry member resolves to, so both forms read the same post-descriptor corpus, this member’s own descriptor included. They agreed on stdout, stderr and exit code over the live tree, both fixture cases, and both fail-closed arms no committed fixture can reach.
check-test-hermetic
Two assertions guard a run against ambient state. (A) Every bespoke gate-tests/*.test.sh under the configured kit dirs either sources lib/test-hermetic.sh or carries a # hermetic-exempt: <reason> marker line. (B) A credential-managing smoke script, one that assigns a *_CRED_FILE, pins every own-kit bin call to a *_CRED_FILE path on the invocation line, or carries the same # hermetic-exempt: valve.
Assertion A’s exposure: run-gate-tests runs fixture pairs cd‘d into a case dir, hermetic by construction, but runs the bespoke unit tests with the invoker’s cwd. A gate a unit test drives then silently inherits the consumer’s <KIT>_CONFIG_FILE resolution, so a test can green under the consumer’s posture rather than kit defaults. The bootstrap is the fix, one shared pin rather than one per test (§lib/test-hermetic.sh); the marker is the valve for a test that establishes hermeticity otherwise, by constructing its own cwd.
Assertion B’s exposure: a smoke script drives its kit’s bins under the real $HOME, so a bin resolving its credential file from the ambient ~/.claude (the <KIT>_CRED_FILE default) reads live login state. Its verdict then turns on the wall-clock age of the operator’s credential, non-deterministic by construction. The fix pins the cred at an absent path, so no ambient auth event leaks in. The trigger is the script’s own *_CRED_FILE assignment, so a smoke script that manages no credentials is never held to the rule. The scan keys on the $SMOKE_KIT_ROOT/bin/ own-kit-bin convention, so no kit’s credential-consuming bin roster is spelled out in gate code.
With no argument the gate runs both — each gate_kit_roots kit’s gate-tests/ (A) and smoke/ (B); a positional arg scans the named gate-tests dir(s) (A, the mode the fixture pair drives); --smoke [dir...] scans the named smoke dir(s) (B). Tier precommit; the # graph: couples kit:gate-tests/*.test.sh, kit:smoke/install.sh, and kit:smoke/violation.sh (dir=one, a one-way audit over the test and smoke trees).
Its implementation is a compiled subcommand, on §check-action-pinning’s terms — declaration path check-test-hermetic.gate, rule out of the gate binary. Its two # assertion markers live with the rule in the implementation module, where §check-gate-assertions looks for a .gate-declared member’s.
check-path-dialect
Invariant (§The path-dialect contract), in three clauses:
- every platform-native path producer in the tree converts at its own point of production, or records at the site why it does not;
- every text-level path primitive in the crate is spelled in the crate’s speller, and every shell absoluteness test in the tree asks
gate_path_rooted, or the site declares its value out of the filesystem namespace; - every shell file composing two roots anchors its cwd first.
It exists because the migration decays inside its own iteration otherwise: every newly ported file adds a producer to the remainder. An at-site verdict replaced §Worked dispositions’ per-site roster, which went stale on every ported file.
The corpus is two halves. The tracked shell tree (walk::tracked_shell_tree, already two other arms’ corpus, so its *.test.sh filter and its prune set are the ones this tree already resolves) and the crate source under GATE_SDK_NATIVE_SRC, walked with the same prune set.
The producer roster is per-substrate, which is §The path-dialect contract’s own shape rather than an exception to it. On shell: git rev-parse with --show-toplevel, --git-dir or --git-common-dir. On Rust: std::env::current_dir(), std::fs::canonicalize(), env!("CARGO_MANIFEST_DIR"), and the git rev-parse --show-toplevel spawn — --git-dir and --git-common-dir are not on the Rust roster, for the reason the value-bound rule below states.
One test decides what counts as an occurrence: is a value bound here. A producer produces a value; where none is bound, nothing was produced and there is nothing to cross. The test is answered at the occurrence, never by tracing a binding through a file, which is the locality §The path-dialect contract’s producer discipline preserves. Three consequences, each an instance of the one test:
- A producer named inside a comment is not an occurrence. The gate splits each line’s code half from its comment half before scanning, so a producer named in prose cannot false-positive. Both fixture cases carry a commented producer that must stay green.
- A shell occurrence whose stdout is redirected to
/dev/nullis not one, since only the exit status is read and an exit status has no dialect. The redirect is read as the descriptor names it: a bare>and1>are stdout,&>is both, and2>is the stderr hedge every crossed site already carries and clears nothing. The search is scoped to the occurrence’s own command substitution, so a later command’s redirect on the same line cannot clear it. --git-dirand--git-common-dirleave the Rust roster, because there the test cannot be answered locally:proc::runbinds aCompletedand the presence read (stdout().is_some()) lands a statement or more later. This gate does not assert that a future--git-diranswer in the crate is crossed; §The crate’s crosser’s producer monopoly and the review that holds it cover that. A recorded verdict at each of the crate’s presence probes is refused, since it re-creates a maintained enumeration of what derivation decides.
A call to walk::cwd, walk::toplevel, walk::toplevel_in, walk::toplevel_opt or walk::canonicalize is not an occurrence either: those are the crosser’s own API.
Four clearances, and the shell one is cd. An occurrence that binds a value is red unless:
- the substitution is the direct argument of a
cd; - it is inside
native/src/walk.rs, the declared crosser’s own body; - it is
Path-typed, the direct argument ofPath::new(orPathBuf::from(, so the value never becomes a string andstd::pathcarries the dialect — §Porting to Rust does not retire dialect exposure read positively, which clears the crate’senv!("CARGO_MANIFEST_DIR")sites with no edit; - or it carries a recorded verdict, an adjacent
spec:comment citing §The path-dialect contract, read on the occurrence’s own trailing comment or the contiguous comment run above it.
cd alone clears, and the pwd -P read-back is owed only where a root is bound. cd is the dialect-tolerant consumer The judging predicate (§The path-dialect contract) names. A value handed to it becomes a chdir rather than a tree-internal string, the shell’s counterpart of the Path-typed rule. The read-back is what a site does when it wants a root out of that cd, not a second half the cd needs to have crossed. pwd -P prints the physical directory and never assigns PWD, so a cd to a foreign-dialect absolute path leaves bash’s own $PWD foreign whether or not a binding follows. Repairing PWD would be cd -P’s business, outside this contract. The clearance does not weaken the sweep: a site binding the producer’s answer into a variable and cd-ing that variable indirectly is red.
One red rides on that clearance. Where a producer sits in cd position and the cd’s effect is read back into a value, the read-back is pwd -P and never a bare pwd. Bash’s logical pwd prints an absolute argument straight back, so that spelling changes nothing while looking exactly like the fix — the trap The shell crossing idiom (§The path-dialect contract) exists for. The arm is anchored to an already-cleared occurrence and reads only the first non-blank, non-comment line after the cd, so the red set stays inside the producer roster.
The producer arm’s exemption needs no new comment grammar. It reuses canon-kit’s spec: one-line binding, which already carries a mandatory cited section and is gate-read on both sides; walk.rs writes exactly that form. A dedicated tag class for that arm would be a second grammar for no error class the existing one misses. check-comment-tier is unchanged: the citation is a real binding to a real section, which is what that gate requires.
A second arm asserts locality of the text-level primitives, the clause of §The path-dialect contract that review alone had held. §Porting to Rust does not retire dialect exposure states which spellings are walk.rs’s; this arm holds it over the crate source, one finding per line per primitive:
- testing absoluteness from a path’s text — a leading-separator test, and
std::path’s ownis_absolute, which answers false on a separator-rooted path under Windows wherewalk::path_rootanswers true; - composing a prefix to test containment — a bare trailing-slash composition, whose only consumer is a prefix test, and a
format!handed straight tostarts_withorstrip_prefixwith a separator in its template. The bound form is in the vocabulary because a synthesized<anchor>/prefix-tested a statement later is the shape that broke both Windows install-smoke legs.
Its shell half holds the first primitive over the tracked shell tree. A [[ ]] glob test or a case alternative that is exactly /* is a finding outside gate_path_rooted’s own body, cleared by the same path-dialect-exempt: token spelled as a # comment. The glob test is ==, != or a single = after a blank, optional blanks, then /* (or its separator quoted), closed by a blank, ], ; or the line’s end. The case alternative is opened by the line’s start, a blank, ( or |, and closed by optional blanks then ) or |. So a prefix or containment glob — */*), /dev/*), "$d"/*), /*/sub — stays green. The body clears by name, from a line whose code opens gate_path_rooted() { through its closing }, so the bootstrap’s twin and a fixture’s copy clear the same way. Its count rides the clean line as its own phrase, N shell absoluteness test(s) — M inside gate_path_rooted, K declared out of the filesystem namespace, beside the crate’s primitive tally.
Two clearances, and a recorded verdict is deliberately not one of them. The site is walk.rs itself, or it declares its value out of the contract with // path-dialect-exempt: <reason>, on its own line or in the contiguous comment run above it. That is the window the producer arm’s verdict rides, with a mandatory reason; an empty one declares nothing. The spec: verdict does not clear this arm, because a clause every site can cite its way out of is the review-held clause this arm replaced. The two tokens answer different questions: one says this site deliberately does not cross, the other this value is not a filesystem location. One token doing both jobs would hide which claim a site makes (canon-kit/SPEC.md §check-comment-tier holds its roster row).
The third primitive is held by review, and that is ruled rather than deferred. Joining a root onto a segment is the contract’s too, but lawfulness there turns on where the composed value goes. That is dataflow, and a form scan of the join would red the hundreds of Path-bound and diagnostic joins the clause permits. The escape this contract fears most is already a red: a join handed to a prefix test, directly or through a bound trailing-slash prefix, is the containment form above. What stays unheld is a joined value bound to a name and printed or compared a statement later. Telling that from a lawful diagnostic needs per-function taint tracking, which gives up the one-occurrence locality this gate is built on. It is held by review and by walk.rs’s monopoly on the producers those roots arrive through, and a defect found in it reopens the question with its instance.
A third arm asserts the cwd-anchor clause on the shell side. §The path-dialect contract obliges a script composing two roots to anchor its own cwd first. A builtin produces no foreign value but propagates one: an absolute cd leaves $PWD foreign, and a later relative cd … && pwd concatenates onto it. Neither half is a producer occurrence, and the pwd -P read-back arm reaches only a cd this gate already cleared. So the arm pairs the two facts the hazard needs, since either alone reds files that compose nothing foreign. It reads the producer arm’s shell corpus one file at a time, on the same code-and-comment split, because the contract judges consumption within a file.
- A root is a name bound on one line from a command substitution that
cds and capturespwd: to a path derived fromBASH_SOURCE, or after agit rev-parse --show-toplevelcd. Anexport,localorreadonlyprefix binds the same name. - Red when a file binds two or more roots, at least one from
BASH_SOURCE, and applies string arithmetic to any of them. That is a join inside a word —$<root>/,${<root>}/, or either quoted and followed by/, which is one shell word — or a${<root>%…}or${<root>#…}strip. - The sole clearance is the anchor itself: a line whose code half is exactly
cd "$(pwd -P)", before the file’s first root binding. Nospec:verdict or exempt token clears this arm, on the locality arm’s ground. The finding names the file and its first root binding, and the anchor as the remedy.
Its honest limit: a file composing one root with a caller’s foreign absolute path passes. The composition is out of this arm’s reach, but its precondition is not: a caller’s foreign absolute path now reads as rooted through gate_path_rooted, which the locality arm’s shell half holds every absoluteness test to.
Its own vocabulary is a recorded verdict, and its unit tests compose from that one site. The module names the forms it hunts, so it would red on itself; the two constants carrying the roster take the same spec: verdict as any deliberate non-crossing site. The unit tests build their inputs from those constants with format! rather than re-spelling a producer, so the roster sits in exactly one place and a test line cannot drift from it.
Fail-closed, and the honest limit. The tracked shell corpus degrades to empty outside a work tree, so the repository is probed first and an absent one refuses with exit 2 rather than reporting a clean scan over nothing. The verdict is otherwise a syntactic property of the source, exercisable on any host, so the fixture pair is an ordinary one and no injected input is owed for the gate. What stays unexercisable is the premise underneath — that getcwd(3) answers in the MSYS spelling and git rev-parse does not. The gate inherits that premise rather than discharging it (§How the claim is held, with no oracle that can run it): a green check-path-dialect asserts that every producer crosses, never that the crossing works. Its verdict is monotone in the violation set — it reds on finding a violation, never on finding none — and it holds no coverage floor.
Its implementation is a compiled subcommand, born native, on §The port-candidate criteria’s terms for a new gate: declaration path check-path-dialect.gate, rule out of the gate binary. Tier precommit with trigger=*: the corpus is the whole tracked shell tree, so no couple glob covers it and the hook invocation is unconditional (§check-tree-terms takes the same shape for the same reason).
Its fixture pair exercises both corpus arms, both assertions and every clearance, and its bad/ case asserts the violation count, so a clearance arm that stopped clearing reds that case rather than passing quietly.
good/carries the primitives spelled in the speller’s own body, a module reaching each through a named helper, and a declared namespace site. It also carries an anchored two-root file, a one-root file doing arithmetic, and a shell file defininggate_path_rooted()with both single-dialect forms inside it, a site routed through it, a# path-dialect-exempt:line and the prefix arms that must stay green.bad/carries each vocabulary member, a declaration with no reason, an unanchored two-root file, one anchored after its first binding, a[[ … == /* ]]file and acase … /*)file.
check-pipe-membership
Invariant: no set producer feeds a short-circuiting reader in a shell file under pipefail. It holds §run-gates’ rule — a short-circuiting consumer may not be fed by a producer inside the same gate — at the one shape that rule’s defect takes. The reader exits on its first match while the writer is still writing, the writer takes SIGPIPE, and pipefail makes the signal the pipeline’s status, so a present member reads as absent. The onset is a roster size rather than a race on every run, which is why review does not hold it.
The corpus is walk::tracked_shell_tree, the corpus and prune set §check-path-dialect walks, plus the tracked unit-test suites, read on the same code-and-comment split. A suite is each *.test.sh whose parent directory is named gate-tests (the kit layout’s) or is the resolved GATE_SDK_TESTS_DIR, and whose path above it is unpruned. That is the <tests-dir>/*.test.sh shape §run-gate-tests runs, so a consumer relocating its tests dir keeps its suites in scope. The suites join because a suite is a shell file under pipefail like any other; a case directory’s files stay out, since its bad/ trees carry deliberate violations. The clean line counts the two halves apart.
A file is in scope when a code line sets pipefail: set -o pipefail, or a combined flag word ending in o (set -euo pipefail). A line whose code half ends in \ or | continues onto the next, so a pipe broken across lines is one pipeline.
The predicate is keyed on the producer, and that is what keeps it narrow. A pipeline reds when its last stage is a short-circuiting reader and its first stage is a set producer.
- A short-circuiting reader is
head, orgrepcarrying-q,--quiet,--silent,-m/--max-count, or a short flag cluster holdingqorm. - A set producer is
printforechowith an array expansion ([@]or[*], never a${#…}length) among its arguments, or a loop whosedonefeeds the pipe. That is an in-shell write loop issuing onewrite(2)per element, which is what keeps writing after the reader has exited. - Whether the status is consumed is not asked, since a membership pipe exists to be tested. A nested command substitution or subshell is read as a pipeline of its own, and quoted text is content, so a
|inside a pattern splits nothing.
Any other pipe into a short-circuiting reader is safe or benign: its producer writes at most one line (find … -print -quit, head -1), reads a small fixed input, or feeds a verdict nobody reads. A gate reddening those would teach the tree to ignore it. Membership is the only reason to stream a set into a short-circuiting reader, while an array producer feeding a full reader (paste, sort, git hash-object) is left alone.
No valve. A deliberate early-exit pipe is outside the predicate by construction, because its producer is not a set; a site that needs a set tested against a value has the loop form. The finding names the file and line, and the remedy is the rule’s own: membership is a for loop over the set, or a [[ ]] test, never a pipeline.
Honest limit. A producer that is a command reading a large file or roster, such as grep -Ev … "$roster" | grep -qxF …, passes: its writes are the command’s own buffered output rather than a per-element loop, and judging its size is dataflow. The live instance of that shape runs under /bin/sh with no pipefail, outside the hazard’s precondition.
Fail-closed. Outside a work tree the corpus degrades to empty, so the repository is probed first and an absent one refuses with exit 2; an unreadable tracked file is exit 2. Its implementation is a compiled subcommand, born native, precommit with trigger=* for §check-path-dialect’s reason, and install: zero-config.
Its fixture pair asserts the count. bad/ carries each producer shape under pipefail, a suite among them and a second in the tests dir the case’s knob file relocates. good/ carries an array pipe with no pipefail, an array pipe into a full reader, a one-line producer into a quiet grep, the loop form, and a violating file nested inside a case directory under a tests dir.
check-front-end-fail-open
Invariant: each front-end stub names exactly the crate’s fail-open set. The set is the arms a stub exits 0 for when the binary is absent or not executable; §The harness-integration arm rules who belongs in it. The crate declares it as FAIL_OPEN_ARMS beside the arm table, and each stub carries a copy on one declaration line (§run-gates). A harness-integration arm minted without its name in a stub wedges a binary-less adopter at the point a hook grades a user action. A name the crate does not declare turns a verdict a timer or a battery reads into a silent 0.
The crate’s declaration is the reference, and the arm contracts are not. Only the binary holds a declaration a test can check against the arms it names. The crate’s own unit test holds each FAIL_OPEN_ARMS name to an arm-table row, so a renamed or deleted arm reds there first. Prose carries no declaration a gate could read without a grammar invented for the purpose, and a per-arm status sentence would be a third copy of the set. So the class rule stays in prose, stating why an arm is in the set, and the list lives in the one place a test can reach.
It reads two declaration lines, from the gate-sdk root that is its one positional, defaulting to the gate-sdk root locator:
- the
FAIL_OPEN_ARMS=line of<gate-sdk root>/bin/run-gates.sh, one single-quoted value split on blanks; - the
$FailOpenArms =line of<gate-sdk root>/bin/run-gates.ps1, an@(...)array of single-quoted strings.
A line that reads the name without assigning it — each stub’s membership test — is not a declaration line. Exit 0 when every present stub names exactly the declared set, the clean line naming the set and each stub. Exit 1 when a stub names an arm the crate does not declare or omits one it does, the finding naming the stub, the arm and the direction. Exit 2 when a present stub carries no declaration line or two of them, or its line does not parse, since a stub the gate cannot read is not a clean one. An absent stub is reported on the clean line, not red: a consumer tree may drop the PowerShell twin, and a tree with no front-end has no copy to drift.
Its implementation is a compiled subcommand, born native (§The port-candidate criteria): declaration path check-front-end-fail-open.gate, tier precommit. Its install disposition is zero-config, because its subject is the vendored gate-sdk/bin/ that init writes. It couples the two stubs (kit:bin/run-gates.sh, kit:bin/run-gates.ps1) beside the arm table’s module. Its fixture pair reds both directions in both dialects: bad/’s shell stub omits --statusline and its PowerShell stub adds --usage-poll.
check-assertion-strength
Invariant: an assertion is at least as strong as its own failure message. In a smoke or gate-test script, a guard that discriminates only zero from non-zero must not carry a failure message naming a verdict token bound to one specific non-zero exit code. The binding is the invoked script’s declared exit contract.
The exposure is not a false green but a masked regression. The attested case is delegation-kit’s smoke guarding a usage-verdict call with a bare if, which accepts PAUSE (1) and STALE (2) alike under a message asserting specifically that the call “did not PAUSE”. A STALE regression then reports itself under the wrong name — worse than an honest silence, because it sends the reader to the wrong place.
The declared exit contract. A script may declare its exit codes in its header comment block. A declaration may sit inside a wider # usage: block written # exit: … with leading whitespace, so the parser keys on ^#[[:space:]]*exit:, not a bare # exit: prefix. The grammar: each uppercase token binds to the nearest preceding integer on the line, yielding a token→code map. Tokens admit internal hyphens, so RESET-OK reads as one token rather than two. A token bound to more than one code, or to code 0, is not discriminable and is skipped. That rule is load-bearing: a truthiness guard discriminates code 0 exactly, so a message naming a code-0 token claims no more than the guard established, and the rule keeps the gate off an honest did not verdict OK neighbour.
This is the same seam move check-test-hermetic makes with the own-kit-bin convention: the verdict vocabulary (PAUSE, STALE, and any other kit’s tokens) is derived from the callee’s own declaration and never appears as a literal in gate code. A gate shipping a vocabulary would publish it; a gate deriving one cannot.
Reach is opt-in and the gate never widens it. A callee declaring no # exit: header is simply out of reach — the gate demands the header of no one, so it imposes no obligation on every script in the tree. The reach is now empty, which is that design reporting honestly rather than a broken gate: the tree holds no # exit: declarer the own-kit-bin/ convention resolves, and delegation-kit’s smoke now calls the front-end, which is not that shape. The call count on the clean line is the visible surface, so a reach that went to zero is legible rather than implied. The scanned-script count beside it moves with every kit’s smoke/ and gate-tests/, unrelated to the reach, so it is read off the gate rather than pinned here.
Two widenings are left to a unit of their own. Widening the callee resolution to reach a compiled arm needs the arm’s exit contract published on a surface this gate can read, which mints a name and a producer. Leaving the # exit: header behind in a stub shell file keeps a gate green by keeping a dead file, the shape a port exists to end.
Detection. A guard is in reach when it invokes a declaring script through the own-kit-bin convention (bin/<name>.sh resolved against the scanned dir’s kit root) and discriminates by truthiness only — the call sits in an if or chains into ||/&&. The gate reads the guard body’s failure text within GATE_SDK_ASSERTION_STRENGTH_WINDOW lines after the call, truncated at the next such call so guards cannot excuse each other. It reds when that text names a discriminable token while the guard compares no status to that token’s code.
Valve. A # assertion-strength-exempt: <reason> marker, for a guard that establishes the outcome by other means. It is an inline per-site directive, sited on the guard it excuses — its own line, its immediately preceding comment block, or its body — and its discipline is the adjacent <reason>, exactly as # fail-closed-exempt: and the # hermetic-exempt: valve it is modelled on. It is deliberately not queue-linked: §check-gate-exemption-tasks scopes itself to # exception-list:-tagged arrays and rules this class out as local and self-evident via its adjacent comment.
Honest limit. The gate reads guard shape, not semantics. An assertion weakened without a token-naming message is out of reach, as is any callee declaring no exit contract; a message naming a token bound to several codes is skipped rather than guessed. What it catches is precisely the attested shape — a message more specific than the guard behind it. Like its siblings, a false positive is loud (a forced reword or an explicit exemption), never a silent miss.
A second limit is structural. The gate resolves a callee only through the own-kit-bin/ convention, so a member that has crossed to the binary is not a callee it can reach, whatever its exit contract. Its scan roots are smoke/ and gate-tests/ and never bin/, so a port changes what it can resolve and not what it scans.
With no argument the gate scans each gate_kit_roots kit’s smoke/ and gate-tests/; positional args scan the named dir(s), the mode the fixture pair drives. Tier precommit, matching its sibling; the # graph: couples kit:smoke/*.sh, kit:gate-tests/*.test.sh, and kit:bin/*.sh (dir=one, a one-way audit — an edit to a scanned script or to a declaring header re-fires it). The scan roots come from the kit-roots derivation, as check-test-hermetic’s do.
The window is the gate’s one knob, because how far a guard’s message sits below its call is a script’s style and not the contract. It takes no off: the window bounds the gate’s only assertion, so a consumer wanting none unregisters the gate. The guard’s own body is no structural bound, because the gate reads guard shape line by line and parses no shell block; the next-call truncation already bounds the window from the far side.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms — declaration path check-assertion-strength.gate, rule out of the gate binary. The three bounded matchers and the # exit: line grammar are hand-written kit literals, not an interpreted consumer pattern, so this member owes no ERE-engine work.
check-gate-exemption-tasks
Invariant: every temporary-disposition annotation in scope names a live task — on a gate declaration, and on any tracked script for the held-port field. Two annotations are in scope:
- Every element of an
# exception-list:-tagged array in acheck-*.shgate carries exactly one of two disposition annotations.# until: <slug>is temporary and must resolve to a live task in the queue file’s New Features / Technical Debt / Deferred sections — live meaning the slug of an entry heading within that span, one per entry, never every slug-shaped token in it.# permanent: <reason>is structural out-of-scope. An element with neither, a# until:slug that is Done-only or missing, or elements sharing the array’s opening=(line are violations. - Every
# port-until: <slug>header field on a declaration (§The# graph:manifest) resolves through the same live-slug map; a slug that is Done-only or missing is a violation.
Inline per-site directives (# fail-closed-exempt:, # no-fixture:) stay out — they are local and self-evident via their adjacent comment.
The held-port field’s corpus is the tracked shell tree beside the declaration set, since the field may sit on any tracked script (§The # graph: manifest). An undeclared hold is counted owed, the status quo a reader’s own audit catches. A stale declaration whose blocker landed under-counts the owed set and hides real work, the direction no shape assertion covers, which is why a slug is held to a live queue entry rather than to a shape.
The walk is a union, never a replacement. A .gate descriptor is no *.sh, so a corpus replacing the declaration set with the tracked shell tree would silently drop every descriptor-borne field. The walk is the declaration set plus the tree corpus §port-blockers derives, de-duplicated against both halves of the declaration walk — the in-scope set and the out-of-scope set. De-duplicating against the in-scope half alone would double-count a kit-shipped declaration on the skipped tally. The union is monotone: it can add findings and never remove one.
Where there is no tracked set the tree half is empty and the declaration half still asserts. That is the opposite disposition from §port-blockers’ --tree arm, whose whole subject is the tracked tree, so a non-repository leaves it nothing to answer and it refuses. Here the tree corpus is an addition to a corpus the gate can still read, and degrading to none is the direction gate_authoring_tree already degrades in when git cannot answer it. An unresolved prune knob still fails closed, since that is misconfiguration rather than an absent corpus. A bespoke test stands its sandbox up outside any repository, the one arm no fixture pair reaches.
A disposition is read from the file’s header block alone over the tree corpus, and the declaration corpus keeps its whole-file scan. The tree corpus contains scripts that write shell, such as smoke scripts, installers and template authors. A line-anywhere scan cannot tell a declaration from a heredoc literal there. # port-until: is a header field, so confining the tree read to the leading run of shebang, comment and blank lines removes that false-positive class by construction. Narrowing the declaration corpus’s scan instead would retire findings a shipped scan already makes.
Both arms are scoped to the tree that authored the declaration. A temporary disposition names a task in a queue, and only the party owning that queue can make the task land. A vendored kit’s declaration is the kit author’s, shipped read-only into a tree whose queue never carried the slug, so asserting it there would red every freshly initialised consumer’s battery on gate-sdk’s own files. A declaration is in scope iff it is under the consumer’s own gates directory, or this tree is the one that authored the kits it carries. The authoring test is gate_authoring_tree (§lib/gate.sh), the predicate §check-gate-substrate-parity’s assertion B scopes by, shared rather than spelled twice. In the authoring tree nothing is out of scope.
The tree corpus takes the same rule lifted from a directory to a file: a tracked script is in scope iff this tree authored the kits it carries, or the script sits under the consumer’s own gates directory. Its bound: in a vendoring tree a consumer’s own scripts outside its gates directory are not asserted. Under-assertion is the safe direction, and closing it needs the per-kit authorship marker the honest limit below prices.
The rule is the class, not the instance. Both annotations take the scope rule, the # exception-list: element’s # until: as well as the declaration’s # port-until:, because the exposure is identical by construction. The array arm is un-instantiated across the shipped kits only by accident.
The skipped set is counted on the clean line, so the scope rule cannot become a silent stop: a scope that quietly widened is otherwise indistinguishable from a corpus with nothing to assert. The count is non-zero exactly in a tree that vendored a kit shipping such an annotation, and zero in the tree that authors them.
Its honest limit: the predicate is tree-shaped where the question is kit-shaped. A consumer that authors its own kit beside vendored ones reads as non-authoring, so its own kit’s dispositions stop being asserted, while its consumer-gates-dir declarations still are. §check-gate-substrate-parity’s assertion B carries the same limit for the same predicate, and narrowing it means a per-kit authorship marker, a different unit.
The header-field arm enters the walk independently of the # exception-list: marker. The array arm opens on that marker, so a declaration carrying only a header field would be skipped by it. The header arm is a second entry into the walk, over the gate_check_dirs declaration set plus the tracked shell tree. A bare field with no slug is §check-gate-substrate-parity assertion G’s shape clause and is passed over here. There is no slug to resolve, and reporting it in both places would give one defect two reds whose wording disagrees about what is wrong.
Slug liveness lives here, and field shape in assertion G. A header field is a different syntactic subject from a per-element trailing comment, but the two annotations make one claim about one queue. This gate already holds the live-section span, the entry-heading predicate and the queue-file coupling, so the held-port arm adds no holder of the predicate the honest cost below prices, and no queue-file coupling to a gate that has none. Shape is the dispatch seam’s own partition and stays with assertion G. The spelling collision with # until: is deliberate: the two differ in subject and in prefix and are read by one liveness predicate, and a grep for until: finds both.
The live-section span is positional, and a consumer’s optional queue tiers ride on that. The scan opens on the first of those headings and closes on the done or lessons heading, with no reset on an unknown heading. So any section a consumer places between the deferred and done sections joins the live set. queue-kit’s optional icebox tier (queue-kit/SPEC.md §The icebox tier) states its position as a contract for this reason. An exemption backed by an iceboxed task keeps resolving, because an iceboxed task is unbuilt. Nothing enforces section order, so an icebox placed after the done section silently drops those slugs from the live set. The same-shaped scan in drift-kit’s kpi-deferred-age does reset on an unknown heading and so excludes the tier; same shape, opposite behavior, both wanted.
The live slug of an entry is its heading’s whole text, one per entry. The line predicate is ^#{3,4} [a-z0-9][a-z0-9-]*[[:space:]]*$, the ### entry and the #### sub-task (queue-kit/SPEC.md §The queue format). Reading every slug-shaped token on every line of the span instead is a loud fail-open. Entry prose names ordinary kebab words, and any of them joins the live set. An # until: then resolves against no task at all, while the clean line still claims every element declares until-with-live-task. The sub-task level is the format rather than a tolerance. Sub-task slugs share the one global namespace an # until: may name, so a ###-only anchor would fail closed at the other end. A holder that wants entries only is entitled to that narrower anchor: the level is the reader’s choice, the predicate is not.
gate-sdk cannot depend on queue-kit for the section set or the entry format, so the coupling is carried by both SPECs rather than by code. gate-sdk is the substrate every kit vendors, and a queue format must not become a precondition for running any gate. A consumer vendoring gate-sdk and no queue-kit still gets a working exemption gate. queue-kit/SPEC.md §The queue format states the same re-implement-and-cite rule for drift-kit, with a cycle rather than a layering inversion as the reason. No knob: the entry shape is the one format the # until: contract is written against, and a consumer free to redefine it could redefine it back into the fail-open this closes. The compiled form keeps the queue-format literals as literals and does not reach the crate’s knob-taking queue module, which would silently acquire the knob this section refuses.
The honest cost: seven independent holders build a live-slug set from that predicate, and no gate enforces their agreement. They are:
queue_slugs(native/src/spec.rs), canon-kit’s holder, deliberately not pointed at the crate’s own queue module, since one shared function would end the arrangement this count prices;- the crate’s own queue module;
- the section-pool builder inside drift-kit’s
kpi-queue-net-delta; - lifecycle-kit’s gap-capture resolver;
check-provenance-seam’s queue-slug arm;- evidence-kit’s baseline liveness;
- and this gate.
Call sites of one shared crate function are one holder, so the inline scans in check-task-names and check-queue-entry-budget and both of the queue-index arm’s walks count once. Only public library functions are named; the rest are cited by the surface that owns them, since naming another kit’s private helper would couple this count to an identifier no gate holds. A set builder with a wrong predicate fails silently, in wrong membership, which is this gate’s own defect class, while a per-entry extractor fails loudly and locally — a missing index row, an extent measured wrong. So a format change costs an edit per holder and endangers only the few that build sets. It is accepted on the section-set ground: a cross-kit code dependency would cost more than the divergence risk. Whether the hand-coupled parsers earn a shared derivation, a conformance test, or a gate is a different unit.
Its [queue-file [dir…]] positional pair is independent of the scope anchor. The dirs positional says what to read, while gate_authoring_tree and the gates directory say whether what was read is in scope, so overriding one never moves the other.
Criterion 4 binds in every configuration. In an authoring tree this member’s own declaration is in the scanned set; in a vendored consumer it is in the out-of-scope set, which is still read to build the skip count. No configuration clears it, so the criterion is paid by the pair: both cases ship a descriptor on each side of the scope rule, since a glob arm no case reaches is an arm the live tree cannot exercise either.
Its read-root set is empty. The declaration corpus is resolved by one-level pathname expansion over each listed directory and the tree corpus by enumeration of tracked files, so --reads reports nothing to cover (§check-reads-couples). Neither half is a recursive walk: a git ls-files spawn asks the index rather than descending a directory, so the recorder observes no root. The tree corpus does read the prune-dir set --tree’s corpus rule reads, whose two knobs the member declares like any other, so the declaration readers see the read (§lib/gate.sh).
Clean-line contract. The line reports the exemption-array count, the # port-until: header-field count, the out-of-scope kit-shipped declaration count and the derived live-slug count. All four are §run-gates’ vacuous-pass tripwire applied to this gate’s sets, and they read in opposite directions. An empty array or header-field set means that arm ranged over nothing, while an absurdly large slug set is the fail-open above, silent by construction because every # until: then resolves. A number on the line makes the last readable without an audit, and its drift signals that the predicate has come loose from a reformatted queue.
check-graph
Invariant: the # graph: manifest on every gates.list member is well-formed and consistent, and the committed coupling-graph artifact is their faithful projection. The manifest grammar a gate author writes against has four required keys (couples/dir/valve/tier) plus the optional mode/trigger; a retired gen= is a finding naming its migration (§The # graph: manifest). Each couples=/trigger= token is a syntactically valid glob, or a kit:<glob> form that expands before the vocabulary and parity checks, and surfaces come from the declared vocabulary when one exists. The valve= value follows the cycle rule: a dir=bi gate spanning a declared-leading and a declared-lagging surface carries valve=PROPOSED, a leading-only gate may carry either, and a gate with no leading surface carries valve=none.
From that grammar the check derives its guarantees. Each couples= surface is covered by the gate’s trigger= globs (trigger defaulting to couples), so editing a coupled surface always fires the gate. The coupling-graph projection at GATE_SDK_GRAPH_ARTIFACT (default <gates-dir>/CHECK-GRAPH.html) equals the emission this member computes in process, down to every emitted asset href resolving under the artifact’s own directory. The remedy lines print the resolved artifact path, so a consumer that republishes it is offered its own regenerate command. An amendment-body manifest is held to the glob grammar but not to the vocabulary or trigger parity, since the gate it describes is unbuilt; parity re-fires through the registry once the gate lands.
Which amendment-body spans are manifests. Assertion G reads every SPEC-*.md under the scan root. Outside a fence, a manifest is an inline code span opening with the manifest header whose first word after it is a <key>=<value> pair with a non-empty value, wherever the span sits in its sentence; two on one line are read apart. A span whose first word is a bare key or prose is a mention and is never read, so a sentence may name couples= without meeting the four required keys. In a fence, a manifest is a line beginning with the header, and a proto fence is never read. Honest limit: an inline manifest whose first key is bare is read as a mention and goes unchecked, so a manifest meant to be held leads with a valued key or sits in a fence.
The assertions, by label: A the manifest’s well-formedness, B couples-within-trigger coverage, C the cycle valve, E the graph artifact’s freshness, F its asset hrefs, G the amendment manifests, H its external references, and I its render cap.
No trigger widening is owed for check-graph. A knob’s value comes from its kit’s knob file. check-graph’s effective trigger already carries the knob files its declaration reaches: gate-sdk’s through its own knobs, and every kit a corpus knob: token names through the couples-knob sentinel (§The # graph: manifest). A hand-written glob would restate live coverage without adding any.
The amendment-body glob grammar admits one optional special prefix, and the admitted set is registry::COUPLES_PREFIXES rather than a second spelling of it (§The # graph: manifest). valid_glob_token strips at most one prefix from that constant and holds the remainder to [A-Za-z0-9._*?/-], so a prefix the resolvers recognise cannot be redded here and one they do not cannot be admitted. A validator and its resolvers disagreeing about a closed set is how a token becomes an inert literal glob and its trigger is silently lost.
The live-registry manifest loop asserts each knob: token’s admissibility against the member’s registry declaration: a token naming a knob the member does not declare is a finding. The crate declares only the knobs its own code reads, so an admissible token is provably a corpus the gate reads. A bare knob: token whose row is declared .words() is a finding in that loop and in assertion G alike, read off the row table alone, so the verdict is the same on every tree and the fixture pair reaches it through --amend-only. A rooted token (§The # graph: manifest) is admitted on the bare token’s test or as a locator. Its knob must hold a directory or a word list of them, so a name no static kit declares and an indexed or packed row are findings in both places; the member-declaration test is the loop’s alone, since an amendment manifest has no member. A member carrying an unrootable token is not expanded, so its finding is reported rather than masked by the expansion’s refusal.
Couples-to-trigger parity is unaffected by either token, structurally: both operands pass through the same expansion, so they agree by construction. The derived couples join both fields after the vocabulary check. The loop appends a member’s derived knob and module files (§The # graph: manifest) to its expanded couples= and trigger= alike, so couples-within-trigger parity is unchanged. The GRAPH_VOCAB surface check reads the authored field alone, because a derived path is no surface a consumer wrote. The graph emitter appends the same set, so the published graph draws the edge a derived couple creates. A token repeated within one authored couples= or trigger= field is a finding in the loop and in assertion G alike, reported once per token and read before expansion: a repeat couples nothing a single token does not, and it reads as a second coupling.
A bare-name interpreter spawn is resolved by the owner, not by the call site. The standing instance is the installer’s spawn of the vendored front-end on every init. It spawns the bare literal bash as every other spawn in the crate does, and proc::run* resolves it. The name-to-treatment question lives in one governed roster in proc.rs, SYSTEM_DIR_HOMONYMS, whose rows are program names. A host offering a row’s program nowhere but the system directory is refused by name, saying what was skipped and why. bash is the one row, because a shell that is not a shell is worse than not running. A name belongs on the roster when the Windows system directory ships a program of that name that is not the program the payload wants, a platform fact, so no knob widens it, on WINDOWS_SYSTEM_DIR_VIEWS’s own ground. The funnel, spawn_target, applies the roster to every spawn. proc::resolve_floor_tool keeps its own callers, because it is a reporting resolver whose value doctor’s banner and the env-probe emitter render. That is the one identity the funnel cannot absorb, and its fall-back to the bare name is its own rather than a roster row’s (context-kit/SPEC.md §bin/env-probe).
Why the owner and not the call sites. The shipped bare-literal spawn population, git and bash at two and three orders of magnitude past what a call-site sweep can be costed at, with nine other programs behind them, rules out a sweep. A lint forbidding the bare literal is refused too: under the funnel the literal is correct, so the lint would red every correct site. Enforcement-first is owed instead to §Fail-closed contract’s no-subprocess-outside-proc.rs assertion.
GATE_SDK_PROGRAM_FLOOR guarantees that a bash is present on an adopter’s host, never that the bare name bash reaches it (§The port-candidate criteria, criterion 7). On Windows it does not. The installed binary is a native Windows process, so the bare name takes a Win32 PATH search in which %SystemRoot%\System32 precedes Git-for-Windows’ usr/bin. The only bash.exe Windows ships there is the WSL launcher, which on a runner with no distro exits 1 saying it has no installed distributions. So the resolution walks PATH in the operating system’s own order over the PATHEXT candidate set (§Fail-closed contract owns both), with one rejection. A candidate directory inside the Windows system directory, %SystemRoot%\System32 and its SysWOW64 and Sysnative views, is skipped, since it holds nothing else the payload wants.
The rejection is program-class-specific: it applies to a roster member alone, since a name with no homonym there has no reason to be denied a directory that may hold it. The comparison is case- and separator-folded, since c:/windows/system32 names the same directory as C:\Windows\System32, and a comparison missing that would pass on every developer host and fail on the one host the rejection exists for. On every other target the rejection is inert. When nothing survives it, the resolution refuses by name, saying what was skipped and why, and the spawning site returns that on its existing Err arm: a could-not-run verdict, as an absent bash is, arriving with a cause.
A spawned child that could not run reports why. A script dying under set -e says nothing on stdout, so a refusal naming only the arm says nothing either. The refusal carries the child’s whole account of itself, exit code and both streams (§Fail-closed contract’s widening), so the cause is read off the log rather than bought with another CI round. Two mechanisms produced an empty account and are both closed: a child diagnosing itself on stdout, which stdout() withholds on a non-zero exit, and a child silent on stderr. So the suffix is always present, and a silent child yields its exit code.
The suffix’s grammar keeps the widening monotone for every reader: the same verdict and the same one-line fold of newlines, so --emit-parse-gates-log and a CI log reader see the shape they parse. The widening’s reader is a session reading the Windows leg’s job log, and nothing parses the suffix. Because the report is non-empty on every non-zero exit, the caller composes it with no empty case. Completed::failure_report returns an Option only to stay unreachable on success, and where the type demands that impossibility be handled the fallback is a named non-empty refusal, never an empty string.
Dual-couple manifest. The artifact path is a knob, but check-graph’s own # graph: manifest is kit-shipped static text that cannot read it. So it lists both artifact homes as couples, the gates-dir default and docs/check-graph.html, and check-graph re-fires on whichever a consumer publishes to. For a default consumer the docs path is an inert trigger pattern that never stages; couples-to-trigger parity holds because the hook arm selects from the same manifest.
The vocabulary file is GATE_SDK_GRAPH_VOCAB, default <gates-dir>/graph-vocab.knobs, in the knob-file grammar (§The knob file), read by check-graph and --emit graph through one reader, native/src/graph_vocab.rs. It declares six names: GRAPH_VOCAB, GRAPH_LEADING, GRAPH_LAGGING, GRAPH_LAYERS and GRAPH_LAYER_RULES indexed, and GRAPH_LAYER_DEFAULT a scalar, default surfaces. An absent file leaves every indexed name empty, which disables the checks each drives. The file is a document, not a kit’s knob file: it takes no environment override and no local overlay. A line naming any other name, or a form disagreeing with a name’s shape, is refused with its file and line, exit 2. A <gates-dir>/graph-vocab.sh beside it is refused at exit 2, naming the migration, as every legacy shell config is. Each reader’s descriptor couples the vocabulary as knob:GATE_SDK_GRAPH_VOCAB, which expands to the resolved path.
Rule content is config, not code. GRAPH_VOCAB is the legal surface tokens (empty disables the vocabulary check). GRAPH_LEADING/GRAPH_LAGGING are the assertion-C sets (empty disables cycle-valve classification, leaving the no-leading valve=none rule). GRAPH_LAYERS is the projection’s subgraph grouping (empty renders one layer). The layer lookup is data rather than a hook: GRAPH_LAYER_RULES holds ordered <path-prefix>:<layer-id> elements split at the last : (a layer id is [A-Za-z0-9_]+, so a prefix may carry one), first match wins, and GRAPH_LAYER_DEFAULT is the layer a surface no rule matches takes.
The layer rule is a prefix test, not a glob. The crate’s component-wise and slash-spanning matchers are each spoken for by a field: couples= the slash-spanning one, filter and prune the component-wise one (§Reading a couples= field’s reach). A prefix test owns no glob semantics and needs neither.
The --amend-only [dir] mode runs only assertion G over a given directory, so the fixture pair exercises it hermetically.
Assertion B’s coverage predicate asks whether a couple pattern is contained in a trigger pattern, which no path matcher answers. A couple is covered when any trigger token satisfies one of four branches. A * trigger covers everything; an exact string match covers; a literal couple, one carrying no * or ?, covered by the trigger read as a bash pattern covers; and a *.<ext> trigger covers a couple ending in that suffix. The field’s matcher is branch three alone, and substituting it or the component-wise filter matcher for the whole predicate flips verdicts on the live registry. Criterion 6’s globstar commitment (§The port-candidate criteria) governs a Rust glob matcher over a knob’s value and does not reach this predicate, which matches no glob. The predicate is sound for the field’s one semantics and deliberately incomplete (§Reading a couples= field’s reach): a *.<ext> trigger contains every pattern ending in that suffix only because * crosses /.
Coverage ruling. A full couples ⊇ find-globs parity check over arbitrary shell is undecidable, neither cheap nor low-FP, so check-graph does not carry it; the couples-within-trigger guarantee already fires the gate on a coupled surface’s edit. The statically resolvable slice is its sibling check-reads-couples’ (§check-reads-couples), and the undecidable remainder stays the author’s duty under §The # graph: manifest.
Theme seam. The emitted HTML bypasses the consumer’s site generator, so it renders foreign beside a docs host unless the host theme is inlined, and the theme is consumer-specific, so the emitter must not hardcode it. GATE_SDK_GRAPH_THEME_DIR (default <gates-dir>/graph-theme/) names a directory of at most three optional part files, each inlined byte verbatim at one injection point:
| part file | injection point | absent |
|---|---|---|
theme.css |
the <style> element’s body, replacing the kit default stylesheet |
kit default stylesheet |
header.html |
directly after <body>, above the kit header |
nothing emitted |
footer.html |
directly before </body> |
nothing emitted |
The roster is closed at three: a part file with no injection point is not defined. An absent directory or part falls back as the table says, so a themeless consumer’s output is the kit default. The kit neither adds nor strips a trailing newline.
A directory of files rather than knob values, because a knob value cannot carry a newline (§The knob file), and a stylesheet and two HTML fragments are newline-bearing. Only the path is a knob, relative, and the binary reads the files itself: a configurable document is a path, never a value (§lib/gate.sh).
The retired sourced-function seam fails loudly. GATE_SDK_GRAPH_THEME and the graph_theme_css/_header/_footer override functions are retired, and a graph-theme.sh is read by nothing. The gate refuses at exit 2, naming the migration, when a file exists at <gates-dir>/graph-theme.sh, and gate-sdk’s table lists GATE_SDK_GRAPH_THEME as a retired name, so an exported value or a file line naming it is refused with its replacement named (§The knob file). The tripwire is permanent kit weight, worth it because a themed consumer that silently lost its theme regenerates an artifact the byte-compare cannot tell from a legitimate theme edit. The theme stays the consumer’s, as §The provenance seam’s worked example; only its form is declarative.
Determinism. The freshness assertion’s in-memory emission and the emit arm a consumer redirects into the artifact read the same part files, so the byte-compare holds, and a styling change lands in a part file or the emitter, never as a hand-edit. The node set is byte-sorted, deterministic across locales, since a locale-dependent order would make the artifact machine-dependent. Injected content is inline, and a theme emitting a relative asset href must resolve under the artifact dir or assertion F reds, which forces the link-the-site-stylesheet shortcut into inlining.
The external-ref assertion (H) reads the same in-memory emission: every absolute (://-carrying) href/src value and every ESM import specifier must prefix-match the allowed set, or the gate reds naming the URL. The set is seeded with the emitter’s own pinned-major mermaid ESM import from the jsdelivr CDN, the one external reference the kit emits, always allowed so a consumer cannot lock it out. The renderer is client-side, and inlining a megabyte-scale library into a byte-compared artifact is the worse trade. GATE_SDK_GRAPH_EXTERNAL_REFS (space-separated URL prefixes, default empty) adds a consumer’s sanctioned prefixes, such as a theme linking its docs host and source repo: rule content the kit must not hardcode, as with graph-vocab. It runs whole-tree and hermetically via --refs-only, which check-graph-refs.test.sh drives. Honest limit: the scan covers href/src values and import specifiers only, so CSS url() and an inline-script fetch() pass unseen, and xmlns= values are out of scope. Dark mode is the theme owner’s: the kit default keeps its light and dark scheme, and the emitted mermaid init keys on prefers-color-scheme, so a theme’s chrome must honour that query or clash with a dark-rendered graph.
The render-cap assertion (I) reads GATE_SDK_GRAPH_MAX_EDGES, default 100000. Mermaid refuses to render a flowchart whose edge count exceeds maxEdges (its own default is 500) and paints an error graphic instead, a page that never draws while its HTML stays well-formed for the byte-compare. The emitter writes the knob’s value into the page’s mermaid.initialize call, and the assertion re-derives the cap and the edge count from the same in-memory emission and reds when edges > cap, Mermaid’s exact boundary. The default is effectively unlimited for a manifest-bounded graph; a consumer whose graph dwarfs it, or who wants the guard to bite sooner, sets its own, and page and assertion read the one value. A missing maxEdges in the emission falls back to Mermaid’s 500, catching a regression that drops the init key. It runs whole-tree and hermetically via --cap-only, which check-graph-cap.test.sh drives.
Criterion 4 binds on this member under every configuration: it reads every gates.list member’s declaration for its # graph: line, so a registry member’s declaration is always inside its corpus. Its fixture corpus carries every assertion, split by shape:
- The
good/+bad/pair carries assertion G and only G, through--amend-only. A case runs one argv, so it selects one mode. The whole-tree default reaches E, which agood/case cannot satisfy: it would take a committedCHECK-GRAPH.htmlinsidegate-tests/, the second copy of a generated file this corpus refuses.--refs-onlyand--cap-onlyalso escape E, but H and I read an emitted artifact their own drivers construct, while G’s corpus is fixturable in place. A fourth mode selecting the registry assertions is refused, as consumer-visible surface bought for a corpus a driver already has. - Assertions A, B, C, E and F take a constructed mini-consumer in
check-graph-tree.test.sh: amktemp‘d git repo carrying a registry, five member declarations in both spellings, and a vocabulary file. Its.gatemember exercises assertion A’s descriptor branch. A manifest carrying the retiredgen=reds assertion A, and a registry line resolving nowhere greens with its skip counted. The baseline case greens only if all four coverage branches and all three cycle-valve branches clear, so a branch that stops firing reds. - Assertions H and I keep their own hermetic drivers
(
check-graph-refs.test.sh,check-graph-cap.test.sh), and the theme seam keepscheck-graph-theme.test.sh. Assertion F is reachable only through a theme, because the kit’s own emission carries no localhref/src. - A bespoke driver reaches the gate through
gate_run, never by script path (§run-gate-tests).
Assertion A forbids a descriptor and a script coexisting in one resolve dir, so criterion 4’s live-tree comparison is smoke rather than proof for this member: it ran on the pre-descriptor tree and found no disagreement. The ported surface was check-graph.sh’s 632 lines.
check-reads-couples
Invariant: for every registered gate, every statically resolvable recursive walk in its source has its tracked read-set covered by the gate’s expanded couples= — the reads⊆couples half check-graph leaves to the author (§The # graph: manifest).
This is check-graph’s coverage sibling: check-graph proves editing a coupled surface fires the gate, and check-reads-couples proves the couples name every surface a resolvable walk reads, so no recursion hides a surface the couple never listed.
A member resolving to a .gate is answered by the substrate, not parsed. The walk parser reads shell, so a binary gate would yield zero walks and print clean, an absence of findings indistinguishable from an absence of coverage. So the gate is given an answer and the port no exemption: for a .gate member it reads the declared roots the --reads arm reports and feeds them into the coverage assertion the shell arm uses. A shell reader invokes "$GATE_SDK_NATIVE_BIN" --reads "<name>" (§Layout and configuration); the compiled reader calls the registry in process (below).
The substrate may not answer ? for a root it can bound. A crate-side assertion reds a ? standing where a root resolves statically (§Meta-gate conservation for the binary substrate). The gate does not reach into a module to resolve what the registry declined to declare; the registry has no licence to decline where the root resolves. A hardcoded literal root and a positional argument with a literal default, which the crate spells at least six ways, are instances rather than the definition: read the property, and treat any enumerated pattern as a floor.
A runtime-selected corpus helper declares every branch, the kit-literal fallback included. Each fallback walk declares its root under its selector’s guard. A tree that configures the selector neither analyzes nor counts it, and a tree that leaves it empty has its coverage asserted like any other walk’s. The kit’s literal couples must therefore cover the fallback’s selection on a tree the kit has never seen. The field’s one semantics makes that expressible: a *<name> or *.<ext> token contains every path a name: or ext: filter can select at any depth. The crate holds those literals to this tree before they ship (below).
The --reads report is one line per walk root and nothing else, with no count line and no header, since a transcribed total would be a second source for a derivable count. Each line is a repo-relative directory path, for a root the gate declares, or a ?, a tab and its ground, for a root it cannot bound statically. A path is read by the tracked-file enumeration below, at the per-root coverage loop. A ? is read by the skip counter at the clean-line parenthetical, the same reader the shell arm’s unresolvable roots have, and its ground by the session reviewing that skipped population at the cadence row. There is no third line kind because there is no third reader. The producer is the binary’s --reads arm, printing each registry member’s declared roots, held to what the code walks by two crate-side unit tests.
--emit reads-census measures the ? population by an oracle rather than a sweep. It prints one line per registry member declaring at least one ?, in registry order. Its four tab-separated columns are the member’s name, its ? count, the roots it does declare comma-joined or - where none, and the grounds of its ? roots in declaration order, comma-joined. The class partition is therefore a projection of the fourth column rather than a second report. The report keeps --reads’ rules:
- No count line: the member population is the line count and the root population the sum of column 2.
- The declared-root column prints the root expression alone, not its filter. The census asks how much of a member is bounded, which tells a wholly-undecidable member from a partly-declared one; the field detail is
--reads <name>.
It reads registry data and nothing else: field 2, already answered per member by --reads, so the arm is the roster form of that question. It declares no knob, adds no registry field, and parses no source.
Three denominators are live over the ? population, and they are not interchangeable. The root-lines are member-expanded, so a shared root const is counted once per member using it: the census’s column-2 sum. The members are the census’s line count. The textual declaration sites are the locator assertion’s enumeration (below), which counts a shared root const’s ? once however many members use it. Root-lines and sites agree only while no shared root const holds a ?, so an assertion over this population says which it counts. Re-run the oracles for the figures.
Every ? declares its ground, and the ground names where the walk is. A root declaration’s fourth field is empty on a declared root and required on a ?. A ? with no ground, or with a filter or prune, fails to compile, as does a declared root carrying a ground; that keeps --reads’ second column unambiguous. A class names what would retire the ?, the question the cadence review asks. One class is live:
dynamic— a walk whose root no literal names: computed at run time from state the registry cannot hold. Nothing at this altitude retires it.
No class is offered for a ? that is in fact a hardcoded literal root or a positional with a literal default whose filter the field can express: it should be declaring the root.
The ground is <class>@<path>:<line>, and a locator off its site’s home module carries one via clause. <path> is crate-relative and <line> is the line of the walk the ? stands for. A ground is authored per declaration site, not per member: a shared root const is one site however many members use it. A site’s home module is the module of its member’s registry dispatch function; a site inside a shared root const has none. Where the walk line lies in the home module the ground takes no clause. Otherwise it takes via <symbol>: the first <module>::<fn> the home module calls on the way to the walk line, or const <NAME> for a site inside that const. The clause is forbidden in-module, so each situation has one spelling. The locator on the walk line ties it to a walk, and the clause ties it back to its member on the first hop, with no exemption list. It sits in the grammar rather than a landing checklist so the next off-module ? is constrained too.
A crate-side locator assertion holds every site to that grammar. It enumerates the sites off the registry’s own source and requires that enumeration to agree with the grounds the registry carries, so a ? spelled off the one-line form cannot escape. It reds on a ground that:
- does not resolve;
- resolves to a line calling no member of the root-entry roster the recursive-walk refusal below reads, wherever that line lives;
- has its
viaclause missing off-module or present in-module; - names a
<module>::<fn>with no definition, or one the home module does not reference; - or names a
via constthe site is not inside.
A seeded locator of each kind is refused by a test of its own. Placement is anchored on the roster, so an entry point added there reaches this assertion with no second edit, and not on a member’s module or anything it transitively calls, which would make placement near-vacuous. The site count and its in-module, off-module and per-class partitions come from this enumeration, the one oracle for the site denominator.
It decides presence and placement, never the class or the chain. Whether a root is a run-time computation no gate can decide, so the class is read no further than its spelling. The assertion resolves a via symbol and its first-hop reference, never the path from symbol to walk line, which would be call-graph analysis. Where a member’s walk lives off its module, the clause records the chain without proving it. A fixture case taking the walk holds the declaration, which is why a freshness member whose bad/ case steers through positionals keeps a good/ case on its emitter’s default branch. The locator names the walk rather than transcribing its root expression, so no second spelling exists to drift, and writing it means finding the walk.
A walk is bounded by three things, and the declaration has a field for each: where it starts, where it refuses to descend, and which of the files it reaches it selects. A root line is <root>, <root><TAB><filter> or <root><TAB><filter><TAB><prune>, a trailing empty field omitted. The fields’ producer is the --reads arm; their only consumer is this gate, at one transition, the per-root coverage assertion.
The filter field is <kind>:<source>, and the kind is mandatory. The source is knob:<NAME> for a knob’s value or lit:<comma-list> for a kit-generic literal the crate owns at the walk site. The kind is glob:, name: or ext:, naming which of the crate’s three matching disciplines the walk’s entry point applies: glob_files’ root-relative component match, find_named/-name’s basename match, or find_files’ extension test. A name: token carrying / is put to the root-relative path as a glob: token is, because a walker matching a path-shaped name knob does exactly that. A basename never carries /, so the rule moves no basename match. The kind does not default. Read kind-blind, with one matcher guessing from a token’s shape, the assertion produces spurious findings in bulk. The reader stands for a walk it cannot see, so the walker’s discipline travels on the field or nowhere. The field is optional, and omitting it positively declares the walk unfiltered.
Three resolution facts are contract, because each produces a wrong verdict rather than a refusal. The value is tab-split before matching, since the wire joins a knob’s members with a tab and a many-glob knob matched whole selects nothing. An indexed knob resolves to its elements and a keyed knob to its values, and a knob whose elements pack several fields is reached through a declared field (§The # graph: manifest). And “unresolvable”, “no filter” and “resolved empty” are three verdicts, not two: a name no static kit owns is the knob reader’s own refusal, an omitted field is unfiltered, and a declared knob a consumer left empty selects nothing, as the member’s own walk does. Sharing a verdict between the last two would turn the narrowest configuration into the widest demand.
The prune field is a list of root-relative directory globs the walk refuses to descend into. Each is a literal or a knob-path token (§The install disposition). The field’s two readers resolve it verbatim, as the filter’s knob: source is resolved by its reader and printed unresolved. The resolver already filters a reported root by a prune list exactly as a gate_find walk is (below); the field lets a member declare its own. A declared prune narrows the demand, so its failure direction is the inverse of a root’s: a prune the member does not apply would hide the very gap this gate exists to catch. So the prune rides the recorder as the root does, and the recorder’s subset test runs inverted for it, declared ⊆ observed: a member may declare only what its walk actually pruned.
An unrecorded prune declaration is refused, since without that reader the form is a self-certified narrowing. A prune is not a filter with an exclusion: a filter selects files and a prune names subtrees never entered, two dimensions that do not share a field. Honest limit: the form reaches a narrowing expressible as a directory prune; a content predicate or a per-file exclusion list is a new question.
Two opt-outs are refused. Declaring ? for a root that resolves statically would be the foreclosed opt-out moved into the registry, since ? marks a root that cannot be bounded statically. Re-implementing a scan over git ls-files to fall outside the analyzed class is opting out spelled in code, and it silently narrows the scan to tracked files where the walk sees an untracked one too.
The filter’s source is a knob name and never a transcribed pattern. The walks this exists for select by knob values, which the shell analyzer cannot extract, since it discards a pattern containing $. Transcribing a knob’s value into the registry would second-spell that knob’s default, which de-literalization forbids. That ground is narrow, and lit: does not breach it. A kit-generic literal that is nobody’s knob has no default to second-spell, and yml/yaml and md carry no adopter-specific information, and a knob holding them would hand every adopter a knob whose only correct value is the kit’s. Wherever a knob does exist, the refusal holds in full.
An empty root set means the member performs no walk in the analyzed sense. git ls-files enumeration, single-file reads and a single-level listing are each outside the class, so a member doing only those declares no root at all. That is no opt-out: the recorder’s check requires observed roots to be a subset of declared ones, so a false no-walk claim reds. The same rule reaches a ?: one standing for a read outside the class leaves the root set, so a member keeps exactly one ? per recursive walk its roots cannot bound. A member whose subject is the index itself, such as an exec-bit check reading modes through git ls-files -s, is not the re-scan evasion refused above. The converse does not follow: an empty root set is a false no-walk claim wherever the member reaches a root-entry call off its own module, and the recorder catches that only on a fixture case that takes the walk.
The analyzed class is recursive. git ls-files is excluded because it is not a walk, a statement about mechanism. walk::list_dir is a single read_dir over immediate children and walk::glob_entries a one-level pathname expansion; each descends nothing, so each is outside the class by the same discriminator and no ? ground can locate one. The discriminator lands in both places or neither: a non-recursive listing leaves the recorder and the root-entry roster the refusal reads, or a member whose only listing is non-recursive reds the empty declaration that is its correct one.
A declaration, a refusal and a skip are each per walk, never per member. A member walking several times with different root shapes declares the resolvable roots and keeps ? for the rest. A member whose corpus helper selects its file set at runtime declares its root once per branch, and may guard a branch with else:<SELECTOR>:, which the resolver reads at run time. A branch whose selector knob resolves non-empty is a walk this tree does not take, so it is neither analyzed nor skipped-and-counted. The guard names the knob and never the knob’s state, so no kit descriptor carries a consumer’s configuration. A configured tree never analyzes a guarded branch, so a crate unit test runs every guarded root’s coverage with its selector resolved empty over the kit’s own tracked tree, and reds on an uncovered read or on analyzing none.
The coverage assertion is the same on both substrates; only the source of the roots differs, a shell parse for a .sh member and the substrate’s report for a .gate one. Two calibrations follow from what a binary does not report, and both demand more coverage rather than less. A reported root is filtered by the prune list exactly as a gate_find walk is, from GATE_SDK_PRUNE_DIRS and GATE_SDK_PRUNE_EXTRA_DIRS together (§lib/gate.sh), since honouring only one of an additive pair would scan a different tree than the shell for a consumer who set the other. And no literal -name primary is extractable from a binary, so a bare root’s enumeration is unfiltered, the answer the shell arm gives a walk whose pattern is a variable. A ported gate narrows by declaring a tighter root or by naming the knob its pattern comes from (above), never by declaring a pattern.
The refusal survives where the gate still cannot see. A descriptor naming a subcommand the substrate does not carry is exit 2, so an unavailable read set cannot read as “reads nothing”. An unresolvable filter knob is exit 2 by the same contract (§Fail-closed contract): “cannot verify” and “verified covered” must not share an exit code.
The compiled member reaches the read set in process rather than spawning the arm, as check-gate-binary-fresh does. That call across crate modules is a nameable coupling the descriptor carries (§The non-gate arm). The binary is running by construction, so it has no absent-binary refusal.
The knob a run-time-named filter resolves through is declared by a union sentinel. A filter knob’s name is not known until this member reads another member’s declared roots, and a member’s declaration is a fixed set. So the member declares, beside its own knobs, a sentinel the registry expands to every filter-knob name any member declares, a guard’s selector and a knob-path prune’s knob included. It is computed from the compile-time tuple the roots are declared in, so a newly declared filter knob cannot be forgotten. The expansion is a crate-internal carrier, and no .gate descriptor field moves. Spawning a shell to reach a kit library instead is refused, since it would make an interpreter a run-time dependency of a compiled gate, against a closed ruling. If a consumer’s filter knobs outgrow a single union, the fallback is a per-kit prefix family; only an all-kit wildcard has no owner.
There is deliberately no descriptor-level exemption. A # reads-couples-exempt: line in a descriptor once bought a member a pass, unwritten here, and it is removed. An opt-out written in one sentence is how a port would end this assertion instead of answering it, so a port ends it by answering it: the couples widen to cover what the walks read. check-stage-entry is the live filtered instance, declaring one root twice under two filter-knob names (lifecycle-kit/SPEC.md §check-stage-entry), so the coverage assertion runs for real.
gate-tests/check-reads-couples.test.sh holds the .gate arm’s branches over real registry members, letting the substrate answer rather than a stub. Its cases are a covered root pair, a one-segment couple covering a deep file, and a couple stopping a level short, whose finding also proves the filter applied. Then a ? root counted in the skip counter, a descriptor still claiming the removed exemption, which buys nothing, and both surviving refusals. The shell arm runs alongside, so no case can pass on a parse failure.
Its couples expansion is the shared reader’s, called rather than copied. A second copy of registry::expand_couples would let a token form applied to one and not the other silently diverge the coverage reader from every trigger reader. The member declares registry::EVERY_COUPLES_KNOB for the reason check-graph does: it expands another member’s field, so the knobs those tokens name are outside its own roster (§lib/gate.sh).
The shell arm’s tractable class is stated as the invariant, because deciding what arbitrary bash reads is undecidable and a gate must not over-claim. A walk is a gate_find or find at command position. It is resolvable when its first directory argument is one of three shapes: a quoted literal repo-relative path, "$KIT"[/sub] (the analyzed gate’s own kit dir, from the source’s checks/-parent), or "$REPO_ROOT"[/sub]. For each resolvable root the gate enumerates the tracked files under it with git ls-files, filtered by a literal -name '<pat>' primary when the same invocation carries one, gate_find walks also dropping the pruned dirs. It asserts every one matches at least one expanded couple under the field’s one matcher (§Reading a couples= field’s reach). A walk whose root does not resolve is skipped and counted in the clean line. Only tracked files need coverage, since couples fire the hook on a tracked-path commit, and only recursive walks are analyzed. On a corpus with no gate scripts the shell arm is dead, kept for a vendoring consumer that still ships them.
Over-demand from a shell walk is absorbed one of two ways. Add the covering sibling glob, the correct fix, or mark the deliberate uncoupled walk # reads-couples-exempt: <reason> on the walk’s own line or the line directly above, as comment-tier-exempt is. A trailing marker excuses only its own line. The marker’s sole reader is this gate, and the skip counter’s is the clean-line parenthetical. Neither is available to a compiled member: the marker is withheld from one, and the covering glob is unavailable where the seam refuses the literal it would need. That is why the third dimension is a declared prune: precision about what the walk reads, not a wider claim about what covers it.
Manifest tier=precommit trigger=*. The unconditional trigger is load-bearing: the invariant breaks on a gate edit that changes a walk or on a new subdirectory under a walked root, and no couple glob can name a directory that does not exist yet. Its couples= names what it reads as content: every checks/ dir, gates.list, and the crate’s source, since an edit to a gate’s implementation changes what it walks. The tracked-file enumeration is git ls-files metadata, not a content read, so it needs no couple. The crate couple fires in the tree that tracks the crate; a consumer receives no crate source, so a glob matching nothing is correct there. The hermetic fixture affordance: positional gate-source arguments make the gate analyze the given source(s) with git ls-files anchored to the case dir, instead of walking the real gates.list.
projection-witness
--projection-witness is a non-gate arm (§The non-gate arm), an Arm::Run with a 0/1/2 exit, and the dynamic complement of §check-reads-couples’ static reads-within-couples half. It holds a generated projection’s declared trigger against what its freshness gate actually reads, by perturbing the tree and re-running the gate. A projection’s trigger is its gate’s trigger=, else its couples=, plus the derived couples: the set run-gates --for computes and the pre-commit hook fires on (§The # graph: manifest). That declaration is the trigger, and the witness holds it honest where a prose roster restating it would be a second source.
- Members. Every registered gate carrying
# projection:(§The install disposition). With none, the arm prints0 projectionsand exits 0. - Scratch. One history-bearing tracked-tree scratch (§Consumer smoke,
tracked_history_scratch). A history-less copy is not enough: an emitter reading git history or theoriginURL reds at baseline there, so the scratch is a shared clone with the working tree laid over it. The running binary is placed whereGATE_SDK_NATIVE_BINresolves in the scratch, throughplace_artifact, and the scratch is removed on every exit path. - Baseline. Each member is spawned by name, the arm’s own executable with the scratch as its working directory and the gate-sdk locator blanked so the child resolves the scratch’s own root. A member red at baseline is exit 2 naming it, since the projection is already stale and nothing can be witnessed.
- Classes. The tracked set grouped by first path segment and extension, a root file’s segment empty. For each member, each class’s first member in byte order that the member’s expanded trigger set does not match (under the field’s one matcher) is its out-of-trigger sample.
- Two perturbations per class. An edit appends one line to the sample, and the file’s bytes are restored after. A create stages a new file
<segment>/.projection-witness.<ext>, skipped where the trigger set matches that path, and is unstaged and removed after. The member runs after each. - Verdict. A member red after an out-of-trigger perturbation read a file its manifest does not declare, so the hook never re-runs it when that file changes. That is a finding, exit 1, one
WITNESS: FAILline naming the gate, the perturbation, the path and the gate’s first red line.
The positive direction is a report, never a red. For each member and each class its trigger set does match, the arm also edits that class’s first matching member, and a gate that stays green prints coupled but inert: <gate> <path>. A couples= is a sound superset of a gate’s reads, never a minimal set: §The # graph: manifest sanctions the over-approximation, since an extra trigger runs a green gate while a missing one skips a red. So the line serves a person tightening a trigger, and no suite parses it.
Exit 2 carries WITNESS: FAIL(env) for a member red at baseline, a scratch that could not be built and a spawn failure. The clean line counts members, classes, perturbations and inert couples, so a run whose class set came out empty prints zeros rather than passing as coverage.
Honest limits. An append can miss a read confined to a marker block, so an out-of-trigger file read only inside markers passes; the create does not share that blind spot for an emitter that enumerates files. Classes are sampled one member each, so a read of one specific file in an otherwise-unread class is missed. Both limits under-report and never over-report: a red is always real. Each gate run is a spawn and each class costs up to three, so the arm is a validate suite rather than a battery member; a consumer registers it as an evidence-kit suite whose exit code is the verdict (evidence-kit/SPEC.md §bin/run-validate.sh).
Its consumers are that suite and a person reading the inert lines. Its knob declaration names GATE_SDK_NATIVE_BIN for the placement and the two knobs its member resolution reads. A crate test witnesses a known defect: a toy projection whose emitter reads a file outside its declared trigger, run through the same scratch, class and verdict machinery with the gate spawn injected, reds naming that file.
enforcement-map
The emitter is a non-gate arm of the gate binary, --emit-enforcement-map (§The non-gate arm), reached as bash gate-sdk/bin/run-gates.sh --emit enforcement-map. It writes the enforcement-map page (GATE_SDK_ENFORCEMENT_FILE): a kit-first map of every check surface, by kit, governed surface and enforcement class, derived from the class registries so it cannot drift from what actually runs. It is check-graph’s sibling in shape, an emitter whose output a freshness gate byte-compares, and advisory by construction: it never joins gates.list. A healthy run exits 0 whatever registries are absent. A misconfigured run exits 2, fail-closed, leaving an empty projection rather than a plausible partial page that would byte-match itself at the next freshness check.
Each registry’s owning kit decides misconfigured against unadopted, at its first resolution. A knob’s value reaches the reader with no set-ness attached, so the owning kit’s table validator reads the value’s origin. DRIFT_KIT_KPIS_FILE resolves empty for not-adopted (drift-kit/SPEC.md §Layout and configuration), and CONTEXT_KIT_SETTINGS_FILE refuses on an explicitly-set missing path (context-kit/SPEC.md §Layout and configuration). The reader’s own half is that a registry resolving to nothing drops its section, and a roster naming a member the family does not hold refuses naming it.
The emission is a library function the arm wraps, which lets §check-enforcement-fresh call it in-process and the value rollup consume the class taxonomy and per-kit counts as data instead of re-parsing this page’s headings.
Each enforcement class reads one registry, every registry defaulting to this repo’s layout through the owning kit’s knob:
- Blocking gates —
gates.listplus each gate’s# graph:tier=field. The owning kit comes from the runner’s name-resolution walk, so a consumer-dir gate groups as the consumer’s. - Advisory KPIs — drift-kit’s
kpis.listregistry (DRIFT_KIT_KPIS_FILE). - Session warnings and guards — the tracked harness settings file (
CONTEXT_KIT_SETTINGS_FILE), parsed with the crate’s own JSON reader:SessionStartcommand hooks are session warnings, and every other event’s command hooks are guards. Events sort by name and hooks keep registration order within one. Every event is read, never a roster of events, because a registration the page cannot see is a guard the map says does not exist. - Validate suites — evidence-kit’s suite registry: the roster from
EVIDENCE_KIT_SUITES, and each suite’s run command looked up by name in theEVIDENCE_KIT_RUN_family, never enumerated, since a prefix is a resolution set and not a roster (§lib/gate.sh). - Monitors — the one class with no parseable registry, read from a line-start
# enforce: class=monitor <free-text>marker a non-gate surface declares itself with, underGATE_SDK_ENFORCE_SCAN_DIR. A site-health workflow is the shape: deployment truth, not tree truth.
A guard row carries its event beside its intercepts cell, which holds the harness matcher verbatim, or * where the event takes none. Its | alternation is escaped into the table, a case no freshness gate can report, since emitter and page would carry the same broken row. A hook that registers a member through gate-sdk’s front end is rowed under the member’s name and credited to the kit the member table names as its owner (§The harness-integration arm). Any other hook is rowed and credited by its command path. An operand the table does not carry keeps the path attribution, and check-settings-paths reds it.
A monitor marker is dormant in a template or fixture, an inert copy-source, and activates only where a consumer copies the file into a live path. So the walk skips any path under a templates/ directory, atop the prune set’s gate-tests exclusion: site-kit’s shipped templates/site-health.yml marker projects no row, while a consumer’s copy under a live workflow path does. The # enforce: grammar is the reusable name a future uncovered class adopts rather than a bespoke registry.
Cross-registry reads are a reporting surface, and each class distinguishes two absences, under the set-but-missing rule the kit config loaders share (§lib/test-hermetic.sh). A registry not adopted, its knob unset with the default path absent, degrades to an absent section, so a gates-only consumer still gets its gate map. A registry adopted but broken, its knob explicitly set to a missing path, refuses at exit 2 with a stderr line naming the knob. The settings file also checks parseability when set: set and unparseable refuses likewise, a typo inside an adopted file being the same laundering vector as one in its path. The default-path file unparseable keeps degrading, so a stray .claude/ cannot break a zero-config consumer.
The emitted page owns the enforcement-class taxonomy — what each class means and how hard it binds — with the emitter’s own text as its single source; this section owns the emitter contract. The page is a docs-site artifact and carries the site’s link topology. The taxonomy is a bulleted roster, each class citing its mechanism owner, the kit SPEC section defining the class, through the reference-link grammar (canon-kit/SPEC.md §The reference-link grammar). That is a self-repo blob link built from gate_self_repo_prefix on the CANON_KIT_DOCS_BLOB_REF ref (§lib/gate.sh), pinned to the ref check-md-refs’ self-repo pass validates. The citation degrades to the bare <path> §<section> text when the identity is unknown (no origin) or the owning kit is unvendored (its SPEC untracked), so a gates-only consumer keeps a resolvable page. The kit column links a kit’s docs page (<kit>/index.md, relative to the page) where the page’s directory tracks it, so a tree with no docs layout carries no dead link; a kit with no page, and the (consumer) group, stay plain text.
The kit-page link carries two honest limits. The link base is the knob’s directory, so a page redirected to another path, or compared at another path through §check-enforcement-fresh’s positional, is linked as if it sat at the knob’s. And the page depends on a tracked file’s presence that check-enforcement-fresh’s couples= cannot name, since a knob: token roots at a directory knob and this is a file knob’s directory. Adding a kit page alone does not fire the gate at commit; the full battery and CI do.
check-enforcement-fresh
Invariant: the committed enforcement-map page (GATE_SDK_ENFORCEMENT_FILE) byte-matches the enforcement-map emitter, the check-graph byte-compare pattern. Bare, it runs the emitter and compares the committed page. Given two arguments (projection-file emit-file) it compares pre-baked files, the hermetic mode the bad/ fixture case drives off the live registries. good/ exercises the live emitter on its default branch over a staged synthetic registry set. That case is hermetic only while it carries none of the class roster’s owner SPEC paths: the emitter links an owner section through the enclosing repo’s origin whenever that path is tracked, and a committed case file is tracked. An emitter unit test holds the case to that. Fail-closed: a missing projection, a missing emit source, or a failed emit is a red (exit 2), never a false clean.
It is a registry member of the gate binary, and calls the emitter module’s emit() in-process, as §check-footprint-fresh does. It declares every registry knob the emitter reads, the EVIDENCE_KIT_RUN_ prefix family included. The bad/ case bypasses the emitter and the good/ case stages no suite roster, so in a fixture run those knobs are resolved but unread, which is why an empty family must resolve rather than refuse (§lib/gate.sh). Its # graph: manifest couples every class registry beside the artifact itself, so any registry change re-runs the compare: the gate sources (so a tier= edit re-fires), kpis.list, the settings file, and every file the monitor walk reads, through knob:GATE_SDK_ENFORCE_SCAN_DIR/*. Its help text names the regeneration command, reachable because the gate rides the pre-commit hook, as check-graph does.
check-kit-enum
checks/check-kit-enum.gate (precommit, binary-dispatched). Invariant: for every gates.list member, a couples= set that literally names two or more gate_kit_roots members under a common glob suffix must name every kit root having tracked files matching that suffix. kit:<glob> removes the drift axis by derivation, since the reader expands it to all roots. This gate is the residual check on the hand list an author writes anyway, and the fix its help text names is the token, not a longer list.
Completeness is measured against tracked files (git ls-files at the repo root), so a suffix no kit carries forces nothing and the over-approximating token stays a superset of what the gate requires. A lone named root is not a hand list; the check engages at two. A member violating on several globs lists its groups in first-seen order, the manifest’s own. Fail-closed: an unreadable manifest or a non-repo cwd is red, not a skip. A member with no # graph: line is check-graph’s finding, not this gate’s.
The manifest is read off whichever declaration spelling resolved, so a descriptor’s hand list groups exactly as a script’s does. The gate’s own declaration sits inside its corpus, so criterion 4 binds through the walk and the trigger alike (§The port-candidate criteria). Both fixture cases carry a descriptor declaring a complete two-root group, and good/ asserts the group count.
check-kit-registration
Invariant: every kit root gate_kit_roots enumerates is registered in the consumer’s human-facing docs. check-kit-enum guards gate-coupling hand lists only, so without this gate a landed kit can silently fall out of the registry docs. Two assertions:
- (A) Registry row. The registry doc carries, for each root, a markdown link into the kit root: any target under the repo-root-relative prefix
<kit>/, the bare directory or a page beneath it such as<kit>/index.md. The prefix is the assertion because a source tree browses to the directory while a rendered site must name the page, and both are one registration. - (B) Fixture-runner line. Every kit root with tracked
gate-tests/files (git ls-files <kit>/gate-tests/) has a line in the runner doc naming<kit>/gate-tests, the documented fixture-runner invocation. A kit shipping no trackedgate-tests/files owes nothing under B. The carve-out is derived per run, so a kit that grows fixtures loses it once they are tracked.
B is deliberately redundant with evidence-kit/SPEC.md §check-battery-roster, whose assertion (A) holds the same runner doc against the configured suite roster and supersets B where that config derives the fixture suites from the gate-tests/ roots. The redundancy stands because a gate-sdk gate may not require evidence-kit config. §enforcement-map may read that config opportunistically and emit no suite rows without it. An assertion cannot, since a gate with nothing to compare against is either fail-closed or a false clean. So B is the arm that survives a gate-sdk-only adoption, and one omission named from each side (a kit root here, a suite there) is a duplicate finding, not a contradiction.
Config: GATE_SDK_REGISTRY_DOC (default README.md) is A’s doc and GATE_SDK_RUNNER_DOC (default README.md) is B’s. Both resolve relative to the git toplevel, and a positional [registry-doc [runner-doc]] overrides. Both defaults are rows of gate-sdk’s table, because a compiled member declaring a knob its owning kit’s table lacks is refused (§The knob file). Fail-closed (exit 2): a configured doc that does not exist, a non-repo cwd, or an empty roster. A consumer keeping no prose registry opts out by not registering the gate; there is no empty-knob valve. Close’s kit-table review keeps only the un-gated remainder: row descriptions and per-kit READMEs.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms, declaration path check-kit-registration.gate. A consumer moves the invariant onto another registry doc by binding GATE_SDK_REGISTRY_DOC, never by wrapping the gate. A wrapper over a doc the knob also names reds twice on one missing row and carries a copy of the rule to keep in step.
check-kit-roots-dialect
It is the publisher’s own gate and does not ship. Its subject is the binary’s derivation over a fixture tree, not a surface an adopter holds, and a red names a crate defect no adopter can repair. An adopter’s oracle for the binary is the fixture pairs (§Fixture-pair discipline). The descriptor and its fixture pair sit in the publisher’s gates dir; the rule stays in the crate.
Invariant: one kit tree yields one kit-root-derived corpus whether it is vendored at the repository root or under a subdirectory. This is the executable form of §Layout and configuration’s dialect rule. It is a gate rather than a convention because the rule is unobservable in every tree that does not nest: walk::kit_roots and walk::kit_roots_rel are byte-identical while the gate-sdk root is a direct child of the toplevel. A reader joining a root onto a path with the kit-parent spelling passes every flat test, and reaches a subdirectory-vendoring adopter as a corpus that shrank to nothing and exited 0.
The gate vendors a kit tree twice under GATE_SDK_TMP_DIR: once flat, once with the kits under a subdirectory and the gates directory left at the toplevel, which is the whole difference between the layouts. Each vendoring is made a git repository, since the compared arms read git. The gate re-runs the arms through the binary that is running, from inside each vendoring, handing down only GATE_SDK_ROOT. Re-running the deployed derivation rather than re-implementing it is what makes the comparison evidence. Handing down anything further is not neutral: an environment value outranks a knob file and would overwrite the config the vendoring’s own gates directory carries. Every child, each git step and each arm, runs with git’s repository locators stripped, as §check-crate-arms’s do: under a partial-path commit git exports a relative GIT_INDEX_FILE to the hook, and inside a vendoring it names no index.
Two assertions:
- (A) The derivation sees the layout it stands in: the nested run must not answer the flat spelling. The vendoring step refuses unless the subdirectory holds the gate-sdk root, so a nested run answering flat names is the defect itself, not a fixture that failed to nest. Root basenames must also match across the layouts, since only the anchor may differ.
- (B) One corpus per arm: each compared arm emits the same number of rows in both layouts.
A count, not a diff, and only over arms whose corpus is a bare kit-root join. The failure is a corpus that silently shrank, and an arm’s rows carry the layout inside them, so a diff would differ on every run. The roster is --emit fixture-suites and --emit enum-sets: each reads kit roots and nothing path-shaped from consumer config, so a count difference can only be the dialect. An arm whose corpus rides a consumer glob is excluded, because a glob’s depth alone can move its count and the gate would report config drift under this member’s name. Both counts print on green as well as red, so a fixture that vendored nothing is visible.
Config: GATE_SDK_TMP_DIR is the scratch base, shared with §upgrade-smoke so one value repoints both. GATE_SDK_GATES_DIR names the directory that stays at the toplevel in both layouts, and GATE_SDK_KIT_DIRS is read only to be withheld from the children. The tree defaults to the gate’s own good/ tree under GATE_SDK_TESTS_DIR; a positional overrides it, which is how the fixture pair names its trees. Fail-closed (exit 2): a missing tree, a vendoring carrying no gate-sdk root, a failed git step, an arm exiting non-zero, or a flat vendoring that enumerates no root.
good/ is the regression guard. It passes only while every path-consuming reader takes the working-directory spelling, and moving one back to walk::kit_roots_rel reds it, naming the arm and both counts. bad/ carries a GATE_SDK_KIT_DIRS override hand-listed for the flat layout: the same defect reached through consumer config, the one shape this repository’s own config cannot supply.
check-readme-roster
Invariant: every kit README’s register-the-gates block holds name-set parity with the kit’s shipped checks/ basenames, both directions, over both declaration spellings, *.sh and *.gate. It is the check-install-toolchain fork applied a second time. The roster names are derivable, but the annotation clauses beside them are hand prose, so the gate holds a hand-maintained register honest rather than generating it.
Markers: each kit README wraps its register block in <!-- gate-roster:begin --> / <!-- gate-roster:end -->, the docs/requirements.md toolchain:begin shape. Inside them, a line’s first check--prefixed token is a roster name and the rest of the line is never read. Marker lines may carry leading indentation, since a README nests the fenced block in a list item, so the scan trims whitespace before matching markers and names.
Two assertions per kit, roster set against checks/ basename set: (A) every shipped check appears in the roster; (B) every roster name resolves to a shipped check. Each finding names the kit and the name.
Sweep: kit roots come from gate_kit_roots (GATE_SDK_KIT_DIRS, §Layout and configuration); no new knob. A kit root without checks/ is skipped and counted. A kit root with checks/ but no marker block in its README is red, since the kit-landing checklist (§Consumer smoke) has a kit that ships checks register them. check-readme-roster [root] resolves relative kit roots against a fixture tree whose gate-sdk-config.knobs names the fixture kits; bare, it sweeps against the git toplevel. Fail-closed (exit 2): a non-repo cwd with no root argument, an empty roster, or an unreadable README marker scan.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms, declaration path check-readme-roster.gate.
check-packed-links
It is the publisher’s own gate and does not ship. Its subject is the packer’s output, and only the publisher packs a payload; an installed tree holds READMEs already packed. The descriptor and its fixture pair sit in the publisher’s gates dir; the rule stays in the crate. precommit, binary-dispatched.
Invariant: the README the packer would put in the payload carries no link to a path the payload withholds. Two assertions per kit root, over the packed bytes rather than the tracked ones:
- (A) Under a non-empty base, no packed README’s link target resolves to a withheld path. The withheld set is read from
GATE_SDK_PAYLOAD_WITHHOLD, never re-listed, so a member added to that knob extends the assertion with no gate edit. - (B) Under an empty base, the packed README is its tracked source byte for byte, which proves the rewrite conditional where A alone cannot.
The gate packs its own corpus by calling the packer’s rewrite function, not by spawning the packer. --pack-installer carries a clean-worktree refusal, an npm spawn and a prebuilt-artifact roster, preconditions a developer mid-edit cannot meet. Extending the consumer smoke instead would re-point a surface whose copy-vendoring is its contract (installer/SPEC.md §The consumer smoke). One function leaves no second implementation to drift: the gate asserts on the bytes the packer writes.
Where the configured base is empty, the gate packs under its own probe base, since an assertion firing only for a configured publisher reads vacuously clean on every unconfigured tree. The probe value is the gate’s own literal and reaches no output but a finding’s resolved path.
A target is judged where it lands, resolved lexically against the README’s payload directory: SPEC.md lands under the linking kit and ../<other>/SPEC.md under another. The rewrite reads the packer’s packed-leaf set, gate_kit_roots’ leaves, so a ../<other>/SPEC.md survives to land only where <other> is no packed kit. A target escaping the payload root, bearing a URL scheme, root-absolute or anchor-only is not a payload path and is passed over. A link title and a fragment are stripped before the judgement. A finding names the kit, the README line, the target as written and the path it resolves to.
Sweep: kit roots come from gate_kit_roots, and a kit root with no README.md is skipped and counted. The clean line prints the swept-README, resolved-link and skipped counts, so a run that packed or resolved nothing is visible on green. Fail-closed (exit 2): a non-repo cwd with no root argument, an empty kit roster, an empty withhold roster, or an unreadable README.
Honest limits. B is falsifiable only by a code regression, since no README content can make a conditional rewrite unconditional; bad/ reds on A alone, and B rests on the assertion plus the packer’s unit tests. The manifest couples kit:README.md and the packer module but not knob:GATE_SDK_PAYLOAD_WITHHOLD: that knob’s value carries whitespace, which §The # graph: manifest refuses after expansion. The knob is still read at run time; only the hook trigger on a change to its value is lost.
check-smoke-entry-guard
Invariant: for every kit root in the roster, every smoke/install.sh and smoke/violation.sh present contains the entry-point guard expansion ${SMOKE_KIT_ROOT:? (§Consumer smoke’s contract clause). A mutating smoke script run bare, outside the harness that exports SMOKE_KIT_ROOT, must abort rather than write into the caller’s repo. A missing guard is a finding naming the file.
Honest limit: the gate asserts the guard’s presence, not its position. A guard below a mutating line passes, so ordering is review’s; position would need a mutation model the gate does not carry. Whether the script parses is §check-shellcheck’s, which lints the smoke directory.
Sweep: kit roots come from gate_kit_roots; no new knob. A kit root without smoke/ is skipped, and violation.sh is checked only where it exists, since it is optional (§Consumer smoke). check-smoke-entry-guard [root] resolves relative kit roots against a fixture tree; bare, it sweeps against the git toplevel. Fail-closed (exit 2): a non-repo cwd with no root argument, an empty roster, or an unreadable smoke script. The # graph: couples kit:smoke/install.sh,kit:smoke/violation.sh (dir=one, tier=precommit), so editing one re-fires the gate.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms, declaration path check-smoke-entry-guard.gate.
check-core-files
checks/check-core-files.gate (precommit, binary-dispatched). Invariant: every path in the consumer’s core-files.list manifest exists in the worktree and is tracked (git ls-files --error-unmatch). One existence-plus-tracked test catches a plain rm, a git rm and a listed-but-never-added path alike, with no --diff-filter timing window.
The manifest is optional consumer config (the graph-vocab.knobs pattern). Its path knob is GATE_SDK_CORE_FILES_FILE (default <gates-dir>/core-files.list, a derived row of gate-sdk’s table). Each line is one repo-relative path or one kit:<path> token, # comments and blanks ignored. An absent manifest is clean with a note, an empty or comment-only one is clean, and a present-but-unreadable one is exit 2. The removal valve is the manifest itself: retiring a surface deletes its line in the commit that removes the file, so the gate is re-scoped in the open, never weakened.
gate-tests/check-core-files.test.sh carries the expansion, the wildcard refusal and the untracked-but-present branch. None fits the pair, since the fixture runner reads exit 2 as a harness error and runs each case with the fixture dir as cwd. It dispatches through gate_run, which keeps a bespoke test alive across a substrate move (§lib/test-hermetic.sh).
A kit:<path> line derives one <kit-root>/<path> entry per gate_kit_roots member, the expansion # graph: couples= fields use (§check-graph owns it); this gate is its second reader. A manifest with no kit: line reads no root set. The compiled form joins over walk::kit_roots, the working-directory spelling, because each expanded path is tested on disk and with git (§Layout and configuration).
One restriction, fail-closed. The expansion emits <root>/<path> per root with no existence test. A couples= field matches the result as a glob, but this reader requires each expanded path to exist and be tracked, and “every kit has a file matching checks/*.sh” is a different invariant. A kit: token carrying a wildcard is therefore refused with exit 2 and a message naming the limit: one expansion, two readers, one stated restriction.
A derived line lets a new kit change the checked set without a manifest edit, so the # graph: couples the manifest and the derived basenames (kit:SPEC.md,kit:README.md, tier=precommit). Staging a new kit’s spec or README re-fires the gate at the perturbation. The whole-tree battery stays the backstop for a pure-deletion commit the ACMR pre-commit filter skips.
What a consumer derives, and what it hand-lists. A surface uniform across kit roots is derived and everything else is hand-listed. Uniformity is decided by running the expansion: a basename every kit root carries expands to a wholly existing set, while a single-kit deliverable expands to one existing path and N-1 missing ones. Surfaces with no kit-root shape (a queue, repo-root chrome, workflows, an installer entry point) stay a hand list.
A derived line is worth taking even where another gate seems to cover the surface, unless that coverage is direct, meaning the other gate asserts the surface’s existence as its subject. Incidental coverage keys on a property that can change for unrelated reasons and vanishes with it, which is the failure a core-file pin exists to prevent.
check-identity
checks/check-identity.gate (precommit, binary-dispatched).
Invariant: every expectation in the identity.conf manifest matches this clone’s identity. It backstops the fresh-clone gap where an agent commits or pushes under the wrong identity and fails silently. Misattribution is unpurgeable without a SHA-breaking rewrite, and a wrong SSH key surfaces as a misleading “Repository not found”. Multi-identity machines, a work and a personal account on one box, make this the common case. Scope fence: the identity mapping stays git’s and the GitHub CLI’s job (includeIf, core.sshCommand, the CLI’s account switch); this gate only asserts the mapping applied here.
Three expectation kinds, all local reads with no network:
email <expected>matchesgit config user.emailexactly.remote-host <remote> <host>matches<host>by exact string against the host part ofgit remote get-url <remote>. An SSH host alias is matched as the alias, since that is the identity selector: a scp-likegit@alias:pathcompares asalias, and ascheme://[user@]host/…URL ashost. A configured remote absent from this clone is red.gh-account <login>, exactly two fields, matches the GitHub CLI’s persisted active account for the configured host by exact string.user.emailand a remote’s host are per-clone, but the CLI’s active account is machine-global. One project switching it repoints every CLI call in every other clone, the push credential helper included, and the symptom is a permission error against the wrong repo that never names the account.
The manifest is optional consumer config (the graph-vocab.knobs pattern): the path knob is GATE_SDK_IDENTITY_FILE (default <gates-dir>/identity.conf), line-based key value… with # comments and blanks ignored. An absent, empty or comment-only manifest is clean with a note. A mismatch, or a manifest-named remote that is absent, is a violation (exit 1). A malformed line, an unknown key or a wrong field count, is fail-closed (exit 2). Those contracts reach the account kind unchanged, which is why it lives here rather than in a gate of its own.
CI step-aside. A run under CI (the vendor-neutral CI env var) is clean-skipped ahead of the manifest reads: the server-side battery is not a committing clone, and its unset user.email is expected. The step-aside binds only where an actual is still read from the clone. With GATE_SDK_GIT_EMAIL_FILE and GATE_SDK_GIT_REMOTES_FILE both configured, the run is a configured read, so it proceeds and the pair stays deterministic wherever it runs. The is-this-a-git-repository precondition is scoped the same way.
Enforcement is dual. The # graph: couples the manifest at tier=precommit. A git config change to the mapping is not diff-visible, and neither is a CLI account switch, whose hosts file lives outside the tree. So the whole-tree run-gates.sh battery is the backstop for the commit-identity half, and --install-hooks runs the gate once at opt-in for the push-identity half; gate-sdk generates no pre-push hook. That rung resolves the gate through the registry and dispatches three ways: this binary re-exec’d for a .gate, a consumer .sh shadow spawned as the consumer’s rule, an unresolvable member skipped (§install-hooks).
The clone’s actuals are redirected by knob, so the gate takes no manifest positional and no fixture arm. GATE_SDK_GIT_EMAIL_FILE and GATE_SDK_GIT_REMOTES_FILE each name a file standing in for one actual, and each defaults empty so the gate falls through to the live git read. GATE_SDK_GH_HOSTS_FILE is the third member and the one with a live use, a relocated CLI config, so the account kind’s test path is its production path. Reaching the gate through knobs makes the fixture pair a parity oracle for the live arm, the payoff context-kit/SPEC.md §check-memory-off states for the same shape.
Honest limit: a knob redirecting what the clone’s identity is can make the gate agree with a manifest it should disagree with. That is not a weakening: the same operator writes the manifest, the gate is a self-check rather than a security boundary, and the scope fence already puts performing the mapping outside it.
The account kind’s oracle is a local read of the CLI’s persisted config. The CLI’s status subcommand validates the token over the network, which at tier=precommit reddens an offline commit, so it is refused. The gate reads the CLI’s own hosts file, which maps each host to its credentials and active account, resolved through GATE_SDK_GH_HOSTS_FILE. The default derivation honours the CLI’s own config-dir variable first and the XDG config home second, so a consumer that relocated its CLI config is not asked twice.
That derivation lives in the member rather than in lib/gate.sh, departing from the knob-defaults rule, because it reads $HOME. A HOME-less derivation would yield an absent path under /, which the grading below reads as clean, the one false clean this kind exists to refuse. So the knob’s default is empty, meaning derive it rather than no file, and a derivation with nothing to stand on is exit 2. Both halves are context-kit/SPEC.md §check-memory-off’s, which takes the same refusal on the same variable.
The parse. Inside the configured host’s block, the active account is the value of the key spelled exactly user, at that block’s own key indent. The sibling key users, the map of accounts available on this machine, shares that prefix and is written first, so a prefix match would read a structural key as a login. The indent scoping also keeps a login literally spelled user inside that map out of reach.
Version tolerance. The gate deliberately does not read the users map, which answers which accounts exist here rather than which one is active. A file whose shape yields no user key for the configured host is exit 2, so a format change can never silently retire the assertion.
The host is a knob, not a manifest field. GATE_SDK_GH_HOST (default github.com) names the host whose block is read, mirroring the CLI’s own host variable. A three-field gh-account <host> <login> is refused: pinning two hosts from one manifest is a need not in evidence, and the extra field would move the count the fail-closed arm keys on.
The absence posture is graded, three conditions, three verdicts:
- The hosts file is absent: clean, with the fail-open caveat named in the clean line and the unverified expectation excluded from its count. A clone with no CLI cannot push through it, so the hazard cannot arise; the shape is context-kit/SPEC.md §check-memory-off’s.
- The file is present and cannot be read or parsed: exit 2, since a clean there is a false clean on the one condition the kind exists to catch. No probe silences stderr, because a suppressed read error would turn an unreadable file into an absent one and collapse this verdict into the first.
- The file is present with no block for the configured host: a violation, exit 1. The manifest names an account and this machine is not logged in to that host at all.
Reddening on an absent remote is consistent with clean-on-absent-CLI. A remote is repo-local state a manifest may demand, while the CLI is machine state no repo-local manifest can require installed. Absence of the tool is outside the manifest’s authority; absence of a login within a configured tool is inside it.
The subject is the persisted account, not the effective one. A token environment variable makes the CLI authenticate as that token’s account without touching the hosts file. An assertion over “the account the CLI would use right now” would be false wherever one is set. Honest limit: a per-process token override is not detected. It is not the hazard either: the hazard is a persistent switch that damages every other clone on the box, while a process-local token reaches no sibling.
The seam: the kit ships the kind, never an account. A login is one person’s vocabulary, and a crate constant holding one would publish it as everyone’s mechanism. The crate carries the key name, the parse and the comparison; the consumer’s manifest carries the login; the host is a knob with a generic default. This repo’s scripts/identity.conf carries no gh-account line, so no live run here executes the kind. The fixture pair and check-identity.test.sh are its whole oracle, the test holding the graded postures a one-pair harness cannot.
check-exec-bit
checks/check-exec-bit.gate (precommit, binary-dispatched). Invariant: every tracked *.sh path matching an exec-glob carries git index mode 100755, and every tracked *.gate descriptor carries 100644. The second is an explicit assertion so that “not executable” cannot read as “not covered”. A descriptor is data, never sourced or run, and an executable one invites running a file with no interpreter line.
The first class is scripts invoked by path: gate-sdk’s runner, the consumer-smoke harness (which runs each unregistered .sh gate by path), lifecycle-kit’s entry preflight, and any shebang’d bin/ tool. A script committed 100644 degrades silently in a fresh clone: a KPI plugin to n/a (plugin failed), a runner-invoked preflight to a skipped check. The index is checked because a clone receives it, and a Write-tool-authored script lands there at 100644 whatever the worktree says.
The subject class is two knobs in §Layout and configuration’s roster, each a whitespace-list scalar split with walk::knob_words (§lib/gate.sh). GATE_SDK_EXEC_GLOBS is the glob set, * spanning /. Its default */checks/*.sh */kpis/*.sh */bin/*.sh, plus the computed <gates-dir>/check-*.sh and <gates-dir>/kpi-*.sh, covers the per-kit exec dirs and the consumer gates dir, whose gates and KPI plugins resolve first. GATE_SDK_EXEC_PRUNE (default gate-tests fixtures templates smoke) exempts subtrees by path segment. Fixture trees deliberately carry glob-matching paths, and templates/ members are copied content, never invoked in place. lib/ needs no prune, since sourced libraries match no default glob.
Honest limit: the gate reads the index, so a mode broken only in an uncommitted worktree file is invisible until staged, and the whole-tree battery backstops a mode-only change no ACMR filter surfaces. A non-repo cwd is fail-closed (exit 2).
Argument mode: check-exec-bit [ls-files-dump] lints a canned git ls-files -s dump instead of the repo’s index, so a fixture is hermetic (the check-merge-attrs shape). The live read is NUL-terminated (§Fail-closed contract) and a dump is lines, a fixture format, so the record reader takes either terminator: a listing carrying a NUL is split on it, any other on its line ends.
The two classes report one at a time, which shapes the pair. A dump violating both prints the script class and exits, so bad/ carries the descriptor violation. The script class has its own case in gate-tests/check-exec-bit.test.sh, which builds a temp git repo and re-stages a KPI at 100644 then 100755 on the live git ls-files path. That test invokes the member through gate_run rather than by script path, which keeps it alive across a substrate move (§lib/test-hermetic.sh).
check-root-tiering
checks/check-root-tiering.gate (hermetic, precommit, binary-dispatched). Invariant: every tracked top-level entry of the scanned tree is covered by an allowlist entry, an exact name or a glob (SPEC-*.md). The entries are the git ls-files first-segment set; the scan root is the optional second argument, default .. The repo root is the orientation surface a reader lands on, and agent-authored repos accumulate root scratch by reflex. The allowlist makes a new root surface a deliberate config edit and keeps workflow machinery under the configured dirs. A non-repo cwd or an unreadable allowlist is fail-closed (exit 2).
The allowlist is optional consumer config (the graph-vocab.knobs pattern): GATE_SDK_ROOT_ALLOWLIST (default <gates-dir>/root-allowlist.list), one entry per line, # comments and blanks ignored. An absent file falls back to a built-in orientation set: README.md, LICENSE, the configured queue file, the configured agent file (GATE_SDK_AGENT_FILE, default CLAUDE.md), .gitignore, and the SPEC-*.md amendment glob. The glob is in because a root-component amendment is a legitimate transient surface for any canon-kit consumer, and a gate reddening every authoring stage would only train bypassing.
The valve for a new root surface is the manifest: add its allowlist line in the same commit, a diff-visible edit needing no exemption tag. The # graph: couples the manifest at tier=precommit, and the whole-tree battery is the backstop for a pure-addition commit outside the trigger’s staged view.
check-workflow-tiering
Two assertions, over GATE_SDK_WORKFLOW_DIR: (A) partition totality, every member tracked or ignored, never neither; (B) header presence and form, every tracked member’s first line # contract: with a payload in the pointer form or the version-marker form. Both are §The workflow directory’s rules; this section is the mechanism, that one the argument.
It deliberately does not re-resolve the pointer’s path and heading: check-spec-pointer already does, and a second resolver is the parallel copy canon-kit’s own tiering rule bans. Presence and shape are checked here and resolution there, the split check-comment-tier and check-spec-pointer already hold.
No new knob: GATE_SDK_WORKFLOW_DIR is the whole configuration surface. Fail-closed (exit 2) on a non-repo scan root, on a workflow directory the knob names but the tree lacks, and on an unreadable member. A directory member is partition-checked and header-exempt, since a header belongs to a file. Tier precommit; the # graph: manifest couples the workflow dir and .gitignore.
check-action-pinning
Invariant: every uses: ref in a scanned YAML file is immutable: a full 40-hex commit SHA, or a repo-local ./ path to an in-repo composite action, which the checkout already pins. A tag or branch ref (@v5, @main) reds, because whoever owns the tag can repoint it and the code a run executes stops being the code reviewed. The ref is the first whitespace-delimited token after a key-position uses:. A trailing # v1.2.3 comment falls away, and a commented-out step, the copy-paste seed a template ships, is read like a live one.
The name is not check-workflow-*: “workflow” is already spent on §The workflow directory and GATE_SDK_WORKFLOW_DIR, and a second meaning would collide on the reader’s only disambiguator.
The scan set is derived, not rostered: a gate_find walk for *.yml / *.yaml from the scan root (the optional positional, default ., the check-root-tiering form). It is not a git ls-files enumeration, which would put the fixture pair’s untracked synthetic tree out of reach. The walk covers .github/workflows/ and the shipped copy-outs (templates/gates-workflow.yml, site-kit’s templates/site-health.yml) with no roster to drift. The shared prune set keeps it out of gate-tests/, so bad/ cannot red the whole-tree run. No new knob (§Shared cross-gate values). A tree holding no YAML exits clean on a zero count, the counted inertness that makes this kit mechanism: a consumer running no GitHub Actions pays nothing.
The gate does not assert the trailing version comment. Checking that it names the tag the SHA resolves to needs a network call, which breaks the hermetic contract, and demanding an unverifiable comment is the trivially-true proxy §When a gate earns its place bars. The comment stays a review convention. The currency limit the pin does not buy is §templates/gates-workflow.yml’s.
Tier precommit; the # graph: couples the scanned YAML surfaces, dir=one, a one-way audit. install: on-surface, permanently: a tag ref runs, so pinning is a supply-chain house rule rather than a defect in the adopter’s workflow (§The install disposition), and the workflow init seeds does not arm it over the adopter’s own. That workflow is in this gate’s subject, so the installer’s smoke runs the gate over it and expects green (installer/SPEC.md §The consumer smoke).
Its implementation is a compiled subcommand. The declaration path is check-action-pinning.gate, a .gate descriptor carrying the manifest above and nothing else (§The # graph: manifest), and the rule runs out of the gate binary. Everything this section specifies is substrate-independent: the seam carries a gate’s rule without changing its verdict, so nothing above is restated per substrate.
check-action-run-shell
Invariant: every GitHub Actions run: literal block scalar in a scanned YAML file is ShellCheck-clean at -S warning under the dialect the step actually runs. It closes the class §check-shellcheck’s target derivation leaves open: a workflow’s run: body is shell that nothing lints or executes outside a push or a tag. It is not the only gate on that surface. §check-action-gh-repo reads the same bodies for a semantic property ShellCheck is blind to, and §check-action-run-path reads them for the scripts they invoke.
Kit mechanism, not a consumer gate, because kits ship workflow templates carrying run: shell: §templates/gates-workflow.yml and site-kit’s templates/site-health.yml, the copy-outs consumers vendor. A consumer gate could lint both in one tree and leave every downstream vendor unlinted, the fix-the-instance shape §check-action-pinning rejects. The second template belongs to a different kit, so no consumer gate or site-kit-local gate covers both. It is named action- for §check-action-pinning’s reason.
Scan set: derived, then narrowed to the subject. Stage one is a pruned walk for *.yml / *.yaml from the scan root (the optional positional, default .) with the shared prune set, so bad/ cannot red the whole-tree run. Stage two is the Actions-shape predicate: a walked file enters the subject only if it carries a top-level jobs: key (a workflow) or a top-level runs: key (a composite action). Everything else is skipped and counted. The predicate governs extraction as well as refusal: a file it skips is neither linted nor refused.
Whole-tree reach is the wrong boundary here, although §check-action-pinning takes it. uses: with a 40-hex ref is self-limiting grammar, but run: is a YAML key in more than one CI schema. Whole-tree reach would lint foreign-schema text as shell, a stream of silent false positives §When a gate earns its place forbids. A gate whose name and reach disagree also teaches the wrong boundary, and gate-sdk has no standing to impose the literal-form rule below on YAML where it ships no template.
The # graph: manifest couples the walked surface rather than the matching one, since a file that gains a top-level jobs: key must retrigger the gate. The block tally is a measurement, never a contract.
The extractor is one pass per file, keyed on block-scalar indentation. It lives in shared crate code because it has two consumers, this gate and §check-action-run-path, on the rule that a helper earns its place at a second consumer. It is deliberately not a consumer of §check-action-gh-repo’s job-partitioned walk (native/src/actions.rs): the two share an indentation convention, not a mechanism, and that section carries the reasoning. Its rules:
- The key column is the column of the key token, never the list dash. A
- run: |item’s dash sits left of the key. Taking the dash’s column as the body’s floor pulls every sibling key of that step (env:,name:) into the shell body, dedented so thatenv:arrives asv:. That is a false-positive engine, and this is the extractor’s most important line. - The body is every following line more indented than the key column, blank lines included, dedented by the first body line’s indentation, ending at the first non-blank line at or left of the key column.
- No block header is recognised inside a block, so a body holding a heredoc whose text is literally
run: |stays shell. - A comment line is never a header, so the commented example a shipped template carries is not a step.
- The job partition. A column-0 key
jobsopens the job section and any other column-0 key closes it, the Actions-shape predicate’s own test reused. The first indent underjobs:is the job column: a line there begins a job, ends the one before, and resetsruns-onto absent. The first indent under a job id is the job-key column, whereruns-on:captures the runner value. A value empty on the key line takes the following lines more indented than that column, so a block sequence or mapping is captured whole. A trailing YAML comment is stripped from the captured value. - A
runs:-shaped composite action never enters the job section, so its steps carry no job and noruns-on. That is correct: GitHub’s schema makesshell:required on a compositerun:step, so a composite step reaching the unresolved row of the table below is invalid Actions YAML and the finding is true.
Resolution happens at two boundaries. The step boundary stamps the step’s explicit shell: onto its blocks, since that key may sit either side of the run: block it governs. A job’s runs-on may likewise follow the whole steps: list, so the inferred half cannot resolve at the step boundary. A block carries its step’s explicit value as an optional, and the job boundary or the file’s end stamps the job’s runs-on class onto every block that has none.
GitHub expressions. ${{ … }} is not shell syntax and, left raw, is a parse error. It is replaced per line by ${GHEXPR}, a braced parameter expansion, which ShellCheck sees as the opaque runtime value a GitHub expression is. A bare word does not work: a literal constant draws ShellCheck’s constant-expression analysis, manufacturing SC2050 inside [ … ] and SC2194 inside case on correct code. The braced form causes no finding in any tested position.
Dialect: resolved, never assumed, on two axes. Linting a block under the wrong dialect manufactures false positives. The step’s effective shell comes from its shell: sibling key where it has one, and from the enclosing job’s runs-on where it does not:
shell: |
runs-on |
resolution |
|---|---|---|
| absent | non-Windows | -s bash — GitHub’s documented default for a run: step everywhere but a Windows runner |
| absent | Windows | finding — the runner’s default is pwsh and the step does not say so |
| absent | unreadable, or the step has no enclosing job | finding — the dialect cannot be stated, so it is not assumed |
bash (with or without arguments) |
any | -s bash |
sh / dash / ksh |
any | the matching ShellCheck dialect — linting a POSIX body as bash hides the portability findings that dialect exists to surface |
anything else (pwsh, python, a custom {0} template) |
any | the block is skipped and counted — the body is not shell, so there is no shell to lint |
A step’s dialect must be knowable, and where the gate cannot state it the step says it. The finding line names the file, the run: block’s line and the condition that fired. The class takes its own help: line (§Output contract’s rule for a gate with more than one failure class), naming a one-line remedy: shell: bash, or shell: pwsh where pwsh was meant. A hosted runner starts a shell: bash step with -e set, so a step reading an expected non-zero status clears it first, which set -uo pipefail does not.
runs-on is classified by a Windows test, never by a platform roster. The captured value’s labels resolve to one of three classes:
- Labels. A scalar value is one label; a flow (
[a, b]) or block sequence is its members. A mapping is a runner group: itslabels:members are the labels, and agroup:with nolabels:yields none. - Windows if any label, lowercased and stripped of surrounding quotes, is exactly
windowsor beginswindows-. Any matching label makes the job Windows, since a mixed self-hosted selector may land on a Windows machine and a dialect that is only probably bash is not one the gate may state. - Unreadable if the captured text contains
${{anywhere, or the value yields no labels: a group-only mapping, an empty value, an absentruns-on. - Non-Windows otherwise, the only other answer. Bash is GitHub’s default everywhere but Windows, so a roster of Linux or macOS labels would only drift against a provider’s release notes, which derivation-first refuses.
Honest limit: a self-hosted Windows runner registered without a windows label reads as non-Windows, and its unshelled bodies are linted as bash. The label is the only platform signal a tree-local reader has, and guessing at label vocabularies turns a stated assertion into a heuristic. The remedy there is the same one line the finding asks for.
The Windows row is a finding, not a skip. A skip would leave a silent lint hole on Windows and leave any shell: bash convention a habit rather than a mechanism. A finding costs one line on a Windows step. An author who wants pwsh writes shell: pwsh and is skipped and counted through its own row.
The unreadable row is a finding, not a refusal. Exit 2 is for a construct the gate cannot process. Here the gate processed the file and found a step whose dialect nothing in the tree states, a property of the workflow, so it is exit 1 with a remedy the author can take. Nothing is captured and read as clean (§Fail-closed contract). The shape is a matrix-resolved runs-on: ${{ matrix.runner }}: once a Windows target joins the matrix, its bash bodies run under pwsh while a gate assuming bash reports them clean.
Its implementation is a compiled subcommand wrapping shellcheck. The declaration path is check-action-run-shell.gate and the rule runs out of the gate binary. The payload does not carry shellcheck, so criterion 7 reports the member (§The port-candidate criteria), and the compiled form spawns the program and refuses at exit 2 when it is absent. The wrapper mechanisms are §Fail-closed contract’s, shared with §check-shellcheck. The presence probe fires before any lint, and the merged capture’s exit code tells a finding (1) from a fragment ShellCheck could not lint (≥2).
The skipped and counted, never linted as shell ruling binds to a line of implementation, so it lives in the crate module (§The # graph: manifest, the annotation partition), not the descriptor. The walk sorts its corpus, so a multi-file finding list comes out in a deterministic order. Criterion 4 binds, and the live-tree arm is recorded as parity proved: the pre-port rule ran under a non-resolving name over the committed tree (§The port-candidate criteria).
Severity is -S warning, and it is contract, not calibration. It is §Self-lint’s level, the one every shell surface the family lints is certified at, kit workflow templates’ run: blocks among them. An author lowering it inherits false positives this gate would create: SC1091 (“not following: … was not specified as input”) fires on any block sourcing a library, because an extracted fragment has no resolvable source root. It sits at info, below the threshold.
The fidelity limit keeps three classes apart, since each carries a different remedy. What the extractor refuses loudly can never become a false negative; what is out of reach is a stated cost; what is out of subject is a boundary the kit declines to cross.
Refused: exit 2, naming the construct. Each is detectable, so the gate stops rather than guessing, and every refusal fires only inside the Actions-shape subject:
- a folded block scalar (
run: >,run: >-), since reassembling folded lines needs YAML’s folding rules and mis-folding manufactures findings; - an explicit indentation indicator (
run: |2), which can contradict the indent derived from the first body line; - a YAML anchor or alias as the
run:value, since no anchor resolution is attempted; - an unbalanced
${{on a body line; - a
defaults:key at column 0 or at the job-key column whose subtree carries arun:key.
The last is the dialect resolution’s own refusal. defaults.run.shell overrides the runner default for every step beneath it, so a resolver reading runs-on and not defaults would answer bash for a job whose steps all run under pwsh. Modelling it means a third inheritance layer, while refusing it costs nothing and can never become a false negative. A defaults: block setting anything else changes no dialect and is extracted normally.
Refusing the folded form makes the literal form a conformance requirement for a multi-line run: body in an Actions-shaped file, where gate-sdk ships the template and owns the contract. The chomping indicators |- and |+ are ordinary spellings, extracted with the body bytes intact. Skipping them would be the worst hole of the set, since authors reach for |- by habit.
Out of subject. Other CI dialects that also spell a shell step run:, CircleCI’s .circleci/config.yml the concrete case. There - run: | is genuinely shell and the extractor would handle it, but gate-sdk ships no CircleCI template and owns no contract over that file. This is a decision, not an inability: widening the predicate is a question of what the kit claims, not of what the extractor can parse.
Out of ability. Single-line run: values are extracted, their text read as §check-action-run-path states, but not linted. A plain scalar’s YAML rules differ from a shell’s (a space-preceded # opens a YAML comment), and reading them exactly means parsing the scalar, a dependency this gate declines. They are counted in the output so the cost shows on every run. Following a uses: call into another repository is beyond any tree-local gate; a called workflow’s blocks are linted in the file defining them. GitHub-expression injection is a non-goal: the substitution makes an interpolation opaque, and an unquoted ${{ }}’s injection hazard belongs to a dedicated workflow-security linter.
Tier precommit; no new knob: the scan set is derived, the prune set is shared, and the severity is the family’s literal. No valve, deliberately: the manifest keeps valve=none, because every unstated-dialect finding is discharged by one shell: key. A marker would be one line leaving the dialect unstated, where the key is one line stating it. A missing shellcheck binary is exit 2, as §check-shellcheck models, and so is an unreadable walked file. A tree holding no YAML exits clean on a zero count, the counted inertness that makes this kit mechanism.
check-action-run-path
Invariant: in every Actions-shaped YAML file, every repo-relative .sh path in invocation position in a run: body whose step runs a shell resolves to a file under the scan root. The class is a step still invoking a script a later change deleted or renamed. Nothing else reads a workflow’s commands as paths, so the battery stays green and CI finds it only on the push that runs the step. A single-line plain scalar is the form such a step most often takes, the one run: form §check-action-run-shell counts and does not read.
Its own member, not a second class on a sibling. A class on §check-action-run-shell is refused. That gate refuses at exit 2 without shellcheck, so a consumer without the linter would lose a check that needs none, and a gate whose name and reach disagree teaches the wrong boundary. A widened corpus for canon-kit’s check-docs-cmd is refused too, since its corpus is the governed doc set and an Actions file is gate-sdk’s subject.
Scan set, subject and bodies are §check-action-run-shell’s, consumed whole: the derived walk with the shared prune set, the Actions-shape predicate, and the extractor with its refusals, expression substitution and two-axis dialect resolution. A body is read when its resolved dialect is a shell (bash, sh, dash, ksh). Any other body is skipped and counted: a pwsh or python body, a Windows step with no shell:, or a step whose runs-on cannot be read. The unresolved dialects are already §check-action-run-shell’s findings, and one defect owes one reporting gate.
The bodies are the literal block scalars and the single-line values. A single-line value is read in one of two forms. It is a plain scalar up to its first space-preceded #, which opens a YAML comment, or a single- or double-quoted scalar with its quotes removed ('' read as one quote). A double-quoted value carrying a backslash escape is skipped and counted, since unescaping it is YAML parsing the extractor declines. So is a quoted value its line never closes, a multi-line flow scalar. The expression substitution applies where a single-line value’s expressions balance and leaves it raw otherwise. No single-line form is refused, since the block-scalar refusals are §check-action-run-shell’s conformance rule for multi-line bodies.
Which token is a script is canon-kit’s predicate, called and never copied (canon-kit/SPEC.md §check-docs-cmd, assertion A). The token is the first word of each ;/|/&-separated segment, or the first non-flag argument when that word is bash, sh, source or ., and it must be a path of two or more segments ending in .sh. A path in argument position, such as a cp destination, is never a finding, and a path under a variable or expression carries a $ and is not a token. Body lines are handed over one at a time, except a line whose first non-blank character is #, since a commented-out step invokes nothing.
Resolution is against the scan root, the checkout root a step’s shell starts in. A token resolves when <scan root>/<token> is a regular file, a leading ./ dropped. A step’s literal working-directory: value is joined to the root and resolved against. The step is skipped and counted when that value carries ${{, is absolute or is empty, and so is an absolute token, which names no repo-relative path.
Red (exit 1): one finding per unresolved invocation, <file>:<line>: run: invokes <path>, which does not resolve under <root>, the line being the body line that carries it. The help: line names the remedy: repoint the step at the script that replaced it, or delete a step whose script is spent. Clean reports the bodies read, the invocations resolved, and the skipped count by cause. Fail-closed (exit 2) on the extractor’s own refusals, an unreadable walked file, and a scan root that is not a directory. A tree holding no YAML exits clean on a zero count.
Tier precommit, no knob, no valve: every finding is discharged by repointing or deleting the step, and a marker would only keep a broken step. The # graph: couples the walked YAML surface and triggers on *.sh too, since renaming a script is the edit that strands a step. A deleted script is a staged D, which the hook’s ACMR filter skips, so the full battery and CI catch the deletion, the partial route context-kit/SPEC.md §check-settings-paths records. Born native, since no shell exception class applies. install: on-surface, like every check-action-* sibling (§The install disposition): the subject is the adopter’s own workflows, and the workflow init seeds carries no run: body.
The good/+bad/ pair is its oracle. bad/ has a block-scalar and a single-line plain invocation of an absent script, each beside a resolving one, so a broken body arm shows as a missing finding. good/ exercises resolving invocations in both forms, both quoted single-line forms, a commented-out invocation, a pwsh step and an unshelled Windows step (both skipped), a path in argument position, a $-rooted path, and a literal working-directory:.
check-action-gh-repo
Invariant: in every Actions-shaped YAML file, a job whose run: bodies invoke gh establishes a repository context. Either the job has a checkout step ordered before its first such invocation, or GH_REPO is set at workflow, job or invoking-step level, or every detected invocation carries --repo. A job satisfying none cannot resolve a target repository, and nothing else catches it until a tag fires.
The class is attested: a release job died on its first tag, in seconds, on failed to run git: fatal: not a git repository before its first API call. It was green everywhere and red only at the tag, on the release path itself. §check-action-run-shell passes that block, since its shell is valid. The defect is semantic, an assumption about the runner’s filesystem no syntactic linter can hold.
Kit mechanism rather than a consumer gate, on §check-action-run-shell’s deciding fact: a kit ships a workflow template carrying gh calls in run: bodies, site-kit’s templates/site-health.yml. A consumer gate would leave every downstream vendor of that template unchecked, and the template belongs to a different kit. It is named action- for §check-action-pinning’s reason.
Scan set and the split predicate. The walk is §check-action-pinning’s: gate_find for *.yml / *.yaml from the scan root, shared prune set, no roster and no new knob. §check-action-run-shell’s Actions-shape predicate is split, because this gate needs its two arms apart. A top-level jobs: key makes a file the subject. A runs:-shaped composite action is skipped and counted: it has no job and inherits the calling job’s repository context, so the assertion belongs to the caller. A file matching neither is outside the scan.
The detector: the trigger and the --repo arm are one. The gate must answer “does this line invoke gh” to arm at all, so the --repo arm is evaluated per detected invocation, universally quantified over the detected set. One unprefixed call fails it by construction, and a job mixing prefixed and unprefixed calls reds. Over each run: body in the job:
- Logical lines. Backslash continuations are joined before matching, so a split call is one unit and its
--repois found wherever it sits. A call’s--repois looked for only within that call’s extent, so a second call on the line cannot lend the first its flag. - Command position. A
ghtoken counts only where a shell would read it as a command: opening a logical line, or following a command separator, an opener or a command-introducing keyword. Whole-word matching keepsghostandgh-pagesout. The roster of separators, openers and keywords is a calibration the corpus forces. A job opening onif ! gh release viewneeds the negation among the openers, or the gate takes a later call as the job’s first invocation and orders the checkout arm against the wrong line. - Comment lines. A logical line whose first non-blank character is
#is not a command line. A trailing#comment is left in place, which over-detects, the safe direction. - Scalar forms. A literal block scalar is scanned line-wise, a folded one the same way though folding would join its lines, and a plain single-line scalar as one logical line. All three over-detect rather than skip, so this gate refuses none of §check-action-run-shell’s forms: it reads for one token, not for shell it must reassemble. A comment set off from a block indicator by white space is no part of it, so
run: | # whystill opens a body. - Step sequences. A sequence whose dashes sit at the
steps:key’s own column is read as one indented below it. A dash there under any other job key is not a step. - The bias. Every ambiguity resolves toward over-detection. A false positive costs an author one
GH_REPO:line or one exemption marker; a false negative is the release-path failure this gate exists for.
The checkout arm and its honest limit. A step satisfies it when its uses: ref before the @ is actions/checkout and its line precedes the job’s first detected invocation; a checkout after the call establishes nothing. A quoted ref is what stands inside its quotes and a plain one ends at white space, so a trailing comment is no part of either. Any other means of establishing a git remote, a hand-rolled git clone or a different checkout action, is outside the gate’s theory and takes the valve. A looser match would turn the semantic assertion back into a syntactic one.
The environment arm. GH_REPO satisfies it when set at workflow-root env:, at jobs.<id>.env:, or on the env: of the step whose body carries the invocation. gh resolves its target from GH_REPO and from the git remote, so no other variable joins the arm. This arm survives maintenance: a job-level GH_REPO cannot be undone by a new gh call, whereas per-call --repo can. The step-level lookup is load-bearing, since a release job commonly sets GH_REPO on the invoking step alone, and a gate reading only the outer two would red it falsely.
The arms are disjoined per job, each quantified over the whole detected set. A job passes on one arm alone: the checkout, GH_REPO in scope for every invocation, or --repo on every one. It never passes by pairing a GH_REPO step with a --repo step. Mixing them reds, a false positive one env: line or one marker resolves. A per-invocation disjunction would let a job satisfy the invariant in a way no reader could state.
The valve. # gh-repo-exempt: <reason>, the kit’s # <thing>-exempt: marker shape, binds by its own indentation. At or left of the job-id column it precedes a job, at the step-list dash column it precedes a step, and inside a step’s keys it binds that step. A job-bound marker skips the job; a step-bound one drops that step’s invocations and holds the job’s others to the arms. The reason is required and non-empty, and a bare marker is its own failure class with its own help: line.
The required reason is a deliberate tightening over the kit’s other -exempt: markers, which read none. This valve stands a job outside a release-path assertion, where an unexplained exemption is the failure the gate exists for; whether the siblings follow is not this section’s question.
The walk is this gate’s rule, held in shared code. This section owns the job partition, the gh detector with its command-position roster and line joining, the checkout arm’s uses: recognition and ordering, the env: ladder and the valve’s indentation binding. They are held in native/src/actions.rs, beside walk.rs, because §check-action-permissions wanted the walk whole as its second consumer. Two things are the caller’s argument. One is the valve marker’s spelling: this gate passes gh-repo-exempt. The required-reason rule and the bare-marker class belong to the walk and are shared. The other is a word list, §check-action-step-order’s: for it the walk emits each step’s uses: ref and each listed word’s spelling on a run: body line, each with its line. A caller passing no word receives no word event. This gate and §check-action-permissions ignore both events, so their findings, counts, clean lines and exit codes are unchanged by them. §check-action-job-ref reads the walk for its job keys alone.
§check-action-run-shell’s extractor is not that walk’s third consumer. It keeps a job partition of its own, since its dialect resolves at the job boundary, but emits none of the uses: or env: lines the arms read. It must reassemble a block scalar byte-exactly and refuse five constructs, where this walk over-detects by design and refuses nothing. The two share an indentation convention, find the job column and the job-key column, not a mechanism, and a helper holding that convention for two differently shaped state machines has no invariant to hold.
Tier precommit; the # graph: couples the surfaces §check-action-pinning couples, dir=one, a one-way audit. A tree holding no YAML, or no job invoking gh, exits clean on a zero count. bad/ opens on the attested miss itself, the release job as it shipped, and carries further reject jobs for the arms that one does not exercise. §When a gate earns its place’s demand that a higher-false-positive gate wait for a real miss is met by a fixture that is the miss.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms, declaration path check-action-gh-repo.gate; the two form one cohort because they share the walk.
check-action-permissions
Invariant: in every Actions-shaped YAML file, a job that consumes the GitHub token has the scopes it takes declared rather than inherited from an invisible repository default. A permissions: block is an allowlist, so an undeclared scope makes a read come back as an HTTP 404. A 404 on a read is indistinguishable from an absent resource, so the failure looks like “no such Release” rather than “not permitted”.
A release-body read missing contents: read reports an absent Release. On a public repository the omission stays invisible, because the content is anonymously readable anyway. It surfaces the first time a private-repo consumer copies the workflow, which is exactly the shape a kit ships into.
Kit mechanism rather than a consumer gate, on §check-action-run-shell’s and §check-action-gh-repo’s deciding fact: kits ship workflow templates their consumers vendor, §templates/gates-workflow.yml and site-kit’s templates/site-health.yml. A consumer gate could repair one tree and leave every downstream vendor unchecked. It is named action- for §check-action-pinning’s reason, and the collision is real: check-workflow-tiering already governs §The workflow directory.
Scan set, split predicate and walk are §check-action-gh-repo’s, consumed whole: the derived walk with the shared prune set, the jobs:-key subject with a runs:-shaped composite action skipped and counted, and the job-partitioned walk in native/src/actions.rs, of which this gate is the second consumer. A composite action has no job and no permissions: of its own, inheriting the calling job’s, so the assertion belongs to the caller. This section adds only what the walk emits for it and what is done with that.
The trigger: a job is armed by any one of three, the detector the walk already runs for its sibling:
- a step whose
uses:ref before the@isactions/checkout; - a
ghinvocation in one of the job’srun:bodies; - a
secrets.GITHUB_TOKENorgithub.tokenreference in the job’s extent.
Trigger 3 is a textual match, and its over-detection is the safe direction, §check-action-gh-repo’s bias inherited with the walk. A job mentioning the token in a comment arms the gate, and its author writes one permissions: block or one valve. secrets.NPM_TOKEN and every other secret are not matched, since they are not the GitHub token and carry no permissions: scope. The reference’s line number is deliberately not carried: a finding names the job, never the evidence line.
The scopes in scope for a job are its own block where it has one, and the file’s workflow-level block otherwise. A job-level block replaces the workflow-level one, GitHub’s own rule, so a job declaring id-token: write alone inherits no contents: from a workflow-level block. Modelling the inheritance is load-bearing: site-kit’s templates/site-health.yml and its filled instance leave the probe job’s block at workflow level. A gate reading only job-level blocks would red a correct file, the shape §check-action-gh-repo’s step-level GH_REPO lookup has.
Scopes are read as key names with their values, in the three shapes GitHub admits. A mapping of <scope>: read|write|none, block or flow. The scalar shorthands read-all and write-all, which grant every scope at that level. The empty mapping, {} or a permissions: key with an empty subtree, which grants nothing. The two shorthands are not told apart, because both satisfy both arms and nothing reads the difference.
Two arms, disjoined by what the trigger proves:
- Arm A:
contents:, where the consumption is exact. A job armed by trigger 1 must havecontents:atreadorwritein scope (writesatisfiesread; either shorthand satisfies both). A checkout fetches repository contents, so the scope is not a guess and the gate may name it. - Arm B: a declaration, where the scope depends on the call. A job armed by triggers 2 or 3 and not by 1 must have a non-empty set of scopes in scope. Which scope a
ghcall consumes depends on the subcommand, and a verb-to-scope map is an unbounded, provider-versioned vocabulary a kit must not ship as a literal (§The provenance seam). So this arm asserts that the job says what it takes: a reviewed allowlist instead of a default nobody in the tree can see.
A job armed by 1 takes arm A, the stronger; the arms are not mixed within a job. A job armed by none is inert and counted, the counted inertness that makes this kit mechanism: a consumer none of whose jobs touch the token pays nothing.
permissions: {} fails both arms, and that is the gate working. An empty allowlist grants nothing, so a job that checks out under it has no contents:. It fails at runtime on a private repository at once, and on a public one only once the content stops being anonymously readable. That silence is what this gate is built on, and bad/ opens on it.
A reusable-workflow call (jobs.<id>.uses:) has no steps:, so triggers 1 and 2 cannot fire in it. Trigger 3 still can, rightly. A job handing the token to a called workflow passes its own permissions: along, so declaring them is the act the arm asks for. The called workflow’s own jobs are held in the file that defines them, the boundary §check-action-run-shell draws for a called workflow’s run: bodies.
Output. Clean is one line naming the armed jobs, inert jobs, Actions-shaped files, walked files and the files the predicate skipped. A finding names the file, the job id, its line and the arm that failed. The two arms are two failure classes with two help: lines (§Output contract), one naming contents: read and one the declaration; the bare-marker class inherited with the walk keeps its own third.
The valve. # action-permissions-exempt: <reason>, reason required and non-empty, binds by indentation exactly as its sibling’s does (§check-action-gh-repo owns the binding rule). A job-bound marker skips the job; a step-bound one drops that step’s evidence from the trigger set and holds the job’s other evidence. A valve is minted here, unlike in §check-action-run-shell, because the remedy is not always available: the gh detector over-detects by design, and a job reaching the token through a third-party action’s machinery is outside this gate’s theory.
The refusals. Exit 2, naming the construct, and only inside the Actions-shape subject: a permissions: value that is neither a mapping, nor read-all/write-all, nor empty, such as a ${{ }} interpolation, an anchor or an alias. The gate cannot resolve what such a block grants and does not guess.
Out of ability. A third-party action that consumes the token implicitly, through its own with: inputs or the environment, is not detected; guessing at action names would be a maintained roster of someone else’s software. A curl to api.github.com is likewise undetected, a bare URL with no token binding the gate can see. Both take the valve, the honest limit of a tree-local reader.
Not asserted, deliberately: that a declared scope is not wider than necessary. Over-declaration is a real hazard for a different gate. It needs the verb-to-scope map arm B refuses to ship, and a minimum is mechanically decidable where a maximum is not.
Tier precommit; the # graph: couples the surfaces §check-action-pinning couples, dir=one valve=none: a one-way audit with no leading surface. The valve= field is §The # graph: manifest’s cycle-valve classification, unrelated to the exemption marker above. No new knob: the scan set is derived, the prune set is shared, and the asserted scope name is GitHub’s published vocabulary, not rule content a consumer could own differently. install: on-surface, permanently, on §check-action-pinning’s ground: a workflow with an undeclared scope runs, so declaring one is a house rule rather than a defect in the adopter’s workflow (§The install disposition), and the workflow init seeds does not arm it over the adopter’s own. That workflow is in this gate’s subject, so the installer’s smoke runs the gate over it and expects green (installer/SPEC.md §The consumer smoke).
Its implementation is a compiled subcommand, born native. The declaration path is check-action-permissions.gate and the rule runs out of the gate binary (§Porting a gate to the binary substrate). Widening the shared walk’s event stream for it must leave §check-action-gh-repo’s findings, counts, clean line and exit code byte-identical.
check-action-job-ref
Invariant: every backticked reference to a CI job, in the governed prose and the workflow files themselves, names a job some workflow defines. A job key is renamed in one file and cited in many, and a citation to a key that is gone reds nowhere else.
The reference. A single-backtick code span on one line whose whole content matches one of GATE_SDK_JOB_REF_PATTERNS, anchored at both ends. The knob is an array of EREs, default empty, and an empty set is a clean skip whose clean line names the knob. Which job names a tree cites is its own vocabulary, so no pattern ships as a kit literal (§The provenance seam). A span outside every pattern is not a reference, so a queue slug sharing a prefix with a job family is out of reach by construction.
The job set. Every job key under a top-level jobs: key of each YAML file the walk finds, read by the job partition §check-action-gh-repo specifies (native/src/actions.rs), not re-parsed.
Corpus. A pruned walk from the scan root (the optional positional, default .) for *.md, *.yml and *.yaml, with the shared prune set, so bad/ cannot red the whole-tree run. A workflow file is read twice: once for its job keys and once for the references in its full-line comments.
Red: one finding per reference that names no job key, as <file>:<line>: <reference> names no job in any walked workflow. Exit 2: a pattern that does not compile, and an unreadable file.
Honest limits. A leg described in free prose (“the Windows leg”) is out of reach, and so is a reference outside every pattern, a misspelled bootstrap segment included. A reference to a job no workflow defines any more is found only while its name still matches a pattern. The durable catch for a rename is the renaming amendment’s retired-spelling block (canon-kit/SPEC.md §check-amendment-retired-spelling), which this gate complements. A code span split across lines, and a trailing comment on a YAML line, are unread.
Tier precommit, # install: on-surface. The # graph: manifest couples *.md, *.yml and *.yaml. The knob is a vocabulary rather than a walk filter, so it takes no knob: token. Born native: native/src/gates/action_job_ref.rs, declared by check-action-job-ref.gate.
check-action-step-order
Invariant: in every Actions-shaped YAML file carrying a top-level jobs: key, a job whose run: bodies spell a listed program has a step on the listed action ordered before the job’s first such spelling. §check-crate-arms states the instance, the toolchain action ahead of a job’s first toolchain call, which review and a grep alone held before this gate.
The two values are the declaring tree’s. GATE_LOCAL_STEP_ORDER_ACTION (scalar) is the action, compared with a step’s uses: ref up to any @. GATE_LOCAL_STEP_ORDER_PROGRAMS (indexed) is the program set. Both are declared on the member’s descriptor (§The declaration cohort), so the crate carries neither an action path nor a program name. Either one empty is a clean skip whose clean line names the empty knob, a different sentence from a nothing-found clean.
A spelling is a whole word, anywhere on a body line. A listed program counts wherever it stands as a whole word on a line of a run: body. It is bounded on each side by a character that is none of an ASCII letter or digit, _, . and -. That is the class §check-action-gh-repo’s detector tests after its token, keeping ghost and gh-pages out, here applied before the word as well, since command position is not required. A line whose first non-blank character is # is not read.
- Command position is deliberately not required. That roster misses a call behind
if,while,exec, an environment prefix or a wrapper, each of which still fetches. - A word in an argument or a string therefore arms the job, the over-detecting direction. The body’s shell is not consulted, since the fetch happens under any of them.
The order is by line. The job’s first step on the action satisfies it when its line precedes the job’s first spelling, each spelling read on its own line of the body. A step on the action after that spelling, in another job, or commented out, satisfies nothing.
Scan set and subject are §check-action-gh-repo’s: the derived walk with the shared prune set and the optional scan-root positional, read through the job-partitioned walk in native/src/actions.rs. A runs:-shaped composite action is skipped and counted, since it has no job and the action being ordered is itself one. A job spelling no listed program is inert and counted. A reusable-workflow call has no steps: and is inert.
Red (exit 1): one finding per job, <file>:<line>: job <id> spells <program> with no earlier step on <action>, the line being the first spelling’s. The help: line names the remedy, a step on the configured action ahead of the job’s first listed program. Clean reports the armed jobs, the inert jobs, the Actions-shaped files and the composite actions skipped. Exit 2 on an unreadable walked file and a scan root that is not a directory.
No valve. Every finding is discharged by one step, the ground §check-action-run-shell gives for minting none. The cost is stated: a job that only mentions a listed word pays one step on the action or rewords the line.
Honest limits. A call reached through a script a step runs is outside the read, since the member reads workflow text and follows no path. So is a call inside a composite action or a called workflow, each held where a job of its own would be. An if:-conditioned step on the action counts as taken. The member holds order, never that the action fetches or retries.
It is the declaring tree’s own gate and does not ship. The sibling check-action-* members are kit mechanism because a kit ships a workflow template carrying their subject. No kit template calls a toolchain, and which action precedes which program is one tree’s practice. So the rule is generic, and its two values and its registration are the consumer’s. In an adopter’s tree §check-gate-substrate-parity’s assertion I scopes a consumer-declared member out, so no adopter registers or declares it.
The pair is its oracle. good/ holds a job with the action ahead of its first spelling, a job spelling nothing, a composite action spelling a program, a spelling on a comment line, and a listed program as part of a longer word. bad/ holds a job with no step on the action, one whose step follows the spelling, one whose only step on the action sits in another job, and a spelling behind if. Each case declares the two knobs on its own descriptor. check-action-step-order.test.sh holds the two empty-knob cleans, which a one-verdict case cannot.
Tier precommit. The # graph: manifest couples *.yml and *.yaml, dir=one valve=none. Born native: native/src/gates/action_step_order.rs, declared by scripts/check-action-step-order.gate, which binds this tree’s action to ./.github/actions/toolchain and its programs to cargo, rustc, rustup, rustdoc, rustfmt and clippy-driver.
check-commit-msg
checks/check-commit-msg.gate (commit-msg, binary-dispatched). Invariant: the prospective commit message (the commit-msg hook’s $1) matches no banned pattern. It is the message half of a public repository’s ban on leaked local paths and private repo, project or account terms, the surface the pre-commit hook never sees. The commit-msg hook (tier=commit-msg) rejects the message before the commit exists, where a history scan would find the leak after push, when the only remedy is a destructive rewrite. Subject shape is §check-commit-subject’s job: this gate is the leak guard, that one the parse guarantee.
Patterns come from gate_msg_pattern_files (lib/gate.sh). Explicit positional pattern-file arguments win. Otherwise the set is GATE_SDK_MSG_PATTERN_FILES, tracked and required (fail-closed), plus GATE_SDK_MSG_PATTERN_FILES_LOCAL, gitignored and skipped when absent so a fresh clone without the operator’s private list still commits. Each file holds one grep -E pattern per line, # comments and blanks ignored. The consumer’s copy is the one pattern source both readers share (§Layout and configuration), so a pattern it adds also scans the tracked tree through §check-tree-terms.
The shipped defaults in templates/msg-patterns.list are generic mechanism, a public host or a shape and no private term, so they ship tracked, not local:
- absolute home paths;
- the Claude Code promo URL, the marketing link that ends a generated PR body;
- the session-reference class: the session-share URL host, and a trailer-shaped
Key:lead-in whose value is one token ending in a full UUID, the shape of a harness-injected session-id trailer; - the account-identification class ruled below.
The UUID half is anchored to a trailer-shaped line, so the shared set matches an injected footer and never the synthetic session UUIDs the tracked smoke/ trees carry. The value is bounded by the token rather than a count: a trailer’s value is one token, while prose quoting a UUID puts spaces before it. So the pattern needs no reach and matches the same on a wrapped or unwrapped tree.
Banning the Co-Authored-By trailer is a consumer-selectable class the shipped default leaves unset. The default list matches nothing of it, and a consumer choosing the ban adds its own pattern to its copy of the template. That is the Policy-as-choice form, with no knob beside a file the consumer already owns, the ground The reach is a policy below states for the account class.
A no-argument run (the whole-tree battery) is a clean skip: the message is not a tracked surface, and the history-scan backstop is deferred to the hosted-attestation rung. A missing message-file argument, or a missing required tracked pattern file, is fail-closed (exit 2). The # graph: couples the pattern file, not a tree path, since the gate runs in the commit-msg hook, on the message file.
A commit-msg-tier refusal names the safe recovery, and one helper prints it for both gates. This tier refuses before the commit object exists, so git commit --amend, the reflex repair, rewrites the commit already at HEAD, which under a shared index need not be this session’s. Re-issuing git commit is the safe form. Both commit-msg-tier gates print it from the one function this module exports, so they cannot name different remedies. No gate asserts the general rule “every refusal names a remedy”, a judgment about prose, so the shared helper is the contract.
The account-identification class bans a shape, not a name. Public prose cites the role (“the account carrying workflow scope”) and never the account filling it, because forge accounts are volatile and naming one correlates identities. A denylist of names catches only a name someone thought to list. So the shipped pattern matches a handle-shaped backticked token within a few characters of an account noun, singular or plural, in either order and inside one clause. A . or ; between the two ends the match, so a verb reading of the noun never binds the next sentence’s token.
The pattern spells no handle, domain or project term, which is what lets it ship tracked (§The provenance seam) and hold in a fresh clone with no private list. The plural is not optional: a sentence naming several identities at once is the correlation case itself. Handle-shaped excludes a short all-lowercase token, a CLI name such as gh, git, npm, cargo or ssh, and requires at least one letter or digit. POSIX ERE has no lookahead, so that requirement is spelled by splitting the token on its first non-lowercase character.
The line is the record unit and never the window. Both readers match line by line, so the pattern reads as line-scoped, but the clause bound is a code-point reach with the clause punctuation excluded from the gap class. A consumer keeping one paragraph per line hands the pattern no wider co-occurrence surface than a wrapped one does.
The calibration is tree-exact, stricter than a message-only guard would take. One pattern source feeds two readers (§check-tree-terms) whose economics differ. An over-refusal in a message is argued down once, by a human, at the one commit it blocks. An over-refusal in the tree is a standing red clearable only by rewriting prose that was never wrong, and a quotation would be falsified by the edit. So where the two tolerances disagree the tree’s wins, and a heuristic red is never licence to reword a quotation.
The reach is a policy, and the consumer already holds it. The clause bounds the match structurally, and the code-point reach inside it is a calibration tuned for tree-exactness. It ships in the pattern template a consumer copies into its gates dir and reads through GATE_SDK_MSG_PATTERN_FILES, so the consumer’s copy is where it is recalibrated, with no knob beside a file the consumer already owns.
Honest limit: the scope is handle identification and nothing wider. Account topology, that one machine holds two accounts or that one of them maintains this repo, is a proposition rather than a token, and no pattern reaches it. That residue is a review obligation, named so a green gate is not read as the whole assurance.
The pattern set is two whitespace-list knobs, GATE_SDK_MSG_PATTERN_FILES and GATE_SDK_MSG_PATTERN_FILES_LOCAL, each split on whitespace and expanded by nothing, so a glob in a value names a literal path. Both are pre-binary knobs (§lib/gate.sh): gate_msg_pattern_files reads them in shell for bin/build-native.sh, and the compiled members read them through the crate’s knob reader, one resolution for this gate and §check-tree-terms.
Matching runs through the crate’s own POSIX ERE engine (§The POSIX ERE matcher) rather than grep -E; the shipped pattern shapes are unit-tested against it beside the pair. A malformed pattern is exit 2 with the §Fail-closed contract’s line, and the diagnostic above it names the offending pattern and what is wrong with it.
check-commit-subject
Invariant: the prospective commit message’s subject line (the commit-msg hook’s $1, first line) parses as <type>(<scope>)?!?: <summary>, with <type> drawn from the shared roster and <scope> a [a-z0-9./-]+ token. The alternative is a git-generated carve-out: Merge , Revert and the fixup! / squash! autosquash forms. A subject that does not parse is an unread write to a governed projection, not a style nit. The trajectory arm’s feat/debt column classifies commit subjects, and the closed-row freeze relies on docs and chore filings sitting outside that harvest. A mistyped prefix is therefore a blocked commit rather than a silently drifted evidence row.
It rides the commit-msg hook (tier=commit-msg) beside §check-commit-msg, each an independent assertion with its own fixtures. Its refusal ends with the shared re-issue remedy §check-commit-msg owns, printed from that module.
The roster is GATE_SDK_COMMIT_TYPES (default feat fix refactor perf docs test build ci chore style), a whitespace-list row of gate-sdk’s table. This gate and the evidence classifiers both key off commit types, ruled share the roster, keep the mappings. The roster’s single home is gate-sdk’s table, while drift-kit’s kpi-task-split and the trajectory arm keep their own class mapping (feat against fix+refactor), a classification over roster tokens rather than a second roster.
Edges match §check-commit-msg: a no-argument run is a clean skip, and a missing message-file argument is fail-closed (exit 2). The trigger is the roster’s config home, gate-sdk’s knob file as a derived couple (§The # graph: manifest), not a tree path.
check-tree-terms
checks/check-tree-terms.gate (precommit, binary-dispatched). Invariant: no tracked file matches the banned-pattern set. It is the tracked-files half of the leak ban, sharing one pattern source with §check-commit-msg (gate_msg_pattern_files) so the two halves cannot drift apart. It runs over git ls-files (scan root the optional first argument, default .; pattern-file overrides follow) at tier=precommit with trigger=*, since any tracked file can carry a leak.
Two skips keep it off its own scaffolding. The shared prune dirs put a fixture tree under gate-tests/ out of scope. A file is self-exempt when it is a pattern file the gate resolved (the two knobs’ files, or the positional list) or a kit’s own starter roster under <kit root>/templates/. A pattern list contains what it bans, and a private list is kept out of the tree by gitignore, not by this gate. A non-repo cwd, or a missing required tracked pattern file, is fail-closed (exit 2). An empty pattern set leaves the tree unchecked and clean, since the fail-closed obligation is on a missing file. The member declares # armed-by: content over the two pattern knobs, so doctor names that state (§The install disposition), as check-commit-msg does.
One resolution, two readers. This member declares the same two knobs as §check-commit-msg and reads the same resolved values through the helpers that module exports. A second compiled copy of the resolution would reinstate the drift gate_msg_pattern_files exists to prevent. §check-portability-floor resolves its own files the same way and shares the crate’s live-line filter, fresh::live_line, but carries a separate knob pair. Folding a portability construct into msg-patterns.list would make a leak and a BSD incompatibility indistinguishable in one file, on one corpus, under one verdict. No pattern is baked into the crate (§The provenance seam).
Cheap filter, then match. A fork-free per-path filter runs first, over prune dirs, the self-exempt paths and the regular-file test, and the pattern set is compiled once for the whole walk. Recompiling per file is the regression the split prevents, and a compiled substrate makes that wrong shape cost nothing visible.
Three rulings on edge behaviour:
- A GNU escape in a consumer’s pattern file is refused, loudly. The crate’s POSIX ERE engine (§The POSIX ERE matcher) refuses
\b,\s,\wand the rest of the GNU set by name at compile, and this member turns that into exit 2. A pattern the engine cannot honour must never read as a clean tree. The POSIX spelling of a word boundary is(^|[^A-Za-z])term([^A-Za-z]|$), the form docs/ddd.md’s worked adoption example uses. - A banned shape inside a tracked binary yields a path-only record, and reds. A NUL byte marks the file binary; the match runs on the decoded text and the path is emitted alone, with no
:lineno:linesuffix. A binary’s bytes can run a megabyte without a newline and can carry control characters, either of which corrupts thepath:lineno:linegrammar, and there is no line to edit anyway. - Fail-closed arms are discharged by §check-gate-fail-closed across the whole registry, on §Fixture-pair discipline’s terms. Honest limit: an unreadable tracked file is skipped by the content match rather than failing closed, so a path the read cannot open leaves the scan silently short.
The binary arm is live wherever a consumer tracks the gate binary, as the installer smoke’s by-name run over each installed consumer does. A leak found there is fixed at its source: the build remaps the builder’s paths out of the artifact and verifies the result against this same banned set (§build-native).
The authoring rule runs both ways. native/src/gates/tree_terms.rs is a tracked file inside the corpus this gate scans, so a banned shape spelled literally in its source or tests reds the gate against itself. The module composes such shapes at runtime, as native/src/gates/commit_msg.rs does. Everything under gate-tests/ is pruned and may spell banned shapes freely, which is what lets the pair’s skip cases prove a skip rather than an absence. The module’s test inputs come from the fixture pattern files, never the consumer’s tracked list, since a compiled-in live pattern is a consumer-config literal no gate catches.
Criterion 4 binds through the walk (§The port-candidate criteria): every registry member’s declaration path lies inside the corpus, the widest couple-clears-walk-binds instance. The live-tree comparison is recorded as no disagreement found on the pre-descriptor tree, never parity proved; parity is proved over the pair.
The pair carries every arm of the derivation. good/ holds a pruned-directory file, a roster named by a path with no shipped prefix and a kit’s starter roster under its declared kit root. Each carries a banned shape, so its skip is proved by greenness. good/patterns.list carries a blank line and every live shape, the anchored one included, since a port can silently drop ERE anchoring. bad/ carries two leaking files, one with two leaks on a line and two on identical separate lines, which pins the record grammar: one record per matching line, however many patterns hit it, and no dedup. It also carries a msg-patterns-named file no knob names, which reds.
A case dir is not its own repository. git ls-files inside one returns the outer repo’s index scoped to that subdir, relative to cwd. So the non-repository arm is unreachable from a case dir, the gate-tests prune never fires on a case’s emitted paths, and a case file written but not git added is invisible to the gate.
check-tree-terms.test.sh carries the residue: the non-repository fail-close, and the missing-required-pattern-file fail-close with the env-knob resolution both cases bypass by passing a positional. It also holds the empty-set clean, the binary path-only record, the record order a pair cannot assert, and the dangling-symlink arm.
The dangling-symlink arm lives in the test because a case dir may not ship it. A fixture is payload content (§Consumer payload), and tar cannot create a dangling link on a native Windows host. The test builds the link at run time in its own sandbox repository, whose blob content is a banned shape, and asserts the index holds mode 120000. The link must stay dangling: the module filters with is_file(), which follows a link, so a resolvable one is scanned rather than skipped. Where ln -s fails for want of a privilege, the arm skips and declares. Why no gate holds the invariant instead is §Fixture-pair discipline’s stated non-target.
check-portability-floor
checks/check-portability-floor.gate (precommit, binary-dispatched). Invariant: no file on the configured install-path corpus matches a configured banned-construct pattern, except at a site carrying a # portability-declared: <reason> valve, and no PowerShell member of it (*.ps1, any case) carries a non-ASCII byte outside a full-line comment. It runs over git ls-files under the configured pathspecs at tier=precommit with trigger=*, since any install-path edit can add a construct.
Exit 1 on a matching site with no valve, reporting path:lineno:line and the pattern that matched. Exit 2 on the fail-closed set: an unreadable corpus member, a present but unreadable pattern file, and a pattern the ERE engine refuses at compile, the refusal §check-tree-terms takes. So a GNU escape (\b, \s, \w) in a portability blocklist is itself refused. A pattern file this gate resolved, or the kit’s own starter roster, is self-exempt, on §check-tree-terms’ shape: a roster of banned constructs necessarily spells them.
What a green run buys. A blocklist is a roster of what someone thought to name. This gate buys the next instance of a named construct, never portability: a construct nobody listed passes, and no verdict here says the install path runs on a non-GNU userland.
The ASCII arm is a language fact, not a roster. Windows PowerShell 5.1 reads a BOM-less script in the host’s ANSI code page, so a non-ASCII byte in code changes what the script says, and inside a string, where the string ends (installer/SPEC.md §Requirements). The arm reads each .ps1 member’s raw bytes and reds a line carrying one, reporting path:lineno:line and non-ASCII outside a comment. The exception is a full-line # comment outside a here-string. A here-string opens on a code line ending @" or @' and closes on a line opening "@ or '@ at column 0; every line between is code.
A trailing comment after code, and a <# … #> block, are read as code: telling a # in a comment from one in a string needs a lexer the crate does not carry, so the arm over-refuses and never under-refuses. A BOM reds on line 1. The arm takes no valve, since the byte is a parse failure on the host the rule exists for; the remedy is spelling the character by code point ([char]0x2014). The clean line counts the PowerShell members checked, so a corpus holding none reads differently from one whose members passed.
Both the vocabulary and the corpus are consumer config, and absent config disables the assertion. GATE_SDK_PORTABILITY_PATTERNS names the pattern files and GATE_SDK_PORTABILITY_PATHS the corpus pathspecs (§Layout and configuration owns both defaults). The kit ships templates/portability-patterns.list as a copyable starting roster, on templates/msg-patterns.list’s model, and no corpus at all. An absent or empty roster disables the roster arm alone, since the ASCII arm needs none; an empty corpus disables both. Either case exits clean with the absence named in its detail line, the §check-graph / graph-vocab.knobs degradation §The provenance seam names.
This departs from §check-commit-msg, whose tracked pattern file is required, because the kit cannot know any consumer’s install path. A fail-closed default would red every adopter’s first commit on a roster only their project can write. Absence and unreadability are different facts: absence is how a consumer declines the gate, an unreadable present file is a machine that cannot answer, and only the second is exit 2.
Honest limit, since the degradation is the whole risk: an adopter who deletes the roster, or never names a corpus, disables the gate, and no gate catches that. Two things bound it. A clean verdict saying no corpus is configured is a different sentence from one saying no violation was found. And the member declares # armed-by: GATE_SDK_PORTABILITY_PATHS (§The install disposition), so doctor names it disarmed while the corpus is empty. A deleted roster is bounded by the detail line alone.
The seam ruling. A construct list is a vocabulary, and a kit literal spelling a project’s install path publishes its layout too. Kit mechanism is the scanner, the ASCII arm, the valve grammar, the resolution and the two knobs; consumer config is the pattern roster and the corpus. A construct name is not an identity, so a roster is generic portability knowledge and ships tracked, with no .local sibling.
A declared construct is marked at its site, not remembered. A matching corpus line is clean when it carries a # portability-declared: <reason> marker on that line or the one above. The reason is mandatory, and an empty one is a violation: the field’s only reader is the human reviewing the diff that adds a valve, and a valve nobody has to justify is one everybody uses. The window and mandatory reason are the update-target-exempt and comment-tier-exempt conventions. The token is on canon-kit’s built-in directive roster (canon-kit/SPEC.md §check-comment-tier), so a full-line valve is a directive.
The cheap alternative is refused. Leaving declared constructs out of the blocklist would make nothing match and still catch a new find -printf. But a new use of a declared construct in a file that never had one would pass silently, and the declaring prose and the code would drift apart. With the valve, the declared constructs stay in the blocklist, and every live site carries a marker naming the declaring surface. A new site is red until its author has been to that surface, so the declaration is a coupling instead of a memory.
The corpus is git ls-files over the pathspecs, deliberately not a directory walk, a ruling within §check-reads-couples’ analyzed class. What an adopter’s machine executes is what the payload carries, and the payload is assembled from tracked files. An untracked file under a corpus root is not on the install path, so the enumeration’s narrowing is the corpus definition, not a loss. The listing is the crate’s NUL-terminated one, which refuses a member whose name is not UTF-8 (§Fail-closed contract). The member declares no walk root, as §check-tree-terms does; a pathspec roster is a knob value no registry could name as a root. A binary member is skipped and counted in the clean line, since a match inside a compiled artifact names no line to edit.
The couples= names the pattern file and never the corpus. Corpus membership is a knob a consumer may legitimately empty, and §check-graph reds a couples= glob matching nothing, so coupling the corpus would red a consumer for declining the gate. The trigger is trigger=* regardless, so nothing is lost.
A coverage-floor assertion is refused on the same point. Every corpus root resolves to at least one file reds on a zero count and is non-monotone: a consumer narrowing its install path to one file would go red for narrowing. This member’s verdict is monotone in the violation set, since a narrower corpus can only remove violations. Roster completeness is left to review, the line canon-kit/SPEC.md §check-amendment-update-target draws for its own roster.
Reuse rather than a new resolver. The pattern-file resolution is gate_msg_pattern_files’ shape, and the module reads the two whitespace-list knobs exactly as native/src/gates/tree_terms.rs does, sharing the live-line filter fresh::live_line. The roster is not shared, on the separate-knob-pair ground §check-tree-terms states.
Born native, no shell form authored. No exception class at §The port-candidate criteria applies. Criterion 4 clears: the corpus is the configured pathspecs, none of them a .gate descriptor or a native/src/gates/*.rs module.
The good/+bad/ pair is the oracle, each case with a stated job. good/ holds a declared construct under its valve in both window positions, a member with none, a pattern file with comments and blanks, and a PowerShell member whose non-ASCII sits only in full-line comments. It also holds the self-exempt roster, spelling every construct it bans, so its skip is proved by greenness. bad/ holds an undeclared construct, an empty-reason valve, a valve one line too far away, and PowerShell non-ASCII in a string, a trailing comment and a here-string, one per arm.
check-portability-floor.test.sh holds what a one-pair harness cannot: the two disabled-by-absent-config cleans and the sentence telling them from a nothing-found one, the ASCII arm under an empty roster, and the same knob path finding the violation once set. It also holds the GNU-escape compile refusal, the unreadable-pattern-file fail-close, the binary skip-and-count, a member under a name git quotes, and the record order, each expect.txt line being an independent substring test.
Producer of a verdict alone; its consumers are the committing session through the output contract, on the pre-commit hook, run-gates.sh and CI, and the --run-gate-tests arm through the pair. Its inputs are the two knobs, resolved by lib/gate.sh, and this repo sets the enabling configuration, so the gate is live here and not only in fixtures.
check-harness-literal
The publisher’s check-harness-literal.gate (precommit, binary-dispatched, # armed-by: GATE_SDK_HARNESS_LITERAL_PATHS). Invariant: no file on the configured corpus carries a configured harness literal, except at a site carrying a harness-binding: <reason> valve. It runs at tier=precommit with trigger=*, since any prose edit can re-acquire one. It is the catcher for §The adopter constraints’ master-harness bullet.
- The literals are
GATE_SDK_HARNESS_LITERALS, a whitespace list of fixed strings, matched as bytes with no pattern syntax. A harness’s variable name is a word, and a roster of words a reviewer can read needs no regular expression to hold it. - The corpus is
git ls-filesoverGATE_SDK_HARNESS_LITERAL_PATHS, a whitespace list of pathspecs passed to git as written. An adapter surface that is a whole file or directory leaves the corpus by git’s exclude magic in the same knob, since a second list of exclusions would be a second spelling of what a pathspec already says. A binary member is skipped and counted. - The valve marks an adapter surface narrower than a file: a section that reads the literal, or a sentence that declares the binding. A matching line is clean when it, or the line above it, carries
harness-binding:and a non-empty reason, which ends at a comment closer. In markdown the valve is an HTML comment, and an empty reason is a finding. - Exit 1 on a matching line with no valve, reporting
path:lineno:lineand the literal. Exit 2 on a non-repository working directory and on an unreadable corpus member. - Both knobs default empty, and an empty one disables the gate, clean with the absence named in its detail line, a different sentence from a nothing-found clean.
It is §check-portability-floor’s scanner under a second knob pair and a second valve, on that section’s separate-knob-pair ground: a harness literal folded into the portability roster would share one corpus and one verdict with a BSD incompatibility. The module imports that member’s valve window and binary skip, and lists its corpus through the shared reader. It takes fixed strings where that member takes patterns, and has no ASCII arm and no self-exempt file.
The valve is refused nowhere it is honest, and the cheap alternative is refused. Excluding a whole SPEC because one of its sections reads the variable would leave every other sentence of that file uncaught, which is the failure the gate exists for. So a file leaves the corpus only when the whole file is an adapter surface. A narrower surface is marked at its site, where a new site in the same file is red until its author declares it. The token is on canon-kit’s built-in directive roster (canon-kit/SPEC.md §check-comment-tier), so a full-line valve is a directive.
It is the publisher’s own gate and does not ship (§Consumer payload). Its subject is the harness-neutrality of kit prose, a constraint §The adopter constraints puts on the publishing repository, and an adopter holds no kit prose to keep neutral. The descriptor and its fixture pair sit in the publisher’s gates dir and the rule stays in the crate, as §check-crate-arms’ do. The kit ships no literal: a default naming one harness’s variable would be the vendor literal the knob exists to keep in config.
What a green run buys. The next instance of a listed literal on the listed corpus, never harness-neutrality: a literal nobody listed passes, and so does a harness-bound sentence that names no listed word.
Born native, no shell form authored. No exception class at §The port-candidate criteria applies. Criterion 4 clears where the corpus holds prose alone, as this repo’s does: its pathspecs name no .gate descriptor and no crate module. The member declares no walk root, as §check-portability-floor does. Its couples= names the knob file holding both rosters and never the corpus, on that section’s ground.
The good/+bad/ pair: good/ holds a literal under the valve in both window positions, a file excluded by pathspec that carries one bare, and a member carrying none. bad/ holds a bare literal, an empty-reason valve, one closed mid-line and a valve one line too far away. check-harness-literal.test.sh holds what the pair cannot: each disabled clean in its own sentence, the same knobs finding the literal once set, a literal carrying pattern syntax read as a fixed string, the binary skip-and-count, a member under a name git quotes and the non-repository fail-close.
Producer of a verdict alone; its consumers are the committing session, on the pre-commit hook and the battery, and the --run-gate-tests arm through the pair. Its inputs are the two knobs, each read whole on every run. This repo’s tracked gate-sdk knob file sets both, and each site on its corpus that reads the variable or declares the binding carries the valve.
check-projection-roster
checks/check-projection-roster.gate (precommit, binary-dispatched, # install: zero-config, # armed-by: GATE_SDK_PROJECTION_ROSTER). Invariant: a consumer’s roster of generated projections gains a checked row whenever a projection lands. The population is declared at its source, each freshness gate’s # projection: line (§The install disposition). The roster is the section GATE_SDK_PROJECTION_ROSTER_SECTION names in the file GATE_SDK_PROJECTION_ROSTER names (§Layout and configuration). The corpus is every registered member’s header, resolved by name through the gates dir and the kit roots, and that one section.
- A. A declaring member carries one
# projection:line with at least one member. No member carrieskit:. Every resolved path is covered by the member’s own expandedcouples=under the field’s one matcher (§Reading acouples=field’s reach). A member whose line resolves to no path is outside the declaring set, so a roster key naming it reds under C. - B. Every declaring member has exactly one row carrying its key. A row is a top-level
-bullet, and its key is<!-- projection: <gate> -->on the bullet’s first line. One row may carry several keys, so a row describing several gates serves each of them. - C. Every key on a row’s first line names a registered member that declares
# projection:, so a row keyed to a renamed or deregistered gate, or to one that declares no projection, reds. A key anywhere else in the section keys no row, and it reds too.
Rows without a key are not constrained: advisory fan-outs, parity contracts with no emitter, and ungated notes sit in the same section. Exit 1 names, per finding, the gate and the uncovered glob (A), the gate and the keyed rows found with their lines (B), or the key and its line (C). The clean line counts declaring gates and keyed rows, so an armed roster over zero declarations prints 0 rather than passing silently.
Exit 2 is the fail-closed set: the roster file absent, the section heading not found, a registered member that does not resolve or cannot be read, a couples= that does not expand, or a # projection: knob-path token that does not resolve. The section is the heading whose text equals the knob, bounded by the next heading of its level or shallower; an empty section knob means the whole file.
The file path is config, never a kit literal. The kit cannot know where a consumer keeps its roster, so it ships the mechanism and an empty default. An empty GATE_SDK_PROJECTION_ROSTER disarms the gate: it still runs, prints a clean line saying nothing was asserted, and doctor names it disarmed (§The install disposition). Assertion A runs only while the gate is armed, since its only consumer is the roster the knob names.
Honest limit. The gate holds the roster’s rows to the declarations; it cannot tell a freshness gate that should declare and does not. That half rests on the declaration being the author’s step when a projection lands, which is why a consumer’s roster names it in its new-gate fan-out.
Fixtures: good/ holds a declaring member in the gates dir and one under a kit root whose row keys two outputs, a member whose output is relocated through its knob, one whose knob resolves empty and so declares nothing, and an undeclaring member. It also holds unkeyed rows, a deeper heading inside the section and an unread key outside it. bad/ holds an uncovered glob, a kit: member, a declaring member with no row, one with two rows, a key naming an undeclaring member, one naming an unregistered name and a key off a bullet’s first line, each its own expect.txt line.
Producer of a verdict alone; its consumers are the committing session through the output contract, on the pre-commit hook, run-gates.sh and CI, and the --run-gate-tests arm through the pair. Its couples= carries knob:GATE_SDK_PROJECTION_ROSTER beside the descriptor globs, so a roster edit re-runs it.
check-template-copy-parity
Invariant: a kit template and its vendored consumer copy agree on their declared contract surface.
Scope derives from layout, never a roster. The pairing is <kit root>/templates/<name>.sh ↔ <gates-dir>/<name>.sh for each kit root resolved under the scan root (the optional first argument, default the git toplevel), so a subdirectory vendoring is paired where it sits. Two exclusions, both derivable. First, *-config.sh is out by name suffix, since a config template is a starting point the consumer customizes and equality would be the defect. A *-config.knobs template is out on the same ground, by the pairing itself, which reads <name>.sh templates only. Second, a template with no same-named file under the gates dir is silently skipped, not failed.
The two exclusions differ in kind. The suffix exclusion takes files out of the derivation, the config class §The config-seam port disposition rules the adopter’s seam. An unpaired template stays in the derivation and is only skipped by the assertions, so the executable templates left in it are the corpus §The harness-template port disposition rules, members this gate never asserts on included. An unpaired template has no copy to be in parity with, and running a template in place is a legitimate adoption mode, so failing closed there would red a tree working as designed.
The derivation has no floor. An unpaired template is skipped and an empty corpus asserts nothing, so removing pairs is monotone and clearable by inspection; the clean line counts the kit roots read, so an emptied corpus stays visible. The fixture pair is a synthetic tree rooted at the fixture dir, carrying its own copies of the names it asserts over, untouched by any delete in the real tree. §check-template-registry-parity derives its population from layout too, one axis over, without overlap: this gate compares a template to a consumer copy of itself, that one a template to the kit directory it registers.
Byte parity is ruled out, and so is containment in either direction. Every executable pair diverges deliberately, the consumer copy adding its own steering content, so copy ⊆ template is false on purpose, and template ⊆ copy catches only the reverse of the direction that rots.
The declared surface is four classes of declaration, namespaced so a collision across classes cannot mask one:
func:— function declarations, bothname()andfunction namespellings.case:— eachcase-arm’s exit token: the first command word of the arm body.lib:—_-bearing lowercase identifiers in command position: the leading edge of a line, or directly after;,&,|,),$(,&&or||. The intent is the sourced-lib API, and the class is deliberately wider: a locally declared function is matched by its own call sites, and acase-arm pattern at a line’s leading edge in front of its)is matched too.knob:— uppercase_-bearing names read via the defaulted-env idiom${NAME:-…}/${NAME:=…}.
The case: class reads the arm’s action, never its pattern, and that is a privacy boundary. A consumer’s arm patterns are its own rule vocabulary, which a kit gate must never read (§The provenance seam). The gate asserts that a consumer’s divergent rule lines are declared, never what they say. So the compiled form reproduces the discard as a discard, never as a capture later ignored. A pattern kept for a diagnostic, a debug rendering or a test fixture puts consumer vocabulary in the crate, and no case inspects intermediate state to catch it.
The three assertions.
- A — same resolved
spec:target. Both copies’ firstspec:line resolves to the same<file> §<section>, compared on the target with trailing prose stripped, since a pair may gloss one target two ways.check-spec-pointerskips templates by design (§Self-lint), so A is the only mechanism that surfaces a dangling template pointer. - B — the template’s declared surface is present in the copy. It catches a template-side change never propagated and a copy-side removal. B is the only arm that sees a removal, which is why it is not droppable: a pair can be a mutual rewrite, not a pure addition.
- C — the copy declares what it adds. A surface token in the copy but not the template is drift unless a
# copy-divergence: <reason>line in that copy names the token. Naming, not blanket per-file coverage, since a blanket marker would let C fire once and never again.
The # copy-divergence: <reason> marker is source, like the # graph: and # spec: headers beside it. It is hand-authored by whoever adds what the template does not declare, read by assertion C, and a directive under check-comment-tier, since it changes gate behaviour. The reason is required and non-empty, the [drain-exempt:] shape where the reason is the audit trail.
This is the low-false-positive shape: the gate reads declarations, not prose or logic, so a consumer rewording a message, reordering rules or adding steering text triggers nothing. It fires only when a declared contract element appears on one side unexplained. A paired file with no knob-with-default idiom has an empty knob: class, as any class may be empty, and its pair is compared on the other three.
The root default is a derivation no injected case reaches, since every committed case passes an explicit root. check-template-copy-parity.test.sh discharges it, driving the git rev-parse --show-toplevel branch from a subdirectory, where a cwd-relative default would pass vacuously, and its refusal with git unable to answer. Criterion 4 clears, read off the walk: the glob is one templates/*.sh per kit root and cannot reach a checks/ segment. A consumer whose gates dir held a shell declaration matching a kit template basename would flip it.
dir=bi: parity is symmetric, either side going stale is the defect, and B and C split it between them. The fixture pair synthesizes the one-sided edit. good/ proves green on a sanctioned spec: prose difference, beside an unpaired template, a divergent *-config.sh pair and a knob-less pair counted as compared. bad/ proves all three assertions red on a hand-edited copy. Tier precommit.
The harness-template port disposition
Part of the executable-template corpus §check-template-copy-parity derives carries # no-port:, and the ruling is a partition. The one declaring member is gate-sdk/templates/check-skeleton.sh, on the first ground below. §port-blockers’ --tree arm reclassifies it owed → no-port, so the completion predicate over that arm’s owed count subtracts it. The two grounds reach part of the corpus and no further, and stretching either over the remainder is the cross-class averaging §The config-seam port disposition refuses.
The corpus is the neighbouring section’s. §check-template-copy-parity derives its population from layout, and its *-config.sh suffix exclusion is §The config-seam port disposition’s class on the shell side, so the two cuts partition one population with no overlap and no gap. A template with no <gates-dir>/ counterpart stays inside that derivation, merely skipped by the parity assertions, which is why templates/check-skeleton.sh is ruled here though that gate never asserts on it.
The first ground: a cut narrows the port, never an extension point. Where a template’s own body carries the marked gap a consumer fills, that template is the extension point, and porting it deletes the thing there is to fill. It is the ground the config class stands on, applied to a seam that is a file rather than a knob. Being consumer-owned is not the test; carrying the seam is.
The gap is marked in the source, so membership on this ground is read rather than judged. The marker is a fill-in a consumer replaces, carried on a # spec: line or as angle-bracket text in a runnable frame. gate-sdk/templates/check-skeleton.sh carries the second spelling, in a frame that exists to be replaced (§templates/check-skeleton.sh). A vendoring adopter with no crate births a shell gate from it, so porting it deletes the only gate-authoring surface gate-sdk hands that adopter.
The second ground: a consumer copy carrying the consumer’s own rule content cannot cross into kit mechanism (§The provenance seam). No file declares on it today. A file reaches it only where the content is held in the file itself: content arriving through a knob is the consumer-config pattern that section names, and the file reading it ports.
Both sides of a declaring pair are named. A <gates-dir>/ copy takes its template’s disposition because §check-template-copy-parity holds the pair to one contract surface in both directions, and a disposition differing by side would itself be an undeclared divergence. A copy on the owed side is not un-portable: it is deleted when its template ports.
The ruling reaches by ground, not by scope. A member outside gate-sdk declares in its own header, and its own SPEC section says so. Nothing here reaches a file because of the directory it sits in.
A template the grounds do not reach ports, to a harness-integration arm (§The non-gate arm), and its <gates-dir>/ copy goes with it. The consumer’s per-member surface is then the kit’s config file alone. A port author reads the copy’s defaults, not only its # copy-divergence: tags: a behavioural divergence can sit untagged, and a consumer-path default moves into the consumer’s config seam in the deleting commit, the kit library’s default staying empty.
What reopens it, on §Consumer smoke The port disposition’s terms, since # no-port: is the permanent tier and a class ruling owes its reader what would falsify it. The extension-point face dissolves for a member whose marked gap leaves its template, a source-readable condition rather than a judgment. The provenance face dissolves for a copy holding no rule content its template lacks, which assertion C of §check-template-copy-parity already reports on.
check-template-registry-parity
Invariant: a kit’s shipped .list registry template names exactly the artifacts of the sibling directory it registers. Two assertions per registry: (A) every shipped artifact is registered, so a bundled plugin absent from the template is red; (B) every registry line resolves to a shipped artifact, so a retired plugin cannot leave a line that installs a broken registry into every consumer. Each finding names the kit, the template and the member.
A kit template registering artifacts the kit ships names every one of them. The consumer’s copy is the consumer’s to prune. The shipped template is the kit’s claim about what it bundles, and the kit’s SPEC roster, README and smoke assertions are stated over that set. A deliberate starter subset is refused, because nothing distinguishes it from an omission: it is exactly what a dropped line looks like.
Scope derives from layout, never a roster, §check-template-copy-parity’s rule one axis over. A template enters the population when <kit>/templates/<name>.list has a sibling directory <kit>/<name>/ holding the artifacts it registers, or when the binary declares that it natively dispatches that registry. A .list with neither is skipped and counted, not failed.
Shipped members are the basenames of the sibling directory’s *.sh files, extension stripped, read from git ls-files so an untracked scratch file forces no line, joined with the natively dispatched member names. Registry members are the non-comment, non-blank lines: the gates.list grammar gates_list_members reads, which the consumer’s own KPI resolver reads too, so the gate calls a name registered exactly when that resolver would.
Native dispatch is a shipping mechanism. A kit whose registry members moved into the binary ships them as surely as files, answering the same names at the same transition, so the gate asks what answers this name, not what file answers it. A shim file per native member is refused: each would need its own port disposition, turning a blind spot into permanent corpus. Scoping the gate off a ported kit is refused as scoping enforcement off the thing it checks.
The widened predicate is not satisfiable by the population going empty. Emptying a sibling directory reds every registry line, while deleting it takes the template out of the sibling arm. A narrowing that removes a violation by removing the check is worse than the red, because a later session reads a green board with no way to see it. The native declaration holds such a template in population, so a declaration with an empty roster reds every registry line through (B) rather than skipping.
Assertion (C) closes the declaration’s own side: where the declaring kit is in the scan’s root set, its registry template must have been reached, so a declaration outliving its template is a finding. Scoping (C) to the enumerated roots keeps a sandboxed fixture, which vendors neither the kit nor its template, out of it.
Honest limit. The population predicate is inferred, from a sibling directory or a native declaration, with no third signal. A tree deleting a registry’s artifacts and its native declaration in one motion degrades that template to skipped, silently. Deleting the declaration is deleting the dispatch, and the gate cannot tell an intentional retirement from an accidental one.
The population predicate carries the provenance seam, which is why it is structural. The flat rule, a kit’s templates/ registry must be the full bundled set, would force a kit literal enumerating a model roster for drift-kit/templates/price-table.tsv (§The provenance seam). Two kinds of template are out of population by construction. One that is not a .list never reaches the sibling test, as price-table.tsv exits on its extension. A .list whose rows are consumer rule content the kit stubs generically, such as gate-sdk/templates/msg-patterns.list, has no sibling directory of kit artifacts to register.
This is deliberately not a per-file exception list: such a list re-arms on the next template of the kind, since someone must remember to extend it, and forgetting publishes a private vocabulary as a kit literal. The shipped set is the sibling directory’s contents joined with the natively dispatched roster, and nothing else.
Sweep: kit roots come from gate_kit_roots (GATE_SDK_KIT_DIRS, §Layout and configuration); no new knob. check-template-registry-parity [root] resolves relative kit roots against a fixture tree whose gate-sdk-config.knobs names the fixture kits; bare, it sweeps against the git toplevel. Fail-closed (exit 2): a non-repo cwd with no root argument, an empty kit roster, an unreadable template or sibling directory, or a failed git ls-files. dir=bi, since either side going stale is the defect, split between (A) and (B); tier precommit.
The fixture pair synthesizes both sides at once. good/ is green on a registry in parity while carrying both structural exclusions, a .list with no sibling and a non-.list template. bad/ holds a registry one-sided in each direction at the same time. The pair also carries the widening: good/ holds a kit whose registry has no sibling directory and is in population on the binary’s native declaration, and bad/ holds that shape one-sided in both directions.
Its implementation is a compiled subcommand, on §check-action-pinning’s terms, declaration path check-template-registry-parity.gate.
templates/check-skeleton.sh
The copy-paste reference skeleton, the canonical “how to write a gate” template: structure, fail-closed and the output contract. A new gate is a copy-edit of it, shipping with its fixture pair. It is a template, never a registry member; structure is copied, not imported, so it stays per-gate and legible.
It is permanently shell, declared # no-port: under §The harness-template port disposition, because a vendoring adopter with no crate births a gate by copy-editing this file, and compiling it deletes the only gate-authoring surface gate-sdk hands that adopter. The declaration is a property of this template, so its cause carries the instruction to drop the line on copy. A birthed gate’s disposition is its own, decided by §The port-candidate criteria like any other member’s.
templates/gates-workflow.yml
The CI backstop template, the server-side outer tier of §Enforcement tiers. A consumer vendoring without the installer copies it to .github/workflows/gates.yml; init seeds a workflow calling the installer’s CI action instead (installer/SPEC.md §The CI action). It closes the two gaps the local hook cannot by construction: a --no-verify commit, and a clone that never ran --install-hooks.
The trigger is push and pull_request on the consumer’s default branch, a fill-in the template ships as main and says so. The job checks out the full history, because a history-reading gate reads the commit graph a shallow clone lacks. It resolves the gate binary through gate_native_bin_spelled (§lib/gate.sh), since a runner has a checkout and no install and the knob is the door’s one owner, and carries the result to the later steps. It then runs the full battery, then every fixture suite --emit fixture-suites derives from the gate-tests/ trees on disk, so no hand-list drifts as kits are added or renamed. The consumer adds one step after those for each further runner its own process names, such as a guard-test runner or a language test suite. Checkout and bash only, with no caching, no matrix and no third-party actions, keeps the surface an agent could tamper with minimal and reviewable.
The one action that survives that rule is pinned to a full commit SHA, the tag kept as a trailing comment, and §check-action-pinning holds it there. Ruling which actions may appear leaves open which code a named tag resolves to, and a tag its owner can repoint is the same tamper axis one hop further out.
The pin buys immutability, not currency. No update bot refreshes a pinned SHA, so a pin stays as old as the day it was written, and refreshing one is a manual act at release time. A pinned ref is not a maintained ref. This repo’s .github/workflows/gates.yml is the filled example, and both files register in core-files.list, so a silent deletion is red. The instance needs no freshness gate, since a workflow invoking a retired script reddens in CI on its next run.
The runner label is the opposite case: the template ships a floating -latest label and keeps it. A pinned image label ages in every copy with nothing to refresh it, the SHA pin’s cost without its tamper payoff, so a consumer wanting a fixed image pins its own copy. No run in the shipping tree executes a template, so the first run of a template on a migrated image is a consumer’s.
Scope boundary, stated in the template header rather than overclaimed: consumer-owned CI stops bypass, but cannot stop an agent editing the workflow in the same change. A tamper-proof verifier needs one the consumer does not control, which no consumer-owned file supplies. CI is not a smoke surface: it runs the real battery, so no kit’s smoke/ installs it (§Consumer smoke). The branch-protection recipe making the check a required merge gate is a GitHub setting, so it lands as install-page docs, not committable mechanism.
templates/adopt.md
The post-install adoption walk: an agent follows it with the user after init to fit the knobs to the repository’s layout and give each first red a disposition. It is a boundary template, like lifecycle-kit’s upgrade.md, stamping no state and running in any session, and it carries no slots, so a skill binding it binds nothing. It reads checkwright.lock for the profile, kits and selection. It finds its configuration items in doctor’s disarmed lines (§The install disposition) and in the clean lines a --run under GATE_SDK_VERBOSE prints, since a quiet run prints none, and maps each to its knobs through --reads, --emit knob-roster and --emit knob-values. It writes only the kit knob files in the gates directory, the tree’s own fixes and valves, and a gate drop through init --without-gate, which the lock records (installer/SPEC.md §Selecting kits and gates); it never edits a vendored kit file. It names the binary as the one GATE_SDK_NATIVE_BIN names and spells no path to it (guard-kit/SPEC.md §check-door-binding).
It files no install evidence. drift-kit’s install-observation record cannot tell an author’s install from anyone else’s, so reds an author files from its own adoptions would pass as external evidence. A red the gate should not raise goes to the kit’s publisher through the channel its README names.
gate-sdk owns it because every profile carries gate-sdk, starter included, and its subject is gate-sdk’s: the knob seam, the output contract and the registry. It is kit mechanism and names no toolkit, harness or publisher location. It runs over files init wrote, so it is reached after init: by a skill binding it, or by an agent handed its vendored path.