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,sincludeandloadare never followed. - Every non-recipe line of the Makefile and the followed fragments together is one of:
- a comment;
- a blank line;
- an
include,-includeorsincludedirective 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, plusVPATH,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 noRCSorSCCSpath 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-indentedinclude rules.txtandendif, with that rule inrules.txt:config:sits in the branch Make skips, so the include in theelsebranch is the directive.ifeq (a,b),foo:andendif, then a tab-indentedX := $(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 = 1andendif, then a tab-indenteddefine X, adocs-lint: ; @echo operatorrule and anendefinside a secondifeq (a,b):X = 1closes no recipe, so the tab-indenteddefine Xis a recipe line ofalland 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
MakefileHasTargetclaims 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 patternsormissing 'endif',MakefileHasTargetcan 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-rulesetis explicitly and validly declined inadoption.decline, audit reports[PASS] Branch protection ruleset declined by adoption.decline.and passes cleanly without requiring.github/rulesets/main.json. - If
branch-rulesetis not declined and active policy requires branch protection (such as linear history or signed commits), audit fails closed if.github/rulesets/main.jsonis missing, or if its content differs from the ruleset the declared policy renders for the status checks the repository's workflows report. That comparison isforge.ValidateRepositoryRuleset, the same checkpraetorctl syncruns; key order and whitespace do not count, so{}, zero required approvals or a missingrequired_signaturesrule fail where the policy asks for them (internal/adopt/ruleset_audit_test.go). - An unknown decline item, malformed decline entry, or unreadable
.standards.yamlfails 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-rulesetstep 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 requiresand 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
originremote must name--forge-host(defaultgithub.com) and the path<repository.owner>/<repository.name>from.standards.yaml. Another host, a local path or afile://URL is refused before any request (util.ParseGitRemote,TestSync_Remote_OriginHost). - Ruleset. GitHub gets the ruleset
.github/rulesets/main.jsondeclares: the namepraetor-main-protectionand the refsrefs/heads/<default branch>andrefs/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 actorreview_mode: single_maintainerdeclares (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 asreview_mode: single_maintainerincluded. For three parameters where the audit accepts a stricter live value (allowed_merge_methodswhen live is a non-empty subset of declared, andrequire_extra_approval_for_unattributed_changesandrequire_last_push_approvalwhen 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 asruleset "praetor-main-protection" #7orbranch 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.yamlis 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.descriptionandrepository.homepageof.standards.yamlare written when they differ from GitHub's, and the declaredrepository.topicsGitHub 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.visibilityis 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:
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.modnor aCargo.lock(Cargo repositories). Without this a TypeScript or Python repository failed its own pre-push gate atFAIL ./... [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
flavorslist in.standards.yamlpins 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.tomlwithout a committedCargo.lockruns 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
cargoonPATHeach Cargo part readsnot run: cargo is not on PATH (install the Rust toolchain from https://rustup.rs). Unlike the Go scanners,cargo auditis optional: withoutcargo-auditthe part readscargo audit not run: cargo-audit is not installed (cargo install cargo-audit --locked). - Where a
Cargo.lockis 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.lockruns 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 fetchandcargo auditbounds to the other stages' allowance (the whole run's deadline). RaisePRAETOR_TEST_STAGE_TIMEOUTas the race stage's bound describes; it applies to each suite. cargo testandcargo clippybuild intopraetor/cargo-targetunder the repository's git common directory (cargoTargetDirincargo.go), passed as--target-dir. The test worktree is removed after every run, and Cargo's defaulttarget/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 owntarget/. Only the first run in a clone builds cold; give that run a raised bound on a large workspace.- An absolute
CARGO_TARGET_DIRnames the disk to build on, and the gate builds intopraetor-gatebelow 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 compilesCARGO_MANIFEST_DIRinto it, so a shared folder would hand the checkout a binary that reads the removed gate worktree (TestRunCargoTests_Negative_OperatorTargetDirKeepsCheckoutBinariesbuilds 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
--pathnames 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.RunCommandalready 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.jsonplus exactly one lockfile:package-lock.json,pnpm-lock.yaml, oryarn.lock. Apackage.jsonwithout 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_modulesstay 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 viayarn --versionto 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
testscript when declared and thelintscript when declared. The checkout's ownnode_modulesis never linked in: it may hold other versions than the lockfile pins. - What is not applicable: When
package.jsondeclares neither atestnor alintscript, the test stage is not applicable, stated asno 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). Whenpackage.jsonhas no lockfile, toolchain stages do not run and--admit-unsupportedadmits 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.nameor one of itsaliasesis 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 itstimestamp, else the document'stimestamp, the time the document was issued; two statements sharing the latest review time fail the advisory. The document'slast_updatedis never inherited: every edit of the document moves it, and a statement inheriting it would be renewed without a review.vexctl addwrites atimestampon the statement it adds. Before you move the document'stimestamp, give the statements without a time of their own the previous one, as "Updating Statements with Inherited Data" in the OpenVEX specification shows; - its
statusisnot_affectedwith ajustificationlabel and animpact_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 newlast_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 versionnames 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 setsgo-version-file: go.modon theactions/setup-gostep of each job that runs the gate (.github/workflows/ci.yml,.github/workflows/security.yml), so it takes the patch release named by thetoolchaindirective, which Renovate keeps current, not the newest one theactions/go-versionsmanifest lists (the manifest lags a release). - Until you can upgrade, a
not_affectedstatement 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-serviceflavor writes runsgovulncheck ./..., which fails on a called symbol only. The fuller package/module/VEX judgement remains ingate runand 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 auditrecognizesPRAETOR_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 runmake verify-allin the checkout). The stage prints one line naming every linked folder. Python virtualenvs (.venv,venv) are not linked:python -m venvrefuses 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 cidoes) 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 ofmake verify-allstill fails the stage. - Bound: defaults to the race stage's default (
20m), or can be declared in.standards.yamlviagate: 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_TIMEOUToverrides 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 examplecreating the isolated test worktree hit the 1s stage bound (.standards.yaml)(#1566). Output capture is bounded byutil.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 rootpackage.jsonwith 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 insideverify-all(PRAETOR_GATE_DECLARED_VERIFYis set in the command's environment, so averify-allthat runs the gate does not recurse). - Skipped, with the reason: a dry run, and a host without
makeonPATH.
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 statuslists: the repository's ignore files,.git/info/excludeand 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 statusnever compares; - an untracked
.standards-baseline.json,.gosec.jsonor OpenVEX document (security.go_vex, defaultsecurity/vex/go.openvex.json), hidden fromgit statusby 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) -- andgit statusdoes not list an ignored file, so each present one must be tracked in the index (subtractiveInputsininternal/gating/tree.go,util.GitUntrackedPathsininternal/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 statuscompares 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, theHEADthatpraetorctl baseline --recordran 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"
}
anchoris 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_numberis 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 --recordclears 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.lockscans at the limitpraetorctl auditenforces. - A manifest without a lock scans at the HISS-04 ceiling tightened by its
overrides.complexity.max_func_loc. - A tree without
.standards.yamlscans 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 auditfails 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_DEVCONTAINERis unset orauto, and the run is not a dry run;- the repository root holds a
go.modand.devcontainer/devcontainer.json; - the user has a cache directory (
os.UserCacheDir) to keep the container'sHOMEand the pinned devcontainer CLI in; - the host is not Windows, and
dockerorpodmanis onPATHand answers<runtime> infowithindevcontainer.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 verifyapplies (verifyRecordedCompanionsininternal/devcontainer/bootstrap_io.go); - the build's
dockerfileandcontextresolve inside the repository; - a configuration that declares
featuresis on a platform the gate pins Node for:linux-x64,linux-arm64,darwin-x64ordarwin-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 onPATH.
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:
- It downloads
node-v<version>-<platform>.tar.gzfromhttps://nodejs.org/distand refuses it unless its SHA-256 is the pinned one. It then unpacks the archive (util.ExtractTarGz). - 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.jsonfrom thenode-versiondatasource, 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:
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
govulncheckandgosec, 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.organd 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
HOMEis the gate's, so~/.netrc, git credential helpers, SSH keys andSSH_AUTH_SOCKare absent there. A repository whose private modules need them fails the container's prefetch stage; setPRAETOR_GATE_DEVCONTAINER=offto 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:0mapping and rootless podman's--userns=keep-idmapping are covered by the command-line tests only. A docker daemon withuserns-remapgets 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 afterToolchain stages ran in:ascleanup:and carried asexecution.cleanupin--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=offruns 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 1placeholdernpm initwrites. Another declared package manager requires an explicit project contract.internal/nodemanifest/scripts.goholds both decisions. Thetypescript-nodeCI scaffold readspackageManagerand 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 readerforge.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 toobj/praetor-build.sarifrelative to that project. Restore createsobj/before CoreCompile; a customErrorLogparent must exist before then or the compiler can fail with CS0016. The command encodes the version separator as%2Cin-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.inior.pytest.ini(even empty), apyproject.tomlwith a[tool.pytest]or[tool.pytest.ini_options]table, atox.iniwith a[pytest]section, or asetup.cfgwith a[tool:pytest]section (pytest configuration reference). The report names the file and table it used. Apyproject.toml,tox.iniorsetup.cfgwithout 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 (pytestConfigurationininternal/adopt/verification_pytest.go, tested byTestVerificationPytestConfigurationPositiveandTestVerificationPytestConfigurationNegative).tox.iniandsetup.cfgalso 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'sunreadablelist says why, and the report's verification warning names the file (TestVerificationUnreadablePytestConfigurationIsNotConfigurationandTestAdoptWithUnreadableSetupCfgWarnsInsteadOfFailingininternal/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.zigrunszig 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.zigis 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, sincezig build testexists 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 forbuild.zig. Abuild.zig.zonalone selects nothing, and abuild.zigbelow the root is not a marker (internal/adopt/verification.goaddZigVerification,internal/adopt/verification_zig_test.go). The walk skips the trees Zig writes inside the checkout,zig-pkg(fetched packages),zig-outand.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,
--forceor not. The recognised renderings are listed by SHA-256 of their LF text inpriorLefthookDigests;internal/adopt/testdata/lefthook/reproduces each one. A CRLF checkout of one (core.autocrlfon 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;
--forcerewrites 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,
--forceincluded, nor activated: the audit checks only thatlefthook.ymlexists. 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 incommandsorscriptsmaps or in ajobslist; 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, removelefthook.ymland re-run adopt. - A configuration that reaches
.config/lefthook/praetor.yml, the vendorable canonical policy, throughextendsor through theconfigsof aremotesentry is kept the same way, and so is.config/agent/hooks/block_evasion.pybeside it, so the policy and its scripts stay one version. The skip names the policy; update it as.config/lefthook/README.mddescribes.
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'srepository.ownerandrepository.namewhen 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-allonly when the repository has that target after the run: the one adoption generates, or a repository-owned one it preserved. Whenadoption.declinelistsmakefileand no custom target exists, the block namespraetorctl compile-context --verify,praetorctl caveman check --configured-sourcesandpraetorctl auditinstead, and the footer drops itsmake verify-allline (TestHarnessEntrypointFollowsVerifyAll). - Verification line. It calls
make verify-allthe repository's own gate and points at theMakefilefor 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). Whenadoption.declinelistsmakefile, 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'sverification.declaredlist, and the harness footer lists them, so--forceno longer reduces them tomake verify-all(TestAdoptForceKeepsOwnerAndDeclaredCommands). - Receipt. Only
praetorctl gate runmints an Ed25519 Exit-0 receipt. The harness says so, tells agents to report no receipt that command did not mint, and ties none toverify-all(TestHarnessMakesNoUnbackedClaims). - Server side. Adoption installs no CI job that runs a
praetorctlgate; 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 thedocs:seo-portalfacet is declared;.github/workflows/praetor-api.yml, which runs the Go API compatibility gate (go run tools/apicompat/gate/main.go), when theapi:public-contractfacet is declared and git tracks ago.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'sci.ymlrunninggo vet ./...andgo test -race ./..., or a Rust flavor's runningcargo fmt,cargo clippyandcargo test. A workflow the repository already owns, and every flavor workflow whenadoption.declinelistsworking-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.