ADR-0008: Change classification, ruleset routing, and rule scope¶
| Status | Accepted (P2-E5) |
| Date | 2026-07-21 |
| Deciders | Konrad Heimel |
| Context links | ADR-0003 change model · ADR-0007 effects |
Context¶
One repo holds many kinds of entries; one MR may touch several. "This part of the diff is a
Kafka-topic change" must route those changes into the topic ruleset; a tfvars edit under
prod/ must route into a stricter binding than the same edit under dev/. Separately, some
rules must see more than the diff: a naming-convention rule should be able to comment on a
touched entry whose pre-existing state violates convention, which requires the full branch
state, not just the changed fields.
Decision (proposed)¶
1. Classification stage¶
After the differ (ADR-0003), a classifier assigns each ChangeSet entry one or more
change classes via declarative matchers (path globs + content predicates, e.g.
file: topics/** or has(new.partitions)), and detects the environment (path convention
or repo config). Classes and environments are just labels — repos define their own.
Unclassified changes get the implicit class unclassified (which no vouch rule should match
→ fail-safe REVIEW per ADR-0007).
2. Ruleset routing¶
A RulesetBinding document maps (change class, environment) → policy packs + risk
threshold. One MR touching topics and tfvars evaluates both packs, each over its slice of
the ChangeSet; aggregation (ADR-0007) runs over the union of findings with the strictest
matching threshold.
3. Rule scope¶
Each rule declares scope:
change(default) — predicate sees the matched ChangeSet entries (old/new values).branch— predicate additionally sees the full repo state at the head SHA (parsed value trees of the checked-out branch). For: conventions on touched-but-not-changed fields, cross-entry uniqueness, referential checks inside the repo.
Determinism holds: head SHA is pinned input; branch scope is still a pure function.
4. Local checkout is mandatory¶
Evaluation always runs against a local checkout of the MR source branch (merged-result checkout where the forge supports it). CI has this for free; webhook mode (ADR-0009) clones per event. No API-only file fetching — partial views breed nondeterminism and n+1 API calls.
Consequences¶
- The classifier is the routing seam: packs stay small and per-domain (topic pack, catalog pack, tfvars pack) and can be shared/versioned independently — the marketplace unit.
branch-scoped rules are costlier (parse the tree); the engine parses lazily per class/glob.- Cross-repo state stays out of scope: that is what fact providers are for (ADR-0004).
Counterpoints considered¶
- "Put match globs on every rule instead of a classifier stage." — Works at small scale, but environment × class × pack routing then lives half-duplicated inside every rule; bindings centralize it and make the risk-threshold table explicit and auditable.
Amendment (2026-07-21, second review P2-10): fail-safe by construction¶
Convention becomes lint hard-errors: (a) a vouch rule must be scoped to at least one
explicit class or non-catch-all path — match: {changes: [{path: "**"}]} with effect
vouch fails assent lint; (b) the unclassified and assent-policy classes are
engine-reserved — a vouch rule matching them is rejected at load, not merely discouraged;
(c) environment matchers declare explicit priority (or must be provably non-overlapping) —
silent order-dependence of "last match wins" is removed, and reordering the list cannot
silently re-route prod to dev thresholds.
Amendment 2 (2026-08-09, D-133): the checkout is content under judgment¶
§4 mandates a local checkout but says nothing about who authored it. Two fail-open defects
in the checkout reader were argued away on the assumption that a --checkout tree is trusted
operator input. It is not. With --checkout the local tree is the sole authority (D-077),
and head/ is the merge-request head: content the contributor wrote. Git stores a symlink
as a mode-120000 blob, and git clone / git worktree / git checkout materialise it as a
real, possibly dangling, POSIX symlink — so a contributor can ship one.
The boundary is therefore:
- Contained reads. Every read of a checkout side goes through
os.OpenRoot+(*os.Root).FS()— the same containment idiom the provider builtins use (D-129), so the codebase has one, not two. - Symlinks are refused, never followed. A root FS blocks escapes at the syscall level but
still follows a relative link that resolves back inside the root, so the refusal is
explicit and covers every component of the path, not just the last. Containment is anchored
at
base/andhead/: those are operator-provisioned and may themselves be symlinks; nothing beneath them may be. - Refusal is an error, and a partial enumeration is an error. Neither may be answered as
"absent" or quietly skipped: absence is the EFE-S03 presence signal (
nil= absent, non-nil zero-length = present-but-empty), and a dropped path is a path the changed-file set never sees — which is how a.assent/**edit escapes the D-042 self-vouch guard.
Consequence, stated plainly: a repository that legitimately contains a symlink cannot be
judged via --checkout today; the run fails closed with a named refusal and writes nothing to
the forge. Loosening this means folding the refusal opaque (fail-safe REVIEW), never
following the link.