This repository holds the enforcement, never the rule. The rules live one repository away, in
exeris-docs/standards/;
what is here is what makes them fire. A check with no standard behind it is a rule invented in CI,
where nobody reviewed it and nobody can find it.
It does three things.
Community defaults, org-wide. GitHub applies this repository's CONTRIBUTING.md,
PULL_REQUEST_TEMPLATE.md, CODE_OF_CONDUCT.md, SECURITY.md and SUPPORT.md to every repository
that has none of its own. No adoption step; they are simply in force.
Reusable L1 gates, which a repository opts into with one 10-line caller
(caller-example/guardrails.yml): documentation lint, commit lint, pull-request body, Javadoc and
TSDoc. These are mechanical and they run in the adopting repository's CI.
The review and its publication (ADR-087). docs-guardrails-review.md is the organisation's
review routine — one copy, checked out and handed to a model verbatim, so changing it changes every
repository at once. Its rules are grouped into parts — pr, docs, records, code-docs,
code, and a repository's own rules as repo — and a repository may run it whole or one run per
part. What the review produces is a verdict: a JSON object this repository defines a
schema for, composed over the exeris-agents bundle vendored under .agents/. review.yml
produces it, and its verdict job publishes it under the exeris-bot identity — posting the
review, applying the labels of labels-from-verdict.json, and being the required check, review / verdict, that is red when the verdict is absent, invalid, BLOCKED, or resting on a gate that did
not run.
Every change here changes CI for every repository. The workflows are called @main, so a merge
to main is a deployment and there is no staging. The gates in this repository's own
.github/workflows/guardrails.yml run on this repository, including the review: a gate its owner
does not run is a gate nobody has tested.
.github/
workflows/
docs-lint.yml reusable — frontmatter/filenames/registry/private-link checks (error), markdownlint + Vale (warning; retracted figures error), lychee
commit-lint.yml reusable — commitlint on PR commits and on the PR title
pr-body-check.yml reusable — template headings + classification grammar + trailers
javadoc-gate.yml reusable — Javadoc gate: whole-module on `modules`, changed-files on `diff-modules`
tsdoc-gate.yml reusable — TS doc comments + API-surface goldens (tsdoc-conventions.md)
review.yml reusable — the review: a `plan` job names the parts to run, `produce` runs
one leg per part (one leg, `all`, when whole), `aggregate` composes the
parts' verdicts, and `handover` and `verdict` (opt-in, `publish: true`)
post and gate. `verdict` posts as `exeris-bot`, labels, and is the
required check, `review / verdict`; `capture` (opt-in, `capture: true`)
writes the run's row. ADR-087 §B.5, §C.13
docs-review.yml reusable — a one-job wrapper around `review.yml`, kept while a caller still
calls it, so that caller keeps the check `docs-review / publish / verdict`.
Removed when the last caller has moved
publish-verdict.yml reusable — the publishing jobs as they were before they moved into
`review.yml`, kept only for the callers that pin an old `docs-review.yml`
by commit, whose copy reaches this file at `@main`. Removed with them
judge-review.yml reusable — the other half of that row: on `pull_request: closed` it reads
what the human did with each finding and appends a judgement record beside
the run, never into it. ADR-087 §C.16
caps-release-tests.yml the tests of the scripts in `scripts/caps_release/`, on every pull request and every push; fails
when it collects none
guardrails.yml this repository's OWN caller — it runs its gates, and the review in parts, on itself
labels-sync.yml applies labels.yml across the organisation
issue-hygiene.yml ages `needs-reproducer` / `needs-evidence`
PULL_REQUEST_TEMPLATE.md
CONTRIBUTING.md standards links, DCO, and the maintainers list CODE_OF_CONDUCT/SECURITY refer to
CODE_OF_CONDUCT.md Contributor Covenant 3.0 + reporting channel, enforcing party, AI-assisted-contribution line
SECURITY.md org default: private reporting, 72 h / 7 d; exeris-kernel's own file wins there
SUPPORT.md routing table behind the issue forms
scripts/
_common.py annotations + step summary + shared regexes
frontmatter_check.py docs-style-guide.md rules 2,3,5,6,7 (modes: ramp | strict)
registry_check.py adr-conventions.md rules 1–4 (consumer mode / registry mode)
pr_body_check.py pr-conventions.md rules 2–4
caller_permissions_check.py a caller's permission block is the union of every workflow it calls,
nested calls included, and each `with:` key is an input that workflow
declares. GitHub rejects the whole file otherwise — in the adopting
repository, on the first push, with no logs
publish_verdict.py the publish step's decision half: it plans and never writes. Reads the
verdict, validates it, and writes the labels, the comment and the
conclusion the workflow then applies and gates on
publish_verdict_suite.py one case per red path ADR-087 §B.8 names, run in this repository's CI
vale_retracted_figures_suite.py Exeris.RetractedFigures against two corpora in fixtures/vale/:
each retracted figure raises one error, no admitted citation raises any
review_plan.py which parts of the routine a pull request's changed files call for, each
part run or skipped with the reason
review_aggregate.py one verdict from the parts' runs: the worst decision, every finding marked
with its part, and a part that left no verdict named `missing`
bundle_pin_check.py the vendored bundle is the ref docs-lint.yml resolves
commitlint_anchor_repro.sh the report behind `package.json`: builds the caller layout twice,
with the anchor and without, and prints what differs. Needs network, so it
is run rather than wired in
label_map_check.py labels-from-verdict.json names only tags in the schema and labels in labels.yml
caller_bundle_check.py the reviewed repository's bundle pin against this one's (§B.6a)
caps_release/ one script per decision of a capability release, written to be called by a
reusable workflow, `caps-release.yml`, that this repository does not hold yet;
standard library only, no
dependency on GitHub's environment beyond its arguments: resolve_release,
check_release_profile, check_pom_version, scan_snapshots, check_drift,
check_release_readiness, check_secrets, remove_signing_key. Each reports through its exit status (0 ok, 1 refused,
2 usage) and ends every failure, an unexpected exception included, with a
`::error::` line on stderr. The scripts that read a POM refuse a DOCTYPE
before any entity is read
run_caps_release_tests.py runs `tests/caps_release/`; fails when it collects no test
(the agent-layer tooling is NOT here — the bundle owns it. docs-lint.yml checks exeris-agents out
into .agents-tools/ and runs it from there; `.agents/` below is this repository's vendored copy)
package.json anchors this checkout as its own Node project — see commit-lint.yml for what walks up without it
commitlint.config.js commit-conventions.md rules 1–4 (custom rules: exeris-header-length, exeris-mmr-sections, exeris-trailers)
.markdownlint.yaml
vale/.vale.ini + vale/styles/Quarkus (vendored, Apache-2.0) + vale/styles/Exeris (Terminology, RetractedFigures, DriftPatterns, Numbers, Absolutes)
lychee.toml
java/checkstyle-javadoc.xml read from the checkout by javadoc-gate.yml — do not copy it into a repo
java/checkstyle-engine-pom.xml the Checkstyle ENGINE, pinned beside the ruleset it runs
ts/eslint.tsdoc.mjs shared flat-config fragment — the package imports it; the gate verifies it did
ts/tsdoc.json tag definitions for tsdoc/syntax — copy beside the package's tsconfig
ts/typedoc.base.json port target for a library's typedoc.json (rule 1)
ts/api-extractor.base.json port target for a library's api-extractor.json (rules 7-8)
ts/scripts/mcp-tool-surface.mjs tools/list golden for an MCP server — the TS analogue of japicmp
java/javadoc-plugin-block.xml port target for gated modules' pom.xml
caller-example/guardrails.yml copy into each repo's .github/workflows/
docs-guardrails-review.md the review routine, grouped into parts — one copy, read by every repository
pr-review.patch.md the router patch that places the routine inside pr-review.md
labels.yml the organisation label taxonomy (issue-conventions.md rule 3)
labels-from-verdict.json which verdict field applies which label from labels.yml (§B.7)
.agents/
manifest.yaml the exeris-agents pin: bundle, version, ref, sha256
vendor/exeris-agents-<version>/ the verified vendored copy the schema composes over
schemas/verdict.schema.json the organisation's composed verdict — its role, its scope classes,
its `tag` on a finding, and the four objects it closes
docs/adr/ADR-087.link.md link stub for the ADR this enforcement implements
-
Copy
caller-example/guardrails.ymlto.github/workflows/guardrails.yml. Keepmode: rampuntil the frontmatter backfill has landed; keepsection-check: falseuntil subsystem/module pages carry the required sections. -
exeris-docspassesextra-paths: "adr rfc *.md"because its records live at the repo root. -
JVM repos with a gated module add the
javadocjob. It takes two module lists, becausejavadoc-conventions.mdrule 11 asks two different questions:modules— gated in full. Portjava/javadoc-plugin-block.xmlinto each of these modules' ownpom.xml(not the parent:-amwould gate their dependencies too).diff-modules— gated on the pull request's changed files only, which is what rule 11 says for Kernel Core, Community and tooling. No pom change: this half invokesjavadocdirectly with the flags the profile would have set, so a module can be put under the gate without being made clean first.build-modules— built, never audited. An annotation processor or generator a gated module needs is not reachable by-am:<annotationProcessorPaths>is not a reactor edge, and without naming it the install step dies on a missing artifact.
That is the whole adoption cost: the gate checks this bundle out and reads
java/checkstyle-javadoc.xmlfrom there, and it uses./mvnwonly if the repo has one.One optional input,
trivial-accessors, is a policy and not a tuning knob. The default,documented, isjavadoc-conventions.mdrule 1 as written: every public member carries a doc comment.exemptdrops that for a method whose BODY SHAPE is mechanical —return <field>;,<field> = <param>;, either withreturn this;— and a repository takes it only where something else carries the coverage, which forexeris-sdkis its own completeness test. It moves both halves: Checkstyle skips those methods and javadoc runs-Xdoclint:all,-missing. Doclint does not carry the exemption's coverage, it is the half being relaxed —missingis the only group that reports an undocumented accessor, so the exemption costs the whole group, and the ruleset buys back what that group was carrying alone (missing docs above private visibility, and on fields and enum constants). A gated module whose pom hardcodes<doclint>cannot receive the flag; the gate warns and that module keeps the strict policy. The shape predicate lives inscripts/javadoc_trivial_members.py, which filtersMissingJavadocMethodfindings and nothing else: Checkstyle has no property for a body shape, and the one it does have —minLineCount— counts a body an abstract method does not have, so it releases 377 abstract contract methods onexeris-kernel-spiagainst 29 getters. The gate writes which policy is in force into the step summary, because a green tick otherwise means two different things in two repositories and the result does not say which.It is named for the policy rather than for
minLineCount, the Checkstyle property behind it. Line count is a poor proxy for triviality —public Instant deadline() { return start.plus(ttl); }is exempted along with the setters — and a better predicate should be able to replace it in this bundle without every caller changing a line of YAML. -
TypeScript packages add the
tsdocjob. Adoption is two devDependencies (eslint-plugin-jsdoc,eslint-plugin-tsdoc) and one import in the package's flat config:import exerisTsdoc from "./.guardrails/ts/eslint.tsdoc.mjs"; export default tseslint.config(..., ...exerisTsdoc({ gated: ["src/index.ts"] }));
Locally,
.guardrailshas to be a real directory at that path — a symlink to a bundle checkout elsewhere does not work. Node resolves a module's realpath before searching fornode_modules, so the fragment'seslint-plugin-jsdocimport is looked for beside the bundle instead of beside the package:ERR_MODULE_NOT_FOUND, zero files linted. Use agit worktree(or a copy) rather thanln -s, which also makes the local layout the same as the gate's — CI never hits this because it checks the bundle out.The gate checks this bundle out beside the package and fails if that import is missing — a flat config cannot be injected from outside, so a lint step that did not verify the reference would only be running the package's own rules.
The rest is per package and opt-in: copy
ts/tsdoc.jsonbeside the package'stsconfig.json; a library that publishes addstypedoc(and@microsoft/api-extractor), portsts/typedoc.base.jsonandts/api-extractor.base.json, and setstypedoc: truewithapi-report: api-extractor; an MCP server setsapi-report: mcp-tool-surface. Both stay off until the package commits the config and the golden they read — the first golden lands in that build's own pull request, reviewed on its own, because a golden reviewed alongside a feature is a golden nobody reads. -
The review comes with the caller's
reviewjob and needs nothing else to run: it produces a review and posts it, as it did before the split. Publication is opt-in. Addingpublish: trueturns on the second job, which posts the verdict under the organisation's identity, applies the labels oflabels-from-verdict.json, and becomes a check that is red when the verdict is absent, invalid,BLOCKED, or rests on a mandatory gate that did not run.Leave it off until
exeris-systems/.githubhas answered ADR-087 Engineering Protocol 4 on its own pull requests — whether the runner writes a verdict file, and what identity it posts under. When you do turn it on, the check to protect isreview / verdict, and confirm that name against a real run first: a required check whose name does not exist never reports, and a pull request waiting on one can never merge. The name is the caller's job id, a slash, and the job ofreview.ymlthat holds the steps, so a job not calledreviewreports a different name.A repository that still has the job
docs-reviewcallingdocs-review.ymlreportsdocs-review / publish / verdictand goes on reporting it; no ruleset requires that name. It moves in one change, and is added to the ruleset'sincludeonly after that change has merged (rulesets/README.md):- docs-review: + review: needs: [docs, commits, pr-body] if: ${{ always() && github.event.action != 'closed' }} - uses: exeris-systems/.github/.github/workflows/docs-review.yml@main + uses: exeris-systems/.github/.github/workflows/review.yml@main
A repository that pins
docs-review.ymlby commit takes@mainofreview.ymlin the same change, or pins the commit that carries it.Two organisation secrets carry the publication,
EXERIS_BOT_APP_IDandEXERIS_BOT_PRIVATE_KEY. They are optional — without them the review still runs and the check still decides, it is simply not published under the organisation's byline. No permission is added either way: every write goes through the App's installation token, so neither job's ownGITHUB_TOKENholds one.Two labels drive it by hand.
needs-reviewasks for a review — on a push, the standing verdict covers an older commit and the check goes red until one is asked for — and the publication takes it off once the review has run.human-reviewedrecords that a person reviewed the change, which is how a pull request the runner refuses to read (one changing the caller's own workflow file) gets a green check. Both are refused when a bot applies them.A pull request a bot opened is not reviewed and takes its colour from the standing verdict, with one exception: the organisation's execution identity,
exeris-agent[bot], whose pull requests are the organisation's own work (ADR-087 §A.1). Every review — the identity's included — reads the instruction files an agent follows (AGENTS.mdat any depth,.agents/**and other agents' instruction files) as the base branch has them, keeps what the pull request wrote under.exeris-pr/, and names those paths to the reviewer (ADR-087 §C.14a): a pull request does not write the instructions its own review follows.Reviewing in parts is its own opt-in.
review-in-parts: truerunspralways,repowhenrepo-routineis set, anddocs,records,code-docsandcodeonly where their files are in the diff, one model run each, and publishes one verdict with aPartcolumn and a line per part. A part that produces nothing makes the check red. With capture on, each part's run is its own run record. 5a. Capture is the second opt-in, and a separate decision.capture: trueadds a third job: the runner's execution log is committed to the streams repository as content, one run record is assembled from what the producing job exported about the run, and the row is committed straight tomainofexeris-ai-execution, one commit per row, after the producer has run the inbox's own validator on it (ADR-087 §C.13-§C.17). It decides nothing about the pull request — the required check stays the publishing half's — and it writes throughEXERIS_INBOX_APP_ID/EXERIS_INBOX_PRIVATE_KEY, two more optional secrets, again with no permission added to the caller's block. Turn it on after reading rows a producer has already written: a repository that starts contributing to a dataset should have seen what it will be contributing.It runs inside the publishing half, so it needs
publish: trueas well. The capture job lives inpublish-verdict.yml, which the review reaches only through its publish job: with publication off,capture: trueruns nothing, and nothing in the checks says so. 5b. The judgement goes on with capture, and it is the one job that runs onclosed. A review-domain row is complete when the review ends and its outcome isUNKNOWN: the oracle has not judged yet.review-dispositionjudges when the pull request closes — per finding, from what the human did with it — and its verdict is appended as its own record keyed by the run's id (ADR-087 §C.16). Addclosedtoon.pull_request.types, add thejudgejob ofcaller-example/guardrails.yml, and addif: ${{ github.event.action != 'closed' }}to every other job in the file: a gate on a pull request that can no longer merge decides nothing, and the review job would otherwise run the model again on a pull request whose review is over. It reads the same twoEXERIS_INBOX_*secrets and addsactions: readto the caller's permission block — the commit a review READ is the reviewing run's ownhead_sha, which the platform states on the Actions API and nowhere else independent of the row being judged. It publishes no check and posts nothing to the pull request.Keep the close out of the concurrency collapse in the same edit:
cancel-in-progress: ${{ github.event.action != 'closed' && !endsWith(github.actor, '[bot]') }}. Without that first clause the close joins the group of the run still working on that pull request, and the human who merges is no[bot]— so merging as soon as the required check goes green cancels the run that was publishing it and the judgement that follows it. Nothing reports such a cancel: the next judgement reads an index with no entry for the pull request and says, greenly, that there was nothing to judge. -
Install the DCO GitHub App on the organisation with
.github/dco.yml→require: { members: false }(org members exempt from the trailer, Spring's model). -
Delete the repo's own
PULL_REQUEST_TEMPLATE.mdif it has one — the org default applies. -
Community-health defaults (
CODE_OF_CONDUCT.md,SECURITY.md,SUPPORT.md) reach every repository without one of its own, from this repository's root. They name two mailboxes and a private reporting channel, so before they are published:conduct@exeris.euandsecurity@exeris.eumust deliver, and organisation-level private vulnerability reporting must be on (Settings → Code security). A code of conduct whose reporting address bounces is worse than none. Delete a repo's ownSUPPORT.mdorCODE_OF_CONDUCT.mdonly where it says nothing the default does not;exeris-kernel/SECURITY.mdsays more and stays.
frontmatter_check.py --mode ramp fails only on files changed in the PR and downgrades everything else to warnings; --mode strict fails on every file. The rollout (ADR-085 Engineering Protocol 4) is: ramp for two weeks on exeris-sdk and exeris-kernel, then strict there, then fan out. registry_check.py and pr_body_check.py have no ramp — they check only what the PR introduces.
The agent-file checker (now in exeris-agents) warns when an agent file hard-codes a path under somebody's home directory —
~/…, /home/<someone>/, /Users/<someone>/, C:\Users\…. /home/runner/ is exempt: that is the
Actions user, and describing what CI does is describing a shared machine.
It is a warning, and the message says what to do rather than what to delete. A reference to a sibling repository is useful — it is how the reference-first discipline works — but a reader who cloned this repository alone has no such directory, and the instruction does not fail for them: the grep finds nothing and they conclude from the silence. Naming the repository is always true; the sibling path is a convenience, so say it as one.
Generated adapters are skipped. check_adapters already ties them to their source, so the only
actionable copy is the one under .agents/ and reporting both would double the worklist.
Two answers, and a repository should only have to give one. Directory names — node_modules, target, build, dist and the rest of scripts/_common.py's taxonomy — cover output that lands where output usually lands. A committed report does not: api/<pkg>.api.md is generated, is meant to be in the diff, and is a Markdown file sitting in a directory with an ordinary name.
For those, the repository's own .gitattributes is the answer, which ADR-085 §F.21d already asks for: a tree marked linguist-generated is skipped by frontmatter_check.py, frontmatter_backfill.py and markdownlint. Both spellings work (linguist-generated and linguist-generated=true), and -linguist-generated on a path puts it back under the checks. The attribute is resolved by git check-attr, so nested files, negation and precedence behave the way GitHub's own diff collapsing does. Off a work tree — no git, no repository — the marker is simply not read, and the directory taxonomy stands on its own.
Vale and lychee still do not read it: both carry their own path lists, and a generated report is still checked for prose and links. Prose is warning-level; links are not, so a generated file full of URLs remains a reason to reach for exclude.
pip install pyyaml && pip install vale # or brew install vale
python scripts/frontmatter_check.py --root docs --mode strict
python scripts/registry_check.py --index ../exeris-docs/adr-index.md
python scripts/caller_permissions_check.py # the caller blocks and every `with:` key
python scripts/bundle_pin_check.py # the vendored bundle is the ref docs-lint uses
python scripts/label_map_check.py # the label map names only what exists
python scripts/publish_verdict_suite.py --root . # the publish step's rules, no network, no token
python scripts/run_caps_release_tests.py # the capability release scripts, on plain files
python scripts/vale_retracted_figures_suite.py # the error-level Vale rule, on the Vale docs-lint pins
# agent layer: from the bundle, not from here
git clone https://github.com/exeris-systems/exeris-agents ../exeris-agents
python ../exeris-agents/tools/agents_file_check.py --root .
python ../exeris-agents/tools/agents_render.py --root . --check
npx --package @commitlint/cli --package @commitlint/config-conventional commitlint --config commitlint.config.js --from origin/main
vale --config vale/.vale.ini docs/Vale inline toggles are the sanctioned way to quote a retracted figure on purpose (a withdrawal note, a Withdrawn list):
<!-- vale Exeris.RetractedFigures = NO -->
… earlier revisions asserted ">160 GB on a 4 GB payload"; no campaign supports it …
<!-- vale Exeris.RetractedFigures = YES -->