Skip to content

Praetor Fast Adoption Guide

Apply Praetor governance scaffolding to a legacy or greenfield repository and retain explicit errors for incomplete steps. Verify the resulting repository before treating adoption as complete.


Prerequisites

  • praetorctl, installed with the workstation command (workstation install). standardsctl is the older name of the same binary.
  • A Praetor checkout (a Git clone of the engine; adopt refuses a git archive export, see the source-root rules below), here written /path/to/praetor. --lock-source-root names it: the first adoption pins your profile and facets to the content digests of its catalog, and copies the pinned catalog into your repository. Without it a first adoption stops with new lock pins require an explicit verified lock source root (ErrLockSourceRequired in internal/adopt/lock.go). A repository that already holds a valid lock and its .config/archetypes catalog needs no source for a later run.
  • A Git repository with your files committed, run from its root or with --path. Adoption writes the Go API compatibility gate only where Git tracks a go.mod (Go API compatibility gate), so adopt after the first commit and stage what adoption writes before you audit.
  • A known hosting forge. An origin remote on github.com, or repository.forge declared in .standards.yaml (github, forgejo or gitlab). Any other host with no declaration gets no Paperclip harness, and praetorctl audit then fails on the missing .paperclip/harness.json (Hosting forge).

Adopt a repository

Run praetorctl adopt (the older name standardsctl adopt is the same command):

# Adopt current repository (auto-detects language and frameworks)
praetorctl adopt --lock-source-root=/path/to/praetor

# Stage what adoption wrote, then verify the result
git add -A
praetorctl audit --offline

# Dry-run simulation: inspect proposed changes without writing files.
# --lock-source-root is required here too on a first adoption, because the plan resolves the
# policy the lock would pin.
praetorctl adopt --dry-run --lock-source-root=/path/to/praetor

# Regenerate drifted audit-locked files; an existing debt baseline is kept, not re-recorded
praetorctl adopt --force --lock-source-root=/path/to/praetor

# Replace an existing debt baseline with a rescan; a higher count needs the allowance
praetorctl adopt --rerecord-baseline --allow-increase --reason="why the debt is accepted"

# Change the declared profile or facets later, or re-pin to a newer catalog
praetorctl profile set os-image --lock-source-root=/path/to/praetor --dry-run

--force refreshes rather than resets: what it rewrites, merges and keeps is in What a forced re-adoption changes.

Choose the profile

Adoption detects the profile from the files it finds (the report's Archetype: line) and a dry run shows it before anything is written. When the detected profile is not the one you want, pass --profile on the first adoption, or move an adopted repository with praetorctl profile set <profile> --lock-source-root=/path/to/praetor. profile set rewrites the declaration, the lock and the catalog, then lists any audit-locked file that no longer matches the new declaration (such as the DevContainer) and the praetorctl adopt --force --lock-source-root=... --dry-run command that previews their refresh. Run praetorctl audit --offline afterwards. See Changing the profile, facets or catalog.

praetorctl init instead of adoption

praetorctl init writes .standards.yaml, a zero-debt .standards-baseline.json and an unpinned .standards.lock placeholder with no content digests, and compiles agent files from AGENTS.md only when that file already exists (onboarding guide). It takes no --lock-source-root flag; audit and devcontainer generate refuse the placeholder lock until you pin it with praetorctl profile set --lock-source-root=/path/to/praetor. Even pinned, an init-only repository does not pass audit --offline: init writes no branch ruleset, no documentation gate tooling (tools/markdownlint under docs:seo-portal), no API compatibility gate (tools/apicompat/gate under api:public-contract), no HISS-11 supply-chain exception, no Paperclip harness, agent definitions or git hooks, so use adoption for a repository that must audit clean.

--facets names the facets adoption writes when it creates .standards.yaml. Omitted, adoption writes the defaults of the detected profile (config.DefaultFacetsFor in internal/config/facets.go): security:high and agent:sandboxed for every profile, api:public-contract only for framework, library-client and web-package, which publish an API, and docs:seo-portal only for pages-site, framework and library-client, which publish documentation (TestDefaultFacetsFor_3D). A gitops-infra cluster repository gets neither an API gate over its tool modules nor a documentation portal it has no site for. An existing .standards.yaml keeps the facets it declares, none or an empty list included, and adoption ignores --facets for it (TestManifestForLock_FacetsScaffoldOnlyANewManifest_3D in internal/adopt/paths_read_test.go). adopt --help says both (TestAdoptHelpStatesTheDefaultFacets in cmd/standardsctl/profile_test.go). To change the facets of an adopted repository, see Changing the profile, facets or catalog.

The report's Facets: line shows the facets the run applies: the declared ones when .standards.yaml exists, whatever --facets names (TestAdopt_Positive_ReportsTheDeclaredFacets in internal/adopt/facet_selection_test.go). A --facets that differs from them, --facets= included, adds a warning naming both lists and the praetorctl profile set --facets=... command that changes them (TestAdopt_Negative_WarnsAboutIgnoredFacets). An overridden --profile warns the same way and names praetorctl profile set <profile>.

When a first adoption falls back to the default facets, notes under Facets: say so, say whether --facets was omitted or given empty (--facets= also declares the defaults), and name what each default facet raises over the detected profile alone in branch_protection and supply_chain, read from the pinned catalog (config.EffectivePolicy.FacetEffects in internal/config/facet_effects.go). For an empty repository adopted as template-seed:

Facets:             [security:high agent:sandboxed]
  default facets: --facets was omitted, so adoption declares the defaults of profile template-seed, security:high, agent:sandboxed, in the .standards.yaml it creates
  security:high raises over template-seed alone: branch_protection.required_approving_reviewers 1 -> 2, branch_protection.require_signed_commits false -> true, supply_chain.slsa_level 1 -> 3, supply_chain.enforce_cosign false -> true, supply_chain.require_sbom false -> true
  agent:sandboxed raises over template-seed alone: no branch-protection or supply-chain setting
  choose other facets with --facets=<id>,... on a first adoption, or afterwards with praetorctl profile set --facets=<id>,... --lock-source-root=<praetor checkout> (--facets= declares none)

TestFacetEffects_Positive_ShippedDefaultsOverTemplateSeed in internal/config/facet_effects_test.go pins these values against the shipped catalog. A run that resolved no pinned policy, such as --dry-run --record-baseline=false without --lock-source-root, says the effects are not listed instead (TestAdopt_Boundary_EmptyFacetsFlagAndNoPolicy). The report's JSON carries the same facts as facet_origin (declared, requested or default) and facet_notes, and the MCP standards_adopt tool prints each note as a facet note: line (TestFormatAdoptMCPResultPrintsFacetNotes).

Dry-run ruleset preview

adopt --dry-run writes nothing. For the branch protection ruleset (.github/rulesets/main.json), it prints what a real run would do and what the file would contain (AdoptReport.Previews, internal/adopt/preview.go). The MCP standards_adopt tool prints the same preview text (adopt.FilePreview.Text, TestFormatAdoptMCPResultPrintsPreviews):

Action Meaning Printed
create no ruleset yet; the run writes one the rendered ruleset
update the run replaces the ruleset: the rendering current before the run, which it refreshes, or any differing ruleset under --force while the policy requires one unified diff from the file on disk to the rendering
unchanged the ruleset already is the rendering, line endings aside nothing more
keep the ruleset differs and stays: --force was not passed, or the policy requires neither linear history nor signed commits the diff regenerating it would apply

The preview takes its action from the same keep-or-replace decision as the real run (internal/adopt/preview_test.go). Its status checks come from the workflows the run leaves before its branch-ruleset step, not only the ones on disk:

  • workflows the dry run records as written, replaced under --force or removed, such as the documentation gate's (TestReplaceExisting_3D_DryRunPlansTheReplacedBytes in internal/adopt/replace_test.go);
  • the workflows of the flavor the run applies for the profile it records (flavor.PlannedWorkflows), which a dry run plans but does not write.

On a first adoption the preview is therefore the file the run writes, byte for byte (TestAdoptDryRun_Positive_FirstAdoptionPreviewIsTheWrittenRuleset).

What a dry run lists

adopt --dry-run, --force included, lists every path the real run writes, with the action the real run records, under the same effective policy (AdoptReport.EffectivePolicy):

  • the flavor templates, which the flavor layer plans through the decisions an apply makes, writing nothing (flavor.ApplyOptions.DryRun, internal/flavor/dry_run_test.go);
  • each pinned catalog file below .config/archetypes/;
  • the persona and skill copies of the canonical personas and register skills the run writes (compiler.PlanAgentSurfacesOver);
  • the pre-commit hook that hook activation installs below .git/hooks. Only running lefthook install shows whether it succeeds; a failed one installs the fallback hook at the same path. A directory git does not know fails activation in both runs;
  • the ADR template and .paperclip/rules.md under their own paths. An existing one beside a missing ADR index or harness is kept, not overwritten;
  • an existing file the run overwrites, as replaced (Planned Replacements), never as created.

internal/adopt/dryrun_parity_test.go runs a dry run and a real run on two copies of one fixture, a first adoption and a forced re-adoption among them. It fails when either report has an entry the other lacks, when the real run writes a file no planned path names, when a report lists an existing file as created, or when the effective policies differ.

Which jobs the ruleset requires

Adoption, flavor apply, sync and the audit take the ruleset's required status checks from one function, forge.RequiredStatusContexts (internal/forge/workflow_checks.go). A job's check is required when the job reports on every pull request:

  • Its workflow triggers on pull_request without paths or paths-ignore. A filtered workflow does not run on every pull request, and a required check that never reports leaves the pull request waiting forever.
  • The job has no if:, or a condition that holds on every run: always(), success() || failure() or !cancelled(), bare or as one ${{ }} expression. A disjunction containing github.event_name != 'schedule', which holds on every pull request run, counts too (TestHoldsOnEveryPullRequestRun in internal/forge/required_contexts_in_test.go). So does a repository guard that holds for the manifest's identity (see Which workflows run where).
  • A condition that leads with the conjunct !(startsWith(github.head_ref, 'renovate/') && github.event.pull_request.user.login == 'renovate[bot]') is judged by the rest alone: the rest must be one parenthesised group or hold no top-level || (TestRenovateBranchSkipKeepsAJobRequired in internal/forge/workflow_guard_test.go). Only that exact term counts; a looser skip, such as the head branch test alone, makes the job optional. The term skips pull requests the Renovate app opened from a renovate/ branch only, and requiring the job keeps every other pull request protected. The engine's heavy jobs use it because its landing pipeline takes Renovate pull requests over (Renovate pull requests). A repository without such a takeover should not use it: a skipped job reports success, so nothing then verifies the Renovate pull request it merges.
  • The job is not advisory: continue-on-error is absent or false.
  • No proven aggregate job needs it directly (see the aggregate rule below).

Any other condition makes the job optional, a status function joined with anything else (always() && ...) included. GitHub reports a job its condition skipped as successful, so a lane that runs only when a planner job selects it would pass as a required check whether or not its work ran.

A draft skip, if: github.event.pull_request.draft != true on the job, is such a condition too, even in a workflow that runs again on ready_for_review: the skipped draft reports success, and once the draft is marked ready that success satisfies the ruleset until the new run reports (TestDraftConditionsAndRequiredContexts in internal/forge/workflow_draft_condition_test.go). The hosted gates adoption writes keep their job unconditional and refuse a draft in a step instead: on a draft the job reports a failed check by design, and the ready_for_review run reports the same check on the same head commit, which replaces the failure (When the workflow runs).

A repository that prefers the draft to skip the gate's work, not to run the gate job, can opt in with hosted_gates.draft: skip in .standards.yaml (fail is the default and the only other value; internal/config/hosted_gates.go). Adoption then renders praetor-api.yml and praetor-docs.yml from the same hosted gate definition with the draft step replaced by a job-level skip, github.event_name != 'pull_request' || github.event.pull_request.draft == false, on the gate job. The gate job, now named <context> gate, no longer reports the required check. A second job, <job id>-result, reports the original context: it needs the gate job, runs under always(), and fails unless the gate job succeeded (when the gate job was skipped it first prints the draft annotation the checkpoint planner reads), so a draft that skipped the gate job fails the required check until the ready_for_review run reports (ghworkflow.RenderDraftSkip, TestRenderDraftSkipPositive). The audit accepts the opt-in only while the job reporting the required check is a proven aggregate (the rule above) that needs the gate job and fails when it is skipped; otherwise it refuses and names the missing condition: no aggregate, an aggregate that passes on a skip, or one the proof cannot read (forge.DraftSkipFault, TestDraftSkipFaultRefusesWhatLetsADraftPass). It then locks the rendering byte for byte (TestAuditLocksTheDraftSkipRendering). The fail-closed text and the skip text refresh into one another with a plain praetorctl adopt, without --force, and an edited copy of either is kept (TestAdoptRendersTheSelectedDraftShape). The skip text of an earlier gate text refreshes too, once that text is in the family's Prior (TestPriorRenderingReadsASkipCopyOfAnOutgoingText); a change to the skip text itself fails the shipped-text ledger of the family until the outgoing skip digest is recorded there as well (TestShippedTextLedger). The skip shape is accepted by the HISS-18 workflow trigger audit only under the same guard (Workflow triggers).

The opt-in is a trade-off, not a way to a green draft. A draft still starts the <job id>-result job, because it runs under always(), and that job fails, so the required check stays red on a draft exactly as it does with the fail-closed step; what the skip saves is the gate job's own work. A repository whose contract allows no job to start on a draft therefore has to declare the result jobs (api-compatibility-result in praetor-api.yml, documentation-result in praetor-docs.yml) in the list of jobs that may start there; without that declaration the skip shape breaks the contract it was chosen to keep.

A repository guard is judged against the manifest identity. An operational fork resolves that identity to its repository.source, so the ruleset the fork commits equals the canonical one. On the fork's forge the guard is false, and a guarded matrix job is skipped before its legs exist, so none of their checks is ever reported. sync --remote therefore adds only the checks whose jobs report in <repository.owner>/<repository.name> (forge.RequiredStatusContextsIn) and prints the ones it left off (TestSync_Remote_RequiresOnlyChecksThatReportInTheRepository). It never removes a check the live ruleset already requires, so it warns about a left-off check an earlier sync added; remove that check from the live ruleset by hand (TestSync_Remote_WarnsAboutLeftOffChecksTheLiveRulesetStillRequires). The Platform Neutrality matrix is the case in point (HISS-21).

Path-filtered CI therefore gets its protection from an aggregate job. GitHub skips every job whose needs include a skipped job, and a skipped matrix job reports none of its per-leg checks, so a lane the planner skips can leave a required leaf check unreported forever. The ruleset requires the aggregate, and a job the aggregate needs directly is not a required check of its own when the workflow file proves that the aggregate fails whenever that job failed or was cancelled (internal/forge/workflow_aggregate.go).

The proof reads an allow-list of aggregate shapes and refuses every other one, so an aggregate it cannot read keeps the jobs it needs required beside it, as before. A proven aggregate:

  • needs at least one job;
  • runs under if: always() alone, bare or as one ${{ }} expression. A gate with no condition, with success() or with any other condition is skipped when a need failed, and a skipped gate passes a required check. !cancelled() and success() || failure() make the gate required but cover no job: on a cancelled run such a gate does not run, and whether GitHub then reports it as skipped or cancelled is not verified;
  • may lead its condition with the Renovate skip above. That gate is skipped on a Renovate pull request, so it covers only the jobs that carry the same skip; a job it needs without the skip stays required;
  • is not advisory and passes when every job it needs succeeded;
  • has only steps of the two shapes below. Any other step, such as a checkout, is refused: a step can change what a later one runs.

The first shape is an exit step: a run: step with no continue-on-error and no env:, under the runner's default shell or shell: bash, sh, pwsh, powershell or cmd (a custom template such as bash {0} is refused, whether the step, the job's or the workflow's defaults.run names it). Its script is lines of echo, printf, Write-Host or Write-Output, then exit <1..255>, then only blank and comment lines. Every line holds only ASCII letters, digits, spaces and .,:_'"=/+-#, and closes each quote it opens, so no line continues onto the next, opens a here-document, expands a variable, sets a trap or joins a second command. Its if: is a disjunction, bare or as one ${{ }} expression, of contains(needs.*.result, '<result>'), needs.<id>.result == '<result>' and needs.<id>.result != '<result>' terms. The aggregate covers each job whose failure, and whose cancellation, makes that condition hold.

merge-gate:
  name: Merge gate
  needs: [impact-plan, go, test]
  if: always()
  runs-on: ubuntu-latest
  steps:
    - name: Fail when a needed job failed or was cancelled
      if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
      run: |
        echo "::error::a needed job failed or was cancelled"
        exit 1

The second shape is a re-actors/alls-green step pinned by the full commit SHA of a release whose decision code praetor has read: v1.1.0 to v1.3.0, listed in allsGreenReleases in internal/forge/workflow_allsgreen.go. A tag, a branch or any other commit is refused, v1.0.x included, which declares no allowed-skips. The step has jobs: ${{ toJSON(needs) }}, no continue-on-error and no input the action does not declare. It has no if:: a condition on another need could skip it on the very run where a covered need failed, so an alls-green step with any if: is refused and the leaves stay required. allowed-failures and allowed-skips must be absent or literal lists of job ids, comma-separated or JSON. The action rejects a failed or cancelled job unless allowed-failures names it, so the aggregate covers every job it needs except those. It also rejects a skipped job that allowed-skips does not name, so name the path-filtered lanes there:

merge-gate:
  name: Merge gate
  needs: [impact-plan, go, test]
  if: always()
  runs-on: ubuntu-latest
  steps:
    - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0
      with:
        allowed-skips: go, test
        jobs: ${{ toJSON(needs) }}

The proof assumes that a listed commit runs the code read at that release: a commit cannot change, so a moved tag does not affect it. A later release proves nothing until its decision code is read and its commit added to the list.

A job the aggregate reaches only through another job stays required: when it fails, the job between is skipped, and a skipped need does not fail the aggregate. Put the planner in the aggregate's needs to cover it. Both examples above are tested as written (TestRequiredStatusContexts_Positive_DocumentedAggregatesNarrowToTheGate). The other tests are TestRequiredStatusContexts_Positive_ProvenAggregateIsTheOnlyRequiredCheck, TestProvenAggregate_SkippedLaneMergesAndFailedLeafBlocks, TestRequiredStatusContexts_Negative_UnprovenAggregateKeepsTheLeaves, TestRequiredStatusContexts_Negative_GateConditionMustBeAlways, TestRequiredStatusContexts_RenovateSkippedGateCoversOnlySkippedNeeds, TestRequiredStatusContexts_Boundary_AggregateSpellings, TestRequiredStatusContexts_Boundary_AggregateReach and TestGateScript in internal/forge/workflow_aggregate_proof_test.go, and the TestAllsGreenAggregate_* tests in internal/forge/workflow_allsgreen_test.go.

A ruleset rendered before the aggregate was proven requires the leaf checks. The audit reports it as drift; delete .github/rulesets/main.json and run praetorctl sync to regenerate it. sync --remote then adds the aggregate to the live ruleset but never removes a check it already requires (TestSync_Remote_KeepsLiveLeafChecksAndAddsTheAggregate in cmd/standardsctl/sync_remote_aggregate_test.go). Remove the leaf checks from ruleset praetor-main-protection by hand in the repository's ruleset settings, then run praetorctl plan --remote to read the result back.

Declared status contexts

overrides.branch_protection.status_contexts adds checks that workflow job discovery cannot derive, such as a commit status posted by a workflow_run evaluator. The ruleset uses the sorted, deduplicated union of these declarations and the checks derived from pull request workflows (forge.selectContexts, TestDeclaredStatusContextsRuleset). Other contexts remain outside that set. Only the repository may declare this key; profiles and facets reject it.

overrides:
  branch_protection:
    status_contexts:
      - evaluator/status

The list accepts at most 256 distinct strings. Each must contain 1 to 255 Unicode characters, with no control characters or surrounding whitespace. An empty list adds nothing. A declaration that also names a derived check produces one required check (TestStatusContextsPositiveAndBoundary, TestStatusContextsNegativeNamedReasons). When using a merge queue, ensure the declared status also reports for each merge group; explicit declarations remain required after workflow filtering. praetorctl sync writes the union to the branch ruleset (TestSyncDeclaredStatusContexts).

overrides.branch_protection.status_checks_app pins every required status check in the rendered repository ruleset (.github/rulesets/main.json and praetorctl sync --remote) to one GitHub App by adding "integration_id" to each entry (forge.RenderRepositoryRuleset, TestRenderRepositoryRuleset_Positive_StatusChecksAppGithubActionsPinsEveryCheck). The slug table (config.StatusChecksAppIntegrationID in internal/config/status_contexts.go) maps github-actions to 15368 (GET apps/github-actions); an unknown slug fails manifest validation naming the known slugs (TestStatusChecksApp_Negative_UnknownSlugAndNonString). When the key is unset, required status checks remain unpinned with no "integration_id" field, and the rendered bytes are unchanged (TestRenderRepositoryRuleset_Boundary_StatusChecksAppUnsetByteIdentical). Committed-ruleset validation (forge.ValidateRepositoryRuleset) and live branch protection evaluation (forge.EvaluateBranchProtection) both check integration_id when the key is set (TestValidateRepositoryRuleset_StatusChecksApp_Negative_MissingOrWrongIntegrationIDNamesContext, TestEvaluateBranchProtection_StatusChecksApp_PositiveAndNegativeDrift). Only the repository may declare this key; profiles and facets reject it (TestStatusChecksApp_Negative_RepositoryOnly).

Review mode

A repository with one maintainer declares review_mode: single_maintainer under overrides.branch_protection (review policy). A first adoption on a GitHub origin writes it itself when GITHUB_TOKEN or GH_TOKEN is set and the forge lists exactly one collaborator with write access, who could not approve their own pull request; the .standards.yaml entry of the report says so (TestSoloMaintainer_Positive_OneWriter in internal/adopt/review_mode_test.go). Adoption reads the token from those variables only, never from the gh session. Without a token, on another forge, or when the forge cannot answer, the entry says approvals may exceed approvers instead, and a re-adoption warns the same way. A re-adoption of a one-writer repository whose manifest lacks the review mode warns and names the declaration to add, because adoption never rewrites .standards.yaml (TestWarnUnsatisfiableReview_Boundary). Adoption, sync and the audit render the ruleset from the same effective policy, so the file adoption writes, with zero approvals, no code-owner review, the repository admin role as a pull-request bypass actor and the aggregate as the only required check, is the one the audit accepts (TestAdopt_Positive_SoloPathFilteredRulesetIsMergeable in internal/adopt/ruleset_solo_test.go).

Merge queue

A branch protected by a GitHub merge queue starts a run for a merge group, not a pull request, and a required check reports for a group only when its workflow triggers on merge_group. A ruleset that requires a context of any other workflow leaves every queued group waiting until the queue's check timeout, and the queue then removes it. Declare the queue in .standards.yaml:

overrides:
  branch_protection:
    merge_queue: true
    codeql_default_setup: true # only when CodeQL default setup scans the repository

Both keys are repository-only: a catalog archetype or facet that sets either fails to decode (ArchetypeControls.validate, TestMergeQueueIsRepositoryOnly). With merge_queue: true:

  • sync --remote writes a second Praetor-managed ruleset, named praetor-main-protection merge queue (forge.MergeQueueRulesetName), that includes the default branch alone and carries the merge_queue rule alone, with the seven parameters GitHub requires at GitHub's documented defaults. The merge method is SQUASH while the policy enforces linear history and MERGE otherwise (TestRenderMergeQueueRuleset). The main ruleset (praetor-main-protection, .github/rulesets/main.json) keeps its includes, the default branch and lts-*, and carries no merge_queue rule (TestMergeQueueRendersOnlyMergeGroupContexts). There is no committed file for the queue ruleset.
  • The required status checks come only from workflows that declare a merge_group trigger (forge.ForMergeQueue). A workflow that would otherwise require a check but lacks the trigger is left out. adopt, flavor apply and sync (with or without --remote) print a warning per omitted workflow with its path and contexts (forge.QueueOmissions, TestQueueOmissions, TestSync_Remote_DeclaredMergeQueueGetsItsOwnRuleset). Add merge_group: to the workflow to require it again. A workflow that requires no pull request check is no finding.
  • Praetor's locked praetor-docs.yml and praetor-api.yml declare merge_group (ghworkflow.HostedGateOn), so their Documentation Governance and Go API Compatibility contexts stay required. An unedited earlier copy refreshes without --force because its text is recorded as a prior (TestPriorDigestsReproduce in tools/markdownlint/assets_test.go, TestPriorDigests in tools/apicompat/assets_test.go).
  • CodeQL default setup never runs for a merge group and has no workflow file, so no check context can require it. With codeql_default_setup: true the ruleset gates pull requests with a code_scanning rule for CodeQL instead (TestMergeQueueRendersCodeScanningForDefaultSetup). A CodeQL workflow of your own that triggers on merge_group is an ordinary required check.

The audit fails a stored ruleset that requires a context whose workflow lacks merge_group while the policy declares a queue, and a live branch that has an active merge_queue rule and requires such a context (TestAuditBranchProtection_MergeQueue_Negative, TestLiveMergeQueueFindings). The failure names the workflow path and the missing trigger. Without a queue the rendering is the previous ruleset byte for byte (TestNoMergeQueueRendersTheOldRuleset).

The declared merge_queue: true key is the only switch that selects the merge_group checks, in sync --remote, plan --remote and the audit alike, so plan --remote previews exactly what sync --remote writes. A merge queue configured only on GitHub does not switch it: the audit fails a live branch with an active merge_queue rule while the policy declares none, names the branch and says to declare merge_queue: true and run sync --remote (TestAuditLiveBranchProtection_MergeQueueWiring, TestSync_Remote_UndeclaredLiveMergeQueueRulesetIsReportedNotWritten). The merge into a live ruleset (each ruleset by its name) keeps the operator's merge_queue parameters (timeout, entry counts, grouping, merge method) and every tool a live code_scanning rule lists, adding only what the live rule lacks (TestMergeRulesetKeepsLiveMergeQueueParameters, TestMergeRulesetUnionsLiveCodeScanningTools). It also keeps every live required check, so a check an earlier sync --remote wrote for a workflow without merge_group stays required: with a declared queue sync --remote reads the ruleset back, prints a [WARN] naming that workflow instead of reporting the ruleset as synchronized, and leaves the removal to you (TestSync_Remote_DeclaredMergeQueueWarnsAboutAStaleCheck). It does drop CodeQL default setup contexts (CodeQL, CodeQL / Analyze (...), Analyze (...)) from the required checks under codeql_default_setup: true (TestMergeQueueDropsCodeQLDefaultSetupContexts). The queue parameters have no policy key: change them on GitHub.

To move a repository that already has a committed ruleset onto a queue, declare merge_queue: true and run praetorctl adopt or praetorctl flavor apply. The unedited queue-less ruleset is recognised as Praetor's and refreshed without --force (TestAdopt_Positive_DeclaringAMergeQueueRefreshesTheUneditedRuleset); a ruleset with a value edited is kept and warned about, and --force replaces it. praetorctl sync writes the ruleset only when it is absent and otherwise reports drift, so it does not perform this refresh.

Declaring merge_queue drops the scaffolded CI contexts of the flavor templates (Go, Rust, Node, JVM, Flutter) from the required checks until their workflows trigger on merge_group: the templates do not declare it yet, so add merge_group: to the emitted workflow yourself. An edited workflow is not refreshed by adoption. Removing merge_queue: true refreshes the unedited queue rendering back to the queue-less ruleset without --force.

GitHub documents that "a merge queue cannot be enabled with branch protection rules that use wildcard characters (*) in the branch name pattern" (managing a merge queue). That is why the queue is not in the main ruleset, whose includes hold lts-*: its own ruleset targets the default branch alone, so the main ruleset and the lts-* coverage stay as they are. sync --remote, plan --remote and the audit read and write both rulesets by name, and the union merge of live includes applies per ruleset, so Praetor never adds a wildcard to the queue ruleset. A wildcard an operator adds to it stays, and plan --remote, sync --remote and the audit report it as drift in Merge queue ruleset refs (TestAuditLiveBranchProtection_MergeQueueWiring, TestPlanRemote_PreviewsWhatSyncWritesForTheQueueRuleset). Removing merge_queue: true does not delete the queue ruleset: sync --remote and plan --remote print a [WARN] that it still exists, and the audit fails while it applies, so delete it on GitHub by hand (TestSync_Remote_UndeclaredLiveMergeQueueRulesetIsReportedNotWritten).

Refreshing a ruleset Praetor rendered earlier

The ruleset is rendered from the effective policy and the workflows present, so it goes stale when either changes. flavor apply before adoption renders it under the built-in policy; the adoption that follows pins an archetype policy and adds the documentation gate.

Each run reads the repository's policy and workflows before it writes anything (forge.ReadRulesetBaseline). The ruleset rendered from that state is the one sync and the audit accepted before the run. A ruleset that is exactly that rendering, line endings aside, is refreshed without --force (forge.PriorRulesetDigests, looked up through util.LookupCanonicalText). Nothing is read back from the file itself.

Ruleset on disk Without --force Test
the current rendering, LF or CRLF verified, left as is TestApplyFlavor_Boundary_CurrentRenderingIsUnchangedInEitherLineEnding
the rendering current before this run, LF or CRLF refreshed, in its own line endings TestAdopt_Positive_RefreshesTheRulesetFlavorApplyWrote, TestAdopt_Positive_FlavorApplyAfterAdoptionRefreshesTheRuleset
a rendering with one value edited: review count, signature rule, status check kept and reported TestAdopt_Negative_ValueEditAfterFlavorApplyIsKept
anything else: another name, layout, a final newline kept and reported TestAdopt_Negative_EditedRenderingIsKept

--force replaces a kept ruleset only while the policy enforces linear history or signed commits: only then does the audit compare the file (rulesetRequired in internal/adopt/ruleset_audit.go). Under a policy requiring neither, --force keeps it too (TestReconcileBranchRuleset_Boundary_ForceReplacesOnlyWhilePolicyRequiresIt).

A ruleset rendered for a policy the repository no longer declares is kept too, for example after .standards.yaml overrides change. praetorctl sync then reports it as drift. Pass --force to adoption or flavor apply, or delete the file and run sync, which writes the missing ruleset. Keep a hand-managed ruleset with adoption.decline: [branch-ruleset].

Protected default branch

The ruleset protects the repository's default branch and every lts-* branch (forge.RepositoryRulesetRefs); a declared merge queue lives in a second ruleset on the default branch alone (see Merge queue). Adoption, flavor apply, sync (local and --remote) and the audit resolve that branch the same way (forge.RepositoryDefaultBranch); the first source that names one wins:

Source Written by Applies
repository.default_branch in .standards.yaml the operator, or the command that creates the manifest (below) whenever it is set
refs/remotes/origin/HEAD in the checkout git clone, git remote set-head origin --auto no declaration
main built in neither
repository:
  default_branch: master

Resolving the branch asks nothing of the forge, so adopt --dry-run and the audit's ruleset check stay offline. The audit's live Actions checks do read the forge, but only when the origin remote names the repository on github.com and a token is found; praetorctl audit --offline skips them, as the generated pre-commit hook does. A CI checkout usually has no refs/remotes/origin/HEAD and would resolve main, so a repository whose default branch is not main declares repository.default_branch; CI then audits the ruleset a local run writes. The manifest writers record it for you (forge.DefaultBranchToDeclare): adoption, praetorctl init and harvester onboarding write the checkout's origin HEAD into the .standards.yaml they create when it is not main (TestAdopt_Positive_MasterRepositoryRulesetProtectsMaster, TestInit_3D_DefaultBranch, TestEnsureOnboardingManifest_Positive_DeclaresAMasterOriginHead). Adoption never rewrites an existing manifest: when one declares no branch and the origin HEAD is not main, it warns, names every file it renders for the branch and the line to add (TestAdopt_Negative_UndeclaredBranchInAnExistingManifestIsWarned). The hosted gate workflows render for the branch too (When the workflow runs), so a manifest that declines branch-ruleset is still warned while a hosted gate is enabled (TestAdoptWarnsAnUndeclaredBranchTheHostedGatesRenderFor in internal/adopt/workflow_branch_test.go).

A declaration outside config.ValidBranchName (1 to 128 letters, digits, ., _, / or -, no ..) fails the manifest load. An origin HEAD outside it fails the resolution instead of falling back to main (TestRepositoryDefaultBranch_Negative_UnusableSourcesAreErrors), and fails adoption, init and onboarding before they write a manifest.

The ruleset name praetor-main-protection and the path .github/rulesets/main.json are the same for every default branch, because a live ruleset is matched by them.

Earlier Praetor versions rendered main whatever the default branch was. In a repository whose default branch is something else, that file is still Praetor's unedited rendering: adoption and flavor apply refresh it to the default branch without --force (forge.PriorRulesetDigests, TestAdopt_Positive_RefreshesTheEarlierMainRulesetOfAMasterRepository). Until then sync and the audit report it as drift.

Large repositories

Adoption discovers verification inputs (Makefiles, manifests, scripts) through a bounded walk of the target: 65536 directory entries, 128 files and 32 levels of depth by default (util.DefaultDiscoveryEntries in internal/util/discovery_bounds.go). The entry count covers every directory the walk enters. Version-control, dependency, build-output and agent scratch directories are skipped by name (skipVerificationDirectory in internal/adopt/verification_inputs.go); generated output under any other name, such as a built documentation site, counts. A repository above those bounds fails with an error that names the flag and the manifest key raising the bound:

verification discovery exceeds 65536 entries; raise max_entries with --verification-max-entries, up to 200000, or declare verification.max_entries in .standards.yaml so every run, the audit's hooks and CI included, reads it

Raise a bound explicitly instead of trimming the tree. Declare it in .standards.yaml when the repository is large, so every run that walks it reads the same bound. The hooks and CI jobs adoption writes run praetorctl audit with no flag, and its Paperclip gate walks the tree too:

verification:
  max_entries: 131072 # also max_files, max_depth

The strict manifest decoder refuses an unknown key, a non-integer value and a value outside 1..its ceiling, naming the key (internal/config/verification.go, TestVerificationPolicy_Negative). A flag overrides the declared bound it names for its own run; every other bound keeps the declared value or the default (adopt.ResolveVerificationLimits in internal/adopt/verification_limits.go):

standardsctl adopt --dry-run --path /path/to/large-repo \
  --lock-source-root=/path/to/praetor --verification-max-entries=131072

--verification-max-entries also raises the editor language scan, which reads the same tree for the IDE step (editor capabilities). --verification-max-files and --verification-max-depth raise the other two bounds. Each value is validated against its ceiling (200000 entries, 512 files, 64 levels; internal/util/discovery_bounds.go). A value past its ceiling is refused, and an unset flag keeps the declared bound or the default. A bound already at its ceiling reports that instead of naming the flag. The flags apply to single-repository adoption; batch --all-missing takes none, so it walks each repository under its declared bounds or the defaults.

praetorctl paperclip harness detects the repository's languages with the same walk and takes the same three flags, and so does praetorctl audit, whose Paperclip gate compares the harness with the synthesis for those languages. Both read the declared bounds first:

praetorctl paperclip harness --path /path/to/large-repo --verification-max-entries=131072
praetorctl audit --config=/path/to/large-repo/.standards.yaml --verification-max-entries=131072

Tests: internal/adopt/large_repo_bounds_test.go, internal/adopt/verification_manifest_test.go, TestPaperclipHarness_VerificationLimitFlags in cmd/standardsctl/adopt_limits_test.go and TestAuditPaperclip_VerificationBoundFromManifest in cmd/standardsctl/audit_paperclip_test.go.

What Adoption Scaffolds Automatically

Adoption is a chain of steps, run in this order (adoptSteps in internal/adopt/adopt.go). Each row is one step, named as adoption.decline and the adoption report name it, with the files it writes in a repository that declares the default facets. Which of them are mandatory and which a repository may decline is in adoption verification. A step writes only what its inputs call for: the API gate needs a tracked go.mod, the REUSE gate a REUSE.toml or .reuse/dep5, and a flavor only where one matches. praetorctl adopt --dry-run --lock-source-root=/path/to/praetor prints the exact list for your repository, about a hundred files for a Go module, before it writes any (TestAdoptionScaffoldTable_MatchesTheStepChain in internal/adopt/scaffold_table_test.go keeps this table equal to the chain).

Step What it writes
manifest .standards.yaml: profile, facets, repository identity, register sources
git-ignore .gitignore: the managed block for private working directories and gate worktrees
lockfile .standards.lock: profiles and facets pinned to content digests of the source bundle
policy-catalog .config/archetypes/: the pinned profile and facet texts audit reads
baseline .standards-baseline.json: the recorded legacy debt
agent-harness AGENTS.md; CLAUDE.md, .cursor/rules/hiss-invariants.mdc, .github/copilot-instructions.md, .windsurfrules, .gemini/GEMINI.md, .codex/rules.md; the register skills in .agents/skills/ and their .claude/skills/ copies
dev-container .devcontainer/ bundle (devcontainer.json, Dockerfile.praetor, source companions); the .gitattributes managed block
documentation-gate tools/markdownlint/, tools/figures/, .github/workflows/praetor-docs.yml (facet docs:seo-portal)
api-compatibility-gate tools/apicompat/gate/, .github/workflows/praetor-api.yml (facet api:public-contract, tracked go.mod)
makefile Makefile with the verify-all target
editors .editorconfig, .vscode/, .idea/, .zed/, .helix/, .fleet/, .nvim.lua, lua/, .dir-locals.el, standards.sublime-project, .clang-tidy where the profile calls for it
formatter-ignore .prettierignore entries for managed artifacts, where a formatter is configured
renovate-ignore the managed-file ignore rule in an existing Renovate configuration; none is created
actionlint-labels .github/actionlint.yaml runner labels
contributing CONTRIBUTING.md
pull-request-template .github/pull_request_template.md
security-policy SECURITY.md
adr docs/adr/README.md and docs/adr/0000-template.md
asdecided .standards/cache/decisions/ and project MCP files for Claude, Gemini, OpenCode and Continue when ADR records exist
readme the governance block in an existing README.md
reuse-gate .github/workflows/reuse.yml, where the root carries REUSE.toml or .reuse/dep5
working-dir-and-flavor the private .workingdir/ ledger, the detected flavor's templates (flavors), and .gosec.json whenever git tracks a go.mod
branch-ruleset .github/rulesets/main.json, the branch ruleset with the required status checks
labels .config/labels.yaml, the label taxonomy
paperclip .paperclip/harness.json and .paperclip/rules.md
agent-definitions .agents/agents/repo-auditor.md and repo-gatekeeper.md, projected to .claude/agents/, .github/agents/, .gemini/agents/ and .codex/agents/
git-hooks lefthook.yml, .config/lefthook/, .config/agent/checkpoint.json, .config/agent/hooks/block_evasion.py, the installed .git/hooks/pre-commit
agent-hooks the hook settings of .claude/settings.json, .gemini/settings.json and .codex/hooks.json for the clients agent_clients selects

The asdecided step and its export and pin-check commands are described in Decision recall.

The groups with behavior of their own:

  1. .standards.yaml: Declarative repository manifest containing profile, facets, tool versions, and policy locks.
  2. .standards.lock: Cryptographic SemVer lockfile binding your repo to exact governance standard releases.
  3. .standards-baseline.json: Technical debt ratcheting baseline. A first adoption records the existing infractions (e.g. legacy loop bounds, unwrapped errors) so legacy code compiles while new code is strictly gated. A re-adoption keeps the file; see The baseline on a re-adoption.
  4. AGENTS.md + 6 Vendor Targets: Canonical agent operating harness transpiled to CLAUDE.md, .cursor/rules/*.mdc, .github/copilot-instructions.md, .windsurfrules, .gemini/GEMINI.md and .codex/rules.md. agent_clients in .standards.yaml limits these to the clients the repository uses (agent client selection).
  5. .devcontainer/devcontainer.json: Multi-architecture container configuration pinned to verified base images. Adoption also pins the bundle's Dockerfile to LF: the managed block at the end of .gitattributes opens with .devcontainer/Dockerfile.praetor text eol=lf, so a Windows checkout with core.autocrlf=true verifies the same bytes. The rule names that one file, so the repository's own files in .devcontainer/ keep their attributes. Every other line of an existing .gitattributes is kept, and a rule of the repository's own inside .devcontainer/ that matches Dockerfile.praetor and contradicts text eol=lf stops adoption before its first write (checkout line endings).
  6. Multi-IDE Configs: Workspace settings for every supported editor, or only the ones editors in .standards.yaml names (editor selection).
  7. Makefile & LeftHook: Automated pre-commit hooks and standard verification targets (make verify-all).
  8. Documentation gate (the docs:seo-portal facet, in the default facet set): the locked Markdown gate under tools/markdownlint/, the interactive figure engine under tools/figures/, docs-lint and docs-figures targets on verify-all, the figure engine's rules in the managed block at the end of .gitattributes, and .github/workflows/praetor-docs.yml with its required Documentation Governance context (documentation governance, figures). The figure checks skip, saying why, until the repository adds its first spec under docs/figures/. Adoption stops, even with --force, when a file the repository already had at one of the engine's paths under tools/figures/ differs from it; move that file aside and rerun. Remove the facet to opt out.
  9. Go API compatibility gate (the api:public-contract facet, in the default facet set): the gate program tools/apicompat/gate/main.go and .github/workflows/praetor-api.yml with its required Go API Compatibility context, which compares the exported API of every Go module with the pull request's base or the newest root release tag (Go API compatibility gate). Adoption writes it only where git tracks a go.mod, at the root or nested; a repository declaring the facet without one gets no gate and no required context, and adoption and audit say that no API compatibility checker runs for its languages. Adoption stops, even with --force, when a file the repository already had at either path differs from it. Remove the facet to opt out.
  10. actionlint runner labels: when a workflow adoption writes, the documentation gate's, the Go API compatibility gate's or a flavor's CI workflow, runs on a runner label actionlint does not know, adoption declares it under self-hosted-runner.labels in .github/actionlint.yaml (or an existing .github/actionlint.yml), so a repository that lints its workflows with actionlint accepts them. It only adds labels and leaves a file it cannot patch safely untouched, with a warning naming the labels (actionlint runner labels, internal/adopt/actionlint.go). Decline actionlint-labels to opt out.
  11. REUSE gate: when the repository root carries REUSE.toml or .reuse/dep5, the two files REUSE Specification 3.3 reads for the whole tree (a LICENSES/ directory alone declares nothing: it may hold third-party notices, #1244), adoption writes .github/workflows/reuse.yml, whose REUSE lint job runs fsfe/reuse-action@v6 within a 10-minute timeout in the shared hosted gate shape (internal/ghworkflow/hostedgate.go: pull request activity and a push to the default branch the branch ruleset protects, a first step that fails a draft run by design, and a not-draft condition on every later step). That branch is repository.default_branch, else the origin HEAD, else main, and adds a reuse-lint pre-commit job to lefthook.yml that runs reuse lint with reuse 6.x, skips where reuse is not installed, and fails on another major. The action tag is the one pin both read (supplychain.ReuseActionVersion in internal/supplychain/reuse_lint.go, also the tag praetorctl bump expects). A root with neither file gets neither; one whose LICENSES/ and file headers label it the REUSE way without either file gets a warning, from adoption and from every audit, that names REUSE.toml (supplychain.ReuseHeadersOnlyNotice). When the root loses its declaration, adoption removes its unedited reuse.yml, so the ruleset stops requiring a check that cannot pass, and migrates lefthook.yml to the matching rendering; it does the same in reverse when the root gains them. An unedited reuse.yml rendered for another default branch is refreshed. An edited reuse.yml is kept, --force included, with a warning when the root no longer declares REUSE (internal/adopt/reuse_gate_test.go). Decline reuse-gate to drop both: no workflow and no reuse-lint job is rendered, a declined step removes an unedited reuse.yml an earlier adoption wrote, and lefthook.yml migrates to the rendering without the job (internal/adopt/reuse_decline_test.go).

Organisation .github repositories

In GitHub organisations, a repository named .github holds default community health files (such as CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, pull_request_template.md, and issue templates). GitHub serves these files as fallback defaults for every repository in the organisation that does not define its own copy.

When adopting a repository named .github (matched case-insensitively against repository.name in .standards.yaml, or the repository directory name), Praetor treats organisation community health files as declined by default:

  • contributing (CONTRIBUTING.md)
  • pull-request-template (.github/pull_request_template.md)
  • security-policy (SECURITY.md)

The adoption report explicitly names each implicit decline with the reason: organisation community health files.

Explicit declarations in adoption.decline continue to take effect. There is no opt-in mechanism to scaffold or overwrite these files in an organisation .github repository, because the organisation maintains its own authoritative copies. Existing community health files in such repositories are never modified, even when passing --force (TestAdopt_DotGitHub_ExistingContributing_ByteIdenticalUnderForce in internal/adopt/dotgithub_health_files_test.go). Furthermore, praetorctl audit skips comparing these files against Praetor scaffolds in a .github repository, allowing audits to pass without scaffold copies (internal/adopt/declined.go, cmd/standardsctl/audit_dotgithub_health_test.go).

The audit engine pin on a re-adoption

A re-adoption writes the managed files of the engine that runs it. A repository whose CI runs the audit engine it pins in tools/go/go.mod (a tool github.com/cordanaLLM/praetor/cmd/standardsctl directive and a require of the engine module) keeps that pin: adoption does not edit the tools module or its go.sum. When the pin names another build than the running engine, adoption warns with the file, the pinned version, the running engine and the command that moves the pin in the same change, go get -modfile=tools/go/go.mod github.com/cordanaLLM/praetor@<engine>. Without it, CI's audit with the old engine refuses the refreshed files as differing from its locked assets (reportStaleEnginePin in internal/adopt/engine_pin.go, TestReportStaleEnginePin_Positive_OlderPinIsReported). A pin or an engine that identifies no build is not compared.

Exceptions on a re-adoption

A re-adoption keeps the manifest it finds, but it records the dated exceptions a first adoption records, in the same exceptions list. When the release workflows fall short of the supply chain the policy declares and the manifest holds no HISS-11 entry, as a manifest an older engine wrote does not, the run adds the entry a first adoption adds and warns that it did. An entry the manifest already declares, renewed or narrowed by hand, stands. The declared-linters entries for declared linters no workflow runs are recorded the same way (syncDeclaredLintersExceptions and withSupplyChainException in internal/adopt, TestAdopt_Readoption_RecordsTheSupplyChainGap).

The baseline on a re-adoption

Adoption records .standards-baseline.json once. A repository that already has the file keeps it on every later run, --force included, so a re-adoption never turns a violation the baseline does not record into accepted debt (reconcileBaseline in internal/adopt/adopt.go).

Baseline file Flags What the baseline step does
absent default (--record-baseline=true) scans and records the first baseline; a --dry-run scans and writes nothing
absent --record-baseline=false writes nothing and warns that no baseline was recorded
present default keeps the file byte for byte, rescans, and reports the verdict praetorctl baseline --verify gives
present --record-baseline=false keeps the file and skips the scan
present --rerecord-baseline replaces the file with the rescan under the rules of praetorctl baseline --record

The kept-baseline verdict is one line under the debt summary; the MCP standards_adopt tool prints the same line (adopt.BaselineRatchet.Line). praetorctl adopt prints text only: Go code that calls adopt.Adopt reads the verdict from the BaselineRatchet field of the AdoptReport it returns (Passed, Recorded, Active, Unbaselined). A baseline entry that matches nothing in the tree is reported as a warning and leaves the verdict a pass.

When the repository carries a finding the baseline does not record, or more findings than it records, the verdict is a rejection. Adoption still reconciles the files it owns and adds a warning that lists the findings, but the rejected baseline is pending, as a Verification Gate short of ready is (adoption verification): an applied run ends without the success line and names the verdict and what resolves it.

Repository onboarded: <owner>/<name>; not ready yet: Debt Baseline. See the warnings above.
  Debt Baseline: Baseline kept, not re-recorded; HISS-13 ratchet rejects: 1 active infractions against 0 recorded, 1 not in the baseline
  Resolve: fix the findings, or accept them deliberately with 'praetorctl adopt --rerecord-baseline --allow-increase --reason=<why>' or 'praetorctl baseline --record --allow-increase --reason=<why>'; until then praetorctl audit rejects the repository

The run exits 0, as every run without an error does, a pending Verification Gate included: praetorctl audit is the command that fails on the debt. The praetor-adopt action carries the same closing lines in its report output and in the job summary and keeps that exit status, so its step passes; adopt.yml runs the audit as the next step, which fails the job before anything is committed (PendingPillars and PendingBaseline in internal/adopt/report.go, printAppliedOutcome in cmd/standardsctl/adopt.go). A dry run closes with the plan line and shows the verdict under the debt summary. --all-missing prints one summary per repository with its warnings and no closing line.

--rerecord-baseline is the explicit route to a new baseline. A rescan that finds more infractions than the file records is refused, the file is kept and the error lists the added findings, unless --allow-increase --reason=<why> accepts the increase; the reason is stored in the file as increase_rationale. A rescan that finds the same debt keeps the file byte for byte, and one without a resolved repository identity keeps the repository the file records. --allow-increase and --reason without --rerecord-baseline, and --rerecord-baseline with --record-baseline=false, are refused before anything is written (adopt.ErrBaselineOptions). The MCP tool has no re-record: accepting debt is a command an operator runs. A baseline file that cannot be parsed fails the step on every path; restore it from version control, or delete it to record a first baseline.

Tests: TestAdopt_Negative_ReAdoptionDoesNotAbsorbNewDebt, TestAdopt_RerecordBaseline_3D and TestAdopt_Boundary_BaselineDryRunWritesNothing in internal/adopt/baseline_readopt_test.go, TestReconcileBaseline_KeepsUnchangedBaseline_3D in internal/adopt/baseline_identity_test.go, TestAdopt_RejectedKeptBaselineIsPending_3D and TestPendingPillars_Baseline_3D in internal/adopt/baseline_pending_test.go, TestAdoptBaselineFlags_3D, TestAdoptRejectedKeptBaseline_3D and TestAdoptAllMissing_PassesTheRerecordFlags in cmd/standardsctl/adopt_baseline_flags_test.go, and TestPraetorAdoptAction_Boundary_PendingBaselineReachesReportAndSummary in internal/forge/adopt_action_test.go.

What a forced re-adoption changes

praetorctl adopt --force (MCP: "force": true) refreshes a governed repository; it does not reset it to the scaffold. It overwrites a file only when praetorctl audit compares that file's bytes, so the audit fails until the file holds them, and merges or keeps every other file. adopt --help states this contract (adopt.ForceContract in internal/adopt/force_contract.go, TestAdoptHelpStatesTheForceContract in cmd/standardsctl/adopt_force_contract_test.go), and the standards_adopt schema restates each clause for agents (TestCreateAdoptTool_ForceStatesTheContract in cmd/standards-mcp/tools_adoption_force_test.go). --force rebuilds the lock, so it needs --lock-source-root (MCP: source_root), a dry run included (TestAdoptForceNeedsLockSourceDryRunIncluded). Preview a forced run with --dry-run first. Every message that tells you to re-run adopt with --force prints the whole command, praetorctl adopt --force --lock-source-root=<praetor checkout>, with the run's own lock source in place of the placeholder when it was given one (adopt.ForceCommand, TestForceRemedies in internal/adopt/force_contract_test.go).

Three rules hold on every run, --force or not:

  • An absent file is created. A file that already holds what adoption writes, line endings aside, is verified. An unedited text an earlier release wrote is refreshed without --force (refreshPriorScaffold in internal/adopt/scaffold.go).
  • Any other overwrite is listed under Files Replaced with action replace, never as created; an editor JSON merge is listed as merge. Both carry the line delta and a backup under .workingdir/adopt-backups/<UTC stamp>/<path> when git ignores that path, and a dry run lists them as planned (replaceExisting in internal/adopt/replace.go; the replaced files item below has the details).
  • Adoption removes no file of the repository's own. A <file>.bak an earlier release left beside a hook file is reported and kept (warnLegacyHookBackup in internal/adopt/replace.go).
Class Files Without --force With --force
Audit-locked, rebuilt under --force .standards.lock and the texts it pins under .config/archetypes/ a valid lock is kept, and one that pins only unmodified earlier catalog texts is re-pinned to --lock-source-root when no value changes; an invalid lock fails the run rebuilt from --lock-source-root, an invalid lock included (TestReconcileLockfile_Positive_ForcedRebuildOverEditedLockReplacesWithBackup)
documentation gate files under tools/markdownlint/ and tools/figures/, and .github/workflows/praetor-docs.yml a drifted file is kept with the warning preserved, not verified (praetorctl adopt --force --lock-source-root=<praetor checkout> regenerates it) replaced; a file under tools/figures/ that predates adoption still stops the run (reconcileManagedFamily in internal/adopt/managed_family.go)
.github/rulesets/main.json kept with a warning; the rendering current before the run is refreshed replaced while the policy enforces linear history or signed commits, kept under any other policy (TestReconcileBranchRuleset_Boundary_ForceReplacesOnlyWhilePolicyRequiresIt)
the .devcontainer/ bundle an existing devcontainer.json keeps the bundle as it is, with a warning regenerated; each image the bundle records is kept or refreshed, and reported (prepareAdoptDevContainer in internal/adopt/devcontainer.go, TestReconcileDevContainer_Positive_ForcedEditIsReplacedWithBackup)
the documentation gate block in the Makefile, the managed block at the end of .gitattributes an edited block fails the run the block is restored (TestReconcileDocumentationMakefile_Boundary_DeltaListsOnlyInBlockLines, TestReconcileMakefile_Positive_ForceRestoresEditedBlockOnAppendPath, TestReconcileGitAttributes_Positive_EditedBlockReplacedUnderForce)
Audit-locked, recompiled every run vendor context files such as CLAUDE.md, persona copies under .claude/agents/, register skill copies under .claude/skills/, plugin copies compiled from AGENTS.md and the canonical personas and skills; before a vendor context file is recompiled, the text it holds that AGENTS.md lacks is folded into AGENTS.md below its instructions (a file a person wrote whole, an edited projection only its added lines), so the new projection carries it; the old file is backed up the same (TestAdopt_Positive_HandEditedVendorFileOnPlainRunReplacedWithBackup, TestFoldAuthoredVendorContext_Positive_KeepsAuthoredText)
Managed block, every run the .gitignore and .prettierignore tail blocks, the README governance block, the Renovate rule for the managed files, the text register block in AGENTS.md Praetor's block or rule is written; every line outside it is kept the same
Merged agent hook settings: .claude/settings.json, .gemini/settings.json, .codex/hooks.json missing Praetor handlers are merged in; every other entry is kept the same (TestReconcileAgentHooks_Positive_MergesPreservingForeignContent)
editor JSON: .vscode/*.json, .zed/*.json, .fleet/*.json, standards.sublime-project kept; the warning names the missing managed values merged, every repository key kept; a file adoption cannot merge is kept with a warning (TestAdopt_Positive_ForceMergesEditorJSONKeepingAdopterKeys)
the AGENTS.md harness kept; only its text register block is spliced from the manifest regenerated, keeping the preamble, invariant rows under the repository's own IDs and the instructions below the harness; an edited generated line is a replace (TestAdopt_AgentsMD_ForceKeepsRepositoryAdditions)
.paperclip/harness.json a harness the repository edited is kept; a platform naming another repository is a warning only that platform is reset to this repository's <owner>/<name>; .paperclip/rules.md stays as it is, a deleted one included (TestAdoptForcePatchesOnlyHarnessPlatform)
.standards.yaml profiles, facets and every other declaration kept (praetorctl profile set changes them); register.sources added, or re-bound only to a harness the run writes the same
Generated, not audit-verified the anti-evasion interceptor, the checkpoint scripts and policy, the personas .agents/agents/repo-auditor.md and repo-gatekeeper.md, the register skills and their upstream LICENSE files under .agents/skills/ (.agents/skills/<name>/SKILL.md and LICENSE), the label taxonomy, CONTRIBUTING.md, the pull request template, SECURITY.md, editor files that are not JSON an edited file is kept with a warning that counts the lines regenerating it would change the same; delete the file and re-run adopt to regenerate it (TestAdopt_Negative_EditedEvasionHookKeptUnderForce)
lefthook.yml a current or earlier Praetor rendering is written, migrated or verified; any other configuration is kept and not activated the same, and a CRLF checkout of the current rendering is rewritten with LF bytes (TestAdopt_Negative_ForeignLefthookKeptWithAndWithoutForce)
the pre-commit hook written when lefthook cannot install a hook Praetor did not write is kept the same (TestAdopt_Hooks_ForeignPreCommitKeptWithAndWithoutForce)
Written only when absent flavor templates such as rustfmt.toml, developer-owned editor files such as .nvim.lua, the ADR directory an existing file is kept the same

A caller that ran adopt --force to reset a repository to the scaffold gets this refresh instead: delete the files to regenerate, then run adopt. scripts/adopt_repos.sh runs adopt --force --lock-source-root=<its checkout> for every target (scripts/test_adopt_repos.py), and the praetor-adopt action passes the checkout it builds standardsctl from (TestPraetorAdoptAction_ForcedRunCarriesTheLockSource in internal/forge/adopt_action_test.go). Loaded remotely, the action has no .git, so a forced run first checks out praetor with git (Inputs, the binary, and the report output).

What Adoption Reads Before It Writes

  • Repository identity. repository.owner and repository.name come from the origin remote only (util.ResolveRemoteIdentity). The checkout path is never read as identity, because its parent directory names where the checkout sits, not who owns the repository (TestAdoptionManifest_CheckoutLayoutIsNotIdentity). The remote must be a network remote naming a host and <owner>/<repo> (util.ReadOriginRemote); a local path or file:// origin names where a copy sits and counts as no remote (TestReadOriginRemote_Negative_LocalRemoteIsNotIdentity). Without such a remote, both stay empty, the report warns repository identity unresolved, the checkpoint lifecycle is not installed, and the README badge block is skipped. The Paperclip harness platform and rules.md heading name the identity .standards.yaml declares, else the origin remote's; with neither, adoption writes no harness, the report records Paperclip harness not written, and an existing harness stays as it is. praetorctl paperclip harness fails the same way instead of writing a guessed cordanaLLM/<dir> (TestSynthesizeHarness_Negative_NoIdentityIsAnError). No cordanaLLM/<name> default and no <parent>/<name> guess reaches any adopted file; a flavor body that names the repository, such as the scaffolded .gitleaks.toml, names the checkout directory alone (TestAdopt_NoRemoteWritesNoGuessedPlatform, TestApplyFlavor_Negative_CheckoutLayoutIsNotOwner). A remote read git does not answer, such as a cancelled run, fails adoption instead of counting as no identity. repository.visibility is always left unset because adoption cannot observe it offline (TestAdoptionManifest_UnresolvedIdentityStaysEmpty).
  • Recovering an unresolved identity. Until both fields are set, praetorctl audit fails with Manifest repository owner and name must not be empty (cmd/standardsctl/audit.go), so the audit pre-commit hook blocks commits. Adoption never rewrites an existing manifest, so set repository.owner and repository.name in .standards.yaml by hand. Add the origin remote too and re-run praetorctl adopt: the re-run installs the checkpoint policy and evaluator, which need that remote, writes the Paperclip harness, and reconciles the README block from the fields you set. A re-run that finds the remote while the manifest still names no identity installs the checkpoint lifecycle and the harness but leaves the manifest and the README as they are (TestAdopt_RerunCompletesOnceIdentityIsSet).
  • Hosting forge. repository.forge in .standards.yaml names the forge that hosts the repository: github, forgejo or gitlab; the strict manifest decoder refuses any other value with the key named (internal/config/forge.go). It selects the push rows of the Paperclip harness: only Forgejo's harness carries the AGit refs/for push. Without the key, a repository whose origin remote names github.com is GitHub. Any other host, or no network remote, names no forge kind, since Forgejo and GitLab instances run under any host name, so adoption writes no Paperclip harness and its report names the key, praetorctl paperclip harness refuses, and praetorctl audit fails until the key is declared (TestForge_Boundary_DefaultOnlyForGitHubHost, TestAdoptHarness_Negative_UndeclaredForgeWritesNoHarness, TestAuditPaperclip_Boundary_OwnedAbsentRulesAndUndeclaredForge).
repository:
  forge: forgejo
  • Profile. The profile an existing .standards.yaml declares outranks --profile, which outranks file markers. A conflicting --profile is reported as ignored (TestAdopt_DeclaredProfileGovernsAdoption). Adoption never rewrites a declared profile; praetorctl profile set does (Changing the profile, facets or catalog).
  • Declared HISS exceptions. A repository whose own standard allows a construct a HISS directive bans declares that exception under hiss.exceptions in .standards.yaml, naming the repository document that records it. The one exception is c_goto_cleanup: a C or C++ goto that jumps forward to the one cleanup label of its function.
hiss:
  exceptions:
    c_goto_cleanup: docs/adr/0003-cleanup-goto.md
    c_goto_cleanup_labels: [unwind] # optional, beside cleanup, out, err and fail

The exception holds only while that document is a regular file in the repository. The audit's native HISS-01 scan then reports every goto except one that meets all five conditions (internal/hiss/cleanup_goto.go):

  1. Forward. The goto line comes before its label.
  2. Same function. The label is in the function body that holds the goto.
  3. Single level. That function defines exactly one label, so it has one cleanup exit.
  4. Body level. The label sits directly in the function body, outside every nested block, so the jump leaves blocks and never enters one.
  5. Name. The label is cleanup, out, err or fail, or is listed in c_goto_cleanup_labels: C identifiers of at most 64 bytes, at most 8 of them, declared only beside c_goto_cleanup.

A backward goto, a jump to a label inside a nested block, a second label, another name and a computed goto all stay findings (TestCleanupGoto_Negative_OtherGotosStillFail). The scan finds a goto anywhere in a statement, if (rc) goto out; included, but not in a string or a comment (TestCleanupGoto_Negative_OneLineGotoIsAGoto); it reads a label only where it opens a line. In a macro body it reads a goto only where a line of the body starts with it. praetorctl audit, the gate's HISS stage, praetorctl baseline, the MCP audit, public-repository verification and adoption's legacy-debt scan all read the declaration through config.Manifest.CleanupGotoException, so they judge the same tree the same way (TestCleanupGotoExceptionReachesTheAudit, TestRunHissStage_CleanupGotoException). HISS-01 in the AGENTS.md and Paperclip harnesses states that rule in place of the C/C++ zero-goto clause, rendered from the scan's own text (hiss.CleanupGotoRule), and keeps Go's own ban. Without the declaration every goto is a finding and the ban stays. A declaration whose document is missing keeps both and warns, naming the document, in the adopt report, praetorctl paperclip harness, praetorctl audit, praetorctl baseline, the gate stage and the MCP audit (TestAdoptHonoursDocumentedCleanupGotoException, TestPaperclipHarness_WarnsOnUndocumentedException). The document path must be a clean repository-relative path of at most 256 bytes, and an exception key praetor does not know fails the manifest (TestHISSExceptions_Negative_BadDeclarationsFail, TestHISSExceptions_Labels). The section is repository-only: no profile or fleet layer declares an exception, and the effective policy does not change with it (internal/config/hiss_exceptions.go). - Existing files. An existing manifest must parse, or adoption fails and leaves it unchanged. Every other existing scaffold is compared with what adoption would write: a match is reported as verified, a difference as differs from the scaffold with a warning, and the file is kept (TestScaffoldFile_ReportsDriftInsteadOfVerified). --force never overwrites an editor file: it merges the managed values into a JSON one, keeping every adopter key, and keeps any other differing file with a warning (editor capabilities). - What --force overwrites (every file class: what a forced re-adoption changes). Only a file the audit compares byte for byte, so that the audit fails until it holds the scaffold (scaffold.auditLocked in internal/adopt/scaffold.go): the documentation gate's and the Go API compatibility gate's managed files and workflows, and the branch protection ruleset while the policy requires one. Every other generated file is not audit-verified and is kept under --force too, with the note differs from the scaffold adoption writes (-N/+M lines); not audit-verified; kept and a warning: the agent anti-evasion interceptor, the checkpoint scripts, the canonical personas .agents/agents/repo-auditor.md and repo-gatekeeper.md, the label taxonomy and the checkpoint policy (TestAdopt_Negative_EditedEvasionHookKeptUnderForce, TestAdopt_Negative_EditedPersonaKeptUnderForce, TestAdopt_Boundary_ForceKeepsDriftedCheckpointScriptLifecycleUnavailable). So is a pre-commit hook praetor did not write (git hooks). To regenerate one of them, delete it and re-run adopt. So is a lefthook.yml that is neither a current nor an earlier Praetor rendering: it is kept and not activated, and the skip names the generated jobs it lacks and the jobs it adds (the lefthook.yml adoption writes). - Replaced files. When --force overwrites a drifted scaffold, the report lists it under Files Replaced with action replace, never as created. The entry carries a line delta (-removed/+added lines and the first three removed lines; a replace that only reorders lines reads -0/+0 lines, N moved with the first three moved lines, so a reordered table names the rows that moved: util.LineDeltaOf, tests in internal/util/line_delta_test.go) and where the prior bytes went: .workingdir/adopt-backups/<UTC stamp>/<path>, written only when git check-ignore confirms that path is ignored. The git-ignore step, which writes the managed .workingdir/ rule, runs right after the manifest step and before every step that can replace a file, so a first adoption keeps a backup of every file it replaces, editor merges included, and a dry run plans the same backups without writing the rule or a copy (adoptSteps in internal/adopt/adopt.go, backupIgnored in internal/adopt/replace.go, tests in internal/adopt/first_adoption_backup_test.go). Without that confirmation, for example when adoption.decline names git-ignore and the repository's own rules do not ignore .workingdir/, the file is replaced without a copy, its entry says no backup, and one warning per run gives the reason. A file that differs only in its line endings is verified, not replaced (TestScaffoldFile_Positive_ForceReplacesWithBackupAndDelta, TestScaffoldFile_Boundary_DryRunPlansReplaceAndDeltaTruncates, TestScaffoldFile_Boundary_CRLFOnlyDifferenceIsNotReplaced). Under --force, a backup root that is a symlink or sits behind one fails the run before the first write (preflightForceBackupRoot in internal/adopt/replace.go, TestAdopt_Negative_ForceRefusesSymlinkedBackupRootBeforeAnyWrite). Agent hook merges keep their copy in the same place (agent hooks). A backup written before the private .workingdir/ exists creates it owner-only (0700), as the ledger setup does (TestBackupExisting_Positive_CreatesPrivateWorkingDir). The standards_adopt MCP result lists the same entries under Replaced Files (Planned Replacements in a dry run), and neither report repeats a replaced file among the reconciled ones (AdoptReport.ReconciledNotReplaced in internal/adopt/scaffold.go, TestServer_Positive_AdoptForceReportsReplacedFile). The --all-missing summary counts replaced files on their own line. - Audit-locked files. Files whose exact bytes audit checks are rewritten, and an overwrite of edited bytes is a replace with its delta and backup, never a create. Under --force that covers .standards.lock, a pinned file under .config/archetypes/, a DevContainer bundle file, a documentation family file such as tools/markdownlint/markdownlint-cli2.yaml, an edited documentation gate block in the Makefile, whose delta lists only the lines inside the block, and an edited managed attribute block at the end of .gitattributes; without --force either edited block fails the run, the attribute block before the first write (preflightManagedAttributes in internal/adopt/gitattributes.go). A disable of docs:seo-portal refuses to touch an edited Makefile block, and refuses an edited attribute block where the disable would remove it, that is, where adoption.decline lists dev-container; otherwise the attribute block stays for its DevContainer rule and follows the rule above. On every run, --force or not, it covers a vendor context file such as CLAUDE.md and a persona copy such as .claude/agents/praetor-auditor.md that holds a hand edit (recordProjections in internal/adopt/vendor_targets.go, internal/adopt/persona_copies.go). A file that already holds its bytes is verified. Earlier Praetor texts are refreshed, not replaced: a vendor file that is the projection of AGENTS.md as the run found it or as the HEAD commit holds it (an AGENTS.md edited after the last compile-context; without a commit only the first counts), a persona copy that is the copy of its canonical persona as the run found it, Praetor's own unedited DevContainer placeholder, a catalog text with a layout-only successor, an earlier text of a documentation family file, the documentation gate block an earlier Praetor wrote, and a managed attribute block holding the rules of an earlier release or of another facet and decline state, such as the block without the DevContainer rule (tests in internal/adopt/locked_replace_test.go, internal/adopt/vendor_targets_test.go, internal/adopt/vendor_head_projection_test.go, internal/adopt/documentation_makefile_refresh_test.go, internal/adopt/gitattributes_edit_test.go and internal/adopt/gitattributes_devcontainer_test.go). Without --force, a symlinked backup root fails the run before its first write when a vendor file or a persona copy holds a hand edit (preflightVendorBackupRoot in internal/adopt/vendor_targets.go, preflightPersonaBackupRoot in internal/adopt/persona_copies.go, TestAdopt_Negative_PlainRunRefusesSymlinkedBackupRootForVendorEdit, TestAdopt_Boundary_PlainRunRefusesSymlinkedBackupRootForPersonaCopyEdit). A re-run lists an existing persona copy and the pre-commit hook adoption installed as reconciled, never as created (internal/adopt/rerun_report_test.go). - AGENTS.md harness under --force. An existing harness is kept without --force, apart from its text register block, which is spliced from the manifest as compile-context splices it (keepAgentHarness in internal/adopt/harness.go, TestAdoptKeptHarnessSplicesRegisterBlock in internal/adopt/harness_register_test.go). A splice that changes the file is reported as a replace with its line delta and backup, like a forced refresh. A block with no end marker, or a backup root adoption refuses, fails adoption before its first write (TestAdoptKeptHarnessSpliceRefusedBeforeAnyWrite). With it, the harness is regenerated and what the repository added around it stays: - the preamble: every line above the harness start, for example an SPDX header. The harness starts at the first # ... Agent Operating Harness title, together with a <!-- markdownlint-disable ... --> line directly above it. A harness whose title was renamed is found by its ## Core Directives & Invariants heading and starts at the nearest H1 above that heading, or at the top of the file when no H1 precedes it, so the renamed title and its intro are regenerated rather than kept above a second copy (harnessStart in internal/adopt/harness.go, TestAdopt_AgentsMD_ForceReplacesRenamedHarnessTitle); - invariant rows under an ID of the repository's own, such as **ACME-01**, appended after the catalog rows in their original order and byte for byte, escaped pipes included. hisscatalog.InvariantTableRows reads both tables through SplitInvariantRow, the splitter ParseGatedInvariants uses. A HISS-<n> row belongs to the catalog, so it is regenerated; - the instructions below the harness boundary: the end marker, the footer of an older harness, or a --- line, searched only below the harness start.

Every other harness line is regenerated. An edited one, such as reworded HISS-02 text, is reported as replace with its line delta and backup, as above. The text register block comes from the manifest, as compile-context renders it. The file keeps its CRLF convention, and a second --force run leaves it byte-identical. A file whose harness has no boundary is left untouched and the run records an error. Prose that only mentions the harness is not a harness: the harness is merged above it and every line is kept (TestAdopt_AgentsMD_ForceKeepsRepositoryAdditions, TestAdopt_AgentsMD_ForceKeepsCRLFPreamble, TestAdopt_AgentsMD_ForceRefusesUnknownBoundary, TestAdopt_AgentsMD_ForceKeepsProseNamingTheHarness in internal/adopt/adopt_test.go). - Text register policy. Every run renders the harness's text register block from the manifest, --force or not. A policy the renderer rejects, such as a register.tasks entry that is not a target_tasks label (text register), fails adoption before its first write, a dry run included; a manifest that declines agent-harness is not checked (preflightAgentHarness in internal/adopt/adopt.go, tests in internal/adopt/register_preflight_test.go). - Register skills. With --lock-source-root, the agent-harness step installs the skills the text register block names, social-text and caveman, and adhd-format, which social-text inherits from, into .agents/skills/, each with the upstream LICENSE its declared origin requires (.agents/skills/<name>/LICENSE). Shipped skills carry an inline credit and name no repository path an adopter lacks; adoption checks that on the source bundle (compiler.CheckShippedSkillReferences) and never on the copy the repository already holds. A declined agent-harness step, or a run without --lock-source-root, installs none, and neither the block nor the Paperclip harness then names one. A declined agent-definitions step installs none either while agent_clients selects Claude Code, since no step would write the .claude/skills/ copies. A symlinked .claude/skills fails adoption before its first write (register skills, internal/adopt/register_skills.go). - Agent context verification. The agent-definitions step projects the canonical personas and skills through the writer compile-context uses (compiler.CompileAgentSurfaces): the persona copy in every persona directory agent_clients selects, the copy of each register skill the repository carries in .claude/skills/ when Claude Code is selected, and, when .agents/plugins/praetor/plugin.json exists, the plugin persona and skill copies. A hand-edited plugin copy is replaced with its line delta and backup like any persona copy, and a symlinked plugin directory fails adoption before its first write. Every canonical persona must declare a model tier (model: flash or model: pro), projected per-client or stripped with a documented reason. After the last step, adoption runs the check compile-context --verify runs (compiler.VerifyCompiledContext, verifyAgentContext in internal/adopt/context_verify.go). The check reads the whole repository, not only the files adoption wrote. Each rejection is an error on the report, led by "compile-context --verify rejects the repository's agent context after adoption", and on the step that writes what it rejects: agent-definitions for a persona or skill copy, git-ignore for the evidence ignore rule, agent-harness for AGENTS.md and the vendor files. The run is then incomplete and praetorctl adopt exits non-zero; a caveman finding on text adoption keeps as written stays a warning. A dry run, and a manifest that declines agent-harness or agent-definitions, skip the check (tests in internal/adopt/plugin_projection_test.go and internal/adopt/context_verify_test.go). Earlier releases exited 0 in these cases, which now fail the run: - A file in a persona directory that projects no canonical persona. compile-context --verify treats every .md file in .claude/agents, .codex/agents, .github/agents, .gemini/agents and .agents/plugins/praetor/agents as compiled output, so a subagent written there by hand fails the run. Move it into .agents/agents, from where adoption projects it to every selected client, or remove it (TestAdopt_Negative_ClientPersonaDirFileWithoutCanonicalPersona). Adoption never rewrites or removes such a file. - A manifest that declines git-ignore while Git does not ignore .workingdir/evidence/. Add .workingdir/ to the repository's own .gitignore (TestAdopt_DeclinedGitIgnoreNeedsTheOperatorWorkingDirRule). - Any other failure compile-context --verify names: fix what it names, or run praetorctl compile-context. - Earlier Praetor output. The manifest, lock, label taxonomy, pinned catalog, flavor YAML (.clang-format and .clang-tidy included) and the docs:seo-portal documentation gate's YAML that adoption writes pass yamllint --strict with its default rules (make hooks-lint, git hooks). YAML written by other commands is not covered yet: .needs.yaml, FRAMEWORK_DEMAND.yaml, changelog.d fragments and .github/FUNDING.yml still fail yamllint --strict. Files an earlier release wrote before that layout, and nobody edited since, are refreshed on a plain re-run with every value unchanged: a manifest that is exactly yaml.Marshal of what it declares (TestAdoptMigratesAnEarlierManifestRendering), the earlier label taxonomy (TestReconcileLabels_Positive_RefreshesPriorTaxonomy), and a lock that pins only earlier catalog texts, which is re-pinned to --lock-source-root while the pinned catalog files are replaced (TestAdoptRepinsAnUnmodifiedEarlierCatalog). The catalog re-pin happens only when every source file decodes to exactly the values of the earlier text it replaces; a source that changes even one value keeps failing plain adoption (TestAdoptDoesNotRepinAnEarlierCatalogToChangedValues). The failure names each declared text whose values the source changes and the narrow re-pin, praetorctl profile set (TestExistingLockMismatchNamesTheValueChanges in internal/adopt/lock_remedy_test.go). The files --force no longer overwrites are refreshed the same way when they hold a text an earlier release wrote: the agent anti-evasion interceptor, the checkpoint scripts, which go to the --lock-source-root bundle, and the two canonical personas, which go through the root-pinned writer compile-context uses (TestAdopt_Positive_PriorEvasionHookRefreshedOnPlainRun, TestReconcileCheckpointBundle_Positive_PriorScriptRefreshedOnPlainRun, TestAdopt_Positive_PriorPersonaRefreshedOnPlainRun). Their sets hold the current text too, and a test fails until a changed text is added, so the release that changes one still refreshes the copy adopters hold (priorPersonaDigests in internal/adopt/ruleset.go, TestPriorPersonaDigests_Boundary_CurrentTextsRecorded; the hook files are covered in git hooks). An earlier text is recognised in either consistent line-ending style, so a CRLF checkout of the manifest, label taxonomy or a persona (core.autocrlf on Windows) is refreshed too and keeps CRLF (TestAdoptMigratesACRLFEarlierManifestInItsOwnStyle, TestReconcileLabels_Positive_RefreshesCRLFPriorInItsOwnStyle, TestReconcileAgentDefinitions_Boundary_CRLFPriorKeepsCRLFAndSymlinkNotWrittenThrough). The catalog differs in one respect. Lock verification judges a uniformly CRLF checkout of a pinned file as its LF text, so it passes without an eol=lf rule, while a file with mixed line endings is hashed byte for byte (fileDigest in internal/config/lockdigest.go, TestCheckoutLineEndings_Positive_CRLFCheckoutAuditsAsLFClone in cmd/standardsctl/checkout_line_endings_test.go). The adoption refresh still compares catalog files byte for byte (mayReplaceCatalogText in internal/adopt/policy_catalog.go), so a CRLF copy is left as it is rather than refreshed; keep .config/archetypes at eol=lf in .gitattributes when adoption should refresh it. An edited copy of any of them, or one with mixed line endings, is left as it is and keeps the contract above (TestAdoptDoesNotRepinAnEditedOrForeignCatalog). - Files the repository ignores. Once every step has run, adoption asks git whether the repository's own ignore rules exclude any file it created, verified, merged or replaced (reportIgnoredWrites in internal/adopt/ignored_writes.go). An ignored file that git does not track is still written, so the local checkout works, but no commit carries it, so a clean checkout lacks it. Each one is reported on the step that wrote it, naming the rule and the negation that re-includes it, such as tools/figures/player/loader.js remains committable under a conventional dist/ rule; adoption does not add re-include rules to an adopter's ignore file. The negation names the shallowest ignored parent directory, because git never looks inside an ignored directory. The finding is an error, except for editor files, which audit never reads: those are a warning, and the IDE Ecosystem pillar shows it. The private ledger paths the managed block ignores on purpose and the installed Git hooks are not checked. Neither is an entry whose file is not on disk after the run: without a Prettier configuration the formatter-ignore step records .prettierignore but writes nothing, so a rule that ignores that name is no finding. A dry run checks the files it plans to create instead (writtenPath). When git cannot answer, the report warns that the check was skipped (TestReportIgnoredWrites_Positive_FindingsLandOnTheWritingStep, TestReportIgnoredWrites_Negative_PrivateHookRemovedAndSkippedPathsAreNotChecked, TestReportIgnoredWrites_Boundary_OnlyFilesOnDiskOrPlannedAreChecked, TestAdopt_Negative_UnwrittenPrettierIgnoreIsNotReported, TestAdopt_Negative_DeclinedGitIgnoreProposesTheNegation).

Praetor writes its pinned catalog, label taxonomy, checkpoint policy and hook scripts under .config/, the name Kconfig gives its build configuration file. Where a Kconfig-style rule (a bare .config, /.config or .*) hides that directory, the git-ignore step adds the anchored, directory-only negation !/.config/ to the managed block and the .gitignore entry says so. Kconfig .config files stay ignored at the root and at every depth, a .config/ directory below the root (a vendored tool's) stays ignored, and a later run keeps the negation. A repository that ignores the directory itself with .config/ gets no negation, only the errors. With git-ignore declined, each error proposes !/.config/, and a dry run plans the negation without writing it (kconfigConfigRule, TestAdopt_Positive_KconfigRuleGetsTheDirectoryNegation, TestAdopt_Boundary_DryRunAndRootConfigFile). The negation also re-includes any file already below .config/ that adoption does not write, such as a tool's credentials, so the next git add -A would commit it. The git-ignore step warns and names each such file, leaving out tracked files and, in a real run, files another rule still ignores; a dry run cannot see those rules yet and says so. Ignore each named file by path above the managed block, or move it (reportReincludedConfigFiles, TestAdopt_Boundary_NegationNamesTheForeignFilesItReincludes). A .config file at the repository root cannot share its name with that directory: adoption stops before any write and names the collision (preflightConfigRoot). Moving Praetor's files out of .config/ is planned separately. praetorctl audit fails a documentation gate file that git ignores and does not track, so a local audit gives the verdict a clean checkout gets (auditDocumentationFilesCommitted in cmd/standardsctl/audit_documentation.go, TestAuditDocumentationGate_Negative_IgnoredUntrackedFileFails).

Changing the profile, facets or catalog

praetorctl profile set changes what an adopted repository declares, or re-pins it to a newer Praetor catalog, without adopt --force:

# Move to another profile; preview first, then apply
praetorctl profile set os-image --lock-source-root=/path/to/praetor --dry-run
praetorctl profile set os-image --lock-source-root=/path/to/praetor

# Replace the declared facets; --facets= declares none
praetorctl profile set --facets=security:high --lock-source-root=/path/to/praetor

# Keep the declaration and take up a catalog whose values changed
praetorctl profile set --lock-source-root=/path/to/praetor

It writes three things and nothing else (adopt.SetProfile in internal/adopt/profile_set.go):

File Change
.standards.yaml The profiles and facets lists, rewritten only when they change. Every other line, comment and key keeps its text; a comment on the list's key line is kept. A <profile> replaces the whole profiles list; an omitted --facets keeps the declared facets.
.standards.lock Rebuilt from --lock-source-root for the new declaration.
.config/archetypes The texts the new lock pins, written or replaced. A text the declaration no longer names is kept; delete it yourself when nothing reads it.

Every other file stays as it is (TestSetProfile_Positive_LeavesAnAdoptedTreeAlone). Every file is read, built and checked before the first one is written, so a refusal leaves the repository as it was (TestSetProfile_Negative_RefusalsWriteNothing). Each write is bound to the bytes read first, so a file edited in between fails the run instead of losing the edit. A replaced lock or catalog text is backed up under .workingdir/adopt-backups/ and listed with its line delta, as adoption lists a replaced file. --dry-run writes nothing and prints each changed file as a diff, or its content when it would be created (TestSetProfile_Boundary_EmptyFacetsAndDryRun). A real run reads the lock and the policy back as the audit's manifest and lock gates do: the lock must verify against the vendored catalog without the source bundle (TestProfileSetEndToEnd in cmd/standardsctl/profile_test.go).

Files derived from the declaration

Adoption also renders files from the declared profiles and facets, and profile set leaves them as they are. After a profile or facet change they can fail praetorctl audit. The DevContainer is synthesized from the declaration (devcontainer.SynthesizeWithFeatures), turning docs:seo-portal on or off adds or retires the documentation assets, the README block and the documentation context in the ruleset, and turning api:public-contract on or off adds or retires the Go API compatibility gate and its context in a repository whose go.mod git tracks. So after writing, or in a dry run against the planned declaration, profile set runs the five audit gates that check those files: the README block, the documentation gate, the API compatibility gate, the DevContainer and the branch protection ruleset (declarationGates in cmd/standardsctl/profile.go). Each prints its verdict as praetorctl audit prints it. When one fails, profile set still exits 0, since it wrote what it was asked to, and prints the refresh:

praetorctl adopt --force --dry-run --lock-source-root=/path/to/praetor   # preview
praetorctl adopt --force --lock-source-root=/path/to/praetor

adopt --force rewrites every audit-locked file that drifted, not only the ones these gates check, so read its preview first. Plain adopt does not refresh them: it keeps an existing DevContainer and refuses to retire the documentation or API compatibility context without --force. TestProfileSetReportsDerivedDrift_3D in cmd/standardsctl/profile_test.go covers the report, the refresh and a re-run that passes every gate.

The other audit gates do not run. The invariant scan reads its limits from the effective policy (EffectivePolicy.HISSScanOptions in internal/config/hiss_exceptions.go), so a profile with a lower max_func_loc can report debt the baseline does not hold. The Paperclip harness states the profile's HISS-04 limits and is not among the five gates either, so a profile change that moves those limits leaves praetorctl audit failing with Paperclip harness out of date while profile set names nothing to refresh. Run a plain praetorctl adopt --lock-source-root=... afterwards: it refreshes an unmodified earlier harness without --force. Run praetorctl audit for the full verdict.

--lock-source-root is required. A profile or facet the source bundle does not define fails before anything is written. The error names the bundle and its catalog version, such as lock source /path/to/praetor, catalog v0.0.0+catalog.3536de06c71a: profile "os-image": ..., so a bundle older than the archetype shows up as the stale part (TestBuildLockfileMissingIDNamesTheSourceCatalog in internal/config/lockbuild_test.go).


🤖 AI Agent Adoption via MCP (standards_adopt)

If you are using Claude Code, Cursor, Gemini CLI, Antigravity IDE, or any MCP-compatible coding agent, you can onboard any repository by asking:

"Adopt this repository into Praetor governance."

Under the hood, the agent executes the standards_adopt tool:

{
  "name": "standards_adopt",
  "arguments": {
    "path": ".",
    "dry_run": false,
    "force": false,
    "record_baseline": true,
    "source_root": "/path/to/praetor"
  }
}

"force": true is the refresh described in What a forced re-adoption changes and needs source_root. "record_baseline": true records a first baseline and keeps an existing one (The baseline on a re-adoption). The tool returns a detailed summary of created, reconciled and replaced files, detected archetypes, and recorded legacy debt.


âš¡ GitHub Action Bot & PR Automation

Add Praetor fast adoption to your GitHub repository using our composite action:

name: Praetor Adoption Bot

on:
  issue_comment:
    types: [created]

permissions: {}

jobs:
  adopt:
    if: >-
      github.event.issue.pull_request &&
      contains(github.event.comment.body, '/adopt') &&
      contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
    runs-on: ubuntu-26.04
    timeout-minutes: 30
    permissions:
      contents: read
      pull-requests: read
    steps:
      - id: pr-head
        timeout-minutes: 2
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.issue.number }}
        run: |
          sha="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.sha)"
          [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "no head for #$PR_NUMBER: '$sha'" >&2; exit 1; }
          echo "sha=$sha" >> "$GITHUB_OUTPUT"
      - uses: actions/checkout@v7
        with:
          ref: ${{ steps.pr-head.outputs.sha }}
          persist-credentials: false
      - uses: cordanaLLM/praetor/.github/actions/praetor-adopt@main
        with:
          mode: adopt
          force: true
          cache: false

A /adopt comment on a pull request, written by the repository's owner, a member of its organization or a collaborator, runs the adoption against that pull request's head and leaves the result in the job summary. It commits nothing. Four parts of the example each close a hole:

  • The author_association conjunct. issue_comment runs the default branch's workflow with the base repository's token for any account that can comment. OWNER, MEMBER and COLLABORATOR are the associations the repository granted; the other values GitHub defines (CONTRIBUTOR, FIRST_TIMER, FIRST_TIME_CONTRIBUTOR, MANNEQUIN, NONE) are within any account's reach.
  • The head lookup. An issue_comment payload carries the issue, not the pull request, so a checkout without a ref takes the default branch and the adoption never sees the pull request. The step asks the pulls API for the head by number and fails rather than publishing anything that is not a commit id.
  • Read scopes and persist-credentials: false. The checked-out tree is the pull request's content; the job holds no write scope and leaves no credential in the checkout.
  • cache: false. actions/setup-go restores and saves the Go caches unless its cache input is false, and an issue_comment run saves into the default branch's cache scope, which every branch restores from. The job saves nothing there.

force: true makes the run the refresh described in What a forced re-adoption changes, which rebuilds .standards.lock from a git checkout of praetor. The runner unpacks a remote action without its .git, so the action's build step runs git init, a one-commit git fetch of cordanaLLM/praetor at main (the action's own repository and ref, read from github.action_repository and github.action_ref) and a detached checkout. These land in a fresh praetor-source.* directory under RUNNER_TEMP. It builds standardsctl from that checkout and passes the checkout as --lock-source-root. The fetch is anonymous, from GITHUB_SERVER_URL, so the runner needs to reach it. A fetch that fails, or a checkout that is not praetor, stops the step before anything is built. With a branch ref such as @main, the checkout is that branch's commit when the step runs. A tag or commit pins it.

standardsctl itself still comes from the praetor ref the uses: line pins, not from the pull request (see below). That is why the example accepts a head pushed to a fork: the adoption reads the fork's files but never builds or executes them. A job that builds or runs the pull request's code has to refuse fork heads, as praetor's own adopt-comment job does.

Praetor's own adoption workflow

The hardened example uses .github/workflows/adopt.yml for dispatch and .github/workflows/adopt-comment.yml for pull request comments, with one job per file:

Job Trigger Token What it does
adopt workflow_dispatch contents: write, handed only to the commit step adopts target_path (default .), runs the HISS-13 debt ratchet on it, then commits with a DCO sign-off and pushes; with dry_run set it passes dry-run to the action, which plans the adoption without writing a file, and skips the ratchet and the commit; keeps its own Go build cache through .github/actions/go-cache (job: adopt)
adopt-comment /adopt or /dogfood comment from a trusted commenter contents: read, pull-requests: read resolves the pull request head, refuses it unless it lives in this repository, checks it out without credentials, and runs the adoption and ratchet, or the dogfood benchmark, against it; restores and saves no cache

Inside praetor the action builds the checked-out tree's own cmd/standardsctl, and the job loads ./.github/actions/praetor-adopt from that tree too, so the comment job builds and runs the pull request's code. That is the point of /dogfood, and read scopes alone do not contain it: the job runs in the default branch's context, and code in it can save Actions cache entries into the default branch's scope, which the adopt job and every other branch restore. Three things keep it contained:

  • Only a trusted commenter can start the job.
  • The head lookup refuses a head whose repository is not this one, which covers forks and deleted forks. Pushing a branch here takes write access, and an account with write access can start the job by commenting anyway, so a push that lands between the comment and the lookup admits no one the gate keeps out. The head is read when the job starts, so that later push is what runs; the repository and the commit come from one API answer, so the check covers it.
  • Both uses of the action pass cache: "false", and the job has no cache step, so nothing it builds is saved for a later run.

The adopt job sets Go up itself with cache: false before the action does. That keeps it inside AuditGoBuildCaches (internal/forge/go_cache_checks.go), which reads the setup-go steps of workflow jobs and not those inside an action.

Two checks keep the split honest. AuditPullRequestPermissions in internal/forge/workflow_permissions.go reports any job that pull_request, pull_request_target or an ungated issue_comment can start while it holds a write scope, and TestAuditPullRequestPermissions_Guard_ThisRepositoryIsDisciplined runs it over this repository. internal/forge/adopt_workflow_split_test.go pins the separate files and rejects a comment workflow that also declares workflow_dispatch. internal/forge/adopt_workflow_test.go pins the rest: only the dispatch job writes and pushes, every checkout drops its credential, the comment job checks out the head it resolved, target_path reaches both the adoption and the ratchet through env, dry_run reaches the adoption and keeps the ratchet and the push from running, only the dispatch job keeps a Go cache and stays visible to AuditGoBuildCaches, every job and the head lookup run under an explicit timeout-minutes, and the head lookup's own shell body is executed against a stub gh, which is how a fork head is shown to be refused.

Inputs, the binary, and the report output

Input Reaches Effect
path PRAETOR_PATH --path=<value>, and the --source/--target-dir of the compile-context --verify that follows an adopt run that is not a dry run; an empty value is refused before anything runs
mode PRAETOR_MODE selects the subcommand, adopt or dogfood; any other value is refused
dry-run PRAETOR_DRY_RUN --dry-run=<value>; in adopt mode true plans without writing a file and skips the compile-context --verify, which would otherwise check a tree the run did not touch and fail on a repository with no AGENTS.md yet (TestPraetorAdoptAction_Boundary_DryRunSkipsCompileContext); in dogfood mode it changes nothing, because dogfood applies adoptions only to --targets repositories (testTargetAdoptions in internal/dogfood/dogfood.go) and the action passes none, so the host is audited either way
force PRAETOR_FORCE --force=<value>, adopt only: the refresh in What a forced re-adoption changes. --force rebuilds .standards.lock and needs a lock source, so with force: true the action also passes --lock-source-root=<checkout>, the praetor checkout the build step compiled standardsctl from, and refuses the run when the build step published none (TestPraetorAdoptAction_ForcedRunCarriesTheLockSource). The rebuild inventories that checkout with git ls-files. When the action's own tree has no .git, as when loaded remotely, a forced adopt run first checks out the action's repository at the action's ref with git under RUNNER_TEMP and builds from it. A tree that already is a git checkout is used as it is (internal/forge/adopt_action_source_test.go)
record-baseline PRAETOR_RECORD_BASELINE --record-baseline=<value>, adopt only: true records a first baseline and keeps an existing one, reporting the ratchet verdict on it; false skips the scan. The action has no input that re-records an existing baseline (The baseline on a re-adoption)
go-version actions/setup-go the toolchain the step compiles standardsctl with; it never reaches standardsctl, and it has to satisfy the go directive of praetor's go.mod
cache actions/setup-go its cache input, default true as in setup-go itself; set false in a job a pull request or its comment starts, and in a job that keeps its own Go cache (TestPraetorAdoptAction_Positive_CacheInputReachesSetupGo)

All five runtime inputs reach the run step as environment variables rather than as expressions spliced into its script, so a value carrying a shell metacharacter or a newline is data rather than script. path and the three booleans are then passed to standardsctl as single arguments; mode is not passed at all, it picks the subcommand. mode and the booleans are validated first: mode: Dogfod is a failure, not a silent adopt run. Each boolean is passed as --flag=<value> rather than added when it is true, because dogfood --dry-run and adopt --record-baseline default to true in standardsctl — an omitted flag would be an opt-in, so record-baseline: "false" would have had no effect.

The standardsctl that runs is the one the action's own ref carries: the step builds cmd/standardsctl out of the praetor checkout that GITHUB_ACTION_PATH points into, or, on a forced adopt run of an action tree with no .git, out of a git checkout of the same repository and ref (the force row above), so praetor-adopt@<tag> and @main differ, and @latest builds the commit this repository's moving latest tag points at (releasing). standardsctl itself is never installed from the module proxy.

Outside praetor's own repository, reference the action as cordanaLLM/praetor/.github/actions/praetor-adopt@<ref>. The directory three levels above the action has to declare module github.com/cordanaLLM/praetor and hold cmd/standardsctl, and the step fails before anything runs when it does not:

  • uses: ./.github/actions/praetor-adopt in an adopter repository — that directory is the adopter's own workspace.
  • A copy of the action kept in another repository, such as acme/ci/...@main — the ref GitHub resolved names a commit of acme/ci, not of praetor. Installing praetor under that name would run a branch tip for @main, the newest v1.x.x tag for @v1, and the newest release for @latest (Go modules reference, version queries), none of which the caller pinned.

The error names the uses: form to switch to.

The binary output is the absolute path of the standardsctl the build step compiled. A later step of the same job that has to run praetor again, such as adopt.yml's debt ratchet, runs that binary instead of compiling a second one.

The report output carries the combined output of the run. The step writes it — and, when the runner provides GITHUB_STEP_SUMMARY, appends it to the job summary — before re-raising the command's exit status, and it does so for a refused input as well as for a failed run. Read it from the job summary after a failure: whether a composite action's declared output still reaches the caller once one of its steps has exited nonzero is not something GitHub documents. The append itself is executed by the tests below; the survival of the output is what stays unasserted.

A step that passed is not always a repository that is ready. An adopt run that leaves a pillar pending, the Verification Gate or a kept baseline the rescan rejects, exits 0 and ends its report with not ready yet: <pillars> in place of the success line (The baseline on a re-adoption). The action adds nothing to that verdict and takes nothing from it: read the report's closing lines, or run praetorctl audit with the binary output as the next step, as adopt.yml does.

      - uses: cordanaLLM/praetor/.github/actions/praetor-adopt@main
        id: praetor
        with:
          mode: dogfood
          dry-run: "true"
      - run: echo "$REPORT" >> "$GITHUB_STEP_SUMMARY"
        env:
          REPORT: ${{ steps.praetor.outputs.report }}

Everything above is pinned by internal/forge/adopt_action_test.go, which executes the action's own shell bodies against a stub binary and reads the input defaults out of action.yml, and by internal/forge/adopt_action_source_test.go, which runs the build step's checkout with the real git against a repository served over file://.

Migration: explicit sources for missing lockfiles

Live adoption no longer creates the old placeholder lock. Pass --lock-source-root=/path/to/praetor (MCP: source_root) when a target has no valid lock. The source must have a valid manifest/lock and every selected local archetype source. Digests come from actual source bytes. The MCP source path obeys server root confinement.

A source root is a Praetor Git checkout or a source bundle. A source that holds go.mod also feeds the DevContainer bootstrap, which lists its build inputs with git ls-files, so it must sit in a Git checkout. Adoption refuses one that does not before its first step writes, naming the source, the flag (MCP: source_root) and the cause, so a git archive export never leaves a half-adopted repository (preflightDevContainerSource in internal/adopt/devcontainer.go, TestAdoptRefusesUncapturableSourceBeforeWriting). A bundle without go.mod needs no Git; the DevContainer then records its bootstrap as unavailable. profile set builds no bootstrap and accepts either form. A failing inventory carries git's own error rather than a cause Praetor guessed (TestRunSourceGitCarriesGitStderr_3D in internal/devcontainer).

Lock version: the source catalog's identity

A built lock's pinned_version, and every entry's version, name the source catalog rather than copying the source lock's own pinned_version:

pinned_version: "v0.0.0+catalog.<12 hex digits>"

The hex digits start the aggregate digest over every archetype and facet under the source's .config/archetypes, computed as a lock's top-level digest is, but over the whole catalog instead of the target's selection. The source lock is still validated first; its declared version is not trusted because nothing moves it when the catalog changes. So:

  • re-pinning against changed catalog content writes a new version, even when the target's own digests are unchanged;
  • the same catalog content writes the same version, wherever the source lives;
  • a source whose .config/archetypes defines nothing fails lock generation instead of writing a version it cannot back.

internal/config/lockbuild_test.go pins all three. praetorctl init renders its unreleased build versions (v0.0.0+<revision>) through the same config.UnreleasedLockVersion in internal/config/lockbuild.go. The revision is the VCS stamp of a checkout build or, for go install .../cmd/standardsctl@<commit>, the commit that build's pseudo-version ends with; go install ...@<tag> pins the tag itself (buildid.Identify in internal/buildid/buildid.go resolves the identity, tested in internal/buildid/buildid_test.go; lockPin in cmd/standardsctl/buildidentity.go turns it into the pin, tested in cmd/standardsctl/buildidentity_test.go). An existing lock is not rewritten for its version alone: validation compares digests, not versions, so the catalog version arrives with the next rebuild (praetorctl profile set, --force, or a layout-only re-pin).

An existing valid target lock is preserved. An invalid lock fails adoption unless both --force and an explicit source permit rebuilding it; praetorctl profile set rebuilds the lock alone from an explicit source. A dry run without a source reports lock generation as skipped; it cannot promise a complete adoption. Live errors retain the partial report, since earlier scaffolding may already exist. The same source option applies to adopt --all-missing; integrations invoking adoption must supply it or arrange an already valid target lock.

Migration: the Go API compatibility gate

Only a Go repository that declares api:public-contract, one whose go.mod git tracks at the root or nested, fails praetorctl audit until praetorctl adopt writes tools/apicompat/gate/main.go and .github/workflows/praetor-api.yml; its rendered ruleset then requires Go API Compatibility. A repository declaring the facet without a tracked go.mod needs no change: adoption writes nothing for the facet, and audit prints an [INFO] line saying that no API compatibility checker runs for its languages. A repository pinning the earlier facets/api-public.yaml re-pins it with praetorctl adopt --force. Remove the facet to opt out (Go API compatibility gate).

Migration: --force keeps an operator-owned Paperclip harness

praetorctl adopt --force no longer regenerates an operator-owned .paperclip/harness.json or recreates a deleted .paperclip/rules.md (#502). It keeps the harness, resets only a platform that names another repository to this repository's, and fails on a harness that does not validate, as a plain run does. To regenerate the harness, delete .paperclip/harness.json (and rules.md) and rerun praetorctl adopt.

Adoption does not re-bind register.sources to a harness you edited, under --force either. A declared contract that no longer matches stops the run before its first write with existing register.sources fails its configured gate. The error names every value that differs, extracted from the files as they stand, and two remedies:

  1. Keep the edit: set expected, not_applicable and sha256 under register.sources in .standards.yaml to the extracted values the error reports, then rerun praetorctl adopt --force --lock-source-root=<praetor checkout>. praetorctl caveman check --configured-sources --root=. reports the same values once every input is staged with git add; it refuses untracked inputs.
  2. Drop the edit: delete .paperclip/harness.json and rerun praetorctl adopt. This works only when adoption writes a harness: with adoption.decline: [paperclip] or an unresolved repository identity it writes none, so restore the bound bytes with git checkout instead, as the error then says. A harness adoption writes where none existed is Praetor output. When every register.sources input selects .paperclip/harness.json, as the rows adoption declares do, adoption binds the pins to the harness it writes and reports Re-bound register.sources to the Paperclip harness this run writes where none existed. That includes pins an earlier release bound to the harness it wrote. A contract that also selects another file keeps its gate, because its one digest cannot tell that file's drift from the new harness. The error then reports values that cover the harness the run would write: set them and rerun.

Tests: TestAdoptEditedHarnessFailsBeforeWritingWithRemedy in internal/adopt/adopt_test.go, TestAdoptDeletedHarnessRebindsPinsAcrossReleases and TestAdoptAbsentHarnessKeepsGateOfMixedContract in internal/adopt/absent_harness_rebind_test.go, and TestAdoptForceEditedHarnessNeedsRecomputedPins in cmd/standardsctl/audit_paperclip_force_test.go. The harness rules in full: text register.

Migration: repository.forge and the audited Paperclip harness

The Paperclip harness prescribes the push protocol of the forge repository.forge declares (#321). A GitHub or GitLab harness carries push_format, the review-branch push alone, in place of agit_push_format; a consumer of .paperclip/harness.json there reads the new member. A repository whose origin remote is not on github.com declares the key and reruns adoption:

repository:
  forge: forgejo # or github, gitlab

praetorctl adopt then refreshes a harness an earlier release wrote, with or without --force. The audit no longer accepts that harness as it stands: it fails on unmodified earlier output, with praetorctl adopt as the remedy, and on a rules.md that is not the rendering of harness.json, with praetorctl paperclip harness or deleting rules.md as the remedy. An edited harness.json stays operator-owned and passes (what the generated harness claims). The comparison detects the repository's languages with adoption's bounded walk. A repository that needs --verification-max-* to adopt declares the bound once in .standards.yaml, so the audit in the hooks and CI jobs, which pass no flag, reads it too (large repositories):

verification:
  max_entries: 131072

Lock verification outcomes

Every command that reads .standards.lock uses one validator, ValidateLockfileWithOptions in internal/config/lock.go. It recomputes each declared profile and facet digest from a catalog. The catalog is the .config/archetypes directory under --catalog-root (MCP: catalog_root), or under the repository when no catalog root is given. The effective policy gate resolves the same catalog, so both gates hash the same bytes.

Outcome Meaning
verified Every declared entry's catalog file hashed to its pin.
unverifiable Pins and the aggregate digest are valid, but the catalog has no .config/archetypes directory.
invalid A version, pin, digest or catalog defect. Invalid locks are errors, never a status.

A catalog that exists must define every declared id. An archetype whose id: field was changed, or whose file was deleted, fails with ErrLockSourceMissing instead of skipping the digest comparison.

Each command handles unverifiable as follows:

  • praetorctl audit and the MCP standards_audit tool fail with ErrLockUnverifiable.
  • praetorctl sync prints [UNVERIFIED] and exits incomplete; --catalog-root selects a catalog. The effective policy cannot resolve either, so sync neither checks nor writes .github/rulesets/main.json and counts that one cause once.
  • praetorctl plan and the MCP standards_plan tool fail to resolve the policy and print none; --catalog-root (MCP: catalog_root) selects a catalog.
  • praetorctl adopt records the outcome with a warning; --lock-source-root verifies against that bundle.
  • praetorctl harvest onboard completes with lock_verified: false and lock_status: unverifiable.
  • Lock generation refuses a source bundle without a catalog, or with one that defines no archetypes.

Generated locks omit generated_at; a lock that sets it still validates. The behavior is pinned by internal/config/lock_test.go, cmd/standardsctl/lockdigest_test.go, cmd/standardsctl/sync_validation_test.go, internal/adopt/lock_test.go and internal/harvester/onboard_safety_test.go.