ADR-0018: Policy lifecycle — phase, profiles, comparison¶
| Status | Accepted (D-030 — Phase-3 freeze review) |
| Date | 2026-07-24 |
| Deciders | Konrad Heimel |
| Context links | ADR-0007 (aggregation) · ADR-0008 (routing) · ADR-0010 (pack / config layout) · ADR-0014 (presentation-split amendment) · ADR-0017 §2–4, §7, §9 · D-017 (B2–B4) · OQ-21 (reversed) · named-consumer-compat.md B2–B4 · schemas under schemas/policy/v1alpha1/ + schemas/comparison/v1alpha1/ · planning docs docs/planning/policy-lifecycle-*.md |
Context¶
D-017 (B2–B4) reverses OQ-21's lean: rollout is an explicit off/observe/enforce
phase on rules and packs (not effect-editing), named policy profiles distinguish a
single writing profile from recorder-only counterfactuals, and a closed semantic
comparison taxonomy plus a versioned, promotion-gated PolicyComparisonSuite judge
whether a pack/profile change is safe before it reaches enforce. P3-E4-S01..S03 froze
the schemas and planning contracts; without a named ADR, Phase-3 freeze review has no
decision record to accept, and the named-consumer B2–B4 disposition rows have nothing
precise to link.
This ADR records those three contracts as independently supersedable decisions and freezes
the doc-level assent compare CLI contract (implementation is Phase 5+ / E6). It does not
implement the runner or weaken ADR-0017's enforcing aggregation / Reconcile obligations.
Options¶
| Option | Pros | Cons |
|---|---|---|
| A. Keep contracts only in schemas/planning — no ADR until engine impl | Thin Phase-3 surface | Freeze review has no ADR to accept; B2–B4 stay "new P3-E4"; supersession path unclear |
| B. Single ADR with three numbered decisions (this ADR) | Matches D-017 (B2–B4); each concern supersedable; adopters find one place | Slight restatement of schemas + planning docs |
| C. Three separate ADRs (phase / profiles / comparison) | Maximal independence | Index churn; cross-links heavier than the coupling warrants |
Decision¶
1. Rollout phase field + DecisionRecord observed/enforcing split¶
Every MergePolicy rule and every Pack manifest requires an explicit phase field with
enum off | observe | enforce — no default (ADR-0017 §9 strict-decode). Pack phase is a
ceiling, never additive: pack off evaluates no rules; pack observe caps contained
rules at observe; only pack enforce lets each rule's own phase stand.
| Phase | Evaluate | Finding array | Feeds aggregation (decision / blocks / requiredReviews / score) |
|---|---|---|---|
off |
no | none | no |
observe |
yes | findings.observed |
no (structurally excluded) |
enforce |
yes | findings.enforcing |
yes |
Lint hard-error no-implicit-enforce-phase rejects missing phase. Effect-editing to
simulate rollout is an unsupported modeling choice; phase transitions must be visible in a
generic structural diff of two loaded-policy snapshots (and mirrored as
findings.observed ↔ findings.enforcing path moves on DecisionRecord) with no
phase-aware special case. Normative schemas/docs:
schemas/policy/v1alpha1/merge-policy.schema.json,
schemas/policy/v1alpha1/pack.schema.json,
schemas/decision/v1alpha1/decision-record.schema.json,
docs/planning/policy-lifecycle-phase.md,
docs/planning/lint-hard-errors.md.
2. Named policy profiles + single-writer + precedence¶
A PolicyProfile names a coherent activation of packs for (environment, class) bindings.
Exactly one covering profile may resolve writes: true for any given binding; every other
covering profile is recorder-only (writes: false) — evaluated for comparison, never
authorized to call Reconcile (ADR-0017 §7) or any forge write. Lint hard-error
single-writer-profile rejects zero or more-than-one writers for the same binding (never
last-one-wins).
Precedence is one schema-level artifact on Config.profiles (ordered {name} refs) —
not a second match/routing block. Resolution: coverage → specificity (narrower wins)
→ Config order tie-break; single-writer is checked across all covering profiles.
Normative schemas/docs: schemas/policy/v1alpha1/profile.schema.json,
schemas/policy/v1alpha1/config.schema.json,
docs/planning/policy-lifecycle-profiles.md,
docs/architecture/policy-profiles.md.
3. Comparison delta taxonomy + PolicyComparisonSuite + promotion gates + assent compare¶
Every baseline↔candidate decision difference over a suite case classifies into exactly one
closed taxonomy member — no "other" / free-text kind; unclassified differences are a hard
classification error (fail-closed):
stricter-intervention-addeddestructive-or-authorization-intervention-missedsubject-or-obligation-uncoverednewly-auto-mergeablescore-threshold-changeexplanation-only(includes wording-onlymessagetemplate changes; never trips a gate)
A versioned PolicyComparisonSuite pins an immutable corpus of ReplayBundles by stable
caseId + replayBundleDigest (revise by minting a new caseId, never in-place edit) and
carries five machine-enforceable promotion gates plus an acceptedDeltas allowlist keyed
by caseId + delta identity (rule / subject [/ obligation]) + kind — never by kind
alone. Normative schemas/docs:
schemas/comparison/v1alpha1/comparison-record.schema.json,
schemas/comparison/v1alpha1/comparison-suite.schema.json,
docs/planning/policy-lifecycle-promotion-gates.md.
assent compare CLI contract (doc-level; impl Phase 5+ / E6)¶
| Inputs | Baseline PolicyProfile ref; candidate PolicyProfile ref; PolicyComparisonSuite ref (suite may supply default profile refs; CLI args override) |
| Output | Comparison report whose per-case delta list reuses the ComparisonRecord schema (closed taxonomy + per-delta identity + gate outcomes) |
| Side effects | Side-effect-free — must never call Reconcile (ADR-0017 §7) or any forge write path; both profiles are evaluated as recorders for delta classification even when one is the writing profile in live serve/CI |
Exit codes map 1:1 to promotion-gate outcomes (first failing gate wins if multiple fail; reporting still lists all gate results in the report):
| Exit code | Meaning |
|---|---|
0 |
All-pass — every promotion gate passed (or failed deltas were individually listed in acceptedDeltas) |
1 |
Gate zero-missed-destructive failed |
2 |
Gate zero-missed-authorization-ownership failed |
3 |
Gate no-unexpected-obligation-removal failed |
4 |
Gate bounded-auto-merge-widening failed |
5 |
Gate explicitly-accepted-deltas failed |
Consequences¶
- Phase-3 freeze review accepted this ADR (D-030); Status is Accepted and matches the ADR index row. Named-consumer-compat.md B2–B4 may link here as the accepted ADR.
- Engine impl (
assent compare, profile evaluation, phase-aware aggregation) implements against the frozen schemas + this ADR; superseding any one of the three numbered decisions does not require rewriting the other two. - Recorder-only / compare inertness remains an architectural invariant: comparison never widens forge write authority.
- Effect-editing rollout remains an unsupported anti-pattern; the sanctioned path is
explicit
phase+ profile promotion via the suite gates.
Counterpoints considered¶
"Schemas and planning docs alone are enough — an ADR restates." Rejected: D-017 (B2–B4) and named-consumer-compat require New ADRs authored inside their owning epics and accepted at the Phase-3 freeze review. Without ADR-0018, freeze review has no decision record to accept, and B2–B4 cannot flip from "new P3-E4" to an accepted ADR link.
"Goal/DoD prose says this story does not create docs/adr/0018-*.md." Rejected for this
implementation lane: REQ-P3-E4-S04-01..03 Verify greps require the ADR file, README row, and
assent compare / side-effect-free contract to exist. Following Goal/Non-goals alone would
leave the story vacuous. Chose Verifies (same resolve as P3-E5-S04 / ADR-0019). Acceptance
remains a later operator story.
"Fold phase + profiles + comparison into one unnumbered Decision paragraph." Rejected: the three concerns map to D-017 (B2–B4) and must remain independently supersedable — e.g. a future multi-writer lock protocol must not force rewriting the phase ceiling semantics.