Effective audit policy
CLI praetorctl audit, MCP standards_audit and retained public adoption loops
resolve complexity from the same internal/config implementation. The existing
HISS scanner consumes the resolved max_func_loc. CLI/MCP audits report the same
effective digest for the same input snapshots and show the sources that impose
the limit. Public-loop reports retain the planned policy and each verification's
policy digest, applied limit and baseline evidence.
The first migration covers audit function length. Cyclomatic complexity, cognitive
complexity and statement limits are resolved and retained in the typed policy; the
HISS-04 scan measures against them and fails on none. .config/archetype-coverage.yaml
names every function that reads each key, including the generated editor settings that
receive the cyclomatic and statement limits.
Routing, budgets, credentials and deployment activation are separate consumers.
Pinned profiles and facets contribute every lattice dimension, not only
complexity: branch protection, supply chain, memory, error unwraps, linters and
DevContainer features. The strictness lattice
lists each dimension's join rule. praetorctl adopt renders
.github/rulesets/main.json from the resolved branch protection, and
praetorctl sync and the audit's locked documentation gate check the ruleset
against that same policy (TestSyncRulesetFollowsTheJoinedProfileBranchProtection,
TestAuditDocumentationGateUsesTheEffectiveBranchProtection). When the policy
does not resolve, for example because the pinned catalog is not materialized and
no --catalog-root is given, sync has no policy to check against: it prints
[UNVERIFIED], neither validates nor synthesizes the ruleset, and exits
incomplete without reaching the forge. A lock the catalog cannot verify is the
same cause and is counted once
(TestSyncLeavesTheRulesetUncheckedWhileThePolicyIsUnresolved,
TestSyncCountsAnUnresolvedPolicyOnceBesideAVerifiedLock). Memory and error
unwraps are resolved and sealed into the digest; no gate reads them yet, and the
coverage manifest records both as unconsumed
(Every key names its consumer).
The linter list is read by the audit's declared-linters gate (Declared linters).
Dev Container feature resolution reads the exact pinned catalog artifacts and selects
language-scoped features from the repository root's go.mod and Cargo.toml; mixed-language
repositories receive both feature lists (archetype authoring).
Public adoption loops resolve their policy during the dry run, then independently scan the original source under that policy before applying changes. Applied policy must match the plan. Verification compares the saved baseline's entries with the independent original scan and evaluates current violations against that anchor. Unchanged legacy debt is retained; new violations and violations in touched files fail. Old public-loop reports produced with a fixed scanner limit do not establish verification under a stricter policy; rerun those cases. Public loops currently select repository and pinned-catalog constraints; external fleet/organization/deployment/workstation paths are audit arguments, not public-loop options.
Declared linters
A profile or facet can declare linters, for example security:high declares gitleaks and
trivy. praetorctl audit fails when a declared linter is run by no CI workflow under
.github/workflows, because a declared scanner that never runs protects nothing
(internal/adopt/linters_audit.go, cmd/standardsctl/audit_linters.go, TestAuditDeclaredLinters_CLI).
The verdict names each missing linter and the profile or facet that declared it
(config.EffectivePolicy.DeclaredLinters in internal/config/effective_linters.go).
A step runs a linter when its uses: action, or a command in its run:, invokes the tool:
- the program itself, or a runner that names it:
npx,bunx,uvx,pipx run,pnpm dlx,go tool,go run, a container image named after it; version pins and quotes are ignored (TestCommandRunsTool_RunnerSpecs,TestCommandRunsTool_GoRunners); - a wrapper such as
xargs,env,find -exec,sudo(TestCommandRunsTool_WrapperPrograms); - a repository script named after the tool, or one handed the tool through
--<tool>(TestCommandRunsTool_ScriptTools,TestCommandRunsTool_OptionNamedAfterTool); - a
maketarget whose recipe runs it, followed through prerequisites and Makefile variables (TestAuditDeclaredLinters_PositiveMakeDepth2,TestMakeVariableExpansion); - a golangci-lint linter counts as run when
golangci-lintruns and the repository's golangci configuration enables it (TestAuditDeclaredLinters_GolangciSubLinter).
A name in echo or in a comment does not count, nor does a step with continue-on-error or a
job whose if: is false (TestAuditDeclaredLinters_NegativeEchoOnlyPlanted,
TestAuditDeclaredLinters_NegativeCommentOnly, TestAuditDeclaredLinters_NegativeContinueOnError,
TestAuditDeclaredLinters_NegativeIfFalseJob).
A linter whose input files the repository does not have is skipped with a [SKIP] line, never
silently: a Go linter without Go sources, buf without .proto files, spectral or
oapi-codegen without an OpenAPI or AsyncAPI document, and so on. A linter that table does not
know is always required (TestAuditDeclaredLinters_Applicability_Skip,
TestAuditDeclaredLinters_UnknownLinterRequired).
To schedule a missing CI job instead of failing today, add a dated declared-linters entry to
the exceptions list (Exceptions). An expired entry fails like
a missing one, and an entry for a linter a workflow now runs is stale and fails until it is
removed (TestAuditDeclaredLinters_ExceptionsLifecycle). praetorctl adopt writes such an entry
for every declared linter that no workflow it scaffolds runs, keeps live entries on re-adoption
and drops one once a workflow runs the linter
(internal/adopt/linters_exception.go, TestAdopt_DeclaredLintersExceptionsFreshAdoption,
TestAdopt_DeclaredLintersReAdoptDropsWhenWorkflowAdded).
Per-file linter exclusions
When a repository configures exclusions for declared linters in .golangci.yml or
.golangci.yaml (linters.exclusions.rules[]), each rule must carry a category and expiry
comment directly above the list item or trailing on its first line (internal/adopt/linters_exclusion.go,
TestAuditDeclaredLinters_Exclusion_Passing):
linters:
exclusions:
warn-unused: true
rules:
# category C, entered 2026-10-18, expires 2026-10-28, #133
- path: internal/util/
linters:
- gocyclo
Comment format: category <A|B|C|D>, [entered YYYY-MM-DD, ]expires YYYY-MM-DD, #<issue number>.
The validation reuses exception category bounds (internal/config/exceptions.go): category A uses its stated expiry, at most 90 days, B allows 3 days, C allows 10 days, and D allows 120 days. A rule without the comment,
with an invalid date or category, with an expired date, or exceeding its category interval fails the audit
naming the file, line, linters, path, and failure reason (TestAuditDeclaredLinters_Exclusion_Planted_MissingComment,
TestAuditDeclaredLinters_Exclusion_Planted_ExpiredRule,
TestAuditDeclaredLinters_Exclusion_Planted_CategoryCExceededInterval). Rules targeting linters not
declared for the repository are ignored (TestAuditDeclaredLinters_Exclusion_Boundary_UndeclaredLinterIgnored).
When golangci-lint configuration is stored in .golangci.toml or .golangci.json, exclusions cannot
be inspected with YAML comment preservation; the audit reports [SKIP] Declared linters: exclusions not read (format)
as a named gap rather than silently passing (TestAuditDeclaredLinters_Exclusion_Boundary_TomlConfigGap).
When praetorctl audit is invoked with --linter-log <path> pointing to a golangci-lint log output
(stderr), the audit verifies that every exclusion rule matches at least one finding. The configuration
must enable linters.exclusions.warn-unused: true so golangci-lint logs rules that skipped nothing
(Skipped 0 issues by rules: [...]). Rules that match no finding fail with matches no finding: remove it
(TestAuditDeclaredLinters_Exclusion_Planted_StaleRule).
The log must come from a run over the whole repository (./...): a run over some packages logs
Skipped 0 issues for every rule whose file lies outside them, and each would read as stale.
Without a log, the audit records the gap with [SKIP] Declared linters: stale check not run: no lint log given.
When linters.exclusions.warn-unused is not true, the audit records the gap with
[SKIP] Declared linters: stale check not possible: linters.exclusions.warn-unused is off.
Generated test exceptions block and ratchet
When replacing file-class exclusions (such as _test\.go) with granular, dated per-file exceptions,
praetorctl linters exceptions generates and ratchets an anchored exceptions block inside
linters.exclusions.rules[] (internal/adopt/linters_generated_exceptions.go,
TestGenerateExceptions_FormatAndOrder_Positive, TestGenerateExceptions_Ratchet_Positive_LowerCountRenews):
linters:
exclusions:
rules:
# hand rules stay outside the block
- path: internal/util/
linters:
- forbidigo
# praetor:generated-exceptions begin (praetorctl linters exceptions; never edit by hand)
# category C, entered 2026-10-18, expires 2026-10-28, #1030
- path: ^cmd/standards-lsp/fuzz_test\.go$
linters:
- gocognit
# praetor:generated-exceptions end
The block is bounded by comment markers # praetor:generated-exceptions begin (praetorctl linters exceptions; never edit by hand)
and # praetor:generated-exceptions end. Each rule targets exactly one (file, linter) pair with an anchored regular expression
(^<QuoteMeta>$) sorted by file path then linter name, carrying category C (10-day limit) and issue provenance (#1030).
Two commands drive generation and ratcheting:
praetorctl linters exceptions strip-config --out <path>: writes.golangci.ymlwithout the generated block and withrun.relative-path-mode: gitroot, allowing an unconstrained lint run to report all findings hidden by the block (TestStripGolangciConfig_3D).praetorctl linters exceptions generate --report <report.json> --issue <N> [--category C] [--today YYYY-MM-DD] [--write]: reads a golangci-lint v2 JSON report and applies ratchet invariants (TestGenerateExceptions_Ratchet_Positive_LowerCountRenews,TestGenerateExceptions_Ratchet_Negative_NewFindingRefused,TestGenerateExceptions_Ratchet_Negative_ExpiredRefused):- New finding: a pair present in the report but absent from the block causes exit 3 (
new finding in <file> (<linter>): fix it, a generated exception never grows). - Decreased count: clean pairs drop out, and all remaining pairs renew their entered and expiry dates.
- Unchanged count, active entries: no changes written, exits 0 (
unchanged: N entries live until <date>). - Unchanged count, expired entry: refusal with exit 3 (
count did not drop: N recorded, M measured; renewal needs a lower count).
A configuration without the generated block takes it at the end of linters.exclusions.rules, whose key the
generator finds by YAML structure: an earlier rules: key of another mapping, such as
linters.settings.revive.rules, is left alone, and a configuration that is not YAML is refused
(TestGenerateExceptions_Positive_InsertsIntoExclusionRules, TestFindRulesInsertionIndex_Negative_InvalidYAML).
The workflow is automated via the repository target make lint-exceptions (Makefile), which strips configuration to
a temporary directory, executes the pinned golangci-lint run to generate the JSON report, and runs praetorctl linters exceptions generate --write --issue 1030.
Sources and strictness
Resolution uses these sources in a fixed order:
- Built-in defaults (
config.DefaultPolicy): cyclomatic 15, cognitive 20,max_func_loc: 60, statements 75. - The repository's
.standards.lockand its pinned, materialized profiles/facets. - Explicit fleet, organization, deployment and workstation files, in that order.
- Repository
overrides.complexityfrom.standards.yaml. - The audit compatibility constraint:
max_func_loc: 60.
Repository overrides.branch_protection and overrides.supply_chain apply after
the join. They only tighten, except review_mode: single_maintainer, the one
explicit relaxation; a profile or facet cannot set review_mode.
overrides.actions and workflow_runs take no part in the join: the audit reads them from the
repository manifest and compares them with the live forge (Live Actions checks).
The loader still holds both to the manifest rules, so a malformed declaration fails the policy
resolution instead of reaching the forge (TestActionsPolicy_Negative_EffectiveLoaderValidates).
Every complexity constraint can only tighten an earlier limit. A later limit of
90 cannot override an existing 50. Omitted fields do not contribute. In
external files and repository overrides, explicit zero, negative, null,
noninteger and unknown complexity fields are errors. A profile or facet may
state 0 for "no bound", as upstream-fork does; that limit contributes
nothing, like an omitted one (TestLoadEffectivePolicyLooserFacetNeverLoosens).
Both 60s are one constant. hiss.DefaultMaxFuncLOC in internal/hiss/hiss.go
is the scanner's default; the built-in default, the compatibility constraint
(config.AuditMaxFuncLOC), adoption's legacy-debt scan and the editor, language
server and MCP inspection fallbacks all read it. Because the built-in default
already is the audit's length, the compatibility constraint never lowers the
limit; in an audit it only joins builtin:defaults-v1 as a tied contributor.
internal/config/func_loc_default_test.go pins every consumer to the constant.
What plan reports
praetorctl plan resolves this same policy, with the audit compatibility constraint applied, so a
dry run previews what the audit will enforce, pinned profiles included: plan and audit report the
same limits. Like sync and audit, plan --catalog-root (MCP standards_plan: catalog_root) reads the
pinned profiles from a catalog outside the repository; without it a lock whose catalog is not
materialized fails the preview (TestPlanEffectivePolicy_Boundary_CatalogRootSelectsThePinnedCatalog).
After the policy, plan previews the live Actions workflow permission comparison when it can read
the forge, and never fails on it; plan --offline skips it (Live Actions checks).
Resolving the policy needs a lockfile. In a repository that has not been adopted there are no pinned
profiles, so defaults plus the repository's overrides is the whole policy, and plan says so:
That preview states a function length of 60 there, the length the first audit after adoption
enforces, and so does the MCP standards_plan tool, which reports built-in defaults plus overrides
(cmd/standards-mcp/server.go). The built-in default used to be 100, so a lock-less preview
promised a length no audit accepted, and an override above 60 showed up in it.
Consequence of the 60-line default
Because strictness only tightens, max_func_loc: 60 wins against any archetype declaring more,
with or without the audit compatibility constraint. Ten of the fourteen shipped archetypes declare
a looser bound -- template-seed 100, app-service 80, seven at 75, library-client 70 -- and
every one of them resolves to 60.
An archetype's declared max_func_loc above 60 is therefore documentation of intent rather than an
effective limit. Read the audit output, not the archetype file, to learn what a repository is held
to. A repository can still tighten the limit with overrides.complexity.max_func_loc; nothing can
loosen it. The HISS-04 row adoption writes into AGENTS.md and the Paperclip harness states the
resolved length, and adds (audit ceiling) when that length is the 60-line ceiling. It states the
resolved max_cyclomatic, max_cognitive and max_statements too, which the audit does not cap,
so an override of any of the four reaches the row
(what the generated harness claims).
An explicitly selected external file must contain at least one owned root section:
complexity, backlog (Backlog caps), or one of the operator sections
clients, hooks, update, framework, forge and topology described in
Operator settings.
An empty mapping is valid and contributes nothing. Other existing sections may coexist, but this resolver does not activate them. A misspelled root key or an unsupported-only document fails instead of pretending a policy was applied.
Operator settings
The same external documents carry the operator's settings for agent clients, the
agent-hook command policy, workstation updates, and the operator data described in
Framework, forge and topology. There is no second settings
file: a fleet, organization, deployment or workstation document may hold these
sections beside complexity, or on their own. They go through the same loader and
bounds, and into the same effective digest.
audit resolves and seals these sections but does not act on them. Host commands
select the fleet and workstation documents through config.SelectOperatorSettings
and load the same schema through config.LoadOperatorSettings (or
config.SelectOperatorPolicy, which keeps the policy for relative paths). praetorctl
hook consumes hooks; praetorctl clients permissions consumes the AGY permission
selection under clients; the needs commands consume framework and
forge.default_owner. issue, milestone, project, init and forge
sync-wiki consume forge.default_owner too, and issue reconcile also reads
forge.reconcile_repos; topology audit and topology clean consume
topology.org_containers. Repository policy and host activation therefore share
one schema without making audit inspect a user's home directory.
A workstation document, the layer that holds host paths:
clients:
selected:
agy:
binary: /home/operator/.local/bin/agy
permissions:
manage: true
allow: ["mcp(hindsight/hindsight_list_knowledge_pages)"]
hooks:
command_policy:
deny: ['\bexample-org/']
update:
checkout: /home/operator/dev/example/praetor
Keys and defaults
Keys inside every operator section are strict. A misspelled key, an
unknown client or a value of the wrong type fails the document and names the key.
Every string passes the literal rule shared with the MCP registry
(internal/util/literal.go): no control byte, $, backtick, {env: or {file:.
| Key | Default | Accepted values |
|---|---|---|
clients.mode |
advisory |
advisory reports an ungoverned client and lets work continue; strict makes the client commands fail instead: a launch below discovered, a verify of a required client below observed |
clients.verified_max_age |
168h |
a duration from 1h to 2160h |
clients.govern |
present |
present governs every known client installed on the host; listed governs only clients.selected |
clients.selected.<id> |
every known client | <id> is one of agy, claude, cline, codex, continue, gemini, kilo, opencode-v1 (internal/clientid) |
clients.selected.<id>.required |
true |
boolean |
clients.selected.<id>.scopes |
[global] |
global and/or workspace, each at most once |
clients.selected.<id>.plugin |
true |
boolean |
clients.selected.<id>.binary, .config_root |
empty (look up at run time) | clean absolute path; workstation layer only |
clients.selected.<id>.registry, .connection_profile |
empty | clean path, relative to the file that sets it, or absolute |
clients.selected.<id>.permissions.manage |
false |
boolean; true appends the listed grants to the client's own list and never removes or widens one |
clients.selected.<id>.permissions.allow |
[] |
at most 32 literals of up to 512 bytes, merged across layers |
hooks.scope |
governed |
governed guards repositories that carry .standards.yaml; all guards every workspace |
hooks.command_policy.deny |
[] |
at most 64 RE2 patterns of up to 512 bytes, compiled at load |
hooks.admission.enforce |
false |
boolean; true refuses heavy commands outside praetorctl admit (heavy-step admission) |
hooks.admission.scope_form |
false |
boolean; true also accepts systemd-run --user --scope with MemoryHigh= and --expand-environment=no when host memory and swap readings pass |
hooks.admission.max_jobs_divisor |
3 (when enforce: true) |
integer >= 1; caps -j / --jobs on heavy commands at floor(cores / divisor) |
hooks.admission.commands |
[] |
at most 32 RE2 shapes of up to 512 bytes, compiled at load, added to the built-in ones |
hooks.python |
[python3, python, [py, -3]] |
up to 8 candidates: a bare command, or a list of a command and up to 8 arguments; an absolute path only in the workstation layer |
update.channel |
push |
push; release is reserved until release artifacts ship |
update.source |
checkout |
checkout; artifact is reserved |
update.checkout, update.bin_dir |
empty | clean absolute path; workstation layer only |
update.remote |
origin |
1 to 64 letters, digits, ., _ or -, starting with a letter or digit |
update.branch |
main |
1 to 128 of the same plus /, without .. |
update.pin |
empty | a 40-character lowercase commit id |
update.interval |
15m |
a duration from 5m to 24h |
update.require_signed |
true |
boolean |
update.allowed_signers |
empty | clean path, relative to the file that sets it, or absolute |
update.receipt_public_key |
empty | 64 lowercase hex characters (Ed25519) |
The engine ships no deny pattern and manages no client's grant list. Both are
operator decisions: organisation names belong in hooks.command_policy.deny, and
an operator host opts in to grant management by setting permissions.manage: true
for a client in its own layer, as the example above does for agy. Adopters who
set neither get neither.
config.ValidateCommandPolicyDeny is the one check for those bounds; the hook
policy validates through it.
AGY 1.2.7 grants use exact action(target) rules. The managed adapter accepts
read_file, write_file, read_url, execute_url, command and mcp; an MCP
target names exact server/tool, server/*, or global *. File and URL rules
accept only the documented global *, not partial globs. A read_url or
execute_url target is a bare lowercase host or IP literal, such as
read_url(example.test): AGY matches URL rules by hostname and subdomain, so a
scheme, port, path or userinfo is rejected, as is a DNS name whose last label is
decimal or 0x-hexadecimal, which WHATWG URL parsing reads as IPv4
(httpendpoint.CanonicalHost, tested in internal/httpendpoint/https_test.go and
internal/clientsetup/agy_permissions_test.go). Existing ask/deny URL rules are
compared by their host through net/url, but only when that host is unambiguous:
userinfo, a scheme without // (https:example.test), a DNS name with a bare
port (example.test:443 is lexically scheme:opaque), an empty or out-of-range
port, a percent sign, a backslash, or a host that is not canonical makes planning
fail closed with a cannot prove non-overlap diagnostic. The operator loader keeps the grant strings generic so
future clients can carry their own syntax, while clients permissions validates
the selected AGY rules before producing or changing a settings file.
An allow rule controls which matching call can proceed without approval. Do not
infer an active-workspace exception: an AGY 1.2.7 headless replay denied a native
read inside its active workspace when no matching read_file(...) grant was
loaded. A governed read-only review therefore starts AGY in a separate disposable
workspace and explicitly grants the reviewed repository through
read_file(...). Starting AGY inside the reviewed repository does not make that
repository readable or read-only by policy.
Framework, forge and topology
These sections carry operator data that earlier releases shipped as built-in values
(ADR-0014). Every key is optional and empty
by default, and an empty value never falls back to a maintainer's value. A document
without these sections keeps the digest it had before they existed
(internal/config/operator_framework_test.go).
| Key | Default | Accepted values | Read by |
|---|---|---|---|
framework.targets.<lang> |
absent | <lang> is go, typescript, python, rust or native (config.FrameworkLanguages) |
every needs subcommand, the MCP standards_needs_report tool and the needs-miner agent |
framework.targets.<lang>.module |
empty | a module path whose first element is a host, at most 256 bytes (config.IsModulePathShaped) |
the framework the language's demands are scored against |
framework.targets.<lang>.builder_kits |
[] |
at most 8 <owner>/<name> coordinates, each once |
the scan's builder kits; entry 0 routes needs requests, and its owner is the request's target_org |
framework.targets.<lang>.contract |
empty | clean path, relative to the file that sets it, or absolute | a version-1 capability contract declaring the framework's packages |
framework.targets.go.checkout |
empty | clean absolute path; workstation layer only; accepted for go only |
the framework checkout the needs commands observe |
framework.migration_branch |
empty, meaning refactor/framework-adoption |
a branch name as for update.branch |
the branch needs migrate reports for an admitted migration |
forge.default_owner |
empty | a GitHub owner: 1 to 39 letters, digits and single hyphens | the last owner step of config.ResolveRepositoryIdentity, used by needs epic --publish, issue, milestone, project, init and forge sync-wiki |
forge.reconcile_repos |
[] |
at most 256 <owner>/<name> coordinates |
the issue reconcile scope when --repos is not given; without either, the current repository |
forge.review_bot |
empty | a GitHub owner, optionally followed by [bot] |
validated and sealed; forge.AssignReviewers takes the bot list from its caller, and no command passes this key yet |
forge.claim_stale |
empty, meaning 6 hours | a duration from 10m to 720h |
the window after which an issue claim without an update may be taken over (praetorctl issue claim, issue claims) |
topology.org_containers |
[] |
at most 64 names of lowercase letters, digits, ., _ and -, merged across layers |
praetorctl topology audit and topology clean, which add the names to the built-in containers (topology.OrgContainers) |
A workstation document configuring one framework, with placeholder values:
framework:
targets:
go:
module: example.com/acme/kit
builder_kits: [acme/kit]
contract: frameworks/kit.capabilities.yaml
checkout: /home/operator/src/kit
forge:
default_owner: acme
How the needs engine reads the targets (internal/needs/targets.go):
- No target configured. Praetor ships no framework. Every
needscommand still classifies dependencies into capabilities and reports the framework as not configured: mapping availability renders asn/a (no target framework configured), never 0%,needs migrate --applyandneeds epic --publishrefuse, and requests are unrouted (ADR-0014 ยง4; the full table is under Not configured). - Any target configured. Only the configured languages have a framework. A demand is mapped only when the target's contract declares a package for it; otherwise it keeps its capability and is a gap.
- Framework selection for
report,aggregate,migrate,epicandcontract export:--framework, then$PRAETOR_FRAMEWORK_DIR, thenframework.targets.go.checkout; without a checkout,framework.targets.go.contractdeclares the framework (coverage basiscatalog-declared); without a contract the go target's module names it. Nothing selected reportsFramework: not configured (set framework.targets.<lang>.module and .contract, or pass --framework). The former default checkout under the dev root is gone. - Contracts. A contract's
ecosystemmust match its language (go,npm,pypi,cargo,system).praetorctl needs contract export --language=<lang> --out=<file>writes the framework a target resolves to as a contract that can be configured in its place; see needs capability evidence.
Every needs subcommand accepts --fleet-config, --workstation-config and
--manifest, the flags clients permissions uses; the selection order is the one
below.
How layers merge
Layers apply in the order listed under Sources and strictness: fleet, organization, deployment, workstation.
- A later layer replaces a scalar.
EffectivePolicy.OperatorFieldsrecords the layers that set each concrete key, for exampleclients.selected.agy.permissions.allow. - Once a layer sets
clients.mode: strict,clients.govern: present,required: true,update.require_signed: true,hooks.scope: allorhooks.admission.enforce: true, a later layer cannot loosen it. The error names both layers. A built-in default is not a layer, so the first layer may choose either value. hooks.command_policy.deny,hooks.admission.commandsandpermissions.allowappend without duplicates. The bound applies to the merged list, so 40 fleet patterns plus 25 new workstation patterns fail.topology.org_containersappends the same way, bounded after merging;framework.targets.<lang>.builder_kitsandforge.reconcile_reposare replaced by a later layer.- Two layers giving one client different non-empty
registryvalues fail. - Client binaries, config roots, the fork checkout,
bin_dir, the framework checkout and absolute interpreter paths are host data. Any layer other thanworkstationthat sets one fails.
A relative registry, connection_profile, allowed_signers or framework contract
value keeps its spelling in the digest, so moving the documents does not change it.
EffectivePolicy.ResolveOperatorPath resolves it against the directory of the
file that set it.
A policy whose layers carry no operator section keeps the digest it had before these sections existed. The operator sections therefore did not invalidate retained plans and repair anchors; the 60-line built-in default did (see Provenance and migration).
Which documents the hook, client and workstation commands use
audit and gate take the explicit flags above and read nothing else. For the
hook, client, workstation and needs commands and the MCP standards_needs_report
tool, config.SelectOperatorSettings in internal/config/install_manifest.go
resolves each document in this order (hook, the needs-miner agent and the MCP tool
take no flags, so they start at step 2):
- The command's
--fleet-configor--workstation-configflag. PRAETOR_FLEET_CONFIGorPRAETOR_WORKSTATION_CONFIG.- The path the install manifest recorded. The manifest is
praetor/install.jsonunder the per-user configuration directory; theworkstation installandworkstation updatecommands write it when they ship. The recorded path is used only while the file's bytes still match the digest the manifest recorded. A changed or missing file is an error that says to rerunworkstation updateor pass the path explicitly. - Not configured: the built-in defaults.
There is no separate pointer file. The manifest reader rejects unknown and duplicate members and checks every field.
Tests: internal/config/operator_sections_test.go (four-layer merge with
contributors, loosening, host data, bounds, digest),
internal/config/operator_framework_test.go (framework, forge and topology keys,
the digest golden and the replay of testdata/operator/framework.yaml),
internal/config/repository_identity_test.go (owner resolution) and
internal/config/install_manifest_test.go (manifest and selection order), with
fixtures under internal/config/testdata/operator/.
Backlog caps
A backlog cap bounds how many open items one backlog category may hold (#792). Declare it in
.standards.yaml, in a profile or facet, or in an external document, all under the same keys:
praetorctl state status then prints each capped category after the session lines:
Backlog Cap: defects: 81 of 80, OVER the cap (max 80 set by repository, action gate set by repository)
Backlog Cap: questions: 4 of 10, under the cap (max 10 set by repository, action report set by builtin default)
defects: run 'praetorctl state batch' to write its batch
A category no layer caps has no bound, and state status prints nothing for it.
Categories
| Category | What is counted | Re-check before a batch |
|---|---|---|
defects |
rows of .workingdir/BUGS.md whose status is open or investigating and whose kind is not scope (row kinds); a row without a kind is counted and reported as a finding |
the bug ledger's location re-check (bugledger.CheckLocation, the check praetorctl bugs audit runs): a Go file:line that no longer resolves is marked; any other location is marked not re-checked |
tasks |
every open item of the state ledger: the pending - [ ] rows of .workingdir/OPEN.md (the rows praetorctl state task list numbers, named task <n>), then the pending rows of .workingdir/BACKLOG.md, the deferred workstreams (named BACKLOG.md:<line>). The discharged sections hold the completed rows state task archive moves there, so they count nothing; a pending row under a Discharged heading is counted and reported as a finding |
none; each item is marked not re-checked |
questions |
pending rows of .workingdir/QUESTIONS.md |
none; each item is marked not re-checked |
forge_alerts |
nothing yet: praetor has no reader for code-scanning, dependency or secret-scanning alerts, so the category prints as not counted with that reason, never as 0 |
none |
The counts come from the ledger readers in internal/state (internal/backlogcap/backlogcap.go);
BACKLOG.md is read by state.ListBacklogTasksContext, the OPEN.md task parser applied to it
(TestBacklogCap_Positive_TasksCountBacklogWorkstreams,
TestBacklogCap_Negative_BacklogRowsAloneTripTheGate). Every row is examined; a ledger holding
more than 10,000 open items fails the count naming that bound rather than counting low
(TestPendingTasks_Boundary_FailsAboveTheBoundNeverTruncates).
A category is counted only when every ledger it reads exists and can be read. Otherwise its line
says not counted with the reason, never 0:
Backlog Cap: tasks: not counted: .workingdir/BACKLOG.md is not present in this checkout (max 40 set by repository, action gate set by repository)
Backlog Cap: questions: not counted: QUESTIONS.md line 4: ... (max 10 set by repository, action report set by builtin default)
An unreadable ledger, such as a BACKLOG.md with an unterminated code fence, affects only its own
category; the others are still counted (TestEvaluate_Negative_ReadErrorStaysInItsCategory).
Keys, boundary and actions
| Key | Accepted values |
|---|---|
backlog.caps.<category>.max |
an integer from 1 to 10,000, the bound one count reads (config.MaxBacklogCap) |
backlog.caps.<category>.action |
report (the default once a max is declared), batch or gate |
The cap is inclusive, like max_func_loc: max is the largest count within the cap. A count
equal to max is at the cap and passes; only a count above it is over the cap and triggers the
action (TestBacklogCap_Boundary_ExactlyAtTheCapPasses,
TestBacklogCap_Negative_OneOverTheCapBatchesEveryItemAndGateFails in
internal/backlogcap/backlogcap_test.go).
Each action also does what the weaker ones do:
report:state statusmarks the category over its cap.batch:praetorctl state batch [dir] [--date=YYYY-MM-DD]writes.workingdir/batches/<category>-<date>.md. It lists every counted item once, grouped by file fordefects, by ledger and the heading above the row fortasks(OPEN.md: In-Flight Tasks,BACKLOG.md: Future Workstreams), and as one group forquestions; each item carries its re-check verdict, and the findings follow. The date defaults to today in UTC, and the same input writes the same bytes (TestWriteBatches_DateAndDeterminism).gate:praetorctl auditfails, naming the category, its count and its cap:[FAIL] backlog cap defects: 81 items, over the cap of 80 (action gate, max set by repository). A gate on a category praetor cannot count, because it has no reader (forge_alerts) or its ledger cannot be read, fails too: a gate that cannot count is not a passing gate (TestBacklogCap_Negative_UncountedCategoryIsNeverZeroininternal/backlogcap/backlogcap_test.go,TestAuditBacklogCaps_Negative_ReadErrorFailsOnlyAGateincmd/standardsctl/state_backlog_test.go). A category with actionreportorbatchwhose ledger cannot be read prints a[WARN] Backlog capline and does not fail the audit.
The audit counts the ledgers of the checkout it audits. A checkout without .workingdir, such
as a CI clone, holds no ledger, so a gated category cannot run its gate there. It is not counted,
never counted as 0, and the audit says so instead of passing:
[SKIP] Backlog cap defects: not counted: .workingdir/BUGS.md is not present in this checkout (max 2 set by repository, action gate set by repository)
[SKIP] Backlog caps: the gate on defects did not run: the ledger is not present in this checkout.
The summary is [PASS] only when every gated category was counted and is within its cap, and
[INFO] when no category declares gate (TestAuditBacklogCaps_Boundary_AbsentLedgerSkipsTheGate).
The MCP standards_audit tool does not run this gate and names it among the CLI-only gates in its
summary.
An unknown category, an unknown key and an unknown action fail the document and name the key,
for example backlog.caps: unknown category "bugs". An action that no layer gives a max to act
on fails the resolution (TestBacklogCaps_Negative_ManifestValidationNamesTheKey,
TestBacklogCaps_Negative_ActionWithoutMaxFails in internal/config/backlog_caps_test.go).
Where a cap comes from
Caps resolve through the sources complexity resolves through, and
join the same way: a cap only tightens. The lowest declared max and the strictest action win
whichever layer declares them, so a fleet default, a profile override and a repository override
each win when each is tighter than the one before, and nothing loosens a cap a layer imposed
(TestBacklogCaps_Positive_FleetProfileRepositoryEachWinInOrder,
TestBacklogCaps_Negative_LooserLayerNeverLoosens). max and action join separately: a fleet
gate with a repository max yields the repository's max gated.
EffectivePolicy.BacklogFields names the layers that set each value, keyed
backlog.caps.<category>.max and .action; an equal value from two layers names both. The audit
evidence line prints it, backlog.caps.defects: max=30 (repository) action=gate (repository), and
state status prints it as set by. An undeclared action is report, printed as set by the
builtin default. A policy that declares no cap leaves both out of its digest, so its effective
digest is unchanged.
state status and state batch take the audit's source flags (--catalog-root,
--fleet-config, --organization-config, --deployment-config, --workstation-config) and
discover nothing implicitly. A repository without .standards.yaml has no caps; one without
.standards.lock resolves without pinned profiles and facets, as plan does
(config.LoadUnadoptedEffectivePolicyContext); its notice then names any external document
that still applies, for example no .standards.lock: built-in defaults, repository overrides and
the fleet policy only; no pinned profile or facet applies (TestStateBatch_NoLockNoticeNamesTheFleet).
A policy that does not resolve is printed by state status as
Backlog Cap: unresolved: <error> when a layer declares a backlog section, or when praetor
cannot tell, because the manifest, an external document or a selected catalog profile cannot be
read (config.DeclaresBacklogContext). With no backlog section in any layer it prints nothing,
as for a repository without caps (TestStateStatus_Boundary_AbsentLedgerAndUnresolvedWithoutCaps).
Bounded backlog facet
The engine facet backlog:bounded (.config/archetypes/facets/backlog-bounded.yaml) provides
canonical backlog caps for repositories adopting bounded backlogs:
defects: max 20, actiongatetasks: max 50, actionbatchquestions: max 10, actionbatch
Enable it by listing backlog:bounded under facets: in .standards.yaml:
A repository listing this facet fails praetorctl audit when open defects exceed 20, reporting the
category, count, and cap. Open tasks (above 50) and open questions (above 10) trigger batching
actions but do not fail the audit gate.
Exceptions for backlog caps
When a repository temporarily exceeds a backlog cap, an unexpired exception under the top-level
exceptions list in .standards.yaml suspends the gate for that category until the declared
expiry date. The entry names the category rule (backlog.caps.<category>) and targets tool: backlog:
exceptions:
- rule: backlog.caps.defects
tool: backlog
expires: 2026-11-15
reason: "Triaging defect backlog from Q3 migration"
Supported rule identifiers are backlog.caps.defects, backlog.caps.tasks,
backlog.caps.questions, and backlog.caps.forge_alerts. Target validation enforces tool: backlog
and rejects path or glob entries, as .workingdir ledgers are private and Git-ignored.
While the entry holds (through its expires day), praetorctl audit passes, prints the
category as over its cap and excepted until that date with the entry's reason, and names it in
the summary (TestAuditBacklogCaps_BoundedFacet_DefectsException):
[INFO] Backlog cap defects: 21 of 20, OVER the cap, excepted until 2026-11-04 by the exceptions entry (rule backlog.caps.defects, backlog): triage after the migration (max 20 set by facet:backlog:bounded, action gate set by facet:backlog:bounded)
[INFO] Backlog cap tasks: 60 of 50, OVER the cap (max 50 set by facet:backlog:bounded, action batch set by facet:backlog:bounded)
[INFO] Backlog cap questions: 0 of 10, under the cap (max 10 set by facet:backlog:bounded, action batch set by facet:backlog:bounded)
[PASS] Backlog caps: every category with action gate was counted; over its cap and excepted: defects.
After its expiry the entry excuses nothing. The backlog gate (Report.Gate in
internal/backlogcap/backlogcap.go) fails a gated category over its cap as it would without
the entry, and names the expired entry:
[FAIL] backlog cap defects: 21 items, over the cap of 20 (action gate, max set by facet:backlog:bounded); the exceptions entry (rule backlog.caps.defects, backlog) expired on 2026-10-01
A gated category back within its cap still fails on an expired entry, naming it, until the entry
is removed. An entry for a category with action batch or report excuses nothing, because
those actions never fail the audit; once expired, it still fails the audit's last step, which
fails every expired entry whatever its rule, until it is renewed or removed.
Selecting a shared or private configuration
praetorctl audit --config /workspace/project/.standards.yaml \
--catalog-root /opt/praetor-catalog \
--fleet-config /configuration/fleet.yaml \
--organization-config /configuration/organization.yaml \
--deployment-config /configuration/container.yaml \
--workstation-config /configuration/workstation.yaml
Each path is optional. Omission means no external layer: files in a home directory
or repository are not automatically discovered. An explicitly selected missing
file fails. The catalog root contains .config/archetypes and its facets
subdirectory; every declared profile/facet must match the repository lock digest.
The default catalog root is the audited repository. A file that no longer matches
fails with ErrLockDigestMismatch, naming the profile or facet, the pinned digest,
the file and the digest it hashes to, in the same words the lockfile gate uses
(entryDigestMismatch in internal/config/lockdigest.go, tests in
internal/config/lock_mismatch_report_test.go), so the lock can be re-pinned from
the message.
Adoption writes the pinned catalog into the repository's own .config/archetypes
and, like every other file it writes, asks git whether the repository's ignore
rules exclude it. An excluded file is still written, so a local audit works, but
adoption reports an error for it: git will not commit the file, and a clean
checkout or CI run then audits without its pinned catalog. A kernel-style tree
that ignores a bare .config is the usual cause; there the git-ignore step
re-includes the root directory with !/.config/ in the managed block, and a declined
step gets the same rule proposed in the error. When git cannot answer (not
installed, not a work tree) adoption states the skipped check as a warning
(files the repository ignores).
Tests: internal/adopt/ignored_paths_test.go, internal/adopt/ignored_writes_test.go.
The same explicit configuration can be mounted into a container, bot, plugin or workstation. A private GitOps fork can own these files while consuming the public application. These flags configure one audit invocation; they do not install a global service or activate policy in other tools.
The corresponding optional MCP arguments are catalog_root, fleet_config_path,
organization_config_path, deployment_config_path and workstation_config_path.
They retain the server's existing path-confinement policy. Supplying a path does
not grant permission to read outside the server root. Operator-global server
configuration remains a separate integration step.
Provenance and migration
Resolution reads bounded regular-file snapshots, rejects symlinks, and hashes the
text decoded. A source in one consistent line-ending style hashes as its LF form,
so a Windows checkout (CRLF under * text=auto) reports the source hashes and the
effective digest of an LF clone, and its archetypes match the lock pins; a source
with mixed line endings hashes byte for byte, and a pin mismatch says so
(util.CheckoutTextDigest in internal/util/line_endings.go, tests in
internal/config/checkout_line_endings_test.go and
cmd/standardsctl/checkout_line_endings_test.go). Unsupported YAML aliases,
duplicate keys and multiple documents fail. Pinned sources must exist locally in the selected catalog;
lock metadata alone cannot establish the policy that was applied.
The effective digest includes ordered source identities and hashes, effective
policy values and field contributors. Diagnostic filesystem paths are excluded,
so relocating an identical catalog or mounting the same configuration elsewhere
does not change its identity. CLI/MCP text includes at most 16 source hashes and
an explicit omitted count. The typed result retains every source and contributor.
Memory and error unwraps are omitted from the encoding while unset, so a plan or
repair anchor retained before those dimensions existed still verifies
(TestResolvedPolicyOmitsUnsetDimensionsFromItsEncoding); a freshly resolved
policy carries both, so its digest differs from the one an older release reported.
The built-in function length is 60 lines (BUG-309), the same cap the audit
enforces. The builtin:defaults-v1 source hash encodes the default values, so a
changed default changes every effective digest. Unless a layer tightens function
length below 60, an audit lists builtin:defaults-v1 beside
builtin:audit-compat-v1 as a max_func_loc contributor. Public-loop plans and
repair anchors recorded under a different default fail with public verification
policy differs from the planned policy (internal/dogfood/repair_validate.go);
rerun the dry run to record a new anchor.
Existing repositories with lock pins but no local archetypes must either select a
matching source bundle with --catalog-root or materialize the pinned catalog
during adoption. The audit's lockfile digest gate hashes against that same catalog
and fails when none is materialized; the outcomes are listed in
Lock verification outcomes. The resolver supplies exact validated CatalogArtifacts for
bootstrap; these contain only selected profiles/facets, never external fleet,
deployment or workstation configuration. Files with changed digests are rejected.
Adoption copies those exact pinned profile/facet bytes into the target catalog,
then verifies the target with the default local resolver. Subsequent adoption
can run without the original source bundle. It checks the complete prospective
catalog before writing: duplicate IDs, invalid entries, directory bounds and
symlinks fail. Existing differing files require explicit --force; matching
files remain unchanged. Baseline recording uses the same resolved audit limit.
Adoption keeps baseline recording enabled by default, including its dry-run debt
estimate. Recording creates a first baseline; an existing one is kept and checked
under the same limit (the baseline on a re-adoption).
Both modes require verified local pins and their catalog, or an
explicit --lock-source-root for missing inputs. A dry run without these sources
fails instead of estimating debt with an unrelated default limit. Planning with
--record-baseline=false can explicitly omit that estimate; its report identifies
any unavailable policy verification.
LoadEffectivePolicyContext and ResolvePolicy are additive APIs. Existing
manifest parsing and runner hierarchy APIs remain available. The audit requires
materialized pinned sources by default and returns an error without them;
--catalog-root selects a matching source bundle.