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.
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, isrefreshedwithout--force, in its own line-ending style (TestApplyFlavor_Positive_RefreshesTheRulesetCurrentBeforeApply). - Any other ruleset that differs is
keptand reported, and--forcereplaces 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.yamlstopsflavor applytoo. The decline is read throughadopt.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_EveryBuiltinSettingHasARendererOrProducerrequires one of the two for every built-in flavor. praetorctl adoptapplies the flavor withApplyOptions.TemplatesOnly, because its ownbranch-rulesetstep 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
packageManagernames (npm,pnpm,yarnorbun, 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 inpackageManager. - CI's checkout will hold that manager's lockfile at the root:
package-lock.json(npm 12 reads nonpm-shrinkwrap.json),pnpm-lock.yaml,yarn.lock, orbun.lock(bun.lockbbefore Bun 1.2). The file on disk is not enough: a library that lists its lockfile in.gitignorestill 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 withoutgit, nothing shows what CI checks out, so the job is withheld. - The
testscript is neither missing, blank nor the placeholdernpm initwrites.
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, andflavor applywrites nothing beside it unless--forceis 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/jsREADME documents. It is.mjsbecause a.jsfile is ESM or CommonJS depending onpackage.json"type", and.mjsloads 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-forkdeclares every complexity bound as0and 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-healthrequires 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
idfalls back to its name minus.yaml, so always declare one.TestFacetIndex_Boundary_ResolvesByDeclaredIDNotFileNameininternal/config/shipped_facets_test.gocovers both cases. - Adoption copies a facet under its file name into the adopter's catalog, and the index rejects two
files declaring one id (
TestCatalogProjectionFindsAlternateFilenameCollisionWithoutWritesininternal/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.yamloverrides;branch_protection.status_contexts: additional required checks are repository-only, set in.standards.yamloverrides (declared status contexts);branch_protection.status_checks_app: pinning required status checks to a GitHub App is repository-only, set in.standards.yamloverrides (declared status contexts);- negative
required_approving_reviewersorslsa_level; - an
error_unwrapsvalue other thanstrict_banorallow_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:
lintersis read for one name:clang-tidy, whichnative-gpu-systemsdeclares, enables the audit's clang-tidy translation-unit coverage gate. Every other linter name changes no gate, emitted file or report.error_unwraps, bothmemorykeys,name,descriptionandruntimeare unconsumed: declaring them changes no gate, emitted file or report. A capability thatdescriptionnames is enforced only where a consumed key enforces it.complexity.max_cognitiveis reported: the HISS-04 measurement prints it, and nothing fails on it.- The
backlog.caps.*keys are consumed:praetorctl state statusprints each capped category,praetorctl state batchwrites a batch for one over its cap, and the audit fails a gated one (Backlog caps).forge_alertsis declared but not counted: praetor has no forge alert reader yet. - The shipped
backlog:boundedfacet (backlog-bounded.yaml) declares only these keys: defects max 20 with actiongate, tasks max 50 and questions max 10 with actionbatch. A repository that lists it failspraetorctl auditonce its open defects exceed 20, unless a datedbacklog.caps.defectsexceptions entry suspends that category (Bounded backlog facet). supply_chain.slsa_level,enforce_cosignandrequire_sbomare gated:praetorctl auditfails 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.