Policy Versioning & Rollout: Shipping Rule Changes as Config-as-Code
Versioning policy definitions as config-as-code turns every reimbursement rule change into a numbered, content-hashed release that can be staged, canaried, and rolled back cleanly — so a mid-quarter edit never silently re-judges reports already in flight. Within the broader Core Policy Architecture & Taxonomy Design control plane, this guide owns the release mechanics: assigning semantic versions to rule sets, sealing each version into an immutable point-in-time manifest fingerprinted with SHA-256, pinning exactly one manifest per evaluation batch, and promoting a new manifest through staged rollout stages before it becomes the default. It delegates the field-level detail of protecting individual open reports to its companion guide Rolling out policy changes without breaking open expense reports, and hands versioned manifests downstream to Automated Policy Validation & Anomaly Flagging, which consumes a pinned version number on every verdict it emits.
Problem Framing & Root Causes
The failure this component prevents is retroactive drift: a rule set is edited to tighten a meal cap or add a new geography, the change deploys, and reports filed against the old limit are suddenly re-scored against the new one. An employee whose 75-dollar dinner passed cleanly on Monday sees it flagged Wednesday because the cap dropped to 60 dollars in between — an outcome that is both unfair and unauditable, because there is no longer a single answer to “what was the rule when this was submitted?”
Three named root causes produce it. Mutable rule loading, where the engine reads whatever the current config file says at evaluation time, means an in-flight report is judged by whatever happens to be deployed the moment it reaches the evaluator rather than the moment it was submitted. Unversioned config, where changes ship as an undifferentiated overwrite with no version number or content hash, makes it impossible to reproduce a historical verdict or answer which edit changed a limit. And big-bang cutover, where a new rule set replaces the old one atomically for the entire population, turns a subtle logic bug into a fleet-wide incident with no blast-radius control and no clean way back. The remedy is to treat each rule set as an immutable, semantically versioned artifact, pin every report to the version in force when it was submitted, and promote new versions through observable stages rather than a single switch.
Design Constraints & Prerequisites
This component sits between the config repository and the evaluation engine defined in the Core Policy Architecture & Taxonomy Design guide. It assumes rules are already expressed as validated, typed objects — the PolicyRule contract from that guide — and that each report carries a stable report_id and a submitted_at timestamp. It never mutates a rule in place; versions are write-once. The migration compatibility checks below reuse the same schema validation the engine performs, so a version that would break the evaluator can never be sealed into a manifest.
| Constraint | Requirement | Rationale |
|---|---|---|
| Version identity | Semantic MAJOR.MINOR.PATCH plus a SHA-256 content hash |
Human-orderable version and machine-verifiable integrity in one artifact |
| Immutability | A sealed manifest is never edited; a change is a new version | Guarantees a historical verdict can always be replayed exactly |
| Pinning granularity | One manifest per report, resolved at submission time | Prevents any re-judging of an in-flight report under newer rules |
| Rollout control | Cohort assignment is deterministic from report_id |
A report stays in one rollout stage; retries do not reshuffle it |
| Backward compatibility | MINOR/PATCH must not remove or retype an existing field | Lets a report built under an older schema still validate |
| Storage | Registry is append-only, on durable/WORM storage | Satisfies Sarbanes-Oxley point-in-time evidence rules |
Because the version number and its content hash travel together, the registry is the single source of truth for “which rules were live, and what exactly did they say.” Retention must cover the full audit window; you cannot evict a version while any report that pinned it is still within its reconstructable lifetime.
Production Python Implementation
The implementation is three composable parts: an immutable manifest sealed with a content hash, an append-only registry that pins reports and assigns rollout cohorts, and a compatibility gate that decides the correct semantic-version bump and refuses breaking changes on a non-MAJOR release.
Sealing a versioned manifest
A manifest wraps a rule set with its semantic version, a canonical SHA-256 content hash, and an effective date. Hashing a canonical JSON serialization means two builds of identical rules yield an identical hash, so the version is verifiable rather than merely asserted.
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from typing import Any
logger = logging.getLogger("policy.versioning.manifest")
def _canonical_hash(rules: list[dict[str, Any]]) -> str:
"""Order-independent SHA-256 over a rule set's canonical serialization."""
payload = json.dumps(
sorted(rules, key=lambda r: r["rule_id"]),
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class PolicyManifest:
"""An immutable, point-in-time snapshot of a rule set under one version."""
version: str # "MAJOR.MINOR.PATCH"
content_hash: str # SHA-256 of the canonical rule set
effective_from: date
rules: tuple[dict[str, Any], ...] # frozen copy of the validated rules
sealed_at: str = field(default="")
@classmethod
def seal(cls, version: str, rules: list[dict[str, Any]],
effective_from: date) -> "PolicyManifest":
"""Build and fingerprint a manifest; the hash pins its exact content."""
parts = version.split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
raise ValueError(f"version must be MAJOR.MINOR.PATCH, got {version!r}")
content_hash = _canonical_hash(rules)
manifest = cls(
version=version,
content_hash=content_hash,
effective_from=effective_from,
rules=tuple(rules),
sealed_at=datetime.now(timezone.utc).isoformat(),
)
logger.info(
"manifest_sealed",
extra={"version": version, "content_hash": content_hash,
"rule_count": len(rules)},
)
return manifest
@property
def manifest_id(self) -> str:
"""Stable identifier a report pins to: version plus a hash prefix."""
return f"{self.version}+{self.content_hash[:12]}"
The manifest_id — 2.4.0+9f1c3a2b7e04, for example — is what every report stores. It is both human-readable (the operator sees the version) and tamper-evident (the hash prefix changes if a single limit is altered), which is exactly what an auditor needs to confirm a replay used the right rules.
Registry: pinning and cohort assignment
The registry is append-only. It records every sealed version, tracks which one is the current default and which is being rolled out, pins a report to a manifest the first time it is seen, and assigns rollout cohorts deterministically so a report never migrates between stages on retry.
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
logger = logging.getLogger("policy.versioning.registry")
@dataclass
class RolloutState:
"""The manifest currently rolling out and its cohort percentage (0-100)."""
candidate_id: str
canary_percent: int = 0
class VersionRegistry:
"""Append-only store of manifests with pinning and staged rollout."""
def __init__(self) -> None:
self._versions: dict[str, PolicyManifest] = {}
self._default_id: str | None = None
self._rollout: RolloutState | None = None
self._pins: dict[str, str] = {} # report_id -> manifest_id
def register(self, manifest: PolicyManifest) -> None:
"""Append a sealed version; the first registered becomes the default."""
if manifest.manifest_id in self._versions:
raise ValueError(f"version already registered: {manifest.manifest_id}")
self._versions[manifest.manifest_id] = manifest
if self._default_id is None:
self._default_id = manifest.manifest_id
logger.info("version_registered", extra={"manifest_id": manifest.manifest_id})
def begin_rollout(self, candidate_id: str, canary_percent: int) -> None:
"""Stage a registered version at a cohort percentage without promoting."""
if candidate_id not in self._versions:
raise KeyError(f"unknown version: {candidate_id}")
if not 0 <= canary_percent <= 100:
raise ValueError("canary_percent must be within 0..100")
self._rollout = RolloutState(candidate_id, canary_percent)
logger.info("rollout_staged",
extra={"candidate_id": candidate_id, "percent": canary_percent})
def promote(self) -> None:
"""Make the rollout candidate the new default; clears the rollout."""
if self._rollout is None:
raise RuntimeError("no rollout in progress")
self._default_id = self._rollout.candidate_id
logger.info("rollout_promoted", extra={"manifest_id": self._default_id})
self._rollout = None
def _cohort_bucket(self, report_id: str) -> int:
"""Deterministic 0-99 bucket from the report id — stable across retries."""
digest = hashlib.sha256(report_id.encode("utf-8")).hexdigest()
return int(digest[:8], 16) % 100
def resolve_for(self, report_id: str) -> str:
"""Pin a report to a manifest exactly once; return its manifest_id.
A report already pinned keeps its version forever. An unpinned report
joins the rollout candidate only if its deterministic bucket falls
inside the canary band; otherwise it takes the current default.
"""
if report_id in self._pins:
return self._pins[report_id]
chosen = self._default_id
if self._rollout and self._cohort_bucket(report_id) < self._rollout.canary_percent:
chosen = self._rollout.candidate_id
if chosen is None:
raise RuntimeError("no default version registered")
self._pins[report_id] = chosen
logger.info("report_pinned",
extra={"report_id": report_id, "manifest_id": chosen})
return chosen
def manifest(self, manifest_id: str) -> PolicyManifest:
"""Fetch a sealed manifest by id for evaluation or replay."""
return self._versions[manifest_id]
The load-bearing method is resolve_for: it pins on first sight and is idempotent thereafter, so a report that entered under 2.3.1 is still evaluated under 2.3.1 even after 2.4.0 becomes the default. The cohort bucket derives from the report_id hash, not a random draw, so a retried or replayed submission lands in the same rollout stage every time. The precise rules for holding a report to its pinned version while it is still open are the subject of the companion guide on rolling out changes without breaking open reports.
Compatibility gate: choosing the version bump
Before a manifest is sealed, a compatibility check compares the new rule set against the current default and decides whether the change is MAJOR (breaking — a field removed or retyped, a limit tightened), MINOR (additive — a new rule or geography), or PATCH (a metadata-only correction). It refuses to seal a breaking change under a non-MAJOR bump, which is what keeps older reports valid under the schema they were built against.
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("policy.versioning.compat")
def classify_change(old_rules: list[dict[str, Any]],
new_rules: list[dict[str, Any]]) -> str:
"""Return the required bump: 'major', 'minor', or 'patch'.
- major: a rule_id was removed, or an existing limit was lowered
(an open report could newly fail) -> breaking.
- minor: rule_ids were only added -> backward compatible.
- patch: same ids and same limits, non-enforcing metadata changed.
"""
old_by_id = {r["rule_id"]: r for r in old_rules}
new_by_id = {r["rule_id"]: r for r in new_rules}
if set(old_by_id) - set(new_by_id):
return "major" # a rule disappeared
for rule_id, old in old_by_id.items():
new = new_by_id[rule_id]
if float(new["base_limit"]) < float(old["base_limit"]):
return "major" # tightened -> could newly fail
if new != old:
return "patch" # some field changed, not the cap
if set(new_by_id) - set(old_by_id):
return "minor" # only additions
return "patch"
def next_version(current: str, bump: str) -> str:
"""Advance a MAJOR.MINOR.PATCH string by the classified bump."""
major, minor, patch = (int(p) for p in current.split("."))
if bump == "major":
return f"{major + 1}.0.0"
if bump == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"
def assert_bump_is_safe(declared: str, required: str) -> None:
"""Fail the CI gate if a breaking change ships under a non-major bump."""
order = {"patch": 0, "minor": 1, "major": 2}
if order[declared] < order[required]:
raise ValueError(
f"declared {declared} bump but change requires {required}; "
"breaking policy changes must ship as MAJOR"
)
logger.info("bump_validated", extra={"declared": declared, "required": required})
Running classify_change in CI on every policy pull request turns “did this edit break backward compatibility?” from a judgment call into a deterministic gate. A tightened cap forces a MAJOR bump, which by convention triggers the staged-rollout path rather than an immediate default swap — the change reaches new reports first and never touches reports already pinned to the prior version.
Configuration Reference
Expose the rollout controls through a policy store or environment so finance can stage and promote versions without a code deploy. Pin the version of this component itself alongside the manifests it produces.
| Key | Type | Default | Rationale |
|---|---|---|---|
POLICY_CANARY_PERCENT |
int | 5 |
Initial cohort size for a new candidate; raise only after the canary window is clean |
POLICY_ROLLOUT_STAGES |
list[int] | [5, 50, 100] |
Ordered cohort percentages a candidate walks before promotion |
POLICY_CANARY_WINDOW_MIN |
int | 60 |
Minutes to observe each stage before advancing; guards against fast-flapping |
POLICY_PIN_ON |
enum | SUBMITTED_AT |
Which timestamp pins the version; never EVALUATED_AT |
POLICY_DEFAULT_BUMP |
enum | MINOR |
Assumed bump when a PR omits one; CI still overrides via classify_change |
POLICY_REGISTRY_PATH |
path | /data/policy_registry |
Append-only, WORM-backed store of every sealed manifest |
POLICY_RETENTION_DAYS |
int | 2555 |
Keep versions for the full audit window (~7 years); never evict a pinned version |
Validation & Testing
Test the invariants, not the plumbing: a sealed manifest is stable and tamper-evident, a pinned report keeps its version across a rollout, and a breaking change is rejected under a non-MAJOR bump.
import pytest
from datetime import date
BASE_RULES = [
{"rule_id": "MEALS_STD", "base_limit": 75.0},
{"rule_id": "LODGING_STD", "base_limit": 250.0},
]
def test_identical_rules_hash_identically():
a = PolicyManifest.seal("1.0.0", BASE_RULES, date(2026, 1, 1))
b = PolicyManifest.seal("1.0.0", list(reversed(BASE_RULES)), date(2026, 1, 1))
assert a.content_hash == b.content_hash # order-independent
def test_pin_survives_rollout():
reg = VersionRegistry()
reg.register(PolicyManifest.seal("2.3.1", BASE_RULES, date(2026, 1, 1)))
old_id = reg.resolve_for("RPT-1") # pinned to the default
new = PolicyManifest.seal("2.4.0", BASE_RULES + [
{"rule_id": "TRANSPORT_STD", "base_limit": 120.0}], date(2026, 6, 1))
reg.register(new)
reg.begin_rollout(new.manifest_id, canary_percent=100)
reg.promote()
assert reg.resolve_for("RPT-1") == old_id # still the version it entered on
def test_tightened_cap_requires_major():
tightened = [{"rule_id": "MEALS_STD", "base_limit": 60.0},
{"rule_id": "LODGING_STD", "base_limit": 250.0}]
assert classify_change(BASE_RULES, tightened) == "major"
with pytest.raises(ValueError):
assert_bump_is_safe("minor", classify_change(BASE_RULES, tightened))
Gate deployments on these assertions. Add fixtures for the canary boundary — a report_id whose bucket sits just inside and just outside POLICY_CANARY_PERCENT — to confirm cohort assignment is deterministic and does not drift between runs.
Operational Runbook
- Seal and register in CI. On merge of a policy change, run
classify_changeto derive the bump,assert_bump_is_safeto block a mislabeled breaking change, thenPolicyManifest.sealandregisterthe new version. Nothing goes live yet. - Stage the canary. Call
begin_rollout(candidate_id, POLICY_CANARY_PERCENT). Only reports whose deterministic bucket falls in the band pin to the candidate; everyone else stays on the current default. - Watch the right signals. For
POLICY_CANARY_WINDOW_MIN, compare the candidate cohort’s flag and failure rates against the default cohort. A canary that fails far more items than baseline is a bad rule set, not a spike in bad expenses. - Ramp through the stages. Advance through
POLICY_ROLLOUT_STAGES(5 → 50 → 100) only while metrics track baseline. Because pinning is idempotent, widening the cohort never re-pins a report that already resolved. - Promote or roll back. Call
promote()to make the candidate the default, or drop the canary to0to halt. Rollback is safe by construction: no in-flight report was ever re-judged, so reverting the default changes only which version new reports pin to. - Never evict a pinned version. Keep every manifest for
POLICY_RETENTION_DAYS; an auditor replaying a two-year-old verdict must be able to load the exact manifest that report pinned. Feed promoted versions to Automated Policy Validation & Anomaly Flagging so anomaly scoring records the same version number the verdict carries.
Policy versioning is what makes config-as-code safe to change often. Immutable, content-hashed manifests, submission-time pinning, and staged rollout give finance teams a release process where a rule can be tightened on a Monday and every report already in flight is still judged by the rules it was filed under.
Related
- Core Policy Architecture & Taxonomy Design — the control plane whose rule sets this component versions and ships
- Rolling out policy changes without breaking open expense reports — pinning and migration detail for in-flight reports
- Expense Category Taxonomies — taxonomy edits are a common driver of a MINOR version bump
- Spending Cap Hierarchies — cap changes that trigger a MAJOR, staged rollout
- Automated Policy Validation & Anomaly Flagging — the downstream consumer that records the pinned policy version on every verdict