Skip to content

Adoption verification commands

Adoption prepares governance and declares project commands. It does not execute a project's scripts or certify that the application builds or passes tests. The optional verification object in the adoption report carries the selected argv, runtimes, status, and any reasons requiring review.

Status Meaning
declared-unverified Commands follow discovered project metadata. Execution and tool availability still need verification.
unavailable A required build/test command is missing or ambiguous. Generated recipes fail explicitly.
preserved-unverified Existing custom Makefile ownership is preserved. Review and exercise its verify-all contract.
not-applicable Root holds no supported build-system marker and no explicit test runner. build and test targets report not applicable and exit 0; verify-all runs context and audit gates.

A root with no supported build-system marker and no explicit test runner (such as documentation or organization profile repositories) is declared not-applicable rather than unavailable. Its Makefile build and test targets each print one line stating why they do not apply and exit 0 (no build system; verify-all checks agent context, caveman sources and audit.), allowing make verify-all to run real governance gates (compile-context-verify, caveman-sources, audit) and exit 0. AGENTS.md names the not-applicable targets without exit 1, and the adoption report states the plan is not applicable rather than warning that verification is unavailable.

An unavailable plan's verify-all runs the governance checks and then fails with one printf/exit 1 pair, also when adoption appends the rule to an existing Makefile. The report's Project verification unavailable warning says so, names the missing build or test command, and lists every command discovery found that verify-all does not run, shell-quoted, so you can wire them into the project's own contract. When the existing Makefile already defines a test target, a second warning names it as the likely test contract. A test target whose recipe is that failing pair is adoption's own, such as the one in a Makefile adoption generated earlier, so a re-run does not name it. A Makefile adoption does not recognise as its own current output, but that still holds the failing pair, keeps the plan unavailable on the next run rather than being preserved as a custom verify-all. Adoption recognises its earlier and current Makefile output in either consistent line-ending style, so a CRLF checkout (core.autocrlf on Windows) gets the same plan and report as an LF one, and earlier output it replaces keeps its CRLF endings; a Makefile mixing both styles counts as edited. The tests are in internal/adopt/verification_pillar_test.go: TestAdoptPythonProjectWithoutBuildWarnsVerificationGate, TestNoteExistingTestTargetNamesOnlyTheAdoptersTarget, TestAdoptRerunOnGeneratedMakefileDoesNotNameItsTestTarget and TestPreservedVerifyAllWithPlaceholderStaysUnavailable, with the CRLF migration in TestVerificationLegacyMigrationAndCustomPreservation (internal/adopt/verification_scaffold_test.go). A plan's selected_by list names the configuration that selected a command where the marker alone does not, such as the pytest configuration file and table, and the warning repeats it.

Ownership means a rule. Make reads a line in two steps, and adoption follows both. First it cuts the line at the first unescaped #, which opens a comment, or ;, which opens the inline recipe. Then it decides rule versus assignment on what is left, by whichever operator it reaches first: an assignment operator or a colon.

A Makefile that only binds a variable of the same name declares no target, whether the operator is :=, ::=, :::=, =, ?=, += or !=, whether the line carries an export or override modifier, and whether or not the value itself contains a colon (verify-all = docker run --rm ci:latest check, verify-all = $(SRCS:.c=.o)). The same test then runs over the prerequisites, which is why verify-all: CFLAGS := -g binds a target-specific variable without declaring a recipe -- with or without a trailing comment. A line whose first word is export or unexport is that directive and never a rule, so export verify-all: dep declares no target either; only the first word decides, which keeps override verify-all: dep and private verify-all: dep rules. In all of these adoption appends its own verify-all rule rather than preserving one and reporting a command the project's make answers with No rule to make target 'verify-all'.

An assignment operator binds a variable only when one word stands in front of it, after the override, export, unexport and private modifiers: Make takes no blank inside a variable name. So foo bar = verify-all: ; @echo x, foo\ bar = verify-all: ; @echo x and override foo bar = verify-all: ; @echo x are rules that declare verify-all, $(R) x = y is a bare expansion (below), and export foo bar = ... stays the export directive. In a rule's prerequisites such an operator leaves the line to Make, which reads verify-all: A B = x as a rule with the prerequisites A B = x and stops at verify-all: A B := x with multiple target patterns; the reader claims neither. A backslash escapes #, : and other bytes but neither $ nor a blank: Make has no escape for a reference, so \$(R) expands R like $(R) does, and a blank behind a backslash still separates two words.

Because the cut comes first, an = that a comment or a recipe carries decides nothing: verify-all: lint ## run gates (FAST=1) and verify-all: ; FOO=1 echo c are rules and are preserved. So are double-colon rules (verify-all:: dep), target lists (all verify-all: dep) and rules whose prerequisites hold a substitution reference (verify-all: $(SRCS:.c=.o)) or a function call with a nested reference (verify-all: $(filter-out $(X),a=b)): like Make, the reader counts nested brackets, so a colon or = anywhere inside a reference is not an operator.

Make joins a line that ends in a backslash to the next one before it reads either, and so does the reader: it classifies the joined logical line as a whole. Three shapes a line-by-line read gets wrong are decided that way:

HELP = usage \
  verify-all: run every gate

other: ; @echo step 1 \
  verify-all: not a rule

verify-all \
  other: dep

The first binds HELP and the second continues the inline recipe of other, so neither declares verify-all; a continued tab-indented recipe line is recipe text the same way. The third declares both verify-all and other. A comment ending in a backslash continues onto the next line, and a continued line inside a define body swallows an endef right after it, as in Make.

Also preserved are the forms only Make itself can resolve: an include directive, a load or -load directive, a call that evaluates text ($(eval ...), ${eval ...}, $(guile ...), or a $(call ...) whose first argument may name eval, guile or call, since call invokes the built-in function its first argument names), a != binding, a target name containing %, a computed target name the file does not fix (below), a Makefile that names .RECIPEPREFIX (below), and every top-level bare expansion but one shape. A first argument holding a reference may name such a function when every literal run around its references occurs in the name: $(call e$(S),...) with S = val calls eval, while $(call build_$(ARCH),...) calls a variable of the project. A != binding, X != cmd or define X !=, stores the command's output as the value of a recursively expanded variable, so Make expands that output as makefile text wherever the variable is expanded: measured against GNU Make 4.4.1, with rules.txt holding $(eval docs-lint: ; @echo operator), X != cat rules.txt declares docs-lint from all: $(X), Y := $(X), ifeq ($(X),) and $(info $(X)). The reader runs no command, so even verify-all != date +%H:%M, which declares no target, leaves the file to review. A loaded object's setup can parse any text as makefile syntax through gmk_eval: measured against GNU Make 4.4.1, load ./rule.so with an object whose setup evaluates docs-lint: ; @echo operator declares docs-lint, so the reader, which loads nothing, leaves every load and -load to review. Those rows (makefileLoadRows in internal/util/makefile_target_test.go) record that measurement and are not replayed, since building the object needs a C compiler and gnumake.h. A bare expansion is a line that holds a variable reference and is no assignment, rule, recipe line, comment or directive (a conditional, define, export, vpath, an include or a load). Make expands such a line and parses the result as makefile syntax, so whatever its references hold can declare a rule. Measured against GNU Make 4.4.1, every expansion below declares docs-lint, the docs$(SUFFIX) line with SUFFIX = -lint: ; @echo operator and the last two with that rule in rules.txt:

make-rule = $(1): ; @echo operator
$(call make-rule,docs-lint)

R = docs-lint: ; @echo operator
$R
$(value R)
$(strip $(R))
docs$(SUFFIX)
$(shell cat rules.txt)
$(file <rules.txt)

Telling such a line from a harmless one needs Make's evaluation, so the reader evaluates nothing and leaves every bare expansion to Make. The rule of thumb: adoption appends to a Makefile only when the reader finds the target absent, and a file it cannot read that far fails for review, as the documentation governance guide describes. The reader's answer covers every shape in this guide; the known gaps below list the shapes it does not cover yet. The one exempt shape is a line made only of $(info ...), $(warning ...) and $(error ...) calls, separated by blanks: those calls expand to nothing whatever they print, so the line declares nothing. A call nested inside that evaluates text, runs a command or calls another function (eval, guile, call, shell) takes the exemption away, and so does anything outside the calls. A plain reference inside stays exempt: $(warning CC is $(CC)) prints CC, and a variable whose value evaluates text holds a call, or comes from a != binding, that the reader reports on its own line. Measured against GNU Make 4.4.1, the first three lines below declare nothing and stay appendable; the last three declare nothing either, yet each leaves the file to review, the price of failing closed:

$(info building $(VERSION))
$(warning CC is not set) $(info see the README)
$(error set CC first)
$(if $(CI),,$(error run this in CI))
$(info $(shell git describe))
$(shell mkdir -p build)

Moving such a call into an assignment (_ := $(shell mkdir -p build)) or a recipe, where Make never parses its output as makefile syntax, makes the file readable again. A $(shell ...) in a rule's prerequisites (all: $(shell echo a.c b.c)) or in a conditional (ifeq ($(shell uname),Linux)) is no bare expansion and decides nothing: measured against GNU Make 4.4.1, its output never becomes a target.

A computed target name counts with the value the file fixes for it. The reader resolves $(NAME) or ${NAME} only when NAME is bound exactly once in the file: by a top-level NAME := value, NAME ::= value or NAME = value, outside every conditional, without override, export, unexport or private, on a line before the rule. The value may hold only plain path text (letters, digits, ._-/+@, and blanks) and, after := or ::=, references resolved the same way, at most 16 bindings deep (MaxMakefileVariableDepth). It keeps the blanks Make keeps, so NAME := docs-lint # gate makes $(NAME)x: declare both docs-lint and x. Measured against GNU Make 4.4.1, these lines declare engine/out and no docs-lint, and the Makefile stays appendable:

TOOLS_DIR := .tools
LINTER := $(TOOLS_DIR)/bin/ruff
SRC_ROOT := engine
OUT_DIR := $(SRC_ROOT)/out
$(OUT_DIR): $(LINTER)

Anything else leaves the name to review, as before: a second binding, ?=, +=, define, undefine, a target-specific binding or a binding whose computed name may be the variable, a function call such as $(shell ...) or a pattern or glob character in the value, and a variable bound nowhere in the file or only after the rule, whose value Make takes from the environment. A binding name may be the variable when every literal run around its references occurs in the variable's name: $(P)ME := verify-all may bind NAME (with P := NA it does), while $(PKG)_SRCS := a.c cannot. A file holding an include, a load, an eval call, a bare expansion or a != binding (define X != included) resolves no computed name, since each may rebind the variable: Make expands a != binding's command output as makefile text wherever the variable is used, so X != cat rebind.txt with rebind.txt holding $(eval NAME := build) and a later all: $(X) renames $(NAME): to build, and a loaded object's setup can evaluate NAME := build the same way (makefileEvaluatesText in internal/util/makefile_variables.go). Neither does a file whose computed binding names hold more than 256 literal runs in all (MaxMakefileComputedRuns): the reader splits each such name once and compares every variable it fixes with each run, and the bound keeps that work small on a large Makefile. A line such as $(PREFIX) verify-all: dep with PREFIX bound nowhere counts as no verify-all rule.

The documentation gate block is the one place adoption follows includes. When the Makefile holds include path with literal operands only, adoption reads each file that Git tracks under exactly that name, as a regular, symlink-free text file inside the repository within the same size and line bounds as the Makefile, and decides from the combined text (MakefileExpandIncludes in internal/util/makefile_include.go, read by internal/adopt/makefile_include.go). Nested literal includes are followed to a depth of four (MaxMakefileIncludeDepth) and 64 files in all. The merged Makefile keeps the include lines; the fragments are only read. A fragment that defines docs-lint or docs-figures still refuses the block.

Make remakes an included file before it reads it, and the tracked text then proves nothing, so the follow rule enforces that GNU Make evaluates nothing while it reads the makefiles. An include is followed only when the combined text (the Makefile plus every followed fragment) qualifies under this single rule; anything else stays ambiguous and refuses, naming the include, the first refused line (file and line number) and its shape in the message:

  • The operand is a literal path to a regular file that Git tracks, inside the repository, with no variable, wildcard, function, comment or ..; -include, sinclude and load are never followed.
  • Every non-recipe line of the Makefile and the followed fragments together is one of:
  • a comment;
  • a blank line;
  • an include, -include or sinclude directive with literal operands;
  • a variable assignment (=, :=, ::=, ?=, +=) whose name matches [A-Za-z_][A-Za-z0-9_]*, is not a GNU Make special variable (the Special Variables list of the GNU Make manual section 6.14, plus VPATH, SHELL, MAKESHELL, MAKEFILES, MAKEFLAGS, GNUMAKEFLAGS, MAKEOVERRIDES, MFLAGS, SUFFIXES) and whose value contains no $;
  • an explicit rule whose targets and prerequisites contain no $, %, &: or ::, whose targets and prerequisites contain no followed operand's base name (other than the operand itself) and no RCS or SCCS path element, and whose targets are not a followed operand, Makefile, makefile, GNUmakefile, or a dot special target other than .PHONY;
  • a conditional (ifeq, ifneq, ifdef, ifndef, else, endif) whose arguments contain no $.

The only exception is the exact lines generated by Praetor from util.MakefileCLIVariable (PRAETORCTL ?= $(shell ...), internal/util/clinames.go, tested by TestMakefileExpandIncludesRemadeIncludeStaysAmbiguous in internal/util/makefile_include_test.go and TestRemadeIncludeCasesMatchGNUMake in internal/adopt/makefile_include_test.go): it has a deferred $(shell ...) and, because no other line outside recipes may contain $, is never expanded while Make reads the files (makefileIsCLIVariableLine in internal/util/makefile_shapes.go). When git-hooks is declined, the Makefile resolves PRAETORCTL from PATH using this line (TestVerificationDeclinedGitHooksResolvesFromPath in internal/adopt/verification_scaffold_test.go). A repository that keeps its own lefthook.yml still gets the launcher, engine.mk and the include, and adoption warns about a shadowed PRAETORCTL ?= line only when engine.mk is installed or planned (internal/adopt/engine_makefile_test.go). The Praetor engine include -include .config/praetor/engine.mk is a literal include of a Praetor-managed fragment that sets PRAETORCTL through the hook launcher (internal/adopt/engine_makefile.go). Any other line (export, unexport, override, private, define/endef, vpath, undefine, load, or any $ outside a recipe) leaves the include ambiguous exactly as on main. - The operand does not end in one of Make's default suffixes (.s, .c, .sh and the rest of the default .SUFFIXES list), which a built-in suffix rule can build from a neighbour. - No entry in the include's directory has a name that contains the include's base name other than the file itself, and the directory holds no RCS or SCCS entry. That covers the built-in rules that build gen.mk from gen.mk.sh, gen.mk.c, gen.mk,v or s.gen.mk, and chains of them, covering sources named by rules as well as files on disk. An unreadable directory or one with more than 4096 entries counts as holding one.

Each rejected case was replayed against GNU Make 4.4.1 (TestRemadeIncludeCasesMatchGNUMake in internal/adopt/makefile_include_test.go). Each fragment is spliced between lines that close the open recipe, as Make does at an include (issue #843).

The answer holds for the invocation the gates run, make verify-all or make docs-lint with no variable definitions and no options. A command-line definition, -e with the variable in the environment, --eval, and MAKEFLAGS or MAKEFILES in the environment can change any variable or declare any rule, literal names included, so whoever passes them decides the rules. Inside the file, MAKEFLAGS reaches the same switches: measured, MAKEFLAGS += NAME=docs-lint after NAME := build makes $(NAME): declare docs-lint. A file that binds MAKEFLAGS therefore resolves no computed name, unless each binding lists only options that change no value, such as --no-print-directory (makefileHarmlessFlags in internal/util/makefile_variables.go; the rows are makefileComputedRows in internal/util/makefile_target_test.go).

A define alone only binds a variable: a rule line inside its body declares nothing, and a helper used only through $(call ...) in recipes leaves the Makefile readable. A define is left to Make when something can parse its body as rules, which is an eval call anywhere in the file, define bodies included, or a bare expansion. A define that is never closed is left to Make too, since it would swallow an appended block.

A tab-indented line is a recipe line only while a rule's recipe is open: after a rule line, with nothing since but blank lines, comments, conditional directives and further recipe lines. Before the first rule, or after an assignment, a define, an include, an export or vpath directive or a bare expansion, Make parses a tab-indented line as makefile syntax, and so does the reader. Measured against GNU Make 4.4.1, a tab-indented include rules.mk between ifneq ($(wildcard rules.mk),) and endif before the first rule declares docs-lint when rules.mk does, and so does a tab-indented X := $(eval docs-lint: ; @echo operator) between ifdef MAKE and endif; each leaves the file to review. The same tab-indented include right after all: is a recipe line of all and decides nothing.

A conditional keeps a recipe open only in the branch Make takes: a rule line inside a branch opens a recipe, and an assignment inside one closes it, only when Make takes that branch. The reader evaluates no condition, so it reads each branch as taken, starts an else branch in the state the conditional opened in, and merges the branches at endif. Where they disagree, or a branch without a plain else may be skipped, the recipe state after endif depends on the branch Make takes. Measured against GNU Make 4.4.1, each of these shapes declares docs-lint, and the reader leaves each file to review:

  • ifeq ($(wildcard rules.txt),), config:, a tab-indented ./configure, else, a tab-indented include rules.txt and endif, with that rule in rules.txt: config: sits in the branch Make skips, so the include in the else branch is the directive.
  • ifeq (a,b), foo: and endif, then a tab-indented X := $(eval docs-lint: ; @echo operator): foo: opens no recipe, so the tab-indented eval is makefile syntax.
  • all: with a recipe, ifeq (a,b), X = 1 and endif, then a tab-indented define X, a docs-lint: ; @echo operator rule and an endef inside a second ifeq (a,b): X = 1 closes no recipe, so the tab-indented define X is a recipe line of all and the rule below it counts.

Where the recipe state depends on a branch, a tab-indented include, load, eval call or != binding leaves the file to Make. A tab-indented define or conditional there changes how Make reads every later line, read either way, so the reader stops at it: it claims no rule at or after it and opens no define. It stops the same way inside a conditional whose branches leave different states, since Make reads such a line in a skipped branch too, in the state the taken branches left, and at a define inside a conditional whose body nests another define or holds a tab-indented endef: in a skipped branch Make ends that define at its first endef. A recipe open in every branch stays open, so an in-recipe $(eval ...) after endif stays recipe text.

Outside a recipe, a tab-indented define opens a block, and a tab-indented assignment calling $(shell ...) binds a variable like an unindented one. Assigning .RECIPEPREFIX changes which lines are recipe lines (after .RECIPEPREFIX = >, a tab-indented docs-lint: ; @echo operator is a rule and > docs-lint: dep a recipe line), so a Makefile that names .RECIPEPREFIX outside a comment is left to review and the reader claims no rule from it. A binding whose computed name may be .RECIPEPREFIX, such as $(P)PREFIX := > with P = .RECIPE or $(N) := >, stops the reader the same way; $(PKG)_SRCS := a.c cannot name it and stays readable.

The reader reads a Makefile up to the first point it cannot resolve: line 4097, a logical line longer than 8192 bytes once its continuations are joined, a chain of more than 256 continuation lines (257 physical lines), a line whose meaning depends on a conditional branch (above), or a binding whose computed name may be .RECIPEPREFIX. A rule before that point counts and nothing at or after it does, so adoption leaves the whole file to review and editor generation offers no task for a verify-all rule there, although Make reads it. That includes a rule whose own line is longer than 8192 bytes, such as one with a very long prerequisite list, which the reader used to claim from the part before the bound. The documentation gate uses the same reader to decide whether the project already owns docs-lint (MakefileMayDefineTarget in internal/util/makefile_target.go, ownership rules in internal/util/makefile_define.go, define, recipe and conditional tracking in internal/util/makefile_scanner.go). Editor generation reads the Makefile through that reader too (MakefileHasTarget, called by hasLiteralMakeTarget in internal/editor/capabilities.go), so adoption and the generated editor tasks cannot disagree on whether a line declares verify-all (editor capabilities). The table tests behind this contract are in internal/util/makefile_target_test.go and internal/adopt/verification_makefile_define_test.go; each row was measured against GNU Make 4.4.1.

Known gaps, where the reader's answer can still differ from Make's:

  • A variable imported from the environment or the command line whose value evaluates text can declare a target the reader does not see. A command-line definition of a variable the reader resolves, such as make NAME=docs-lint verify-all, renames the rule the file declares; the gates run no such definition.
  • The reader evaluates no condition, so MakefileHasTarget claims a rule inside a conditional whichever branch Make takes, and editor generation can offer a Verify All task for a rule in a branch Make skips. Adoption stays safe: a rule the reader claims also leaves the file to review.
  • The reader does not check that Make accepts the whole file. In a Makefile that Make rejects elsewhere, for example with empty variable name, multiple target patterns or missing 'endif', MakefileHasTarget can still claim a rule.
  • A newline-terminated Makefile of exactly 4096 lines counts as past the line bound, so it fails for review.

When an edited documentation gate block stops the run, whether adoption was appending verification targets or only attaching the gate, the error names the forced re-adoption that restores the block, praetorctl adopt --force --lock-source-root=<praetor checkout>, with the run's own lock source when it was given one (documentationMakefile in internal/adopt/verification_makefile.go). That run restores the block on both paths and reports the Makefile as a replace with its line delta and a backup (publishVerificationMakefile in internal/adopt/governance.go, tests in internal/adopt/verification_makefile_force_test.go).

The report's steps array records each step of the adoption chain it reached (completed, declined or failed) with the warnings and errors that step recorded. A step that records an error and carries on is failed, like one that stops the chain; --force on an AGENTS.md whose harness has no boundary line is one such case (internal/adopt/harness.go). The Governance Pillars lines that praetorctl adopt and the standards_adopt MCP tool print are derived from it: a pillar is ✓ only when its step completed without warnings or errors, and otherwise names itself planned (dry run), warned, declined, failed or not-run. A run that recorded an error is incomplete even when it was a dry run (Outcome and Pillars in internal/adopt/report.go, tested in internal/adopt/report_test.go). The Verification Gate also follows the verification plan: when the plan is unavailable, the verify-all adoption writes can only exit 1, so the pillar is warned, never ✓ or planned, and carries the plan's warning. An applied run whose Verification Gate is warned, failed or unreached ends with not ready yet: Verification Gate instead of the success line, and exits 0: a pending pillar is not an error of the run. Among the pillars only the Verification Gate counts: a warning on another pillar is informational and stays on that pillar's line, such as the notice that an existing .devcontainer was preserved, which every plain re-run repeats, and a declined pillar does not count either (PendingPillars in internal/adopt/report.go, TestVerificationPillarFollowsThePlan in internal/adopt/verification_pillar_test.go, TestPrintAdoptReportQualifiesSuccessWithPendingPillars in cmd/standardsctl/adopt_test.go, and end to end TestAdoptExistingDevContainerKeepsTheSuccessLine in cmd/standardsctl/adopt_success_line_test.go). One more thing is pending under the same rule: a kept .standards-baseline.json that the rescan rejects. It is named Debt Baseline in the same line, after the Verification Gate when both are pending, and two lines below it give the verdict and what resolves it (the baseline on a re-adoption, TestPendingPillars_Baseline_3D in internal/adopt/baseline_pending_test.go). It has no line among the Governance Pillars; the debt summary carries its verdict.

Repositories declaring docs:seo-portal also receive a locked Markdown gate, its dedicated required CI workflow, and private scratch-link protection. The documentation governance guide describes its inventory, diagnostics, adoption behavior, and bounds.

README governance is adoption evidence, not certification

When README.md exists, adoption owns only the region between <!-- praetor:readme-governance:start --> and <!-- praetor:readme-governance:end -->. It refreshes that region from the recorded debt baseline and preserves content outside it. The badge says HISS Adopted, never HISS Compliant: a baseline records existing scanner debt, while only a commit-bound signed Exit-0 receipt proves that a particular verification run passed.

The block never links a repository-relative path: a documentation portal that includes the README resolves a relative link against its own pages, and mkdocs build --strict aborts on a target it cannot find (#506). The HISS Adopted badge links AGENTS.md on the default branch of the repository that repository.owner and repository.name name, by that forge's absolute URL, only when the forge is known:

Origin host Link
github.com https://github.com/<owner>/<name>/blob/HEAD/AGENTS.md
gitlab.com https://gitlab.com/<owner>/<name>/-/blob/HEAD/AGENTS.md
gitea.com, codeberg.org https://<host>/<owner>/<name>/src/branch/HEAD/AGENTS.md

Each forge resolves HEAD to the default branch, so the link needs no branch name. The manifest fields do not say which forge hosts the repository, so adoption takes the host from the origin remote, and only when that remote's path is exactly <owner>/<name>. Any other host, including a self-hosted instance whose forge software the host name does not reveal, a nested GitLab namespace, and a repository without an origin remote, gets an unlinked image (TestAdoptReadmeGovernanceLinksTheOriginForge in internal/adopt/readme_governance_test.go). The block records the host in the link itself. praetorctl audit reads it back rather than asking the clone's remote, so a mirror verifies the same block; it accepts only the exact link a known forge serves for the manifest's repository, and reports a link into another repository or onto another host as stale.

With the docs:seo-portal facet the block also carries a workflow badge that links the praetor-docs.yml workflow runs on GitHub, where that workflow runs. TestRenderedBlockBuildsInStrictMkDocsPortal in internal/readmegovernance/links_test.go builds a strict MkDocs portal from the linked and unlinked renderings when mkdocs is on PATH, and the link-shape tests beside it hold the same contract on hosts without it. A block an earlier Praetor wrote with the relative AGENTS.md link fails audit as stale; plain praetorctl adopt re-renders the whole marker region, so it needs no --force (TestAdoptReadmeGovernanceRefreshesRelativeAgentsLink). With the facet and a manifest naming no identity, or only half of one, because adoption could not resolve it, adoption leaves the README unchanged and records a Governance block not reconciled warning instead of linking to a guessed repository (TestAdopt_UnresolvedIdentityCompletesWithoutGuessing, TestReadmeIdentity). Set both fields in .standards.yaml and re-run praetorctl adopt to reconcile the block; adoption never rewrites an existing manifest, so adding an origin remote alone does not fill them (TestAdopt_RerunCompletesOnceIdentityIsSet).

The block is a generated artefact (generated artefacts). praetorctl docs readme re-renders only the block, from the state praetorctl audit verifies it against: the recorded baseline, the documentation facet, the manifest identity and the forge host the block's link already records (readmeGovernanceState in cmd/standardsctl/audit_readme.go). --check writes nothing and fails on a stale block. A README without the block, or with the block declined, is left alone (TestDocsReadme_Boundary_NotApplicableDeclinedAndMarkers in cmd/standardsctl/docs_readme_test.go).

An operational fork carries the engine's README, whose block links the public source. praetorctl operational sync plan and prepare read the recorded state, the forge host included, back from that block and render it again for the fork's repository.owner and repository.name with the same renderer, so the fork passes its own audit (README governance block, internal/operationalsync/readme_test.go).

Every line of the block passes markdownlint's MD013 at its default 80 columns, strict mode and tables included, whatever the repository identity, so an adopter's own line-length lint needs no disable comment around it. The badges are reference-style images whose URLs sit in link reference definitions at the end of the block, which MD013 always exempts, and the prose is wrapped. Each gate is a paragraph rather than a list item: MD004 takes a document's list marker from its first list, and a list near the top of the README would impose its marker on every list below (TestRenderedBlockPassesMD013Positive and its negative and boundary cases in internal/readmegovernance, the boundary at GitHub's 39-character owner and 100-character repository limits).

praetorctl audit renders the same expected block in memory and fails when the README block is missing, malformed, duplicated, or stale. Re-run praetorctl adopt to migrate the historical unmarked HISS-16 badge and governance table. A repository that deliberately keeps README ownership can set adoption.decline: [readme] in .standards.yaml; audit reports that explicit decision instead of silently treating an unchecked README as current.

Adoption refuses unbalanced or duplicate live markers before writing README.md. Marker examples inside fenced code are inert. Human-authored and custom badges outside the block remain untouched, and a custom HISS badge prevents the renderer from adding a second badge.

Branch protection rulesets and adoption decline

Repositories adopting Praetor that manage branch protection externally or decline generated GitHub rulesets can record adoption.decline: [branch-ruleset] in .standards.yaml (accepted by standardsctl adopt). When this decline is recorded, standardsctl adopt omits .github/rulesets/main.json.

Both standardsctl audit and the MCP server's standards_audit tool share one authority path (adopt.AuditBranchProtectionWithPolicy) to verify branch protection, against the effective policy the audit resolved: profiles, facets and overrides joined, the policy adopt and sync render the ruleset from. Built-in defaults plus overrides are not a stand-in; every shipped archetype requires signed commits, which the defaults do not (TestAuditBranchRuleset_CLI_Positive_ProfileContributesBranchProtection, TestServerAuditBranchRuleset_MCP_Positive_ProfileContributesBranchProtection):

  • If branch-ruleset is explicitly and validly declined in adoption.decline, audit reports [PASS] Branch protection ruleset declined by adoption.decline. and passes cleanly without requiring .github/rulesets/main.json.
  • If branch-ruleset is not declined and active policy requires branch protection (such as linear history or signed commits), audit fails closed if .github/rulesets/main.json is missing, or if its content differs from the ruleset the declared policy renders for the status checks the repository's workflows report. That comparison is forge.ValidateRepositoryRuleset, the same check praetorctl sync runs; key order and whitespace do not count, so {}, zero required approvals or a missing required_signatures rule fail where the policy asks for them (internal/adopt/ruleset_audit_test.go).
  • An unknown decline item, malformed decline entry, or unreadable .standards.yaml fails closed, ensuring invalid configuration cannot produce a false pass.
  • The committed file says nothing about what GitHub enforces. When the audit can reach the forge, it also compares the live branch protection of the default branch with the declared policy and fails on drift; a declined branch-ruleset step skips that comparison too (Branch protection).

Agent definitions and adoption decline

praetorctl audit counts the agent personas compile-context projects: one file per agent, .agents/agents/<name>.md. The gate reads them through compiler.ReadAgentInventory (internal/compiler/agent_projection.go), the walk compile-context and its --verify project from, so the count and the projected set cannot differ. The gate then decodes the YAML front matter of every counted persona with the strict reader the engine uses for persona metadata; a persona without front matter, with a block that does not close or parse, or without a non-empty name and description fails the audit, naming the file and the key. A pass prints [PASS] Agent definitions verified (2 agents registered).

A subdirectory of .agents/agents, such as <name>/AGENTS.md next to an agent.json, is not a persona file: compile-context neither projects nor lints it. The gate names every such entry with what it holds, and fails even when flat persona files sit beside it:

[FAIL] .agents/agents holds entries compile-context does not project (2):
repo-auditor/ (holds AGENTS.md), repo-gatekeeper/ (holds AGENTS.md); praetor reads one
persona file per agent, .agents/agents/<name>.md; move each definition to that layout, or
decline agent-definitions in adoption.decline to keep another

The line is wrapped here; audit prints it as one line. A subdirectory holding an agent.json but no AGENTS.md is named the same way, never counted. A directory with neither persona files nor subdirectories still fails with contains zero agent definitions. A symlinked persona file is refused, as compile-context refuses it; a symlinked entry without the .md suffix is named and never followed.

A repository that keeps its own agent layout records adoption.decline: [agent-definitions] in .standards.yaml. Adoption then writes no personas, and audit reports the decline together with what .agents/agents holds instead of failing. Persona files that do exist are still projected and linted by the projection and caveman gates, which have no opt-out (the persona and skill gate). An unknown or malformed decline entry fails closed. cmd/standardsctl/audit_agent_definitions_test.go and internal/compiler/agent_inventory_test.go pin each case.

Organisation .github repositories and adoption decline

In an organisation .github repository, CONTRIBUTING.md, SECURITY.md and .github/pull_request_template.md are the defaults GitHub shows for every repository of the organisation that has no copy of its own, so the organisation writes them. Adoption treats contributing, pull-request-template and security-policy as declined there without an adoption.decline entry: the report lists each step as declined with that reason, and an existing health file stays byte-identical, --force included (TestAdopt_DotGitHub_HealthFiles_WritesNoneAndPlansNone, TestAdopt_DotGitHub_ExistingContributing_ByteIdenticalUnderForce in internal/adopt/dotgithub_health_files_test.go).

The repository counts as an organisation .github repository when the manifest's repository.name is .github; without a manifest name, the name from the origin remote decides, then the directory name. The match is exact and ignores case (IsDotGitHubRepository in internal/adopt/declined.go).

What audit does with a declined step

adoption.decline in .standards.yaml lists adoption steps a repository keeps for itself. Adoption skips each one and reports it declined. manifest, lockfile, baseline, documentation-gate and asdecided cannot be declined: adoption refuses the list before its first write (mandatoryArtifacts in internal/adopt/declined.go). Every other step has an audit contract in the same file (declineContracts). Every audit gate that reads a decline, in praetorctl audit and in standards_audit, reads it through adopt.AuditDecline, which applies the list the way adoption does: an unknown or mandatory name fails adoption and each of those gates.

Step Audit with the step declined
git-ignore retained: Git must still ignore the agent evidence directory (register.evidence.dir, default .workingdir/evidence/), and with docs:seo-portal the documentation gate's private paths, through the repository's own rules
policy-catalog retained: the catalog the lock pins under .config/archetypes, which adoption and audit resolve the effective policy from
agent-harness retained: the text register block in AGENTS.md, its caveman lint, and the projection of every agent_clients target
dev-container skipped: a .devcontainer/devcontainer.json the repository keeps is not compared, and adoption leaves the DevContainer rule out of the .gitattributes block (checkout line endings)
makefile skipped by the documentation gate
editors none
formatter-ignore skipped by the documentation gate
renovate-ignore none
actionlint-labels none
contributing none
pull-request-template none
security-policy none
adr none
readme skipped: the README governance block is not required
reuse-gate none
working-dir-and-flavor none
branch-ruleset skipped, in both audits
labels skipped, in both audits: .config/labels.yaml is not required
paperclip skipped: an absent .paperclip/harness.json passes; one the repository keeps is still loaded and its platform checked, never compared with a synthesis
agent-definitions skipped: .agents/agents is reported, not required
git-hooks skipped, in both audits: neither lefthook.yml nor an active pre-commit hook is required
agent-hooks none
  • none: no audit gate reads the step's artefact, so the decline changes no verdict.
  • skipped: the gate that reads the artefact passes and names the decline, for example [PASS] Label taxonomy .config/labels.yaml declined by adoption.decline.
  • retained: the decline stops adoption writing the artefact, and nothing more. Adoption's report entry for the step ends with audit still requires and what that is. A failing check the decline does not cover says so, for example ... (adoption.decline lists agent-harness, which does not cover this check: audit still requires the text register block in AGENTS.md, ...). A passing audit prints [INFO] Agent harness declined by adoption.decline; audit still requires ... after those checks.

A repository that declines agent-harness to keep its own AGENTS.md runs praetorctl compile-context once. That renders the register block between its markers and leaves the rest of the file as written; agent_clients: [] in .standards.yaml selects no vendor file to project. ADR-0010 keeps the block (decision 5) and the caveman gate (decision 11) mandatory, so no decline removes them.

A declined policy-catalog step writes no .config/archetypes, and adoption still renders the ruleset, the documentation gate, the README block and the harness from the effective policy. Before its first write, adoption resolves that policy read-only from the catalog on disk, under the lock the lockfile step leaves. A missing catalog, or one a lock re-pinned by --force or --lock-source-root no longer matches, stops adoption before it writes anything, naming the decline. After the lockfile step, the declined step reads the policy back from disk as audit does. A dry run previews the ruleset rendered from the same policy; it falls back to the built-in defaults only when the lock cannot be planned without --lock-source-root, and a real run never does (adoptionBranchPolicy in internal/adopt/ruleset.go).

Tests: TestDeclineContracts_Boundary_CoverEveryStep and TestDeclineContractsTableMatchesTheGuide (every step is mandatory or contracted, and this table matches the contract) and internal/adopt/policy_catalog_decline_test.go; TestDeclineContract_Boundary_EveryAuditedStepIsLookedUp (every skipped step's gate calls the reader, in the CLI and, where standards_audit reads the artefact, in the MCP server) and the declined-artefact tests in cmd/standardsctl/audit_decline_contract_test.go; cmd/standards-mcp/audit_decline_test.go.

Writing the ruleset, labels and repository metadata to GitHub with sync --remote

standardsctl sync verifies .config/labels.yaml and .github/rulesets/main.json locally. Only --remote writes to GitHub, with a token from --token, GITHUB_TOKEN or GH_TOKEN; the gh CLI session is never used.

standardsctl sync --remote                              # origin must be on github.com
standardsctl sync --remote --forge-host=ghe.example.com \
  --endpoint=https://ghe.example.com/api/v3             # GitHub Enterprise
  • Repository identity. The origin remote must name --forge-host (default github.com) and the path <repository.owner>/<repository.name> from .standards.yaml. Another host, a local path or a file:// URL is refused before any request (util.ParseGitRemote, TestSync_Remote_OriginHost).
  • Ruleset. GitHub gets the ruleset .github/rulesets/main.json declares: the name praetor-main-protection and the refs refs/heads/<default branch> and refs/heads/lts-* (forge.RepositoryRulesetRefs). The default branch is the one the local ruleset was verified for (forge.RepositoryDefaultBranch, see Protected default branch, TestSync_Remote_DefaultBranchMaster). The required status checks it adds are the local ruleset's checks whose jobs report in <repository.owner>/<repository.name>: a job behind a repository guard that names another repository is left off (forge.RequiredStatusContextsIn, TestSync_Remote_RequiresOnlyChecksThatReportInTheRepository; see Which jobs the ruleset requires). After the write, the live ruleset is read back and each left-off check is named: an [INFO] line when GitHub does not require it, and a [WARN] line when the live ruleset still requires it because an earlier sync added it. The merge below keeps that check, so remove it by hand (TestSync_Remote_WarnsAboutLeftOffChecksTheLiveRulesetStillRequires). A leaf check that a proven aggregate job now covers stays required on GitHub the same way; remove it by hand (TestSync_Remote_KeepsLiveLeafChecksAndAddsTheAggregate). A ruleset sync creates carries the bypass actor review_mode: single_maintainer declares (review policy). An existing ruleset is read, merged, updated and read back. The merge sets every parameter praetor renders from policy, a declared relaxation such as review_mode: single_maintainer included. For three parameters where the audit accepts a stricter live value (allowed_merge_methods when live is a non-empty subset of declared, and require_extra_approval_for_unattributed_changes and require_last_push_approval when live is true over declared false), the merge keeps the stricter live value on GitHub and prints a [KEPT] line that names the rule, parameter, live value that stays, and declared value. Every other rendered parameter whose live value was stricter (a higher approving review count, or stale-review dismissal, code-owner review, thread resolution or the up-to-date check policy switched on) applies as declared and is printed on a [LOWERED] line that names the rule, the parameter, the live value and the declared one, so no hosted setting drops silently. The merge keeps everything else: extra refs, bypass actors, other rules and parameters, and required checks praetor does not list. A protected ref is removed from the excludes. The command fails unless the readback includes everything written (internal/forge/ruleset_merge.go, TestGitHubDriver_ReconcileProtection_Positive_MergesLiveRulesetWithoutNarrowing, TestGitHubDriver_ReconcileProtection_Boundary_SingleMaintainerLowersLiveReviews, TestMergeRuleset_Positive_AppliesAndReportsLoweredParameters). Removing a rule or ref from a live ruleset is a manual change on GitHub.
  • Branch protection readback. Before and after the ruleset write, sync reads what the default branch enforces from both of GitHub's mechanisms: the active rules of every ruleset that targets it, organisation rulesets included, and the legacy branch protection object, whose "Branch not protected" answer means the branch has none (forge.GitHubDriver.ReadBranchProtection). A default branch not pushed yet ("Branch not found") has no legacy object either; the report says it does not exist on GitHub yet, and the ruleset still applies to it once pushed. GitHub enforces the union of the two, so each declared property is compared with that union (forge.EvaluateBranchProtection): pull requests, approving reviews, code-owner review, stale review dismissal, signed commits, linear history, deletion and force pushes blocked, and the required status checks. The line before the write lists each [DRIFT] and the mechanisms found, or says that nothing protects the branch. After the write, every property is printed as [OK], [DRIFT] or [STRICTER] with the mechanism that enforces it, such as ruleset "praetor-main-protection" #7 or branch protection. A setting still stricter than declared after the write, marked [STRICTER], comes from something sync does not write: a rule praetor does not render, another ruleset or legacy protection; change it on GitHub by hand if the declared policy is intended. If a declared property is still not enforced after the ruleset converged, because another ruleset, legacy protection or the repository's GitHub plan overrides it, the command fails instead of reporting success, and a legacy protection object the token may not read fails it before any write (TestSync_Remote_ReadsBackBranchProtection_Positive, _Negative, _Boundary).
  • Labels. Every label in .config/labels.yaml is updated on GitHub, or created when GitHub lacks it; labels the taxonomy does not name are left alone (forge.ParseLabelTaxonomy, TestSync_Remote_Labels).
  • Repository metadata. The repository.description and repository.homepage of .standards.yaml are written when they differ from GitHub's, and the declared repository.topics GitHub lacks are added. A field left unset is never cleared on GitHub, and a topic the manifest does not name is kept, so an empty topic list removes nothing. Topics are lower-cased as GitHub stores them, and one GitHub would refuse (anything but lowercase letters, numbers and hyphens, more than 50 characters, or more than 20 topics) fails the sync before anything is written to GitHub, the ruleset and labels included (forge.ValidateRepositoryTopics). Declared topics that would take the repository's own past 20 fail right after the repository is read, before the description or homepage is written; a topic write GitHub rejects after those were written names them in the error. repository.visibility is compared and a mismatch printed as [DRIFT], but never written: making a repository public or private stays the operator's decision (internal/forge/repo_metadata.go, TestSync_Remote_RepositoryMetadata).

Comparing live branch protection with plan --remote

standardsctl plan previews the effective policy, the local files and the live Actions checks (Live Actions checks); --offline skips every forge read. Without --remote it does not read branch protection, so its status ends with [INFO] Live branch protection not compared with the forge instead of saying that no change is required (adopt.FormatPlanStatus, TestPlan_3D). --remote adds a read-only comparison of the default branch's live protection with the declared policy, the same readback sync --remote prints after its write, against the status checks sync --remote would require there. Plan refuses --offline together with --remote, since the two contradict:

standardsctl plan --remote                              # same --token, --endpoint, --forge-host as sync

The token and repository identity follow sync --remote: --token, GITHUB_TOKEN or GH_TOKEN, and an origin remote that names <repository.owner>/<repository.name> on --forge-host, checked before any request. Nothing is written. When a declared property is not enforced, plan prints [DRIFT] GitHub does not enforce the declared ... and the command that reconciles it, praetorctl sync --remote. Like the local drift it reports, this leaves the preview's exit status at zero (cmd/standardsctl/plan.go, TestPlan_Remote_ComparesLiveBranchProtection_Positive, TestPlan_Remote_ComparesLiveBranchProtection_Negative).

Label taxonomy

praetorctl adopt and praetorctl sync write the same eight-label .config/labels.yaml into a repository that has none (forge.DefaultLabelTaxonomy, which internal/forge/labels_test.go holds byte-for-byte equal to praetor's own file). An existing taxonomy is the repository's configuration: adopt --force keeps it (TestAdopt_Positive_ForceKeepsRepositoryConfiguration in internal/adopt/adopt_test.go). With adoption.decline: [labels] adoption writes none, and both audits pass without one (adopt.AuditLabelTaxonomy, declined steps).

Rule enforcement registry and gate stage names

standardsctl rules audit reads .config/rules/enforcement.yaml, which gives every HISS rule and every AGENTS.md operational rule an enforcement class (ci-gate, hook, review-gate, guidance or staged) and evidence pointers, and checks that each pointer resolves: test: to a Go test function, hook: to a lefthook hook command, make: to a Makefile target, workflow: to a workflow job, gate: to a stage of praetorctl gate run, and forge: to a closed branch protection setting: required_signatures, linear_history, required_reviews or required_checks. An unknown setting fails like an unknown pointer kind. The stage names come from gating.StageNames(), which reads the same list the pipeline executes (pipelineStages in internal/gating/pipeline.go), so a stage the pipeline drops stops resolving and fails the audit (TestStageNames_PipelineOrder in internal/gating/stages_test.go). Rules with class guidance, or without an entry, are reported as prose-only; this slice reports them and does not fail.

A committed branch-protection policy is an assertion, not evidence that the forge applies it. Without --remote, a forge: pointer therefore appears under asserted, not read back and does not count as an enforced path. Another resolved pointer on the same rule still enforces that rule. With standardsctl rules audit --remote, the command resolves the same effective policy and status contexts as plan --remote, reads the live branch protection once through the shared forge reader, and uses forge.EvaluateBranchProtection for the comparison. Text and JSON report the setting's asserted value, observed value and match result for each rule. Drift fails with the rule ID and all three values; a failed live read fails by name instead of falling back to the offline result. The remote flags use the same token, endpoint, origin-identity check and bounded command context as plan --remote.

The report also lists guidance or unregistered rules containing the allow-listed instruction-only phrases is told not to, must not or never under instruction-only prohibitions. This is a non-failing conversion list; a rule with executable enforcement is not listed. Computing forge-app permissions as the intersection of task needs and policy remains outside this slice.

Stages that do not apply are skipped, not failed

praetorctl gate run records one of four verdicts per stage (StageStatus in internal/gating/pipeline.go), and a stage that ran nothing is never recorded as passed:

Verdict Report tag Meaning
passed [PASS] the stage ran its checks and they held
failed [FAIL] the stage rejected the repository or could not run; the pipeline stops
skipped [SKIP] the stage applies here but did not run all its checks: a dry run, no race detector, no cargo or cargo-audit, or it ran for one language and not the other
not_applicable [N/A] the repository has nothing this stage checks

--json carries the verdict as stages[].status, and the stage output the Exit-0 receipt signs (praetor-gate-output/v2) carries it on every stage line. The previous passed bool rendered a skipped stage and a passed one identically, so the CLI printed [PASS] and the receipt certified security scans and prefetches that had never executed.

praetorctl gate verify, praetorctl forge validate-pr and praetorctl paperclip verify refuse a receipt whose gate output does not open with praetor-gate-output/v2, after checking its signature and output hash (lockdown.VerifyPinnedReceiptFile and lockdown.VerifyUnpinnedReceiptFile in internal/lockdown/keys.go). A v1 receipt still carries a valid signature, but its stage lines cannot tell a skipped stage from a passed one, so it is rejected with receipt certifies an unsupported gate output version. Re-mint it with praetorctl gate run on the current release. TestVerifyPinnedReceiptFile_Negative, TestRunGateVerify_Negative, TestValidatePRChecklist_Negative_V1GateOutput and TestDisposition_VerifyReceipt_V1Refused sign a real v1 receipt and require each verifier to refuse it.

A skipped or not-applicable stage names its reason and does not fail the repository:

  • The prefetch, security and race-detector stages -- the toolchain stages -- are not applicable where Git tracks neither a discoverable go.mod nor a Cargo.lock (Cargo repositories). Without this a TypeScript or Python repository failed its own pre-push gate at FAIL ./... [setup failed], which reads as a broken repository rather than an inapplicable stage. Such a repository still gets no receipt (below).
  • Flavor conformance is not applicable where the repository's declared profile has no flavor implementing it -- an OS image forge is not a Go service and should not be measured as one. The stage takes no flag: a flavors list in .standards.yaml pins the flavor, or one flavor per path (onboarding guide). With no applicable flavor and no pin the stage is skipped as not applicable, naming that setting, and does not fail; a repository whose markers match a flavor but does not conform still fails; a pin wins over the skip (flavor.IsNotApplicable, internal/gating/flavor_pin_stage_test.go).
  • The race-detector stage is skipped where the race detector cannot build, naming what is missing:
5. [SKIP] Race-Detector Tests       (62ms)
   Reason: race detector unavailable (the C compiler "gcc" named by go env is not on PATH):
           race-detector tests skipped; CI runs this leg on Linux with cgo

The detector needs cgo and a host C toolchain. Checking CGO_ENABLED alone is not enough, and the difference is the common case rather than an edge: a stock Windows Go reports CGO_ENABLED=1 and CC=gcc while no gcc is installed, so the toolchain claims cgo and every race build still fails. The compiler the toolchain names is therefore resolved, not assumed.

Without this the stage did not report a missing compiler -- it reported # runtime/cgo followed by every package failing to build, which reads as a repository whose whole tree is broken. On a workstation without a C toolchain that was every push, including a push fixing support for that platform, so the gate could not be repaired from the platform it was broken on. Set CGO_ENABLED=0 to skip the stage deliberately; install a C toolchain to run it.

A skipped stage prints its reason. That distinction matters: a skipped stage that reads as a pass is how a gate comes to certify what it never examined. The verdicts are pinned by TestExecuteStage_SkipVerdicts and TestStageOutput_3D in internal/gating/gating_test.go.

Tracked Go modules

The Go parts of Prefetch & Lockfiles, Security & SCA Scan and Race-Detector Tests share one discovery result: tracked go.mod files returned by tools/apicompat.TrackedModuleFiles. The rule ignores testdata/, vendor/, dot-prefixed and underscore-prefixed directories and keeps the result bounded at 64 modules. A larger repository fails closed with too many tracked Go modules; it is never silently truncated.

Each stage visits modules in discovery order. Prefetch runs go mod verify and go mod download, security runs symbol-level govulncheck against the repository-root OpenVEX document and gosec, and the race stage runs go test -race ./... in an isolated worktree for each module. Each module's race suite runs in its own memory-bounded user scope (systemd-run --user --scope) when scopes are available, so the out-of-memory daemon reclaims from the step rather than from the caller's session. Defaults are MemoryHigh=16G, MemoryMax=20G, and MemorySwapMax=4G, customizable via PRAETOR_GATE_SCOPE_MEMORY_HIGH, PRAETOR_GATE_SCOPE_MEMORY_MAX, and PRAETOR_GATE_SCOPE_SWAP_MAX. An operator can set PRAETOR_GATE_SCOPE=off to run unscoped. PRAETOR_GATE_ADMISSION=off skips the race stage's wait for host memory, swap and load; the wait only delays the stage and never decides it, and the stage log says it was skipped (the gate's own signal tests set it, because a busy runner held the wait past their timing). On platforms where user scopes are unavailable (such as macOS or Windows), the tests run directly and the stage message reports race tests ran unscoped: <reason>; inside the devcontainer the tests run unscoped too, since its runner has no systemd-run. Before the first module the stage waits until the host has at least 12 GiB available and 15 GiB swap free, capped at a quarter of its memory and half of its swap, so a small host or a CI runner with little swap is not held until the deadline; each held reading prints one line. The signed stage reason contains one result and elapsed time per module, and a failure names the module directory. A root-only repository keeps the existing stage behavior with the root module name (go.mod) added to the result.

The same discovery rule is used by the CLI entry points. praetorctl go vet runs go vet ./... once in every tracked module; praetorctl security govuln does the corresponding vulnerability scan. No tracked module is an explicit successful no-op: a repository with no discoverable module prints a stated skip and exits successfully.

Cargo repositories

Where the repository root holds a Cargo.lock, the toolchain stages run Cargo besides the tracked Go modules, or instead of it (internal/gating/cargo.go):

Stage Cargo command Runs in Bound
Prefetch & Lockfiles cargo fetch --locked the repository DefaultPrefetchTimeout, 60 s
Security & SCA Scan cargo audit, when cargo-audit is on PATH the repository CargoAuditTimeout, 3 min
Race-Detector Tests cargo test --workspace --locked, then cargo clippy --workspace --all-targets -- -D warnings the isolated worktree a race stage bound of its own
  • A Cargo.toml without a committed Cargo.lock runs no Cargo command: every command is held to the lockfile with --locked, and without one there is nothing to hold it to.
  • A missing toolchain is reported, never passed. Without cargo on PATH each Cargo part reads not run: cargo is not on PATH (install the Rust toolchain from https://rustup.rs). Unlike the Go scanners, cargo audit is optional: without cargo-audit the part reads cargo audit not run: cargo-audit is not installed (cargo install cargo-audit --locked).
  • Where a Cargo.lock is present, each toolchain stage's reason names every language's outcome, and that reason is part of the signed stage output. A stage that ran for one language and not the other is recorded as skipped, not passed:
3. [SKIP] Security & SCA Scan       (4.1s)
   Reason: go: passed; cargo: cargo audit not run: cargo-audit is not installed (cargo install cargo-audit --locked)
  • A repository without a Cargo.lock runs the same commands and records the same verdicts and reasons as before Cargo support, so its signed output is unchanged (TestToolchainStages_Positive_GoPathUnchanged).
  • The Cargo suite runs in a worktree of its own under a whole race stage bound (default 3 min), not what the Go suite left of one. The run deadline reserves that bound beside the Go suite's and adds the cargo fetch and cargo audit bounds to the other stages' allowance (the whole run's deadline). Raise PRAETOR_TEST_STAGE_TIMEOUT as the race stage's bound describes; it applies to each suite.
  • cargo test and cargo clippy build into praetor/cargo-target under the repository's git common directory (cargoTargetDir in cargo.go), passed as --target-dir. The test worktree is removed after every run, and Cargo's default target/ inside it went with it, so every run compiled every dependency twice inside the bound. The shared directory outlives the worktree, is the same for every linked worktree of the clone, sits outside every working tree (the tree the receipt certifies stays clean), and does not contend with your own target/. Only the first run in a clone builds cold; give that run a raised bound on a large workspace.
  • An absolute CARGO_TARGET_DIR names the disk to build on, and the gate builds into praetor-gate below that value, passed as --target-dir, never into the folder itself. A checkout that builds into the same value keeps its own test binaries: Cargo gives a workspace member's test binary the same file name in every tree and compiles CARGO_MANIFEST_DIR into it, so a shared folder would hand the checkout a binary that reads the removed gate worktree (TestRunCargoTests_Negative_OperatorTargetDirKeepsCheckoutBinaries builds a fixture crate, runs the stage and runs the checkout's test again). A relative value would resolve inside the removed worktree, so the gate's directory under the git common directory is used.
  • Where no git common directory resolves, for example when --path names a subdirectory of the checkout, the commands run without the flag, and the stage reason says the run compiled every dependency.
  • The directory grows like any target/; delete it to reclaim the space, and the next run rebuilds it.
  • A failing command's standard output is cut at 64 KiB in the stage reason; util.RunCommand already bounds its standard error the same way.

internal/gating/cargo_test.go replays each outcome through a recording command runner, and the TestCargoTargetDir_* cases pin the shared directory. internal/gating/cargo_path_test.go builds a stand-in cargo with testsupport.BuildExecutable, puts it first on PATH, and runs the stages through the production runner: all commands passing mints a receipt that verifies and leaves the build output in the shared directory after the worktree is gone, and a clippy failure mints none.

Node repositories (npm, pnpm, Yarn)

Where the repository root holds package.json plus exactly one supported lockfile (package-lock.json for npm, pnpm-lock.yaml for pnpm, or yarn.lock for Yarn), the toolchain stages run Node alongside Go and Cargo, or instead of them (internal/gating/node.go):

Stage npm pnpm Yarn Classic (v1) Yarn Berry (v2+) Runs in Bound
Prefetch & Lockfiles npm ci --ignore-scripts pnpm install --frozen-lockfile --ignore-scripts yarn install --frozen-lockfile --ignore-scripts yarn install --immutable --mode=skip-build an isolated worktree DefaultPrefetchTimeout, 60 s
Security & SCA Scan npm audit --audit-level=high --json pnpm audit --audit-level=high --json yarn audit --level=high --json yarn npm audit --severity=high --json the repository NodeAuditTimeout, 3 min
Race-Detector Tests npm run test, then npm run lint pnpm run test, then pnpm run lint yarn run test, then yarn run lint yarn run test, then yarn run lint the isolated worktree a race stage bound of its own
  • Detection: The Node part is present when the repository root holds package.json plus exactly one lockfile: package-lock.json, pnpm-lock.yaml, or yarn.lock. A package.json without a lockfile or with multiple lockfiles fails stating the files.
  • Prefetch: Installs strictly from the lockfile without running lifecycle scripts, in a worktree of its own, so the developer's checkout and its node_modules stay untouched; the run fills the package manager's shared cache. Exact flags are taken from installed tool help (--ignore-scripts, --frozen-lockfile, --immutable, --mode=skip-build). Yarn version is detected via yarn --version to distinguish Classic (v1) from Berry (v2+); a version the gate cannot read fails the part instead of guessing.
  • Security: Runs the manager's audit command at a severity threshold of high, failing on findings or unparseable errors. An audit that needs network access and fails is reported as a network failure, not passed.
  • Tests: In an isolated worktree, installs from the lockfile again (without lifecycle scripts, from the shared cache), then runs the test script when declared and the lint script when declared. The checkout's own node_modules is never linked in: it may hold other versions than the lockfile pins.
  • What is not applicable: When package.json declares neither a test nor a lint script, the test stage is not applicable, stated as no test or lint script declared in package.json: Node tests skipped, and Node does not count as verified (refusing a receipt unless another language verifies). When package.json has no lockfile, toolchain stages do not run and --admit-unsupported admits the repository without a receipt.
  • A missing manager binary skips the stage with an install hint and never passes.

Go vulnerabilities and the OpenVEX document

The security stage runs the Go vulnerability gate before gosec (internal/govuln). Run the same check on its own:

praetorctl security govuln                      # govulncheck from PATH, module at the working directory
praetorctl security govuln --path=svc -- go tool -modfile=tools/go/go.mod govulncheck   # a pinned scanner

A relative -modfile (such as -modfile=tools/go/go.mod) is read from the repository root across every module, so submodules do not require a separate tools module.

The gate runs govulncheck -scan symbol -format json ./... and judges each advisory by its deepest finding:

govulncheck reports Verdict
a vulnerable symbol the module calls fails; no statement can cover a call
a vulnerable package the module imports, nothing called fails unless a not_affected statement covers it; component_not_present and vulnerable_code_not_present do not, since the package is in the build
a module of the advisory, none of its vulnerable packages imported fails unless a not_affected statement covers it

praetorctl security govuln exits 0 when every advisory passes, 1 when one fails, and 2 when it reached no verdict: govulncheck exited non-zero (a package that does not compile, no network for the vulnerability database), printed nothing, or did not open its output with a protocol v1 configuration of a symbol scan of source; the document or .standards.yaml does not load; or the command line does not parse (a misspelled flag, a missing subcommand), which also prints the synopsis. Exit 2 is never a pass, and in gate run it fails the stage like a finding.

One failure is retried instead: a parallel build that prunes the shared go-build cache can remove an entry while a Go command reads it, and the command fails with a line naming /go-build/ and no such file or directory. praetorctl security govuln, praetorctl go vet and the gate's Go stages (package listing, vulnerability gate, gosec) then run that command exactly once more and print govuln: note: go-build cache miss, retried once: <module> or gate: note: go-build cache miss, retried once: <dir>; a second miss, a cancelled run or any other error keeps its verdict (internal/govuln/cache.go).

Covered advisories and unused statements print on stdout, failures on stderr:

govuln: GO-2022-1059: golang.org/x/text/language.ParseAcceptLanguage is called (fixed in v0.3.8); no VEX statement covers a called symbol: update golang.org/x/text or stop calling it
govuln: FAIL: govulncheck v1.8.0, symbol scan: 1 advisories present, 1 failing, 0 covered by security/vex/go.openvex.json

The document lives at security.go_vex in .standards.yaml, security/vex/go.openvex.json by default (config.RepositoryGoVEXPath in internal/config/security_policy.go). A repository whose scan reports no advisory needs no document. Without one, every advisory present in the build fails. The gate reads OpenVEX v0.2.0:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/go.openvex.json",
  "author": "Example maintainers",
  "timestamp": "2026-10-05T12:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": {"name": "GO-2022-1059", "aliases": ["CVE-2022-32149"]},
      "last_updated": "2026-10-05T12:00:00Z",
      "status": "not_affected",
      "justification": "vulnerable_code_not_present",
      "impact_statement": "No package of golang.org/x/text/language is built: go list -deps ./... names none."
    }
  ]
}

A statement covers an advisory when all of these hold (assess in internal/govuln/judge.go):

  • vulnerability.name or one of its aliases is the advisory's Go identifier or an alias govulncheck's OSV entry lists, such as its CVE;
  • it is the latest statement naming the advisory. Its review time is its own last_updated, else its timestamp, else the document's timestamp, the time the document was issued; two statements sharing the latest review time fail the advisory. The document's last_updated is never inherited: every edit of the document moves it, and a statement inheriting it would be renewed without a review. vexctl add writes a timestamp on the statement it adds. Before you move the document's timestamp, give the statements without a time of their own the previous one, as "Updating Statements with Inherited Data" in the OpenVEX specification shows;
  • its status is not_affected with a justification label and an impact_statement. OpenVEX asks for either; the gate requires both, the label it checks against the finding and the reason a reviewer reads;
  • it was reviewed within the last 90 days (govuln.MaxStatementAge, the cap the npm audit exceptions carry). To extend it, review it and set a new last_updated.

The document is decoded strictly (internal/strictjson). It may hold every field OpenVEX v0.2.0 defines and the supplier that go-vex and vexctl write on the document, a product and a subcomponent. An unknown or duplicate field, a null, a missing required field (@context, @id, author, timestamp, version, statements, vulnerability.name, status), a status or justification outside the specification, a timestamp that is not RFC 3339 or lies in the future, and another OpenVEX version are each exit 2, and the scan does not start. A not_affected statement no finding matches is printed as a note to remove it; it does not fail.

Standard-library advisories. govulncheck v1.8.0 adds the standard library to the module graph as the module stdlib, at the version of the Go toolchain that runs the scan (NewPackageGraph in golang.org/x/vuln/internal/vulncheck/packages.go). An advisory against that toolchain is therefore at least a module-level finding, even when the build imports none of its packages, and it fails like any other advisory no statement covers. Scanned with Go 1.26.3, for example:

govuln: GO-2026-5037: module stdlib@v1.26.3 is required but not called, and security/vex/go.openvex.json does not exist to hold a not_affected statement for it

After a Go security release, every run of the gate (on a workstation, in the pre-push job, in CI) fails this way until the toolchain that scans is upgraded:

  • Upgrade Go to the patch release that fixes the advisory and re-run the gate; nothing else is needed. go version names the toolchain, and the advisory (for the one above, https://pkg.go.dev/vuln/GO-2026-5037) names the fixed release. This repository's CI sets go-version-file: go.mod on the actions/setup-go step of each job that runs the gate (.github/workflows/ci.yml, .github/workflows/security.yml), so it takes the patch release named by the toolchain directive, which Renovate keeps current, not the newest one the actions/go-versions manifest lists (the manifest lags a release).
  • Until you can upgrade, a not_affected statement for the advisory is the stopgap: vulnerable_code_not_present, with an impact statement naming the affected packages the build does not import. After the upgrade the gate prints the statement as unused; remove it then.
  • The security workflow the go-service flavor writes runs govulncheck ./..., which fails on a called symbol only. The fuller package/module/VEX judgement remains in gate run and the pre-push job.

Adopted Go Security Scan

The go-service flavor writes .github/workflows/security.yml from templates/go/security-go.yml.tmpl. The workflow runs before merge on pull request opened, synchronize, reopened and ready_for_review; on a push to main; for a merge group; and daily at 04:00 UTC. A draft fails in the hosted gate's first step and marking it ready starts a replacement run. The trigger and draft steps render from internal/ghworkflow/hostedgate.go, the same source as the other hosted gates, rather than carrying a second trigger definition.

The workflow never resolves a scanner through @latest. A repository whose tools/go/go.mod declares both scanners uses its own pinned module:

go install -modfile=tools/go/go.mod golang.org/x/vuln/cmd/govulncheck github.com/securego/gosec/v2/cmd/gosec

Otherwise the job creates a throwaway module below $RUNNER_TEMP, adds the exact golang.org/x/vuln, github.com/securego/gosec/v2 and golang.org/x/tools versions required by Praetor's tools/go/go.mod, downloads the selected graph so Go 1.27 has every package checksum, and installs both commands from that module. Module selection raises the scanners' shared golang.org/x/tools to the Go 1.27.2-compatible pin. The version constants live in internal/toolpins/pins.go; TestGoSecurityVersionsMatchToolsModule fails with an update instruction when Renovate moves the tools module without them.

When the security stage finds no gosec on the workstation, it prints the same pinned form as its install advice (flavor.GosecInstallGuide, internal/flavor/security_go.go): install through the tools module, whose golang.org/x/tools is at least toolpins.XToolsFloor. A bare go install of gosec@latest selects an older x/tools, whose analyzer cannot read Go 1.27.2 export data and fails on every package.

Praetor does not create or alter an adopter's tools module. A missing or partial tools module is the normal fallback case, so flavor apply still writes the workflow and flavor audit accepts it as the managed template.

The adopted workflow runs govulncheck ./... and gosec -conf .gosec.json ./.... Adoption writes the pinned .gosec.json configuration (templates/go/.gosec.json.tmpl) into every repository where git tracks at least one go.mod (root or nested), independent of the selected flavor, so the security gate that requires it never refuses an adopted repository for a missing configuration. An edited .gosec.json is kept and reported without being overwritten. It scans the whole module with gosec: the narrower .config/lefthook/scripts/security_scope.py wrapper is an engine file that adoption does not ship. An unedited workflow written before this trigger and pinned-install change is a recorded prior rendering and refreshes to the correct variant on the next adoption. A workflow the adopter edited is kept and reported; use the flavor apply force path only when replacement is intended.

A toolchain whose version carries a suffix, such as go1.27.1-X:nodwarf5 from a GOEXPERIMENT build, gives govulncheck no version it can read (GoTagToSemver in golang.org/x/vuln/internal/semver), so no standard-library advisory reaches the gate on it. A release toolchain from go.dev, which actions/setup-go installs, reports its version.

TestCheck_Boundary_StandardLibraryAdvisoryIsAModuleFinding in internal/govuln/vex_test.go replays such a finding, without and with a statement.

In gate run the document is a subtractive input, like the debt baseline: it must be a tracked, regular file (A receipt certifies only a working tree that matches HEAD). A clean scan leaves the stage reason empty; advisories a statement covers are named in it, so the signed stage output records each waiver. The same check runs in the pre-push security job of the lefthook.yml adoption writes (govulnGateArgs in internal/adopt/hooks.go) and in this repository's security workflow and make vuln.

Tests: internal/govuln replays govulncheck v1.8.0's own output for a called symbol, an imported package, a required module and a failed scan (testdata/scan), and its integration test runs the real scanner from PATH against a scratch module calling language.ParseAcceptLanguage of golang.org/x/text v0.3.7, skipping with the reason where govulncheck or that module is unavailable. internal/gating/security_vex_test.go and cmd/standardsctl/security_test.go run the stage and the command on the same output.

Migration. gate run and the generated pre-push job ran govulncheck ./..., which failed only on a called symbol. A Go repository whose build holds a vulnerable package or module it does not call now fails until the document holds a not_affected statement for each such advisory, or the dependency is updated past the fix. Run praetorctl security govuln to list them, and re-run praetorctl adopt to move an unedited lefthook.yml to the new job. The standard library counts too: after a Go security release the gate fails on every run until the Go toolchain that scans is upgraded to the fixed patch release, with a not_affected statement as the stopgap (Standard-library advisories above). In CI, pin the fixed release in the toolchain directive of go.mod and set actions/setup-go up with go-version-file: go.mod on the jobs that run the gate; check-latest: true resolves through the actions/go-versions manifest, which can lag a release.

No receipt when no toolchain stage ran

The receipt stage signs only after at least one toolchain stage ran and passed for some language (requireVerification in internal/gating/languages.go). When the prefetch, security and test stages were each not applicable or skipped, it fails, the run is rejected with exit status 1, and the reason names what the repository holds:

6. [FAIL] Ed25519 Exit-0 Receipt    (1ms)
   Reason: no verification stage ran for any language: Prefetch & Lockfiles, Security & SCA Scan
           and Race-Detector Tests each ran nothing, so no Exit-0 receipt is signed; the gate runs
           the toolchains of Go (go.mod) and Cargo (Cargo.lock); unsupported languages at the
           repository root: node (package.json), python (pyproject.toml)

The reason says instead that a Cargo.lock is present but its stages could not run on this host, or that no language marker at the root is recognised. Before this check such a repository received a receipt that certified nothing beyond the HISS scan.

Migration. A repository with neither a go.mod nor a Cargo.lock at its root no longer receives a receipt. Commit the Cargo.lock of a Cargo workspace; for any other language no receipt can be minted until the gate runs its toolchain. A dry run is never refused, because it mints nothing. TestRunReceiptStage_Boundary_NothingVerifiedIsRefused and TestToolchainStages_Boundary_CargoAbsentFromPath in internal/gating/cargo_test.go pin the refusal.

The pre-push hook admits languages the gate has no runner for

praetorctl gate run --admit-unsupported changes one verdict. When no toolchain stage ran and the root holds neither a go.mod nor a Cargo.lock, the receipt stage is recorded as not applicable instead of failed, and the run is admitted with exit status 0 and no receipt. Every other stage runs as without the flag, so a HISS or flavor failure still rejects the run. The reason is the refusal's own text followed by the admission, and the report ends with a line saying so:

7. [N/A]  Ed25519 Exit-0 Receipt    (1ms)
   Reason: no verification stage ran for any language: ... the gate runs the toolchains of Go
           (go.mod) and Cargo (Cargo.lock); unsupported languages at the repository root: meson
           (meson.build); admitted without a receipt (--admit-unsupported): verify these languages
           with the repository's own entry point, such as make verify-all

Admitted without an Exit-0 receipt (--admit-unsupported): the gate runs no toolchain for the
languages at the repository root, so they are unverified here; the receipt stage names them.

--json carries the same verdict and "admitted_unverified": true. A root that holds a go.mod or a Cargo.lock is refused with the flag exactly as without it when its stages did not run, so a missing cargo never passes for a Cargo workspace. A Cargo.toml without its Cargo.lock is a language the gate cannot run, so it is admitted and named.

The gate job of the lefthook.yml adoption writes passes the flag (prePushGateArgs in internal/adopt/hooks.go), and its header states the rule. Before, that job ran the plain gate, so a Meson, CMake, npm or Python repository had every push rejected, and the only ways past were editing the generated file, which stops adoption from activating it, or skipping hooks, which the agent hook policy refuses (#648). Adoption migrates an unedited earlier rendering without --force (Migration and activation limits). CI and the gatekeeper need a receipt and run the gate without the flag. Tests: internal/gating/admit_unsupported_test.go, cmd/standardsctl/gate_admit_test.go and internal/adopt/lefthook_gate_admit_test.go.

A declared verify-all runs where no toolchain stage applies

Admitting a root the gate has no toolchain for left the repository's own checks unrun before a push (#1218). The Declared Verification stage closes that gap.

A repository declares its verification command as the Makefile target verify-all. Adoption writes or preserves that target, and the managed README names it as the configured verification cascade. The stage reads the makefile GNU Make would read (GNUmakefile, makefile, Makefile, in that order) through util.MakefileHasTarget.

Where no tracked Go module, root Cargo.lock, or root package.json with exactly one supported Node lockfile selects a toolchain stage, and the target is declared, the stage runs make verify-all:

  • Where: in an isolated worktree of HEAD, as the test suites run, so it checks what is pushed and writes nothing into the working tree. Because the private ledger (.workingdir/) is git-ignored, the stage worktree never holds it: praetorctl state audit recognizes PRAETOR_GATE_DECLARED_VERIFY, reports not applicable, and passes, while an existing but invalid ledger still fails.
  • Dependencies: before the stage runs, git-ignored folders in the checkout whose base name is on an allow-list (node_modules) and whose parent exists in the stage worktree are discovered (the search does not descend below depth 6; at most 64 folders; folder visits bounded to 100,000; exceeding the folder count or visit bound fails closed; folders deeper than depth 6 are not linked) and symlinked into the stage worktree at the same relative path. On Windows, creating symlinks requires Developer Mode or administrator rights; if refused, the stage fails naming the cause and the two ways out (enable Developer Mode, or run make verify-all in the checkout). The stage prints one line naming every linked folder. Python virtualenvs (.venv, venv) are not linked: python -m venv refuses to create an environment over a symlink, and installed entry points embed absolute paths. A verification recipe that needs a virtualenv must prepare its own in the stage worktree (TestRunGatedPipeline_Negative_VirtualenvRecipeWithIgnoredVenvInCheckout).
  • Read-only by check: each linked folder is fingerprinted before the run (relative path, size, and mtime of each entry, bounded to at most 250,000 entries; exceeding fails closed) and checked again after. If any entry differs, the stage fails and names the folder because verification wrote into the checkout's dependencies. The symlinks are removed with the stage worktree; the checkout's folders are never deleted. A build that replaced a link with a real folder (npm ci does) leaves a folder the stage made: it is removed when it resolves inside the stage worktree, and a folder or link whose directory resolves outside it is refused, never followed. Where the stage worktree still lacks required dependencies, a real failure of make verify-all still fails the stage.
  • Bound: defaults to the race stage's default (20m), or can be declared in .standards.yaml via gate: declared_verify_timeout: <duration> (e.g. 20m, Go duration, positive and at most 30 minutes; values above 30m or non-positive are refused naming the key, value, and ceiling). PRAETOR_TEST_STAGE_TIMEOUT overrides the declared value when set. The whole-run deadline (RunBudget) reserves the declared bound so the run deadline does not cut the stage short (#314). When the bound fires, the stage's reason line names where the bound came from (default, environment, .standards.yaml). The bound covers creating the stage worktree too, so on a slow runner setup can be what hits it; that reason names the origin as well, for example creating the isolated test worktree hit the 1s stage bound (.standards.yaml) (#1566). Output capture is bounded by util.RunCommand.
  • Non-zero exit: fails the stage and refuses the push. The reason names the command, its exit code and the end of its output, for example declared verification make verify-all exited 2: ... Error 1 (GNU Make exits 2 when a recipe fails).
  • Exit 0: passes the stage (make verify-all exited 0). The run is admitted, and the receipt stage says the command ran and passed but signs no receipt. Signing a receipt for such a run is a separate question.

The stage runs nothing in these cases:

  • Not applicable: a tracked Go module, root Cargo.lock, or root package.json with exactly one supported Node lockfile (its toolchain stages verify it); a root without the target (today's admission, naming the unsupported languages); and a gate run started from inside verify-all (PRAETOR_GATE_DECLARED_VERIFY is set in the command's environment, so a verify-all that runs the gate does not recurse).
  • Skipped, with the reason: a dry run, and a host without make on PATH.

Tests: internal/gating/declared_verify_test.go, which runs the acceptance fixtures (a pyproject.toml root whose verify-all exits 1, exits 0, or is not declared) through the real pipeline with GNU Make.

A receipt is signed only by the pinned key

When .standards.yaml pins receipt.public_key, the receipt stage signs only with that key's private half. It reads the pin through lockdown.PinnedPublicKey, the read praetorctl gate verify resolves its default key with, and checks the signed receipt with lockdown.VerifyPinnedReceipt, the check gate verify applies (signReceipt in internal/gating/receipt_key.go). A signing key from PRAETOR_RECEIPT_KEY or the per-user key file that is not the pinned key fails the stage, writes no receipt, and names both keys:

6. [FAIL] Ed25519 Exit-0 Receipt    (4ms)
   Reason: Exit-0 receipt not written, gate verify would reject it: receipt was not signed by the
           pinned receipt.public_key: signing key <signing>, pinned key <pinned> in .standards.yaml;
           sign with the pinned key's private half (PRAETOR_RECEIPT_KEY, or the key file
           'praetorctl gate keygen' wrote) or pin the signing key instead

Before this check the stage reported [PASS] and pointed at gate verify, which then rejected the receipt. A repository without a manifest, or whose manifest pins no key, still receives a receipt signed by whichever key is configured; only gate verify --public-key verifies it. A malformed pin and a manifest that cannot be read, such as one with a second YAML document, fail the stage before the signing key is loaded, as they fail gate verify.

Migration. A workstation whose signing key is not the pinned key no longer receives a receipt, so the gate job of its pre-push hook fails in a repository whose root holds a go.mod or a Cargo.lock. A root holding neither is admitted without a receipt before any key is loaded (admission), so a key mismatch does not fail its push. Point PRAETOR_RECEIPT_KEY or the per-user key file at the pinned key's private half, or, when this workstation's key is meant to be the repository's trust anchor, pin its public half as receipt.public_key. TestRunReceiptStage_Positive_PinnedSigningKeySigns, TestRunReceiptStage_Negative_UnpinnedSigningKeyRefused and TestRunReceiptStage_Boundary_PinnedKeyResolution in internal/gating/receipt_key_test.go pin each case.

A dry run changes nothing

praetorctl gate run --dry-run runs only the read-only stages: lockfile verification, the HISS scan and flavor conformance. It records the module prefetch (go mod verify, go mod download), the security scanners (go list, govulncheck, gosec), the race-detector tests and the receipt as skipped, and mints no receipt, and it skips every Cargo command the same way. go mod download writes the module cache and the scanners can fetch modules and query the vulnerability database, so a dry run that started them was not one. TestExecuteStages_DryRunInvokesNoCommand in internal/gating/gating_test.go runs a whole dry run through a recording command runner and fails if any command starts through the stage runner; TestExecuteStages_DryRunWithCargoLockInvokesNoCommand does the same with a Cargo.lock.

The gatekeeper persona that adoption writes (.agents/agents/repo-gatekeeper.md) and the pre-migration epic's verification task both run the full gate, praetorctl gate run --path=., derived from gating.RepoRunCommand (internal/gating/pipeline.go). The persona names --dry-run only as a read-only preflight: its mission is the prefetch, security scans and receipt that a dry run skips. Praetor's own praetor-gatekeeper persona runs the same full gate that praetorctl agent run praetor-gatekeeper runs, then gate verify, and lists the dry run as a preflight. All three previously passed --target=., which gate run rejects as an undefined flag before any stage runs. cmd/standardsctl/gate_command_test.go parses the constant and every gate run line in .agents/agents/*.md against the real flag set; internal/adopt/persona_command_test.go and TestGeneratePreMigrationEpic_GateTaskRunsTheGateCommand in internal/needs/epic_test.go pin the generated text to it.

praetorctl agent run praetor-gatekeeper also fails the way gate run does. It prints the stage report, then the verdict line, and exits 1 when the pipeline is REJECTED. It used to print only the verdict and exit 0 on a rejection, so a script or agent that ran it read a rejected repository as admitted. TestGatekeeperAgent_Negative_RejectedExitsOneWithTheFailingStage in cmd/standardsctl/agent_gatekeeper_test.go pins the exit code and the failing stage's reason in the output.

A help probe never starts the pipeline. --help, -h or help anywhere after agent, for example praetorctl agent run praetor-gatekeeper --help, prints the agent usage and exits 0 before any helper runs, the way praetorctl gate --help does; both use isHelpToken in cmd/standardsctl/main.go. Any other argument after the persona is refused. cmd/standardsctl/agent_help_test.go counts pipeline calls for every help spelling and position.

Run one command in the agent sandbox

praetorctl agent sandbox [--workspace DIR] [--read-only] -- COMMAND [ARGS...] mounts the selected host directory (the current directory by default) at /workspace and runs the command there. The workspace is writable unless --read-only is set. Arguments after --, including --help, belong to the command.

The Linux profile in internal/sandbox is shared with repair verification. It uses bubblewrap namespaces, a read-only /usr, a private /proc and /dev, and a 1 GiB temporary /tmp. Networking is disabled, including host loopback. Egress is reserved for a follow-up; no host-network or pasta mode is available. The command starts with a clean environment; the CLI passes no host variables. Use an absolute executable path, for example:

praetorctl agent sandbox --workspace . -- /usr/bin/true
praetorctl agent sandbox --workspace . --read-only -- /usr/bin/ls /workspace

A systemd user scope limits memory to 4 GiB with no swap, tasks to 128, and CPU to 200%. prlimit additionally bounds address space to 4 GiB, CPU time to 120 seconds, file size to 256 MiB, and open files to 256. The CLI has a five-minute execution deadline. Its exit status preserves the contained command's exit status. Missing bwrap, systemd-run, prlimit or a reachable user manager is an error; macOS and Windows report that Linux is required. No command runs uncontained. The manager probe also requires systemctl on PATH.

internal/sandbox/spec_test.go pins the original repair profile's full argument list. internal/sandbox/run_test.go checks missing runtimes, a marker command that must never start, and real containment when Linux and the user manager are available; otherwise the integration test states its skip reason. cmd/standardsctl/agent_sandbox_test.go covers parsing, command help arguments, unavailability and exit status propagation.

A receipt certifies only a working tree that matches HEAD

The scan stages read the working tree, while the receipt names a commit. gate run without --dry-run therefore refuses, before any stage runs and without writing a receipt, a tree that differs from HEAD:

  • a modified, staged or untracked file anywhere in the repository, the receipt file itself excepted, because the gate rewrites it and a checkout may keep it untracked. "Untracked" means what plain git status lists: the repository's ignore files, .git/info/exclude and your global excludes file (core.excludesFile, else ~/.config/git/ignore) all apply, so editor and OS files you ignore globally do not block the gate;
  • an index entry flagged assume-unchanged or skip-worktree, which git status never compares;
  • an untracked .standards-baseline.json, .gosec.json or OpenVEX document (security.go_vex, default security/vex/go.openvex.json), hidden from git status by any ignore rule, your global excludes file included. All three relax what the gate enforces -- the baseline raises the HISS limit, the gosec configuration selects the rules, the OpenVEX document covers vulnerable code that is present but not called (Go vulnerabilities and the OpenVEX document) -- and git status does not list an ignored file, so each present one must be tracked in the index (subtractiveInputs in internal/gating/tree.go, util.GitUntrackedPaths in internal/util/git_ignore.go); a committed one stays trusted even when an ignore pattern also matches it;
  • any of them that is a symbolic link or any other non-regular file. git status compares a tracked link by its target path, not the content behind it, while the stages follow the link, so a committed link to an ignored file or to one outside the repository would read as clean. Replace the link with the file itself.

The refusal is recorded as a failed Clean Tree Precondition stage naming the first changed paths:

  1. [FAIL] Clean Tree Precondition   (0s)
     Reason: the gate certifies only a working tree that matches HEAD: 1 changed path(s) differ
             from HEAD: ?? scratch.txt; commit, stash or remove the changes, or preview with --dry-run

--json carries the same text as worktree_problem. A dry run mints nothing, so it reports the state on a Worktree: line and runs its stages anyway. The receipt stage reads the tree again before signing and refuses when it changed or HEAD moved while the stages ran. Before this, the gate recorded worktree_clean false in the signed output and signed it anyway, so a receipt could certify a commit whose scan had read uncommitted files (BUG-787) or an untracked baseline (BUG-788).

Verification enforces the same rule on the receipt side: gate verify, forge validate-pr and a paperclip disposition carrying a receipt all reject one whose signed gate output does not record worktree_clean true exactly once in its header. The check (lockdown.RequireCleanWorktree in internal/lockdown/receipts.go) runs inside lockdown.VerifyPinnedReceiptFile and lockdown.VerifyUnpinnedReceiptFile (internal/lockdown/keys.go), right after the format-version check, so all three callers get it from the verifier they already share.

praetorctl paperclip verify still leaves the gate receipt and the disposition file out of its own clean-tree check, so the documented order -- commit, push, mint the receipt, write the disposition -- verifies unchanged.

Cleanliness is read through util.GitWorkingTreeChanges (internal/util/git_status.go), which the gate, praetorctl paperclip verify and release preparation share. It overrides the repository settings that could hide a change (status.showUntrackedFiles, submodule ignore settings, core.fsmonitor, hooks), takes no optional index locks, and refuses a repository whose own configuration names a clean or process filter that a tracked path's filter attribute selects, rather than executing it during a read-only probe; a driver no tracked path selects never runs and passes. Each caller bounds the whole walk: the gate with gating.GitQueryTimeout, paperclip verify and release preparation with util.GitTreeProbeTimeout; a probe that runs out of time is a refusal, never a clean answer. The cases are replayed in internal/util/git_status_test.go and internal/gating/tree_test.go; the receipt-side checks in internal/lockdown/keys_test.go and internal/paperclip/paperclip_test.go.

A HISS rejection names the violations

The gate's HISS stage rejects on the same ratchet as praetorctl audit, and both render the rejection with baseline.RatchetResult.Summary (internal/baseline/describe.go): the counts, then up to three violations of each class as [rule] file:line - message (class). The stage previously reported only hiss ratchet failed: N infractions (M new, baseline B), so a blocked push named no file to open.

A class with more than three violations ends with a line that says how many it hid and which read-only command lists them all. praetorctl baseline --verify and praetorctl audit print the whole list with --all-violations and never write .standards-baseline.json:

  ... and 3 more unbaselined violations not shown; 'praetorctl baseline --verify --all-violations' lists every one
  ... and 2 more touched-file violations not shown; 'praetorctl audit --all-violations' lists every one

Touched-file violations need the audit's change set, so their marker names praetorctl audit; run it with the same --base as the failing run. TestRatchetResultSummary_Boundary_MarkerCountsEachClass in internal/baseline/describe_test.go and TestBaselineVerify_Positive_AllViolationsListsEveryOne in cmd/standardsctl/baseline_ratchet_report_test.go pin both.

A violation in an untouched file that the baseline does not record is not necessarily new code: a Praetor upgrade can add a check that reports code nobody changed. praetorctl audit, praetorctl baseline --verify and the standards_audit MCP tool therefore attribute each one before rendering (hiss.AttributeRatchet in internal/hiss/attribution.go). They compare against the baseline's commits:

  • commit_sha, the HEAD that praetorctl baseline --record ran on;
  • the last commit on HEAD's history that changed the baseline file (git log -1 HEAD -- <baseline>).

The recorder scans the work tree, so a baseline recorded before the code it scanned was committed, then committed together with that code, holds the code only at the second commit. After a squash merge, commit_sha names a branch commit a fresh clone does not hold; the second commit is the squash commit. A commit the clone lacks is left out, and so is a shallow clone's boundary commit, which reads as having added every file whatever it changed. An uncommitted baseline compares commit_sha alone.

For each commit, they copy the violations' files as that commit holds them into a temporary directory and scan the copy with the current checks and the same policy. A Go call cycle brings its whole package. The copy is scanned as its own scope, without asking git which files belong, so a temporary directory inside a work tree that ignores it changes nothing. A copy scan that leaves a file unread, or a file of the commit that does not parse, traces nothing:

Class tag Meaning
(new) The checks report it at neither commit: the code changed since the baseline was recorded and committed.
(check added or changed since the baseline) The checks report the same rule, file, symbol and message at one of the commits, yet the baseline does not record it: a check or limit changed, not the code.
(recorded in the baseline at line N) The baseline records the same rule, file, symbol and message, at line N, under a key no current violation carries: lines moved above an entry of the line-keyed form, or the function or line an anchored entry names was renamed or edited (entry keys). The debt did not change. Needs no commit, so a baseline without commit_sha gets it too.
(not in the baseline) Not traced. The baseline records no commit_sha, the clone holds neither commit, the violations span more than 200 files, they need more than 1000 committed files (a large Go package a call cycle brings counts whole), the copy scan was incomplete, or the rejection comes from a surface that does not attribute (the gate's HISS stage, dogfood verification). The rejection states the reason.

Only a rejection whose every unbaselined violation is (new) keeps the header HISS invariant violations introduced. One whose every unbaselined violation only moved reads HISS invariant violations the baseline records at other lines and names the plain re-record, praetorctl baseline --record: the count did not rise, so it needs no --allow-increase. Every other rejection reads HISS invariant violations the baseline does not record and names the deliberate re-record, praetorctl baseline --record --allow-increase --reason=<why>, for the findings a changed check or an untraced cause explains. Gosec baselines cannot be created with findings or increased; use the gosec dispositions and dated exceptions. Matching ignores line numbers, so when one file holds several identical findings, the count per class is exact but which line gets (new) follows scan order. The verdict never changes: HISS-13 still refuses the higher count (internal/hiss/attribution_test.go, TestRatchetResultAttribute* in internal/baseline/describe_test.go, TestBaselineVerify_Boundary_LineKeyedBaselineMigrates and TestBaselineVerify_Positive_RecordThenCommitIsNotNew in cmd/standardsctl/baseline_ratchet_report_test.go).

Known gaps of the attribution:

  • A baseline without commit_sha, such as one written before the field was filled, stays untraced even when a commit holds the baseline file.
  • The baseline records no engine version or rule set, so the attribution cannot name the check that changed; it re-scans the baseline's commits instead.
  • The gate's HISS stage and the dogfood public-checkout verification do not attribute; their rejections read (not in the baseline).

A ratchet can also fail with both lists empty: every violation matches a baselined fingerprint and no file was touched, yet the total rose above the baseline's. RatchetResult.CountRegressed marks that case, and Summary names both totals (total infractions rose from 5 to 7 (no new fingerprints)) rather than a listing that reads as zero new violations. The standards_audit MCP tool and the dogfood public-checkout verification render their rejections through the same method (TestEvaluateRatchet_CountRegressed_3D and FuzzBaselineRatchet in internal/baseline).

A touched-file rejection counts the findings in touched files in two parts, N in touched files (M not in the baseline, K baselined). It lists the findings the baseline does not record first, tagged (touched file must be clean, not in the baseline), then the baselined ones, tagged (touched file must be clean, baselined), each part with its own bound and hidden-count marker. The touched count alone read as N new findings when most of them were recorded debt (#348).

The split compares counts, not fingerprints (touchedMarks in internal/baseline/baseline.go). Per file and rule, the baseline accounts for as many findings as it records there, and only the excess counts as not in the baseline. A touched file is an edited one, so a recorded finding whose key changed without its debt changing is the normal case there: a line inserted above an entry of the line-keyed form, an edited line under a text anchor. Comparing fingerprints would read each as new. The recorded slots go first to findings the baseline records, then to findings it records under another key, then to the rest in scan order.

The baselined findings fail too, and the rejection says why:

  • By default, touching a file revokes its baseline exemptions. A re-record does not clear them; fixing them does, or, for a provably mechanical change, praetorctl audit --touched-debt-delta-reason=<why>.
  • Under --touched-debt-delta-reason, a touched file fails only when a rule's count in it rose above the baseline's, and its baselined findings pass again once no rule's count does.

TestRatchetResultSummary_*Touched* in internal/baseline/ratchet_report_test.go and TestAudit_Positive_TouchedCountNamesBaselined in cmd/standardsctl/audit_stale_baseline_test.go pin the split, its line-shift case and the explanations.

Separate README presentation debt

.standards-baseline.json carries HISS debt, .readme-baseline.json carries README presentation findings, and .gosec-baseline.json carries security findings. Every recorded/active count and ratchet is per file. praetorctl baseline --record and --verify handle the companion files; a README snapshot is created only when README findings exist or a README baseline was already present.

praetorctl adopt migrates README entries out of a legacy mixed HISS snapshot before record, keep or re-record. It preserves fingerprints, anchors and metadata, subtracts moved entries from the HISS claimed total (floored at zero), and records the README entry count in the new file. --dry-run writes nothing; repeat adoption keeps the migrated bytes. An existing README baseline plus legacy README entries is refused with both paths. Audit refuses mixed files and names praetorctl adopt as the remedy. The planted migration and per-file count parity cases are in cmd/standardsctl/readme_baseline_separation_test.go.

A baseline entry survives a line shift

Inserting lines above a baselined function does not change the baseline. The entry still matches, praetorctl baseline --verify passes, and praetorctl baseline --record keeps the file byte for byte, so two pull requests that both move recorded findings no longer conflict in .standards-baseline.json. praetorctl audit judges a touched file by its own rule, as before: a touched file that carries baselined findings is refused unless the change states a touched-debt-delta reason (see the split above). An entry used to be keyed <file>:<line>:<rule>: the same insert turned the unchanged function into a new infraction and every such pull request had to re-record (#29).

An entry's fingerprint is now <file>:<rule>:<anchor>#<ordinal> (internal/baseline/key.go):

{
  "rule_id": "HISS-04",
  "file_path": "db/cdc/cdc.go",
  "line_number": 320,
  "symbol": "dispatch",
  "message": "Function 'dispatch' (82 LOC) exceeds HISS-04 / NASA Rule 4 limit of 60 LOC",
  "anchor": "fn:dispatch",
  "fingerprint": "db/cdc/cdc.go:HISS-04:fn:dispatch#1"
}
  • anchor is the place the scanner gives the finding (internal/hiss/anchor.go); a scan report carries it per violation. It is one of the three forms in the table below.
  • #<ordinal> tells apart the findings of one rule that share an anchor, two unbounded loops in one function say. It is the finding's rank among them in line order, from 1, so it follows the order of the findings and not their line numbers.
  • line_number is where the finding sat when it was recorded. No match reads it. A record that finds the same entries keeps the file, so the line can lag the tree until the next record that changes the debt (Baseline.SameDebt).
Anchor Finding Scanners
fn:<name> At or inside a function the scanner located. Go names a method <Type>.<name>, Python <Class>.<name>. Go, Python, C, C++, CUDA, HIP, Rust, JavaScript, TypeScript, Svelte, shell; a Go call cycle, by the function it is reported at
text:<hash> Held by no located function: a statement at file level, a function literal bound at package level in Go, a function the scanner could not close. The hash is the first 12 hex digits of the SHA-256 of the finding's line with its whitespace removed. every scanner; always for systemd (the directive's line) and Ansible (the task key's line)
file On a line the file does not hold. every scanner

The ratchet's guarantees are unchanged. A new finding is refused, in a touched file too, and the total cannot rise without --allow-increase. What counts as new follows the key:

  • A new function with the same violation, a renamed function and a renamed file carry keys the baseline does not record. For a rename the count does not rise, so the plain praetorctl baseline --record clears it; the rejection tags the finding (recorded in the baseline at line N) when the baseline records its rule, file, symbol and message.
  • Editing the line of a text: finding changes its key the same way; re-indenting the line or changing its line ending does not.
  • A second finding of the rule in a baselined function is new. When it sits above the recorded one the ordinals shift, so exactly one finding is refused, the last in line order, which need not be the one that was added.

Migration. A baseline recorded before this change needs nothing to keep working. An entry without anchor is in the line-keyed form and is matched as it was recorded, on its file, line and rule, so it verifies the tree it was recorded on and still refuses a shift. Run praetorctl baseline --record once: it rewrites every entry in the anchored form, the count does not change, and no --allow-increase is needed. A baseline may hold entries of both forms; each is matched in its own. An engine older than this change cannot match an anchored entry and reports every finding as not in the baseline, so move every pin (hooks, CI, the gate) before recording.

TestAnchor_KeySurvivesInsertedLines, TestAnchor_RenamedAndNewFunctionsAreNewKeys and TestAnchor_Boundaries in internal/hiss/anchor_test.go pin the anchors per scanner; TestEvaluateRatchet_* and TestLineKeyedBaseline_* in internal/baseline/key_test.go pin the matching and the migration; TestBaselineVerify_Positive_MovedFindingStillVerifies, TestBaselineVerify_Negative_NewAndRenamedFunctionsAreRefused and TestBaselineVerify_Boundary_LineKeyedBaselineMigrates in cmd/standardsctl/baseline_ratchet_report_test.go pin the commands.

A baseline looser than the tree is reported

A cleanup that lands without praetorctl baseline --record leaves baseline entries that match nothing in the tree. The ratchet still passes, and each stale entry is room for a new finding the ratchet would otherwise refuse (#349). On a pass, praetorctl audit and praetorctl baseline --verify count them: per file and rule, how many more infractions the baseline records than the scan found (RatchetResult.Stale, set by staleEntries in internal/baseline/baseline.go). Counts are compared, not fingerprints, so a finding whose key changed without its debt changing, a line shift above an entry of the line-keyed form or a renamed function, is not stale.

[WARN] 19 baseline entries match nothing in the tree: per file and rule the baseline records more infractions than the scan found, and each stale entry is room for a new finding; tighten the baseline with 'praetorctl baseline --record' (reported only; --max-stale-baseline-entries=<n> fails the audit past n)

The audit fails only past a bound it is given: --max-stale-baseline-entries=N fails when more than N entries are stale, and 0 holds the baseline as tight as the tree. Without the flag it warns and passes, so a repository whose committed baseline is already loose keeps passing after an upgrade. praetorctl baseline --verify only warns. The gate's HISS stage and the standards_audit MCP tool do not report stale entries yet. TestEvaluateRatchet_*Stale* in internal/baseline/ratchet_report_test.go and TestAudit_*StaleBaseline* in cmd/standardsctl/audit_stale_baseline_test.go pin the count and the bound.

The HISS stage scans with the audit's function-length limit

The gate's HISS stage resolves the function-length limit through config.ResolveRepositoryComplexity (internal/config/repository_policy.go), the resolver the editor projections use, and prints it on the stage line (function length limit N):

  • A repository with .standards.lock scans at the limit praetorctl audit enforces.
  • A manifest without a lock scans at the HISS-04 ceiling tightened by its overrides.complexity.max_func_loc.
  • A tree without .standards.yaml scans at the ceiling.
  • A manifest or lock that does not resolve scans at the ceiling, and the stage line appends the resolver's repository policy unresolved (...) warning; praetorctl audit fails on that state.

The gate used to scan at the scanner's 60-line default whatever the manifest declared, so a repository with a stricter limit passed the gate with functions its audit rejected (BUG-638, internal/gating/pipeline_test.go).

A failing flavor stage names the files that cost the score

The flavor stage scores required templates and settings, and a setting counts only where the file is present and parses as the shape that setting declares (onboarding guide). A repository can therefore fail this stage with every template present: two settings that exist but do not parse put a go-library repository at 4 of 6 required items, 66.7%, below the 80% bar.

The failure names them, because at that point the push is already blocked:

  4. [FAIL] Flavor Conformance        (3ms)
     Reason: flavor audit failed (score: 66.7%, 0 missing templates, missing or invalid settings: lefthook.yml, .github/rulesets/main.json)

Without the file list the whole report was score: 66.7%, 0 missing templates, which tells an operator that something is wrong and nothing about which file to open. praetorctl flavor audit . prints the same files with their names and descriptions under Missing or Invalid Settings.

Pre-push race package scope

The pre-push hook reuses go_packages(..., reverse=True) in .config/lefthook/scripts/checks.py and hands its selection to gate run through --race-packages=<comma-separated exact import paths>. Changed Go sources, package testdata, EmbedFiles, TestEmbedFiles and XTestEmbedFiles select their packages and reverse dependents. Deleted inputs beneath a package with embed patterns conservatively select that package. Module and Go build/security configuration selects every package in the root module. The gate consumes this selection; it does not compute a second affected-package set.

An empty selection uses --race-packages=none. Its Go race stage records not_applicable with no affected Go package (--race-packages=none) and starts no Go test process. Other applicable language tests still run. A nonempty selection records the exact package list and why it was supplied. The signed output carries both the race_packages header and the stage reason, so a scoped receipt certifies that selection. gate verify, forge validate-pr and lockdown's pinned and unpinned receipt verifiers reject missing or mismatched scoped reasons.

Without this flag, gate run keeps go test -race ./... for every tracked Go module. --race-packages=./... explicitly selects the whole root module; explicit pre-push scopes apply only to that module. CI's Full Verification Harness Gate in .github/workflows/ci.yml invokes make verify-all; its test target in Makefile still runs the full ./... race suite and supplies no scope flag. The executable cases live in internal/gating/race_packages_test.go, internal/lockdown/race_packages_test.go, and .config/lefthook/scripts/test_race_packages.py.

The race stage's bound, and what firing it means

The race-detector stage is bounded, because an unbounded stage is how a gate hangs instead of failing (HISS-02). The default is 20 minutes, and PRAETOR_TEST_STAGE_TIMEOUT raises it up to a 30-minute ceiling. The default rests on measured runs (#1419): on a workstation at load 45 to 60 a full race run took 9 to 10 minutes for the engine and 4 to 7 minutes for adopted repositories, and the earlier 3-minute default refused ordinary pushes there. Twenty minutes is about twice the slowest of those runs and leaves room under the ceiling (TestTestStageTimeoutDefaultCoversTheMeasuredRuns in internal/gating/race_bound_test.go). A suite that blocks forever is still stopped at the bound and reported as the bound firing, at the default and at a raised value (TestTestStageCutsAHungSuiteAtTheDefaultAndARaisedBound). The stage passes the same bound to go test -race -timeout, so every package binary may use the whole stage budget; go test's own default of ten minutes per package would otherwise panic a slow package mid-suite while the stage still had budget left (TestRunTestStage_3D in internal/gating/gating_test.go pins the command line).

The override is clamped rather than trusted. An empty, unparseable, zero, negative or over-ceiling value falls back to the default or the ceiling and says which, so a typo cannot quietly remove the bound or shrink it to nothing. A raised or lowered bound is printed as the stage's reason, so it appears in the receipt: an override that changed the gate's strictness without showing up in its output would be an invisible difference between what one operator verified and what every reviewer reads.

When the bound fires, the stage says so rather than reporting a test failure. These are different outcomes and used to print identically:

5. [FAIL] Race-Detector Tests  (20m0.024s)
   Reason: tests failed in .standards/worktrees/gate-2856-...: ok github.com/... 1.437s

That message is the tail of a successful run that was cut off, so the first reading is always "my change broke the tests" — and the suite had not broken at all. The stage now names the bound, the variable that raises it and the ceiling, so the reader is pointed at the real cause. A suite that genuinely fails inside the bound still reports as a test failure; the fix must not trade one wrong diagnosis for another.

The isolated worktree is created under the same bound, so the bound can fire one step earlier than the tests. On a Windows host with the bound set to 50 ms it did: git worktree add outlasted it, the kill surfaced as a bare exit status 1, and the stage reported a repository whose worktree could not be created — the same misattribution, pointing at the checkout instead of at the suite. Creation now reports the bound when the deadline is what stopped it, and keeps the "could not be created" text for every other cause.

The whole run's deadline

gate run has a deadline of its own, and it is derived from the race stage's bound rather than fixed: the resolved bound plus a five-minute allowance for every other stage (OtherStagesAllowance in internal/gating/deadline.go). The default is therefore 20 + 5 = 25 minutes, and the 30-minute ceiling gives 35. The run prints the value it applies on its Run Deadline: line, and praetorctl gate deadline [--json] prints it without running anything:

$ PRAETOR_TEST_STAGE_TIMEOUT=30m praetorctl gate deadline
Run Deadline: 35m0s (30m0s race stage bound + 5m0s for the other stages)
Note: race stage bound raised to 30m0s by PRAETOR_TEST_STAGE_TIMEOUT

The run deadline used to be a fixed five minutes, so no value of PRAETOR_TEST_STAGE_TIMEOUT could give the race stage more than what was left of those five minutes (#314). The praetor-gatekeeper agent helper runs the same pipeline and takes the same derived deadline.

The deadline is sized to the repository --path names (ResolveRepoRunBudget in deadline.go):

Tracked toolchain state Test suites, one bound each Allowance for the other stages
N discoverable go.mod files, no Cargo.lock and no Node lockfile N (at least 1 for the deadline calculation) 5 min
Cargo.lock or Node lockfile, no discoverable go.mod 1 5 min + 1 min prefetch + 3 min audit = 9 min
N discoverable go.mod files, Cargo.lock, and/or Node lockfile N + non-Go suites 5 min + 4 min per present non-Go language

A mixed repository with one Go module therefore gets 2 × 20 + 9 = 49 minutes by default. Sized for one suite, the run deadline let the Go suite and the Cargo commands spend the time the Cargo suite needed, and no value of the variable could fix it. gate deadline takes the same --path (default .) and reports the suite count as test_suites in its JSON:

$ praetorctl gate deadline --path=.
Run Deadline: 49m0s (2 test suites at a 20m0s stage bound each + 9m0s for the other stages)

A run that will build the repository's devcontainer (below) adds the 15-minute image build bound (devcontainer.ImageBuildTimeout in internal/devcontainer/runtime.go). PlanRun in internal/gating/deadline.go reserves it exactly when the run plans a build, so gate run, gate deadline and the pre-push hook still agree; the human form names it and the JSON reports it as devcontainer_image_build:

Run Deadline: 40m0s (20m0s race stage bound + 5m0s for the other stages + 15m0s to build the devcontainer image)

gate run --dry-run builds no image, so it reserves nothing and asks no container runtime. gate run hands its plan to the pipeline, which builds exactly the image the deadline reserved time for instead of planning a second time.

Two deadlines can now stop the race stage, and the stage names the one that actually fired. Only when the stage's own bound fired does it report hit the … stage bound. When the run deadline fired first — which means the stages before it used more than their allowance — it reports the run deadline and its value instead:

race-detector tests in .standards/worktrees/gate-… did not finish: the gate run's deadline of
25m0s (20m0s race stage bound + 5m0s for the other stages) fired first, not the 20m0s stage bound,
and this is not a test failure.

Before this, a run cut at 4 minutes 57 seconds reported hit the 30m0s stage bound, which sent the reader to raise a bound the stage had never reached. A deadline or cancellation from a library caller that does not use the gate's run deadline is reported as its caller stopped it. Any other stage the run deadline cuts off is labelled the same way (stage "…" did not finish: the gate run's deadline … fired first, so this is not a finding), so a scanner killed mid-run does not read as a scanner finding. The cases are replayed in internal/gating/deadline_test.go and cmd/standardsctl/gate_deadline_test.go.

Where the toolchain stages run

A repository that ships a .devcontainer pins its toolchain there, and a gate that used the host's toolchain instead could fail every push after a host package update while the repository was fine, or give two workstations different verdicts on one commit (#652). gate run therefore runs the Go part of the prefetch and race-detector stages inside the repository's devcontainer where this host can build it, and on the host otherwise. Every report says which, and the receipt certifies it.

When the devcontainer is used. Before any stage runs, planDevcontainer (internal/gating/devcontainer.go) and devcontainer.PlanImage check, building nothing, that:

  • PRAETOR_GATE_DEVCONTAINER is unset or auto, and the run is not a dry run;
  • the repository root holds a go.mod and .devcontainer/devcontainer.json;
  • the user has a cache directory (os.UserCacheDir) to keep the container's HOME and the pinned devcontainer CLI in;
  • the host is not Windows, and docker or podman is on PATH and answers <runtime> info within devcontainer.RuntimeProbeTimeout (10 s), docker first (devcontainer.FindRuntime). A CLI whose daemon is stopped, whose socket this user may not open, or whose podman machine is down is passed over for the next runtime, so it cannot plan a build that then fails every run;
  • the configuration is inside the managed schema, and a recorded Praetor bootstrap's companions verify — the check praetorctl devcontainer verify applies (verifyRecordedCompanions in internal/devcontainer/bootstrap_io.go);
  • the build's dockerfile and context resolve inside the repository;
  • a configuration that declares features is on a platform the gate pins Node for: linux-x64, linux-arm64, darwin-x64 or darwin-arm64. Only the devcontainer CLI applies features; a docker or podman build of the Dockerfile leaves them out, and an image without its declared features is not the repository's devcontainer. The gate runs a CLI it pins itself (below), never one found on PATH.

Any check that fails runs the stages on the host, and its reason is what the report and receipt record. Praetor's own .devcontainer/devcontainer.json declares the common-utils, go and node features, so Praetor's gate builds it with the pinned CLI wherever docker or podman answers on one of those platforms.

A busy host delays the container, it does not refuse the push. The first command inside the container, the toolchain probe, waits for the container to start. It runs under devcontainerStartBound, the run budget's allowance for the stages other than the tests (stageBudget: 5 minutes for a Go repository, more with a Cargo.lock), and the run's own deadline still applies (#1260). When that bound fires, the reason says the container did not start in time and that this does not mean Go is missing; any other probe failure keeps the message that names the missing toolchain. Before #1260 the probe ran under the 15-second git query limit and refused pushes at load averages of 30 and above.

The pinned devcontainer CLI

A configuration with features is built by the reference CLI, @devcontainers/cli, at a version Praetor pins, run by a Node release Praetor pins. Both pins ship inside the binary (internal/devcontainer/cli.go), so every workstation and runner builds with the same CLI:

Pin File Checked by
@devcontainers/cli 0.89.0 internal/devcontainer/cli/package.json and its package-lock.json the lock's sha512 integrity, which npm ci enforces
Node 24.21.0 internal/devcontainer/cli/node.json one SHA-256 per platform, taken from the release's SHASUMS256.txt

The first build with features on a machine fetches both into the tool cache, <user cache dir>/praetor/devcontainer-cli, beside the container's HOME:

  1. It downloads node-v<version>-<platform>.tar.gz from https://nodejs.org/dist and refuses it unless its SHA-256 is the pinned one. It then unpacks the archive (util.ExtractTarGz).
  2. It installs the CLI from the pinned lock with that Node's npm ci --ignore-scripts --no-audit --no-fund, the flags the Markdown gate's locked install uses, and checks the installed version.

Each step assembles in a stage directory and is published by rename, so an interrupted run leaves nothing a later run would reuse. Later builds find both in the cache and fetch nothing. The fetch and install run under devcontainer.CLIProvisionTimeout (10 minutes) inside the image build bound. The npm install reads the host's npm configuration, so a registry mirror or proxy set in ~/.npmrc applies.

A download, checksum or install that fails rejects the run with a failed Devcontainer Image stage that names the cause and PRAETOR_GATE_DEVCONTAINER=off. The gate never falls back to the host or to another CLI. A platform without a pinned Node (a Linux or macOS host on another architecture) runs the stages on the host, and the reason names the features and the platform.

Renovate keeps both pins current (renovate.json):

  • the npm manager moves the CLI's exact version and its lock together;
  • a regex manager reads the Node version out of node.json from the node-version datasource, held to the Node 24 line.

A Node bump changes the version alone, so its checksums must be refreshed on the Renovate branch. TestPinnedNodeMatchesTheRelease compares each pinned checksum with the release's SHASUMS256.txt and fails the branch until they match (it skips under -short and where nodejs.org cannot be reached). The same test rewrites them:

PRAETOR_UPDATE_NODE_PINS=1 go test ./internal/devcontainer -run TestPinnedNodeMatchesTheRelease

How the image is built. With features, the pinned Node runs the pinned CLI: node <tool cache>/…/devcontainer.js build --workspace-folder <repo> --config <config> --no-lockfile --image-name <stable> --image-name <run> --docker-path <runtime>. The CLI otherwise writes a devcontainer-lock.json beside the configuration, which would leave the checkout the receipt certifies dirty, so a repository without one gets --no-lockfile and one that commits it gets --frozen-lockfile, which builds from the recorded feature digests and fails if they no longer match. Without features, <runtime> build --file <dockerfile> --tag <stable> --tag <run> <context> runs with the configuration's build arguments; an image-only configuration is pulled when the runtime does not hold it, and tagged with neither. The build runs under the 15-minute bound, and the runtime's layer cache makes a rebuild of an unchanged definition quick. The built image must have go on its PATH; one without it runs the stages on the host and says so.

The two tags (imageRefs in internal/gating/devcontainer.go) keep the image store from growing with every push:

Tag Form Lifetime
Stable praetor-gate-<hash>:latest, the hash of the origin remote's host and path; every repository without an origin shares one kept. Every clone and worktree of one repository reuses it, the pre-push hook's temporary clone included; a rebuild of a changed definition moves it and leaves the old image dangling for docker image prune or podman image prune
Per run praetor-gate-<hash>:run-<pid>-<time>-<n> the image ID is read through it, so a concurrent build that moves the stable tag cannot hand this run another image. It is removed with <runtime> image rm --no-prune once the stages are done, or at once when the image is not entered

How commands run in it. Each command runs in a disposable container of the image, named by the image ID the runtime reported, so every command runs in exactly the image the receipt names:

Aspect What the gate does
Checkout bind-mounted at its own path, so the paths in stage messages are the host's
Git common dir bind-mounted at its own path when it lies outside the checkout, so a stage worktree's gitdir link resolves inside the container
User the host user, so files the stages write stay the host user's and the host user's files are writable inside: --user <uid>:<gid> on rootful docker; --user 0:0 on rootless docker, whose container root is the host user while every other container UID is a subordinate one (devcontainer.FindRuntime reads name=rootless from docker info's security options); --userns=keep-id on rootless podman
HOME <user cache dir>/praetor/devcontainer-home (os.UserCacheDir: $XDG_CACHE_HOME or ~/.cache on Linux, ~/Library/Caches on macOS), created on the host and bind-mounted at its own path. It lies outside every checkout, so Go's build and module caches persist across runs, including the pre-push hook's runs in a temporary clone. One HOME serves every repository, as the host's own Go caches do, and the prefetch stage's go mod verify re-checks the module cache against go.sum. Its Go telemetry mode is off (.config/go/telemetry/mode, written when the home is prepared): with the default mode, local, the Go tools of concurrent gate runs map the same counter files there, and a go build crashed with SIGBUS when another run grew one (#1429; TestDisableContainerTelemetry_Positive_GoReadsOff)
Environment the image's own, plus GOPATH=$HOME/go (not an image's shared /go), PRAETOR_GATE_DEVCONTAINER=off so a gate a test starts inside never looks for a runtime there, and GOFLAGS as the host's value followed by -modcacherw, so build tags carry over and the cache can be deleted without restoring write permission first. The host's module and network settings are forwarded when set, even empty: GOPROXY, GOPRIVATE, GONOPROXY, GONOSUMDB, GONOSUMCHECK, GOSUMDB, GOINSECURE, GOTOOLCHAIN, and HTTP_PROXY, HTTPS_PROXY and NO_PROXY in both spellings (forwardedEnv in internal/gating/devcontainer.go). Nothing else crosses, credentials included
Init --init: the runtime's own init (docker-init, or podman's catatonit) is the container's PID 1 and reaps orphaned processes as a host's init does. Without it the command itself is PID 1, a killed command's children stay behind as zombies, and a test that waits for a process group to disappear, such as util.RunCommand's own, times out
/tmp a tmpfs mounted exec (--tmpfs /tmp:exec), where go builds its work directory and tests make theirs. On the container's writable layer that traffic runs about ten times slower than on the host: a nested go test in the race stage took 218 s there and 19 s on the tmpfs. A checkout mounted at /tmp itself keeps it; one below /tmp, such as the pre-push hook's temporary clone, is mounted over the tmpfs
Cleanup a container whose command failed is removed by name, because a runtime CLI killed by the stage bound leaves its container running

The race stage keeps its bound and its worktree (inStageWorktree); the worktree is created by the host's git, and go test -race runs in it inside the container. The race detector probe asks the container's go env and looks the compiler up on the container's PATH, so a skip names the devcontainer: the C compiler "gcc" named by go env is not on PATH in the devcontainer. A probe the container does not answer at all fails the race stage instead of skipping it: the run planned the devcontainer, and a broken one must not reach a signed receipt as a skip.

What the receipt records. The signed gate output gains one header line after dry_run, its fields separated by tabs (shown as spaces below), inside praetor-gate-output/v2, so gate verify accepts receipts with and without it (lockdown.ExecutionLine and lockdown.CertifiedExecution in internal/lockdown/receipts.go):

execution devcontainer runtime=docker image=sha256:… builder=devcontainer build (@devcontainers/cli 0.89.0, Node 24.21.0) stages=Prefetch & Lockfiles, Race-Detector Tests (go)
execution host reason=PRAETOR_GATE_DEVCONTAINER=off

gate run prints it as Toolchain stages ran in: …, --json carries it as execution, and gate verify prints it as Execution:. A receipt minted before the line existed prints not recorded; signed output holding more than one such line, which the gate never writes, prints ambiguous with the count.

When the planned devcontainer fails. A plan whose build, setup or go probe fails is not replaced by the host: the run is rejected with a failed Devcontainer Image stage that names the cause and the opt-out. Fix the definition, or opt out.

Opting out. PRAETOR_GATE_DEVCONTAINER=off runs the stages on the host, and the receipt records reason=PRAETOR_GATE_DEVCONTAINER=off. Any value other than auto or off also runs them on the host, and the reason quotes the value.

Platforms (HISS-21). A Windows host runs the stages on the host: a Linux container cannot mount a Windows checkout at its own path, and the reason says so. A macOS or Linux host without docker or podman, such as a GitHub-hosted macOS runner, runs them on the host and records neither docker nor podman is on PATH; one whose runtime is on PATH but does not answer records no container runtime answers: with each runtime's reply. GitHub-hosted Linux runners carry docker, so a gate run there builds the devcontainer, features included, and a fresh runner fetches the pinned Node and CLI on every job.

Known gaps (#652):

  • The security stage's govulncheck and gosec, the flavor audit, the HISS scan, the Cargo parts and the receipt still run on the host; the Go feature installs neither scanner.
  • The first build with features downloads about 60 MB (Node and the CLI) and needs nodejs.org and the npm registry, or the mirrors the host's npm configuration names. An offline host fails that build with the opt-out named. The pinned Node is the glibc build, so a musl host such as Alpine fails the install the same way.
  • Credentials do not reach the container: its HOME is the gate's, so ~/.netrc, git credential helpers, SSH keys and SSH_AUTH_SOCK are absent there. A repository whose private modules need them fails the container's prefetch stage; set PRAETOR_GATE_DEVCONTAINER=off to run the stages on the host. A proxy the host reaches on its loopback address is not reachable from the container's network namespace either.
  • The container path is exercised against a real runtime with docker, rootful. Rootless docker's 0:0 mapping and rootless podman's --userns=keep-id mapping are covered by the command-line tests only. A docker daemon with userns-remap gets the rootful mapping, which that daemon remaps; opt out there.
  • A gate killed before its stages finish (SIGKILL, a lost machine) leaves its per-run tag; <runtime> image ls --filter 'reference=praetor-gate-*' lists it for <runtime> image rm. A removal that fails in a run that finished is printed after Toolchain stages ran in: as cleanup: and carried as execution.cleanup in --json, outside the signed line.
  • The mounts carry no SELinux relabel option, so a host enforcing SELinux may deny the container access to the checkout; the stage then fails, and PRAETOR_GATE_DEVCONTAINER=off runs it on the host.

The seams are replayed without a runtime in internal/gating/devcontainer_test.go and internal/devcontainer/runtime_test.go; TestRaceStageRunsInARealDevcontainer in internal/gating/devcontainer_runtime_test.go builds a FROM scratch image around a stand-in toolchain and runs the race stage in it, skipping with its reason under -short, off Linux, or where no runtime answers. The pinned CLI's fetch, checksum, install and reuse are replayed without a network in internal/devcontainer/cli_test.go. TestPraetorsOwnDevcontainerRunsTheGoStages, in the same file as the real-runtime test, runs the case behind #652 end to end when PRAETOR_DEVCONTAINER_E2E=1 is set: it builds Praetor's devcontainer, features included, through the pinned CLI with the real runtime, then runs the prefetch stage, go version, node --version, a race test, and the internal/util tests that wait for a killed process group to disappear, which pass only where an init reaps orphans.

Governance profile names no longer select Go or Meson commands. A shared plan renders both newly generated Makefiles and AGENTS.md. Discovery recognizes:

  • Go modules: build and race tests; Cargo projects: locked build and tests.
  • Root npm scripts: a declared build, optional check, and a test script that can pass: not blank, and not the echo "Error: no test specified" && exit 1 placeholder npm init writes. Another declared package manager requires an explicit project contract. internal/nodemanifest/scripts.go holds both decisions. The typescript-node CI scaffold reads packageManager and the test script the same way, and installs with pnpm, Yarn or Bun where the repository uses one (archetype authoring). A check or test script that only runs the other one, such as "check": "npm test", is left out of the test recipe so the suite runs once, and the adoption report says so. Only exact alias forms count (npm test, npm t, npm run <script>, pnpm test, yarn run <script> and the like, read with the engine's shell reader forge.SoleCommand); a script with further arguments or commands, two scripts that call each other, and the build script all stay (internal/adopt/verification_scripts.go, #1264).
  • C# project files: locked restore and Release build with warnings as errors; explicit unconditional test projects receive dotnet test. Each build writes SARIF 2.1 to obj/praetor-build.sarif relative to that project. Restore creates obj/ before CoreCompile; a custom ErrorLog parent must exist before then or the compiler can fail with CS0016. The command encodes the version separator as %2C in -p:ErrorLog=obj/praetor-build.sarif%2Cversion=2.1. Conditional, contradictory, or disabled test markers cannot establish a test gate.
  • Python: explicit pytest configuration, or tests under tests/ with an exact Python 3.14+ version pin. Pytest configuration is any root file pytest itself reads, in pytest's precedence order: pytest.toml, .pytest.toml, pytest.ini or .pytest.ini (even empty), a pyproject.toml with a [tool.pytest] or [tool.pytest.ini_options] table, a tox.ini with a [pytest] section, or a setup.cfg with a [tool:pytest] section (pytest configuration reference). The report names the file and table it used. A pyproject.toml, tox.ini or setup.cfg without that table or section is not pytest configuration; section lines are read the way pytest's INI parser reads them, so an indented [pytest] or [ pytest ] is not the section (pytestConfiguration in internal/adopt/verification_pytest.go, tested by TestVerificationPytestConfigurationPositive and TestVerificationPytestConfigurationNegative). tox.ini and setup.cfg also configure other tools, in repositories with no Python at all, so one that discovery cannot read (a symlink, a file that is not UTF-8 text, one past the per-file byte bound, one the process may not read) is not pytest configuration: adoption goes on, the plan's unreadable list says why, and the report's verification warning names the file (TestVerificationUnreadablePytestConfigurationIsNotConfiguration and TestAdoptWithUnreadableSetupCfgWarnsInsteadOfFailing in internal/adopt/verification_pillar_test.go). Unittest commands check the interpreter version before discovery. Python 3.14 fails when discovery finds no tests. A separate build command remains necessary; a Python-only project without one needs a custom contract rather than a generated no-op build.
  • Zig builds: a root build.zig runs zig build, its default install step, ahead of every other build and test command, because language builds such as Cargo's link the native libraries it produces. build.zig is Zig source, not metadata: discovery records its presence without reading it, so a build script of any size fits the byte bounds, and a symlinked one is refused. It supplies no test command, since zig build test exists only where the script declares a test step. The language markers beside it supply the tests; without one the plan is unavailable and its reason names the missing native test step for build.zig. A build.zig.zon alone selects nothing, and a build.zig below the root is not a marker (internal/adopt/verification.go addZigVerification, internal/adopt/verification_zig_test.go). The walk skips the trees Zig writes inside the checkout, zig-pkg (fetched packages), zig-out and .zig-cache (util.IsToolchainTreeDir): their files spend no entry bound, and the C sources of a fetched package do not make a pure-Zig repository C/C++ (TestVerificationSkipsZigToolchainTrees, TestVerificationZigToolchainTreesSpendNoEntries).

Mixed projects retain all detected gates; npm build precedes .NET builds for frontend resources, and zig build precedes both. A solution marker without projects, or Meson/CMake markers without a selected configured build directory, remains unavailable. Discovery is bounded to 65,536 entries (util.DefaultDiscoveryEntries in internal/util/discovery_bounds.go), 32 directory levels (to cover nested public src/test project layouts), 128 metadata files, 64 KiB per metadata file and 2 MiB in aggregate. Generated dependency/build trees are omitted. Exceeding a bound is an error, never a truncated successful plan, and the error names the --verification-max-* flag that raises the entry, depth or file bound for one run and the verification key of .standards.yaml that raises it for every run (large repositories). Selected metadata uses bounded reads that refuse symlinks. Command paths with line breaks are rejected; shell arguments are quoted and Make dollar signs escaped.

The .NET runner selection follows global.json: VSTest uses a positional project, while Microsoft.Testing.Platform uses --project. MTP requires a suitable .NET 10+ SDK and compatible test projects. Selection is still unverified until the declared commands run. See the VSTest CLI reference, MTP CLI reference, and Python 3.14 unittest behavior.

Migration and activation limits

Re-run adoption to replace byte-exact historical Praetor Makefiles, including the old echo-only verify-all stub. The placeholder Makefile written for an unavailable plan, whose recipes fail explicitly, counts as earlier Praetor output once the repository declares build and test commands: a plain re-run replaces it with the runnable verify-all and the Verification Gate reports ready; while the plan stays unavailable the placeholder is current and kept (isPlaceholderVerificationMakefile in internal/adopt/verification_makefile.go, internal/adopt/verification_placeholder_test.go). Custom or edited Makefiles are preserved even with --force; includes, generated target names the file does not fix and pattern rules are treated as ambiguous ownership. A missing target can be appended to a simple existing Makefile without replacing its recipes. Existing AGENTS.md is preserved by default with a command-synchronization warning. Review the declared plan; --force regenerates a recognized harness and keeps its preamble, the repository's own invariant rows and the instructions below it (adoption). Malformed or oversized command metadata now fails before any adoption writes.

lefthook.yml follows the same rule (internal/adopt/lefthook_identity.go):

  • An exact earlier Praetor rendering is replaced by the current one and activated, --force or not. The recognised renderings are listed by SHA-256 of their LF text in priorLefthookDigests; internal/adopt/testdata/lefthook/ reproduces each one. A CRLF checkout of one (core.autocrlf on Windows) is recognised too and replaced by the current rendering's LF bytes, the only bytes activation trusts (TestAdopt_Positive_CRLFPriorLefthookMigratedAndActivated). An edited copy, or one with mixed line endings, is not exact: it is preserved and not activated.
  • A CRLF checkout of the current rendering is verified and kept as it is. Activation trusts only the rendering's exact LF bytes, so the file is not activated; --force rewrites it with those bytes, reports a reconcile rather than a replace, and activates it (TestAdopt_Positive_ForceRewritesCRLFCurrentLefthookAndActivates, TestAdopt_Boundary_CRLFCurrentLefthookKeptWithoutForceAndInDryRun).
  • Every other configuration is the repository's and is never replaced, --force included, nor activated: the audit checks only that lefthook.yml exists. That covers a copy with mixed line endings, which is no checkout, a file that does not parse, and a file behind a symlink, whose target is left as it is (TestAdopt_Negative_ForeignLefthookKeptWithAndWithoutForce, TestAdopt_Negative_ForceKeepsMixedEndingCurrentLefthook, TestAdopt_Boundary_ForceKeepsSymlinkedLefthook). The skip reason names the generated jobs the file lacks and the jobs it adds, whether they are declared in commands or scripts maps or in a jobs list; the checkpoint jobs count as neither. Beside a kept file the checkpoint bundle is refreshed as one unit while each of its files is absent, current or an unedited earlier Praetor text, the copies the audit reports; once one holds an edited copy, the kept file runs scripts of its own, so each file stays and only absent ones are installed (TestAdopt_Positive_KeptLefthookRefreshesEarlierCheckpointBundle, TestAdopt_Negative_KeptLefthookKeepsBundleBesideEditedCopy). To regenerate it, remove lefthook.yml and re-run adopt.
  • A configuration that reaches .config/lefthook/praetor.yml, the vendorable canonical policy, through extends or through the configs of a remotes entry is kept the same way, and so is .config/agent/hooks/block_evasion.py beside it, so the policy and its scripts stay one version. The skip names the policy; update it as .config/lefthook/README.md describes.

The generated hooks resolve praetorctl or, failing that, standardsctl through the same expression as the generated Makefile's PRAETORCTL variable (util.ShellCLIResolution in internal/util/clinames.go). A missing binary blocks the commit or push; a ./cmd/standardsctl source tree no longer stands in for one. Go's gofmt, engine-governed go vet, and engine-governed security govuln jobs are always present so a module added after adoption is covered. The Go engine and the gate share the tracked-module discovery rule; a repository with no discoverable module prints a stated skip. Rust jobs follow the plan's root Cargo.toml marker (the lefthook.yml adoption writes). The gate job runs praetorctl gate run --path=. --admit-unsupported: a root with neither a go.mod nor a Cargo.lock is admitted without a receipt and its languages are named, and a root with either fails where its toolchain stages did not run (no receipt, admission). The file's header comment says so, and every earlier rendering, the Go-only ones and the ones running the plain gate included, is recognised as earlier Praetor output. Tests: internal/adopt/lefthook_gate_admit_test.go, internal/adopt/lefthook_identity_test.go, internal/adopt/lefthook_languages_test.go, internal/adopt/lefthook_keep_test.go, internal/adopt/checkpoint_test.go, internal/adopt/hooks_gomod_test.go, internal/adopt/cli_name_test.go. Generated Makefiles run caveman-sources (praetorctl caveman check --configured-sources) inside verify-all, and praetorctl audit fails when .standards.yaml has no register.sources. Re-running adoption on a repository with an existing manifest adds the contract for its Paperclip harness without replacing operator fields, and binds a valid operator-owned harness byte for byte (TestAdoptExistingManifestAddsSourceContractWithoutDroppingContent and TestAdoptCustomHarnessPreservesBytesAndBindsActualCoverage in internal/adopt/adopt_test.go). A harness still byte-identical to an earlier release's output is refreshed to the current text first (TestAdoptUpgradesReleasedHarnessToPassingSourceGate), including a CRLF checkout (TestAdoptUpgradesCRLFReleasedHarness). A declined paperclip step writes no harness and binds no contract to one it does not write (TestAdoptDeclinedPaperclipWithoutHarnessBindsNothing in internal/adopt/harness_plan_test.go). --force regenerates the harness and re-binds an existing contract, keeping every declared input (TestAdoptForceRebindsExtendedSourceContract in internal/adopt/manifest_sources_test.go). An existing register.sources that fails its own gate stops adoption in both modes (TestAdoptRejectsStaleExistingSourceContract, TestAdoptForceRefusesDriftedSourceContract). The text-register guide describes the upgrade path.

Run the resulting commands under the intended toolchain and retain actual results before claiming application verification. Public dogfood governance verification, HISS language coverage, native tests, and deployment smoke tests remain distinct pieces of evidence. Repository enrollment and external application execution are not activated by this plan.

This correction does not repair every language-specific consumer of the archetype catalog. DevContainer generation, audit and adoption already resolve the selected catalog's pinned feature union (internal/devcontainer/devcontainer.go): a non-Go or empty catalog no longer receives an unconditional Go feature, and a feature conflict fails instead of silently picking one side. Editor generation has not reached the same parity: some editor generators still accept an arch argument and discard it, so the emitted .clang-tidy stays C/C++-only regardless of the adopted archetype (internal/editor/editor.go, generateVisualStudio and neighboring generators). Archetype catalogs and this downstream consumer still require separate reconciliation; a C# command plan does not imply a configured .NET development container or complete C# scanning.

What the generated harness claims

The AGENTS.md harness states only what adoption generated. Its source is internal/adopt/harness.go; the tests are in internal/adopt/harness_truth_test.go.

  • Title. # <owner>/<name> Agent Operating Harness, from the origin remote, or from the manifest's repository.owner and repository.name when no remote resolves. With neither, the title names the checkout directory alone (TestHarnessTitleNamesOwner).
  • Turn-end command. The block under "Before concluding any turn" names make verify-all only when the repository has that target after the run: the one adoption generates, or a repository-owned one it preserved. When adoption.decline lists makefile and no custom target exists, the block names praetorctl compile-context --verify, praetorctl caveman check --configured-sources and praetorctl audit instead, and the footer drops its make verify-all line (TestHarnessEntrypointFollowsVerifyAll).
  • Verification line. It calls make verify-all the repository's own gate and points at the Makefile for its steps, without listing them: the repository owns the target and may change it after adoption, so a restated recipe would drift from what runs. Per plan status it adds that adoption generated the target and executed none of it (declared-unverified), kept a repository-owned target unread (preserved-unverified), or wrote one that fails until the project declares build and test commands (unavailable). When adoption.decline lists makefile, it says no target exists and to run the gates directly (TestHarnessSummaryDescribesVerifyAllAsRepositoryGate). A preserved custom target keeps the commands the project markers declare in the report's verification.declared list, and the harness footer lists them, so --force no longer reduces them to make verify-all (TestAdoptForceKeepsOwnerAndDeclaredCommands).
  • Receipt. Only praetorctl gate run mints an Ed25519 Exit-0 receipt. The harness says so, tells agents to report no receipt that command did not mint, and ties none to verify-all (TestHarnessMakesNoUnbackedClaims).
  • Server side. Adoption installs no CI job that runs a praetorctl gate; those gates run in the local hooks and targets only. Rule 5 says so and names each CI workflow the run does scaffold, with what it runs as read from the workflow body (internal/adopt/harness_ci.go, forge.WorkflowRuns):
  • .github/workflows/praetor-docs.yml, which runs the Markdown verification, when the docs:seo-portal facet is declared;
  • .github/workflows/praetor-api.yml, which runs the Go API compatibility gate (go run tools/apicompat/gate/main.go), when the api:public-contract facet is declared and git tracks a go.mod (Go API compatibility gate);
  • the detected flavor's workflows that flavor apply leaves as its own rendering (flavor.PlannedWorkflows), such as a Go flavor's ci.yml running go vet ./... and go test -race ./..., or a Rust flavor's running cargo fmt, cargo clippy and cargo test. A workflow the repository already owns, and every flavor workflow when adoption.decline lists working-dir-and-flavor, is not named.

A one-line run: step is named by its command. A multi-line script is named by its step name, or its first line when it has none, and labelled step so it never reads as a command to run. Without a scaffolded workflow, rule 5 says the gates run locally only (TestScaffoldedWorkflowsReadWhatAdoptionWrites, TestCIClaimFollowsWhatWorkflowsRun). TestNoScaffoldedWorkflowRunsPraetor checks every line of every step in every flavor workflow and the documentation workflow for a praetorctl call; should one ever run a gate, rule 5 drops its "no server-side praetorctl gate run" sentence. - Invariant table. One row per rule in the HISS rule catalog (internal/hisscatalog/catalog.go), HISS-01 through HISS-21 (TestHarnessTableListsEveryRegisteredInvariant). The rows have the shape hisscatalog.ParseGatedInvariants reads, the parser the generated wiki uses for praetor's own table (TestHarnessDirectives_ParseGatedInvariants). The Rule column is the catalog's adopted directive for this repository (internal/hisscatalog/directive.go): - A clause that names one language's construct renders only where the verification plan found that language, labelled with it: Go's context.Context deadline and unchecked error returns, Rust's .unwrap() / .expect() ban, C and C++'s goto and banned libc calls, the // SAFETY: proof for Go and Rust unsafe. A rule left with no clause, such as HISS-09 in a TypeScript repository, says it has no analogue there. With no detected runtime or source language, every clause renders with its label. - C/C++ (one language for the clauses, labelled C/C++) is detected from a native build marker (meson.build, core/meson.build, CMakeLists.txt) or from C/C++ sources, so a repository that compiles C or C++ from a Makefile, Bazel or a script still reads the C/C++ clauses. The verification walk records the file names it already visits, under the same --verification-max-* bounds, and the plan lists them as source_languages: ["c"] in the JSON report, also when a build marker declares the language too. Every extension the audit's native scan reads (.c, .cpp, .cc, .cxx, .hpp, .cu, .hip, .mm, in any case; hiss.IsNativeExtension) makes the repository C/C++, the same files the scan runs the goto and banned-libc checks on; .mm is read by the C/C++ rules, while .m stays unscanned and is listed under Not Scanned as objective-c where a marker shows the repository is Objective-C. A .h file does too, unless an Objective-C source (.m) sits beside it: .h is Objective-C's header as well, and the audit does not scan Objective-C. Files under a directory the scan ignores (vendor, third_party, testdata, build output, and the Zig trees zig-pkg, zig-out and .zig-cache; hiss.ShouldIgnorePath) never count. In a work tree a file must also be one git reports as the repository's own: tracked, or untracked and not ignored (hiss.GitVisiblePaths, the scan's own listing, asked once and only when the walk saw a C/C++ or Objective-C file). An in-place Cython .c a *.c rule ignores, or C under ignored IDE build output, therefore selects no clause, and a fresh clone and a built checkout render the same table. Outside a work tree git gives no answer and every walked file counts. The walk visits a subset of what the scan reads: it also skips bin, obj, dist and __pycache__, which the scan enters, so C/C++ sources found only there select no clause (internal/adopt/verification_sources.go, TestCSourceObservationDecidesC, TestRepositoryHISSFactsDetectsCSources, TestCSourceDetectionFollowsTheAuditScan, TestRepositoryHISSFactsReadsOnlyGitVisibleCSources, TestGitVisiblePathsAnswersTheScanScope, TestIsNativeExtensionMatchesTheScanDispatch, TestCSourceDetectionStaysInsideTheWalkBounds, TestAdoptedHarnessRendersCClausesFromSources). - A Zig build (build.zig) is a runtime no clause names. It selects no C/C++ clause by itself, since the build script is a program that declares no language; the C or C++ sources it compiles do, as above. A Cargo workspace built with zig build whose crates carry C shims therefore states the Rust and C/C++ clauses and no Go clause, and a Zig-only repository is a known language set in which no labelled clause renders (TestAdoptedHarnessOfZigBuiltWorkspace). - HISS-04 states the function length the repository's audit enforces, read from the policy the policy-catalog step resolved (container-image, for example, enforces 50). At the 60-line audit ceiling it adds (audit ceiling): a pinned profile snapshot such as native-gpu-systems states 75, and the audit-compatibility layer caps it at 60 (effective policy). The ceiling comes from config.AuditMaxFuncLOC; the catalog keeps no copy of it (TestAdoptHarnessesStateTheAuditFunctionLength, TestFuncLOCLimit). - HISS-04 states the cyclomatic, cognitive and statement limits of the same resolved policy, so an overrides.complexity stricter than the profile reaches the row (TestAdoptHarnessesStateEveryComplexityOverride, TestComplexityLimits). The audit does not cap these three, so a profile looser than the HISS-04 defaults (10, 15, 50) is stated as the editor projections and the language server state it. Before the policy resolves, the row states those defaults. One helper, withPolicy in internal/adopt/harness_hiss.go, feeds every HISS-04 limit to both harnesses and to praetorctl paperclip harness. - A C or C++ repository that declares hiss.exceptions.c_goto_cleanup and carries the document it names reads that exception in HISS-01 instead of the zero-goto clause: the exact rule the audit's native scan applies, rendered from the scan's own text (hiss.CleanupGotoRule), so the harness grants nothing the audit rejects. Go's own ban stays. Without the document the ban stays, the scan reports every goto and the report warns (declared HISS exceptions, TestAdoptHonoursDocumentedCleanupGotoException, TestCleanupGotoExceptionReachesTheAudit). - TestAdoptedHarnessGolden pins the whole harness for a Go framework, a Rust crate, a native C engine and one declaring the cleanup-goto exception (internal/adopt/testdata); regenerate with go test ./internal/adopt -run TestAdoptedHarnessGolden -update-harness-golden after reviewing the change.

The Adopted check column names the check and the generated stages that run it, such as praetorctl audit HISS scan in verify-all + lefthook pre-commit/pre-push, qualified by language, or not enforced. It credits only the pipelines this run generates (internal/adopt/harness_pipelines.go): - the verify-all target, unless adoption.decline lists makefile or the plan is preserved-unverified; - lefthook.yml, unless adoption.decline lists git-hooks, the git-hooks step will keep a file that is not praetor's rendering, or lefthook will not install its hooks. The step keeps every existing file that is neither a current nor an earlier Praetor rendering, --force included. The hooks are installed only when lefthook version runs on PATH and hook activation is not skipped. Without a runnable lefthook, adoption writes the fallback pre-commit hook, which runs compile-context --verify and audit --offline alone, so the lefthook stages are not credited.

A check is also language-bound (Rule.AdoptedFor in internal/hisscatalog/adopted.go): the harness credits the audit's HISS scan in Go, Rust, Python and C/C++ sources only, and each rule's scan decides a subset of them. The scan also reads JavaScript, TypeScript and Svelte (internal/hiss/script.go), shell scripts, the run: blocks of GitHub Actions workflows, systemd units and Ansible playbooks (internal/hiss/shell.go, workflow.go, systemd.go, ansible.go), but no directive clause names those languages yet, so their rows understate what the audit checks rather than overstate it. A row reads not enforced where the check decides none of the repository's languages or the rule has no analogue there. HISS-09 in a C engine, HISS-01 in a TypeScript repository and the lefthook go vet row in a Rust crate are examples (TestAdoptedFor).

The line above the table names the pipelines it read and says the cells describe the files as adoption wrote them; a later edit to the Makefile or lefthook.yml is not reflected there. Rule 5 claims hooks only when lefthook installs praetor's lefthook.yml, and says the file's hooks stay inactive when lefthook did not run (TestHarnessTableFollowsGeneratedPipelines, TestAdoptHarnessDropsDeclinedAndPreservedPipelines). TestGeneratedPipelinesPredictGitHooks replays the prediction against the git-hooks step for each case, including a failing and a missing lefthook binary and skipped activation, and checks which pre-commit hook it installed. - Protected files. Rule 3 names the files compile-context writes under the manifest's agent_clients, read from agentcontext.VendorTargets, the selection the transpiler itself applies: all six by default, .gemini/GEMINI.md and .codex/rules.md included, and none for an empty selection (TestHarnessRule3NamesExactlyTheCompiledFiles, TestHarnessNamesEveryProtectedContextFile, TestHarnessRule3FollowsAgentClients). - Paperclip harness. .paperclip/harness.json and .paperclip/rules.md come from internal/paperclip/harness.go. - The invariants are the same adopted directives for HISS-01, 02, 04, 07, 10, 15 and 16, for the repository's languages and declared exceptions (TestSynthesizeHarness_Positive_InvariantsFollowLanguages, TestSynthesizeHarness_Positive_ExceptionAndLimitFollowFacts). Adoption binds the harness in register.sources during its manifest step, before the policy-catalog step runs, so it resolves the policy the planned manifest and lock produce, with the loader and audit layer praetorctl audit uses (resolvePlannedPolicy in internal/adopt/policy_dryrun.go), and HISS-04 states the same function length, cyclomatic, cognitive and statement limits as AGENTS.md (TestSynthesizeHarness_Positive_ComplexityOverrideReachesRules). praetorctl paperclip harness reads the same facts through adopt.RepositoryHISSFacts, which resolves the policy with config.ResolveRepositoryPolicy, and writes the same bytes. Only where no policy resolves yet, such as a first adoption without --lock-source-root or a manifest without a lock, does HISS-04 state the 60-line ceiling, that a stricter repository policy wins, and the HISS-04 defaults for the other three limits. - The receipt row prescribes attaching receipts only when .standards.yaml pins a well-formed receipt.public_key. Without one, every attached receipt is refused, so the row says to attach none and names praetorctl gate keygen (TestSynthesizeHarness_Positive_PinnedKeyPrescribesReceipts, TestSynthesizeHarness_Negative_NoPinnedKeyPrescribesNoReceipt, TestSynthesizeHarness_Boundary_ReceiptKeyShape). - With the key pinned, the row still promises no receipt per proposal. It says only praetorctl gate run without --dry-run mints one, to attach a minted receipt, and to report none that was not minted, the Paperclip counterpart of the AGENTS.md receipt line. A dry run never mints (runReceiptStage in internal/gating/pipeline.go), and a repository-owned pre-push hook may pass --dry-run. A missing go.mod does not change the row: the gate's Go stages then report not_applicable and the receipt stage still signs (TestSynthesizeHarness_Positive_GoModuleKeepsReceiptRule, TestSynthesizeHarness_Negative_NoGoModPromisesNoUnconditionalReceipt, TestSynthesizeHarness_Boundary_ReceiptRuleForms). - An unmodified harness from an earlier release, including the ones that prescribed receipts on every repository or on every pinned one, still counts as earlier output, so praetorctl adopt refreshes it to the current receipt row without --force; an edited harness stays operator-owned (TestPriorGeneratedRecognisesTheCavemanRelease, TestAdoptRefreshesUnconditionalReceiptRows). - The same holds for this release's own harness after a repository fact it reads changes. Pinning receipt.public_key, as the unpinned row advises, adding or removing a language, declaring or withdrawing an exception, declaring repository.forge, or a policy that resolves the function length the harness stated as the unresolved ceiling, or the complexity limits it stated as the HISS-04 defaults, leaves the harness unmodified output, and the next plain praetorctl adopt refreshes it. The refresh key compares the harness byte for byte with this release rendered under both receipt rows and each earlier pinned receipt row (priorPinnedReceiptRows), every forge's push rows and the AGit pair earlier releases wrote on every forge (releasedPushRows), every language set (hisscatalog.AllLanguages), every exception set (hisscatalog.AllExceptions) and the HISS-04 statements it accepts: the 60-line ceiling unresolved, the ceiling resolved, and the plain number this run states, if any, each with the default complexity limits and the ones this run states (policyFacts). So an edit to the receipt row or an invariant still keeps it operator-owned (TestAdoptRefreshesHarnessAfterFactsChange, TestAdoptKeepsHandEditedHarnessAfterFactsChange, TestAdoptHarnessRefreshFactBoundary, TestPriorGeneratedRecognisesThisReleaseUnderEveryFactCombination, TestPriorGenerated_Positive_ComplexityResolvesIsEarlierOutput). - No limit is read from the harness on disk, since any number found there could be an operator's edit (TestPriorGeneratedKeepsEditedFactRows, TestLimitFacts, TestPriorGenerated_Negative_EditedComplexityStaysOwned). The cost: once a policy has resolved the length, a later move of it (50 to 45, 50 up to the ceiling, or back to unresolved) leaves a harness stating the old plain number operator-owned, under --force too. Delete .paperclip/harness.json and rerun praetorctl adopt to regenerate it (TestPriorGeneratedKeepsHarnessAfterResolvedLimitMoves). - The push rows follow the forge repository.forge declares (config.ResolveRepositoryForge in internal/config/forge.go). A Forgejo harness carries agit_push_format under ## AGit Push Protocol: the AGit git push origin HEAD:refs/for/main -o topic=<issue-id> that opens the review, then the review-branch push that records a remote-tracking ref for praetorctl paperclip verify. A GitHub or GitLab harness carries only push_format, the review-branch push git push origin HEAD:refs/heads/paperclip/<issue-id>, under ## Push Protocol; the pull or merge request opens from that branch. An undeclared forge is GitHub when the origin remote names github.com. Any other host, or no network remote, names no forge kind by itself, so adoption writes no harness and names the key, praetorctl paperclip harness refuses, and the audit fails until .standards.yaml declares it (TestForge_Boundary_DefaultOnlyForGitHubHost, TestSynthesizeHarness_Positive_ForgeSelectsPushRows, TestSynthesizeHarness_Negative_UndeclaredForgeRefused, TestAdoptHarness_Negative_UndeclaredForgeWritesNoHarness, TestAdoptHarness_Boundary_EarlierAGitHarnessRefreshed).

praetorctl audit compares the harness with this release's synthesis for the repository's facts, the ones praetorctl paperclip harness reads, under the same discovery bounds: the verification section of .standards.yaml, which the hooks and CI jobs that pass no flag read, and any --verification-max-* flag over it (TestAuditPaperclip_VerificationBoundFromManifest; auditPaperclipSynthesis in cmd/standardsctl/audit_paperclip.go, paperclip.CompareGenerated):

.paperclip/harness.json Audit
the synthesis, LF or one consistent CRLF checkout passes: synthesis for repository facts
unmodified earlier output (the refresh key above) fails: Paperclip harness out of date; praetorctl adopt refreshes it
anything else passes: operator-owned harness.json, the harness adoption keeps

.paperclip/rules.md, when present, must be the rendering of harness.json byte for byte, one consistent checkout line-ending style folded; a hand edit fails Paperclip rules out of sync naming the first line it lacks, and praetorctl paperclip harness or deleting rules.md clears it. It then passes the caveman lint personas and skills pass (praetorctl caveman check --kind=context .paperclip/rules.md). A repository that declines paperclip keeps its harness as its own: the audit loads it and checks its platform only (TestAuditPaperclip_Positive_AdoptedSynthesisVerified, TestAuditPaperclip_Negative_HandEditedRulesFails, TestAuditPaperclip_Boundary_OwnedAbsentRulesAndUndeclaredForge, TestCompareGenerated_Negative_EditedRulesAndStaleHarness).

An existing harness that is neither the current synthesis nor unmodified earlier output is operator-owned and keeps its text, under --force too (TestAdoptForceKeepsReboundEditedOperatingContract). A declared register.sources is never re-bound to an edited harness, so recompute its pins after an edit (text register). --force sets only a platform that names another repository than the identity, the value the audit's Paperclip gate compares. It replaces that value in place (clientjson.ReplaceMember), so every other byte stays, layout, line endings and the \u003c-style escapes of a released harness included, and the reported delta is that one line; a plain run keeps the file and warns (TestAdoptForcePatchesOnlyHarnessPlatform, TestPatchPlatform_Positive_ReleasedHarnessChangesOneLine). To regenerate the harness, delete .paperclip/harness.json and rerun praetorctl adopt. A harness written where none existed is Praetor output, so a contract whose every input selects it is bound to the written bytes, pins an earlier release bound included (TestAdoptDeletedHarnessRebindsPinsAcrossReleases). A .paperclip/rules.md you deleted stays deleted, under --force too (TestAdoptForceKeepsDeletedRulesAbsent). One present where no harness exists is kept when adoption writes the harness beside it (TestAdoptDryRunParity_Boundary_SecondaryScaffoldsListedAndKept).

Canonical context preparation

Adoption preserves repository-specific AGENTS.md instructions when composing the Praetor harness. The combined canonical content must fit every generated client projection's 300-line budget. Context compilation is preflighted before writing the composed canonical file, so a budget failure leaves canonical and vendor context files unchanged. Other adoption stages may already have run; this check does not make the entire adoption operation transactional.

An oversized composition remains an explicit preparation failure. Review and condense the repository's instructions before retrying; adoption does not silently discard them or raise the limit. A dry-run reports the same compilation failure without writing context files.

Generated Markdown and markdownlint

Every Markdown file adoption writes passes markdownlint's default configuration, so an adopter whose own lint runs the defaults does not fail on Praetor output. Where a rule has to be disabled, the disable covers only the lines that need it and ends before the repository's own text.

File Rules disabled
CONTRIBUTING.md, .github/pull_request_template.md, SECURITY.md, docs/adr/README.md, docs/adr/0000-template.md none; lines are wrapped within 80 columns
.agents/agents/repo-auditor.md, .agents/agents/repo-gatekeeper.md none
.paperclip/rules.md none, except MD013 around a push command too long to wrap; contract lines are wrapped within 80 columns
AGENTS.md MD013 from the first line to <!-- praetor:harness:end -->, for the harness table; MD025 after it, because the repository's instructions open with their own H1

Files adopted before this change open with a file-wide <!-- markdownlint-disable MD013 --> (an older harness disables MD013 and MD025 together). Adoption with --force rewrites the personas and refreshes the AGENTS.md harness while keeping the repository's own instructions. .paperclip/ is refreshed, with or without --force, only while it is unmodified earlier output; delete an edited one and rerun adoption to regenerate it. It never rewrites an existing CONTRIBUTING.md, pull request template, SECURITY.md or docs/adr/: those belong to the repository once written (internal/adopt/governance.go), so delete their disable line by hand and wrap the lines it covered.

internal/adopt/generated_markdown_test.go and internal/paperclip/rules_markdown_test.go check each file with testsupport.MarkdownFindings (internal/testsupport/markdown.go), a Go subset of the markdownlint rules these generators must hold.