Skip to content

Archetype and Facet Authoring Guide

Learn how to define new composable profiles and cross-cutting security/operational facets in the catalog Praetor ships, in this repository or in your fork of it.

Policy layers fold in a fixed order through the lattice join; repository control overrides apply after the join.
Lattice Join
Text description
  • Layers, folded in this order: 1. Defaults (builtin:defaults-v1), 2. Pinned profiles (.config/archetypes, lock digest), 3. Pinned facets (.config/archetypes/facets, lock digest), 4. External (fleet, org, deployment, workstation), 5. Repository complexity (.standards.yaml overrides.complexity), 6. Audit compat (builtin:audit-compat-v1, if Audit set).
  • Boxes: Lattice join (ResolvePolicy), Control overrides (branch protection & supply chain), Effective policy (EffectivePolicy, sealed), Consumers (audit, adopt plan, devcontainer, workstation).
  • Layers, folded in this order → Lattice join → Effective policy.
  • Control overrides → Effective policy (ApplyOverrides) → Consumers.
  • Scenario 1, defaults: The join starts from the built-in defaults. newEffectivePolicy seeds the result with DefaultPolicy.
  • Scenario 2, pinned profiles: A profile joins only through its .standards.lock pin. pinnedLayer rejects a file whose digest differs from its lock pin (ErrLockDigestMismatch) or whose id changed. native-gpu-systems then contributes its complexity caps and controls.
  • Scenario 3, pinned facets: Facets pass the same pin check and fold in after the profiles. security:high adds two required reviewers, SLSA level 3 and two linters.
  • Scenario 4, external, repository, audit: These layers add complexity limits and operator settings, never controls. Selected fleet, organization, deployment and workstation files fold in, in that order. The manifest's overrides.complexity joins as one more layer. When Audit is set, as in audit and adopt, the audit-compat ceiling folds in last.
  • Scenario 5, join: ResolvePolicy joins every layer in order. Join keeps the lowest positive complexity cap, the higher reviewer count and SLSA level, true over false, strict_ban over allow_with_comment, and deduplicated unions. ArchetypeControls.validate rejects review_mode on every layer, so the joined policy keeps the independent default.
  • Scenario 6, control overrides: Repository branch and supply-chain overrides apply after the join. finishEffectivePolicy applies only branch_protection and supply_chain through ApplyOverrides; review_mode may relax to single_maintainer here and nowhere else.
  • Scenario 7, consumers: The sealed effective policy drives every consumer. standardsctl audit, adopt plans, devcontainer and workstation install each resolve policy through this loader. ResolveDevContainerFeatures re-reads the pinned files: one feature identity with another tag, digest or options fails closed.

0. The shipped catalog

Fourteen profiles ship in .config/archetypes/:

app-service · closed-private · container-image · framework · gitops-infra · library-client · native-gpu-systems · org-health · os-image · pages-site · planning-artifacts · template-seed · upstream-fork · web-package

Seven facets ship in .config/archetypes/facets/.

Every catalog file passes yamllint's default rules. Adoption copies the pinned files byte for byte into an adopter's .config/archetypes, where the adopter's own lint may run, so each file opens with the --- document start and keeps every line within 80 columns. Wrap a long quoted description across lines at single spaces, which YAML folds back into one space. make hooks-lint lints the whole catalog (scripts/test_emitted_yaml_lint.py).

Changing a file moves its digest. Re-pin .standards.lock with the digests praetorctl audit reports, as the lock's header describes. The audit names the entry, the pinned digest, the file and the digest it now hashes to, and after that the top-level digest the entries hash to (entryDigestMismatch in internal/config/lockdigest.go, tests in internal/config/lock_mismatch_report_test.go). An adopter's lock pins the earlier text, and a re-run of praetorctl adopt against the new catalog fails on it until --force. The exception is a layout-only change listed in priorCatalogDigests (internal/adopt/policy_catalog.go): a lock that pins only those texts is re-pinned without --force (TestAdoptRepinsAnUnmodifiedEarlierCatalog), and only while the new catalog decodes to exactly the values of each earlier text (isLayoutOnlySuccessor). Once a later change moves a value, adopters still on an earlier text need --force again (TestAdoptDoesNotRepinAnEarlierCatalogToChangedValues). List a file whose values change in catalogValuesChangedSinceBUG782 (internal/adopt/policy_catalog_prior_test.go); its test names each file that is missing there.

Every file that selects a DevContainer feature selects it the same way. Feature resolution rejects two references of one feature, such as node:1 beside node:2, and one reference with different options, such as node:2 bare beside node:2 with version: "24" (ResolveDevContainerFeatures in internal/config/devcontainer_features.go), so a repository declaring two of those files would stop resolving. A feature bump or an options change therefore moves every file that selects the feature: pages-site, web-package and the tooling:vscode-extension facet all select node:2 with version: "24", and app-service and container-image both select docker-in-docker:4 with no options. TestShippedFeatures_Positive_OneReferencePerFeature (internal/config/shipped_features_test.go) fails while the catalog names one feature two ways, and TestShippedFeatures_Positive_EveryProfileWithEveryFacetResolves (internal/config/shipped_feature_combinations_test.go) resolves every profile alone, with each facet and with the whole catalog declared at once, and fails on each selection that does not resolve. Praetor's own .devcontainer/devcontainer.json is verified against the rendering of the pinned catalog, so an update of that file alone, as a Renovate DevContainer branch made it, fails praetorctl devcontainer verify and the audit until the catalog files, the lock and catalogValuesChangedSinceBUG782 move with it. renovate.json therefore disables Renovate for the generated bundle and reads the features from the catalog instead: one custom manager matches every ghcr.io/devcontainers/features/<name>:<major> reference under .config/archetypes/, so its branch moves every file naming the feature, and another the version option of the node feature (TestRenovateReadsDevContainerFeaturesFromTheCatalog in internal/devcontainer/renovate_test.go). The takeover of such a branch lists the changed files in catalogValuesChangedSinceBUG782. It re-pins .standards.lock and regenerates the bundle only when a file Praetor's own .standards.yaml declares moved: praetorctl audit --offline and praetorctl devcontainer verify say so, and both passed unchanged for the docker-in-docker:4 move, because Praetor declares neither app-service nor container-image. Options a catalog gives common-utils merge over Praetor's hardening defaults, so a selection with no options keeps them (feature options).

Profiles that support more than one language can put language-specific features under devcontainer_features_by_language. The effective policy resolves go.mod and Cargo.toml in the repository root and adds the matching feature list. A mixed Go and Rust repository receives both lists. The legacy devcontainer_features list stays language-neutral.

A major bump is first read against the feature's own definition in devcontainers/features (devcontainer-feature.json, NOTES.md and install.sh under src/<name>/): the options the catalog passes must still exist, and the feature must install on the reviewed base image, DefaultBaseImage in internal/devcontainer/bootstrap.go. That image is Ubuntu 26.04, where docker-in-docker:2 exits on its default moby: true and releases from 4.1.1 install Moby.

A profile is not a flavor. A profile says what governance applies; a flavor says which templates, settings and toolchains a repository of that kind requires. Six profiles currently have any flavor implementing them — app-service, framework, library-client, native-gpu-systems, container-image and os-image. For the other eight, flavor audit, flavor apply and adoption report not applicable (flavor audit skips with exit 0) rather than measuring or scaffolding the repository against an inferred language flavor. The same holds where the profile has flavors, none matches and none is pinned (flavor.IsNotApplicable). A flavor is only ever chosen among the flavors of the repository's profile (internal/flavor/resolve.go).

Flavor templates: one embedded body, checked content

Every template a flavor requires states where its content comes from, in internal/flavor/definitions.go, exactly one way:

Field Meaning Example
Source a body under templates/, compiled into the binary and rendered by praetorctl flavor apply go/ci-go.yml.tmpl for .github/workflows/ci.yml
Producer the command that writes the file; flavor apply never writes it, --force included, and lists it under Left to Producer praetorctl adopt for .standards.yaml, praetorctl compile-context for CLAUDE.md

A Source template may also carry Resolve, which reads what the body needs from the repository: the facts it renders against (fields of templates.Context) and anything missing for it to work as written. Where it reports something missing, flavor apply writes nothing, --force included, and lists the path and what is missing under Unmet Requirement; praetorctl adopt turns each into a warning. The audit still requires the file.

A template whose body changed may carry Prior: the digests (util.CanonicalTextDigest) of the texts earlier releases scaffolded at its path. Without --force, flavor apply, and so a plain praetorctl adopt, refreshes a file that holds one of them in one consistent line-ending style to the current rendering, keeping that style. It lists the file under Refreshed Earlier Praetor Text (refreshed_templates in the apply report), and adoption records it as reconciled. An edited copy matches no digest and stays until --force, and the manifest, the lock and the ledger are never refreshed. While Resolve withholds the body, a file already there is kept. An earlier text stays unrefreshed and is listed under Unmet Requirement with the reason, and any other file is reported skipped (internal/flavor/target_write_internal_test.go). Every recorded text needs a fixture that reproduces its digest, as TestRustfmtPriorTextsAreEarlierRenderings (internal/flavor/rustfmt_test.go) holds for rustfmt.toml and TestCIWorkflowPriorTextsAreRecorded (internal/flavor/ci_prior_test.go) for every CI workflow template, whose earlier texts sit in internal/flavor/testdata/ci-prior.

A CI workflow template takes its trigger and draft handling from the hosted gate shape (internal/ghworkflow/hostedgate.go) through the template functions hostedGateOn, hostedGateConcurrency, hostedGateDraftStep and hostedGateStepIf (templates/hostedgate.go) instead of spelling them out (Workflow trigger audit). The first three each sit on a line of their own, hostedGateConcurrency directly below hostedGateOn; the last goes on a step's name: line. Its checkout step sets persist-credentials: false. A body with such actions commits its rendering under templates/testdata/rendered, where the yamllint gate lints it (TestBranchingYAMLTemplateRenderingsAreCommitted), and TestCIWorkflowTemplatesRenderTheHostedGateShape holds every rendering to ghworkflow.HostedGateFault.

A template with neither is an apply error, not a placeholder. flavor apply used to write a one-line # <file> configuration for <owner>/<repo> comment for every template it had no body for, which disabled every built-in gitleaks rule (#410) and scaffolded workflows that ran nothing. praetorctl flavor inspect <flavor> prints the producer beside each producer-owned template.

Flavor settings: the ruleset is rendered, the rest are deferred

A flavor's RequiredSettings (SettingItem in internal/flavor/flavor.go) take one of two routes through flavor apply (internal/flavor/settings_apply.go):

Setting What flavor apply does Test
.github/rulesets/main.json renders it through forge.RenderRulesetForRepository, the renderer adoption's branch-ruleset step uses, under the effective policy (config.ResolveRepositoryPolicy, or the built-in default without .standards.yaml) TestApplyFlavor_Positive_EffectivePolicyDecidesTheRuleset
a setting with Producer writes nothing and lists it as deferred with the command that writes it: praetorctl adopt for lefthook.yml, praetorctl editors generate for .vscode/settings.json TestApplyFlavor_Positive_RulesetRequiresTheScaffoldedWorkflows
  • Settings run after templates, so the ruleset requires the status checks of the workflows apply has just written.
  • A ruleset that already is the rendering, line endings aside, is reported unchanged: the text comparison adoption applies to the same file. The rendering that was current before this apply added workflows (forge.PriorRulesetDigests), such as the one adoption wrote, is refreshed without --force, in its own line-ending style (TestApplyFlavor_Positive_RefreshesTheRulesetCurrentBeforeApply).
  • Any other ruleset that differs is kept and reported, and --force replaces it. That includes a rendering with a single value edited, such as a signature rule, a review count or an added status check (TestApplyFlavor_Negative_ValueEditedRulesetIsKeptWithoutForce, TestApplyFlavor_Negative_EditedRulesetIsKeptWithoutForce). Adoption decides the same way (refreshing a ruleset).
  • adoption.decline: [branch-ruleset] in .standards.yaml stops flavor apply too. The decline is read through adopt.RepositoryArtifactDeclined (TestFlavorApply_Negative_HonoursAdoptionDecline).
  • A policy that does not resolve fails the ruleset alone. The templates are still applied.
  • A setting with neither a renderer nor a producer is an apply error. TestRequiredSettings_Positive_EveryBuiltinSettingHasARendererOrProducer requires one of the two for every built-in flavor.
  • praetorctl adopt applies the flavor with ApplyOptions.TemplatesOnly, because its own branch-ruleset step writes the ruleset once every workflow of the run exists.

flavor apply alone makes the flavor audit pass only for a flavor whose other required settings are already in place. The ruleset is the one setting it renders. lefthook.yml and .vscode/settings.json come from their producers, and flavor apply does not render them. TestApplyFlavor_Positive_FreshRepositoryGetsTheRulesetAndPassesTheAudit holds the audit to a pass after flavor apply alone for native-gpu-systems and infra-k8s. For any other flavor, run the producer the report names; flavor inspect prints it next to each deferred setting.

The body is the file. templates/embed.go embeds templates/*/*.tmpl, so the file under templates/ is byte for byte what an adopter receives. Actions use <% and %> rather than {{ }}, because the workflows carry GitHub expressions such as ${{ runner.os }}; a maintainer note goes in a template comment, <%- /* note */ -%>, which renders to nothing. The context a body can name is templates.Context; a body naming anything else fails TestEveryShippedTemplateRenders (templates/embed_test.go). flavor apply fills Owner and RepoName from the origin remote only; without one, Owner is empty and RepoName is the checkout directory's name, so a body that names the repository guards the owner, as templates/native/.gitleaks.toml.tmpl does (TestApplyFlavor_Negative_CheckoutLayoutIsNotOwner). .clang-tidy is shared with the Visual Studio editor target (internal/editor/editor.go), so both commands write one configuration setting WarningsAsErrors: '*' under HISS-10.

The audit reads the content. flavor audit counts a template only when it is a regular file — not a directory, and a symbolic link only when it resolves inside the repository — whose content passes the template's Validator (internal/flavor/template_validators.go). Settings share the same reader. Each validator checks what its format makes checkable and no more:

Validator Accepts
validWorkflow a workflow with a non-empty jobs mapping
validClangTidyConfig a YAML mapping declaring a non-empty WarningsAsErrors setting
validDockerfile at least one FROM instruction
validGitleaksConfig a config that loads rules: [extend] useDefault = true, an [extend] path, or [[rules]]
assignsTOMLKey TOML assigning at least one key
validXMLDocument well-formed XML with an element
validMarkdownDocument text beyond headings and HTML comments
validYAMLMapping, validJSONObject a non-empty mapping or object, as for settings; the JSON is strict
validJSONCObject a non-empty object of JSON with Comments, for a path whose consumer documents that format: .vscode/settings.json (DialectOf in internal/strictjson/dialect.go)
carriesCode a line that is not a comment, for JavaScript, TypeScript and tsconfig.json, which is JSON with comments

flavor apply asks a narrower question than the audit. It writes nothing under the canonical name while any AltPaths file exists, even one the audit rejects (a comment-only tsconfig.base.json, or a link to a config outside the repository): that file is the one the toolchain reads, and a scaffolded rival beside it would contradict it. The audit keeps reporting the template missing until the alternative's content passes. flavor apply lists the file it kept under Kept Existing Config, and adoption records it against that file (covered_templates in the apply report). --force is the exception: it writes the canonical file beside the alternative, so use it only when you mean to replace the alternative, and then delete the old file.

A template whose tool searches several names in a fixed order and reads only the first one it finds declares Search (the tool and its names, in its order) instead of AltPaths. Every name satisfies the template, but the audit validates only the first one present, because the tool never reads a later one, and lists the later ones under Shadowed Templates (advisory) with the file the tool reads. Path must be one of the names; TestEverySearchedTemplateScaffoldsASearchedName (internal/flavor/template_alternatives_test.go) fails otherwise.

Scaffolded workflows and images must run as written. A workflow step may call only what the job installs: the Go CI job runs go vet ./... and go test -race ./..., not make verify-all, whose recipes call praetorctl, which no step installs. Praetor's own gates run from the git hooks adoption writes. TestScaffoldedWorkflowsRunWithoutAPraetorBinary (internal/flavor/emitted_content_test.go) fails on a step naming the praetor binary. The Go Dockerfile builds the module's only main package, wherever it lives; with none or several, docker build stops and names them, and --build-arg MAIN_PACKAGE=./cmd/<name> picks one. TestScaffoldedDockerfileBuilderCompilesTheModulesMainPackage (internal/flavor/dockerfile_build_test.go) executes the builder instruction against each layout. The JVM CI job (templates/jvm/ci-jvm.yml.tmpl) runs whichever build the repository carries: the Maven wrapper, mvn, the Gradle wrapper, then the runner's gradle for a Gradle build with no wrapper. A wrapper committed without its executable bit runs through sh. A Gradle build with no wrapper on a runner without gradle stops with a message naming the missing wrapper. TestJVMBuildStepRunsTheRepositorysBuild (internal/flavor/jvm_ci_test.go) executes the step against each layout with stub build tools. The Flutter analyzer config (templates/flutter/analysis_options.yaml.tmpl) includes package:flutter_lints/flutter.yaml or package:lints/recommended.yaml only when pubspec.yaml declares that package as a dependency or dev dependency (internal/flavor/dart_lints.go). Otherwise it has no include, because flutter analyze fails on an include pub cannot resolve, and keeps its core linter rules, which need no package. TestScaffoldedDartAnalysisConfigIncludesOnlyADeclaredLintPackage (internal/flavor/dart_lints_test.go) covers each case.

The Rust formatter config (templates/rust/rustfmt.toml.tmpl) declares the edition every crate of the root Cargo.toml's workspace is on (internal/flavor/rustfmt.go). cargo fmt passes each crate's edition to rustfmt, but rustfmt run directly, as a hook on staged files does, reads it from rustfmt.toml, so any other edition makes the two disagree (#567). The crates are the root package and every [workspace] member: a listed path, or each directory a * or ? pattern matches that no exclude entry names or contains. A crate inheriting its edition (edition.workspace = true) takes the one [workspace.package] declares, and a workspace with no crate to read takes that one directly. Where no crate declares an edition, the config has none either, since Cargo and rustfmt then both use 2015. The crate editions are unknown when the repository has no root Cargo.toml (a crate in a subdirectory), the root manifest cannot be read (larger than 1 MiB, not a regular file), or a member cannot be read (no Cargo.toml, no [package], a ** or [...] pattern, a pattern matching nothing, which cargo metadata also rejects, more than 256 members). There, and where the crates share no edition, no single edition is known to agree with cargo fmt on every crate: flavor apply writes no config and names the reason under Unmet Requirement. It keeps a rustfmt.toml already there; an earlier Praetor text stays unrefreshed, with the reason listed beside it. Crates that only path dependencies pull into the workspace are not read. TestScaffoldedRustfmtFollowsTheCrateEdition, TestRustfmtApply_Negative_NoCommonEditionWithholdsTheScaffold and TestRustfmtApply_Boundary_NoCommonEditionKeepsAnEarlierScaffold (internal/flavor/rustfmt_test.go) cover each layout.

A body that depends on the repository declares Resolve. The Node CI job installs from the committed lockfile and runs the test script, and typescript-node matches any package.json in an app-service repository and can be applied by name to any other. So flavor apply writes the job only when all of these hold (internal/flavor/node_ci.go):

  • The package manager is known: the one packageManager names (npm, pnpm, yarn or bun, as <name>@<version>), or, without a declaration, the one whose lockfile CI's checkout holds. Undeclared lockfiles of two managers leave the choice to you: name one in packageManager.
  • CI's checkout will hold that manager's lockfile at the root: package-lock.json (npm 12 reads no npm-shrinkwrap.json), pnpm-lock.yaml, yarn.lock, or bun.lock (bun.lockb before Bun 1.2). The file on disk is not enough: a library that lists its lockfile in .gitignore still gets one from a local install, and CI never sees it. Git must track the lockfile, or would commit it because no ignore rule excludes it. A lockfile force-added past such a rule counts, because it is tracked. Outside a Git work tree, or without git, nothing shows what CI checks out, so the job is withheld.
  • The test script is neither missing, blank nor the placeholder npm init writes.

The job then installs with that manager and fails when package.json disagrees with the lockfile:

Manager Set up by Install Scripts
npm actions/setup-node with the npm cache npm ci npm run lint --if-present, the same for build, npm test
pnpm Corepack pnpm install --frozen-lockfile pnpm run --if-present lint, the same for build, pnpm run test
Yarn Corepack yarn install --immutable on Yarn 2+, --frozen-lockfile on Yarn 1 yarn run lint and yarn run build where package.json defines them when the job is scaffolded (Yarn has no --if-present), yarn run test
Bun oven-sh/setup-bun bun install --frozen-lockfile bun run --if-present lint, the same for build, bun run test

Corepack is installed from npm, because Node 25 and later ship without it. Corepack and setup-bun install the version packageManager pins, or their own default. Yarn is 2 or later when the declared version says so or, without a declaration, when .yarnrc.yml sets yarnPath, which Yarn 1 hands the command over to.

These checks cover what the steps need, not whether your scripts pass. Elsewhere adoption requires no Node check, and you write the CI job your repository needs. The packageManager parse and the test-script decision are shared with adoption's verification plan (internal/nodemanifest/scripts.go), which generates npm run commands for npm projects only. TestNodeCIJobIsScaffoldedOnlyWhereItRunsAsWritten (internal/flavor/node_ci_test.go) checks every action, tool, lockfile and script the scaffolded body names against the files git add -A stages in each fixture, which is what CI's checkout carries.

Adoption scaffolds the flavor of the profile it records. praetorctl adopt resolves the flavor under the profile it writes into .standards.yaml (flavor.ResolveForProfile), before it derives the branch ruleset, so the scaffolded CI job is a required check from the first run and is the flavor flavor audit measures afterwards. A Go service whose package.json only holds commit tooling is adopted as framework and scaffolded as go-service; adoption used to scaffold typescript-node there, because it detected across the whole catalog. A profile with no flavor, or whose flavors all fail to match, gets no flavor templates and a warning naming the profile and praetorctl flavor apply --flavor=<name> (internal/adopt/flavor_scaffold_test.go). Adoption once applied go-library there, and its CI job (setup-go against a go.mod the repository lacks) became a required check no pull request could pass.

When you add a template, give it a Source (add the body under templates/<ecosystem>/) or a Producer, and a Validator. TestEveryRequiredTemplateStatesItsContent (internal/flavor/template_content_test.go) fails otherwise, and also fails when the scaffolded body does not pass its own validator or the old comment placeholder does.

The os-image flavor: a forge is what it builds

os-image is the first profile whose flavor is not a language stack. It requires shellcheck and yamllint, plus a yamllint policy for the image and workflow definitions, because an image forge is audited on the pipeline that produces a bootable artifact rather than on a compiler toolchain.

A build engine only where its input is. packer is asked for only in a repository holding a Packer template (ToolchainItem.Markers in internal/flavor/flavor.go): a kernel forge or an mkosi image builds without it, and an item it cannot use is neither counted nor reported missing. praetorctl flavor inspect os-image prints the limit as only where: packer/*.pkr.hcl. A limit may name only the flavor's own detection markers (TestOSImageToolchainMarkersAreForgeMarkers in internal/flavor/os_image_test.go). The toolchain check is advisory and never scored.

Any yamllint configuration name counts. yamllint reads the first of .yamllint, .yamllint.yaml and .yamllint.yml it finds (find_project_config_filepath in yamllint's cli.py, checked on 1.38.0), so the template searches that list (yamllintConfigNames in internal/flavor/definitions.go). The audit accepts each name and judges the one yamllint reads: an empty .yamllint beside a valid .yamllint.yml fails, because yamllint never reads the valid file. With two present, the audit passes and names the file in use under Shadowed Templates (advisory). flavor apply and adoption scaffold .yamllint.yml only for a repository with none of the three (internal/flavor/yamllint_config_test.go, internal/adopt/flavor_report_test.go).

What marks a forge. Four markers, stated once in the os-image rule of the classification table in internal/classify/classify.go. OSImageFlavor.Detect (internal/flavor/definitions.go) reads that rule through classify.HasMarkerOf instead of keeping its own copy, so the profile a checkout classifies as and the flavor it resolves to cannot disagree about what a forge is:

Marker Kind What it identifies
packer/*.pkr.hcl glob a Packer template tree
mkosi.conf fixed path an mkosi image definition
build/mkosi.conf fixed path the same, under a build directory
kconfig/[^.]*.config glob Kconfig fragments of a kernel forge

The archetype names kernels, initramfs and UKIs beside disk images (.config/archetypes/os-image.yaml), and a forge that builds a kernel keeps its Kconfig fragments under kconfig/ with no Packer template or mkosi.conf (#615). The [^.] keeps hidden files out: Go's * matches a leading dot, and kconfig/.config is the configuration Kconfig writes, not a fragment. A root .config or an empty kconfig/ does not mark a forge either (internal/classify/os_image_test.go, internal/gating/flavor_stage_test.go).

The profile promises signed outputs, and praetorctl provenance refuses to attest a .deb that is not a Debian package, an .efi that is not an EFI image, or a Unified Kernel Image without its .linux section, so a placeholder a build left behind fails before it is signed (content checks). A UKI named like a kernel, such as vmlinuz-7.2.4.efi, is declared with --uki (declaring a UKI).

The glob marker rule. Most markers are a fixed path: go.mod either exists or it does not. A Packer tree cannot be written that way, because what identifies the forge is holding some template, not a particular one. util.MarkerExists is the single matcher for both tables: a fixed marker stays a stat, a pattern containing *, ? or [ expands with a bounded scan (maxMarkerMatches = 256, HISS-02) and counts only regular files. That last part is what keeps the negatives honest — an empty packer/, a packer/ holding only a README.md, and a directory named x.pkr.hcl are all non-matches, so no repository becomes an image forge by owning a folder. A malformed pattern matches nothing rather than erroring: a marker table describes the world, it is not user input to validate.

Why it outranks go.mod and pyproject.toml. OSImageFlavor is registered ahead of the language flavors, and its markers sit ahead of go.mod and pyproject.toml in classify.rules(). An image forge carries both — a Go CLI that drives the build, a Python suite that verifies the result — so whichever language flavor claimed it first would describe the tooling instead of the product. That is not hypothetical: an adopter's OS image forge was audited as a Go service and told to add a Dockerfile it has no use for. Order the markers the same way when you add a profile whose repositories are known by their output rather than their source language.

JavaScript flavors: flat ESLint config, never a second one

typescript-node and frontend-svelte require ESLint configuration through one shared definition in internal/flavor/eslint.go:

  • What counts as configured. Any file ESLint already loads: eslint.config.{js,mjs,cjs,ts,mts,cts}, or a legacy .eslintrc, .eslintrc.{js,cjs,json,yaml,yml}. A repository carrying one conforms, and flavor apply writes nothing beside it unless --force is passed.
  • What gets scaffolded. Only for a repository with none of those: eslint.config.mjs (templates/node/eslint.config.mjs.tmpl), holding ESLint's recommended JavaScript rules in the form the @eslint/js README documents. It is .mjs because a .js file is ESM or CommonJS depending on package.json "type", and .mjs loads in both kinds of repository.
  • What is never scaffolded. Legacy eslintrc files. ESLint v10 cannot load them, which is why the shipped catalog already refuses to name them (internal/config/shipped_catalog_test.go).

Two guards in internal/flavor/eslint_guard_test.go hold every flavor to this. One fails if any flavor requires a legacy eslintrc file. The other fails if any JavaScript or TypeScript template has no body of its own or renders one starting with #, which is a syntax error in those languages. frontend-svelte's playwright.config.ts (templates/svelte/playwright.config.ts.tmpl) carries the configuration the @playwright/test documentation shows, without a baseURL or webServer, since those depend on an application server the flavor cannot know about.

Two profiles are worth reading before writing a new one, because their correctness looks like a mistake:

  • upstream-fork declares every complexity bound as 0 and disables linear history and signed commits. A contribution fork must match the upstream it submits to, so a gate that rewrites the tree makes every patch unmergeable. The looseness is the feature.
  • org-health requires one approving reviewer rather than two despite its blast radius, because the repositories that need it are the least maintained ones and a two-approval rule on a repository nobody watches is how it goes stale.

1. Profile Definition Anatomy

Profiles represent the primary technology stack or architecture. Create .config/archetypes/{profile-id}.yaml. The shipped .config/archetypes/native-gpu-systems.yaml, in full:

---
id: "native-gpu-systems"
name: "Native GPU & Compute Systems"
description: "High-performance C/C++/Rust/CUDA/Vulkan systems with deterministic
  memory bounds and zero dynamic frame allocations"
runtime: "native"

complexity:
  max_cyclomatic: 10
  max_cognitive: 12
  max_func_loc: 75
  max_statements: 40

memory:
  zero_frame_malloc: true
  banned_alloc_in_ticks: true

branch_protection:
  enforce_linear_history: true
  require_signed_commits: true
  required_approving_reviewers: 2
  dismiss_stale_reviews: true

supply_chain:
  slsa_level: 3
  enforce_cosign: true
  require_sbom: true

linters:
  - "clang-tidy"
  - "clippy"
  - "semgrep"
  - "cppcheck"

devcontainer_features:
  - "ghcr.io/devcontainers/features/rust:1"
  - "ghcr.io/devcontainers/features/common-utils:2"
  - "ghcr.io/devcontainers/features/nix:1"

2. Facet Definition Anatomy

Facets are cross-cutting policy modifiers. Create a YAML file under .config/archetypes/facets/. The shipped .config/archetypes/facets/security-high.yaml, in full:

---
id: "security:high"
name: "High-Security Provenance & Hardening"
description: "SLSA Level 3 attestations, keyless Cosign signatures, SBOM
  generation, and non-root execution"

supply_chain:
  slsa_level: 3
  enforce_cosign: true
  require_sbom: true

branch_protection:
  enforce_linear_history: true
  require_signed_commits: true
  required_approving_reviewers: 2
  dismiss_stale_reviews: true

linters:
  - "gitleaks"
  - "trivy"

devcontainer_features:
  - "ghcr.io/devcontainers/features/common-utils:2"

The id field is the facet's identity; the file name is descriptive. .standards.yaml facets: entries and .standards.lock resolve against the declared id, because the catalog index keys every file by it (indexArchetypesWithSnapshots in internal/config/archetype_index.go). A file name cannot repeat the id, since : is not legal in a Windows file name, and the shipped names abbreviate (api:public-contract lives in api-public.yaml). For a new facet, replace the colon with a hyphen. Two rules still bind the name:

  • A file without an id falls back to its name minus .yaml, so always declare one. TestFacetIndex_Boundary_ResolvesByDeclaredIDNotFileName in internal/config/shipped_facets_test.go covers both cases.
  • Adoption copies a facet under its file name into the adopter's catalog, and the index rejects two files declaring one id (TestCatalogProjectionFindsAlternateFilenameCollisionWithoutWrites in internal/config/catalog_projection_test.go). Renaming a shipped facet therefore collides with the copy an adopter already holds; keep shipped names stable.

Keep facets language-neutral. Any profile may select a facet, so a facet lists no tool that reads only one language's source (gocyclo, benchstat, oapi-codegen): that tooling belongs to the profile, which declares a runtime. TestShippedFacets_Negative_NoGoOnlyLinter enforces this for the shipped catalog.

Cite gated invariants as gated, and nothing else. A description may cite a HISS invariant from the AGENTS.md table as enforced. An invariant defined only in the extended spec, such as HISS-03 or HISS-14, is cited together with docs/standards/hiss-spec.md (TestShippedFacets_Negative_UngatedHISSCitesTheExtendedSpec).

Claim only what runs. A facet's lattice dimensions are policy; its description names the checks that enforce them. Two facets also enable managed asset families (Families in internal/managedasset/family.go), files adoption writes while the facet is declared, removes once it is not, and audit locks: docs:seo-portal the documentation gate and api:public-contract the Go API compatibility gate. A family may also require something of the repository (Applies): the API compatibility gate is written only where git tracks a go.mod. The latter's description names that workflow, the condition and the Migration: footer check and nothing else; its linters entries are names in the resolved policy, not commands any adopted repository runs. The one linter name a gate reads is clang-tidy (below).


3. The Strictness Lattice ("Highest Standard Wins")

When two profiles or facets define conflicting parameters, the monotonic supremum is calculated: $$\mathcal{P}_{\text{resolved}} = \mathcal{P}_1 \sqcup \mathcal{P}_2 \sqcup \dots \sqcup \mathcal{F}_n$$

  • Lower complexity limits win ($\min$).
  • Greater security reviews and higher SLSA levels win ($\max$).
  • Linters and container features form a deduplicated set union ($\cup$).

Built-in defaults are the first operand, so a profile or facet can only tighten them. Every dimension below reaches the resolved policy (config.Join in internal/config/config.go, tested by TestLoadEffectivePolicyJoinsEveryProfileDimension in internal/config/archetype_test.go):

Key Join Default
complexity.* lowest positive limit; 0 means no bound 15 / 20 / 60 / 75
branch_protection.enforce_linear_history, require_signed_commits, dismiss_stale_reviews true wins true, false, true
branch_protection.allowed_merge_methods intersection forge default (all three)
branch_protection.require_extra_approval_for_unattributed_changes true wins unset (default false)
branch_protection.required_approving_reviewers maximum 1
branch_protection.review_mode no catalog layer may set it (TestLoadEffectivePolicyRejectsInvalidArchetypes), so the join keeps independent; only the repository override may relax it to single_maintainer, after the join independent
supply_chain.slsa_level maximum 0
supply_chain.enforce_cosign, require_sbom true wins false
memory.zero_frame_malloc, banned_alloc_in_ticks true wins (ZeroFrameMalloc over StandardHeap) false
error_unwraps strict_ban wins over allow_with_comment allow_with_comment
linters deduplicated union, first occurrence order govet
devcontainer_features deduplicated union by reference, first occurrence order; see the exception below common-utils
backlog.caps.<category>.max lowest declared max; an undeclared category has no bound none
backlog.caps.<category>.action strictest of report, batch, gate report once a max is declared

Two archetypes that declare the same value tie on it; the result is that value whichever is pinned first (TestResolvePolicyTiedArchetypesOnMemoryAndErrorUnwraps).

DevContainer features are the one dimension that can fail instead of joining. When a consumer resolves them (ResolveDevContainerFeatures in internal/config/devcontainer_features.go), it re-reads the pinned profile and facet files and keys each feature by its identity, the reference without its tag or digest. The same reference with the same options joins once. One identity with a different tag, digest or options is ambiguous and fails closed (mergeDevContainerFeature, tested by TestResolveDevContainerFeaturesRejectsConflictsAndMalformedEntries in internal/config/devcontainer_features_test.go). The merge never picks between two option sets, so the shipped catalog keeps them equal, as "Every file that selects a DevContainer feature selects it the same way" above describes.

The schema is closed

An archetype accepts exactly the keys id, name, description, runtime, complexity, memory, error_unwraps, branch_protection, supply_chain, linters, devcontainer_features and backlog, and inside each section only its documented keys. The backlog section takes caps.<category>.max and .action for the categories defects, tasks, questions and forge_alerts, decoded by the reader the repository and the external documents share (Backlog caps). A misspelled or unknown key fails the file instead of contributing nothing. The catalog index decodes every file in .config/archetypes, selected or not, so one bad file fails lock verification, plan, audit, sync and adopt until it is fixed (TestCatalogIndexRejectsUnknownKeyInAnUnselectedArchetype). Also rejected:

  • branch_protection.review_mode: single-maintainer review is a repository-only relaxation, set in .standards.yaml overrides;
  • branch_protection.status_contexts: additional required checks are repository-only, set in .standards.yaml overrides (declared status contexts);
  • branch_protection.status_checks_app: pinning required status checks to a GitHub App is repository-only, set in .standards.yaml overrides (declared status contexts);
  • negative required_approving_reviewers or slsa_level;
  • an error_unwraps value other than strict_ban or allow_with_comment;
  • an empty linter name, or one with surrounding whitespace or control characters.

A blank or missing id defaults to the file name without .yaml.

Every key names its consumer

Reaching the resolved policy is not the same as being acted on. The effective-policy digest records every key, and some keys nothing reads. .config/archetype-coverage.yaml binds each key config.ArchetypeKeys decodes to the functions that read its value, saying for each whether it acts on the value (renders, gates, selects) or only reports it, or marks the key unconsumed or refused with the reason. That file is the reference for what a key does. Two consequences for authors:

  • linters is read for one name: clang-tidy, which native-gpu-systems declares, enables the audit's clang-tidy translation-unit coverage gate. Every other linter name changes no gate, emitted file or report.
  • error_unwraps, both memory keys, name, description and runtime are unconsumed: declaring them changes no gate, emitted file or report. A capability that description names is enforced only where a consumed key enforces it.
  • complexity.max_cognitive is reported: the HISS-04 measurement prints it, and nothing fails on it.
  • The backlog.caps.* keys are consumed: praetorctl state status prints each capped category, praetorctl state batch writes a batch for one over its cap, and the audit fails a gated one (Backlog caps). forge_alerts is declared but not counted: praetor has no forge alert reader yet.
  • The shipped backlog:bounded facet (backlog-bounded.yaml) declares only these keys: defects max 20 with action gate, tasks max 50 and questions max 10 with action batch. A repository that lists it fails praetorctl audit once its open defects exceed 20, unless a dated backlog.caps.defects exceptions entry suspends that category (Bounded backlog facet).
  • supply_chain.slsa_level, enforce_cosign and require_sbom are gated: praetorctl audit fails when the release workflows measure less than the profile declares (How the audit measures the SLSA level). Declare the level the adopters' releases can reach, and 0 for a repository that releases nothing. A fresh adoption whose workflows fall short gets a HISS-11 exceptions entry that expires in 90 days (Declaring a gap).

TestShippedManifestMatchesTheSource (internal/archetypecoverage/shipped_test.go) type-checks the module's Go source and fails when a key has no entry, when an entry names a key the schema does not decode, when a listed consumer no longer reads the key, when a function reads a key its entry does not list, and when a field of a carrier type no key binds. A new schema key therefore needs its entry, and a first consumer of an unconsumed key changes the entry's state in the same change. A value read through an accessor is credited to the accessor (ComplexityPolicy.Limits), and a whole section passed on is credited where its fields are read.