ADR-0017: Generated Artefacts in Pull Requests
Status
Accepted — 2026-10-01. The owner decided the questions of #696 on 2026-10-01; "Decision" records them. Phase 1 (the declaration, the commands and this record) lands with it. Phase 2 wires the hooks, CI and the release gate to the pull-request mode, folds the scheduled bundle regeneration of #338 into the declared list, and replaces the line-keyed baseline fingerprint (#29).
Context
A governed repository commits files that other files determine: the compiled context projections
praetorctl compile-context writes from AGENTS.md, the debt baseline .standards-baseline.json,
the README governance block, .needs.yaml, the documentation figures, the shipped-text ledger and,
in Praetor itself, the DevContainer source bundle. The gates required every pull request to carry
these files fresh, so any two open pull requests changed the same generated lines and conflicted
with each other even when their sources did not.
Issue #696 measured the cost in an adopter repository: 78 pull requests landed in about 26 hours through a local merge train, each landing a restack, a build and test run and a pre-push hook run of about five minutes, a ceiling near seven per hour. A train that stacked several pull requests could push them together only when they shared no generated file, and nearly every one did. The baseline added a case of its own: its fingerprints are keyed by line (#29), so a change that inserts lines above a baselined function has to re-record the baseline, and two such changes conflict in it.
merge=union in .gitattributes does not help: the forge applies no merge drivers, so a branch
git merge-tree merges cleanly is still conflicting there (#696).
Before this record nothing declared which files are generated. Each tool that touched a branch (a merge train, a conflict resolver, an agent) had to carry its own list.
Decision
1. One declared list
The generated section of .standards.yaml declares the generated artefacts. The manifest loader
reads and validates it (config.LoadManifest, internal/config/generated.go); there is no second
configuration file. Each entry names:
paths: the globs of its files, or, withblock, the region between two marker lines inside each of them, so a hand-edited file that carries a generated block stays editable outside it;command: the argument vector that renders it from the repository root, with optionalenvandtimeout; a first wordpraetorctlruns the running binary itself;sources: the globs it is rendered from.
Praetor's own artefacts are built in (internal/generated/builtin.go) and apply wherever their
files exist; generated.decline leaves one out by name, and generated.artefacts adds the
repository's own. praetorctl ci generated list prints the resolved list, and --json gives merge
trains, conflict resolvers and agents the same list in machine-readable form.
A generated value that lives inside a hand-edited file without markers is not declared, because
declaring the whole file would forbid every edit of it. The one such value today is the
register.sources census pin in .standards.yaml (expected, not_applicable, sha256). The
proposed fix moves the pin to a file of its own beside the manifest, written by a write mode of
praetorctl caveman check --configured-sources and declared as a built-in artefact; until then
the pin stays a hand-maintained value.
2. A pull request must not edit a declared artefact
praetorctl ci generated check --base=<ref> judges the change from the merge base to the head. A
change that edits a declared artefact (a file of a whole-file artefact, or the text inside a
block) fails. The guarded set is the union of the base's and the head's declarations, so a change
cannot stop declaring an artefact and edit it at once.
3. In a pull request the gate proves only that each artefact still renders
The same check renders every artefact that applies at the head in a temporary worktree of the head. A generator that fails, or a rendering that selects no file, fails the check: the gate fails closed in the pull request, not at regeneration time. Whether the committed file equals its rendering is reported but not enforced in a pull request.
4. Freshness is enforced on the default branch only
praetorctl ci generated render --check renders every artefact at HEAD and exits non-zero when a
committed file differs from its rendering, for a scheduled job and for the release gate.
praetorctl ci generated render writes the changed artefacts instead, for the regeneration change.
5. One regeneration change per batch, recognised by a declared marker
The regeneration change is the only change allowed to touch declared artefacts. The gate recognises
it by a marker the base declares, never the change itself: its branch starts with
generated.regeneration.branch_prefix and its title carries the conventional type
generated.regeneration.title_type; both must match. The defaults are regen/ and
chore(generated). A regeneration change may edit nothing but declared artefacts, and each
artefact it commits must equal its rendering.
6. The bound on lag
Between a merge and the regeneration change of its batch, the generated artefacts of the default
branch, the compiled context projections included, can lag their sources. The bound is one batch:
the regeneration change of a batch lands before the next batch starts landing, and no release is
cut from a default branch on which render --check fails. HISS-16's guarantee, every projection
compiled from the canonical AGENTS.md, holds on the default branch after each regeneration change.
A session that needs current projections before then runs praetorctl compile-context locally.
7. Generators are idempotent
Rendering an unchanged source leaves its artefact byte for byte as it is; otherwise the artefact is
stale forever. praetorctl baseline --record therefore keeps a baseline whose debt did not change,
and praetorctl needs scan --write keeps a manifest that differs only in updated_at. An adopter's
own generator has to meet the same rule.
Consequences
Positive
- Pull requests stop conflicting on generated files, so a merge train can gate a stack of them once and land them together; the batch's single regeneration change carries every rendering.
- An adopter declares its own generated files in the manifest it already has, and every tool reads one list.
- A pull request whose generator breaks still fails in the pull request.
Negative / Trade-offs
- The generated artefacts of the default branch, the compiled projections included, lag their sources until the regeneration change lands (decision 6).
- The pull-request check runs every generator. Rendering all of Praetor's built-in artefacts took
under five seconds on a warm build cache (
ci generated render --check, 2026-10-01), but a cold runner pays for each toolchain. - Every regeneration change is one more pull request to review, and a generator that is not idempotent cannot be declared until it is.
Neutral
- Phase 1 changes no hook, workflow or gate: the existing freshness gates keep running until phase 2 wires them to the pull-request mode.
Alternatives considered
- Keep requiring fresh artefacts in every pull request. Rejected: that is the conflict #696 measured.
- Merge drivers such as
merge=union. Rejected: the forge applies none. - Enforce freshness in the pull request as well. Rejected: it brings the conflicts back.
- Recognise the regeneration change by its branch alone, or by its title alone. Rejected: the owner chose both, declared, with documented defaults.
- Stop committing generated files. Rejected for these artefacts: agent clients read the projections from the checkout, the forge renders the README, and a DevContainer build reads the bundle, all without running a generator.
References
-
696: the issue this record decides.
-
338: the scheduled DevContainer bundle regeneration, which phase 2 turns into one entry of the
declared list. -
29: baseline fingerprints keyed by line number.
- Owner decisions of 2026-10-01 on #696, paraphrased under Decision 2 to 6.
internal/config/generated.go,internal/generated/,cmd/standardsctl/ci_generated.go,docs/guides/generated-artefacts.md.