Rolling out policy changes without breaking open expense reports
An open expense report breaks on a policy change for one precise reason: the evaluator read the rules as they stood at approval time instead of the rules that were in force when the employee pressed submit. This scenario sits inside the Policy Versioning & Rollout topic of the Core Policy Architecture & Taxonomy Design control plane, and it covers the field-level mechanics that keep an in-flight report stable: pinning it to the policy version live at its submission time, selecting rules by effective date rather than by “latest,” and running migrations and rollbacks in a way that never re-scores a report already moving through approval.
Why Standard Approaches Fail
The naive design reads the current rule file at evaluation time. That is correct exactly once — the first time a report is scored — and wrong on every re-evaluation after a policy edit. Three failure modes follow directly from it.
- Approval-time re-scoring. A travel report is submitted on the 3rd with a 75-dollar meal cap, sits in an approver’s queue over a long weekend, and is opened on the 8th — after finance dropped the cap to 60 dollars for the new quarter. The evaluator re-runs against the live rules and the dinner that passed at submission now fails. The employee did nothing wrong; the system moved the goalposts underneath an open report.
- Latest-wins rule selection. When rules are keyed only by
rule_idwith no effective date, “which lodging cap applies?” resolves to whichever row was written most recently. A report incurred in March is silently judged by an April rate. This is the same class of temporal defect that Date Window Validation Logic guards against for transaction dates, applied here to the policy itself. - Destructive migration. A schema change renames or drops a field and overwrites the config in place. Reports built against the old shape can no longer be re-validated at all — a rollback cannot reconstruct their verdict because the rules they referenced no longer exist. The audit trail points at a version that was destroyed.
Each failure traces to the same missing primitive: a report has no durable memory of which policy version judged it. Give it that memory and all three disappear.
Architecture & Algorithm
The fix is a two-part contract. First, resolve the correct version by effective date at submission time — the version whose effective_from is the latest date not after the report’s submitted_at. Second, persist that choice as a write-once pin, so every later evaluation, migration, or rollback reads the pin instead of the live default. The resolver below is deliberately narrow: it selects and pins, and it refuses to re-pin a report that already carries one.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime, timezone
logger = logging.getLogger("policy.rollout.pinning")
@dataclass(frozen=True)
class VersionRecord:
"""A registered policy version and the date it became effective."""
manifest_id: str # e.g. "2.4.0+9f1c3a2b7e04"
effective_from: date
class AlreadyPinnedError(RuntimeError):
"""Raised if code attempts to overwrite an existing report pin."""
class EffectiveDatedResolver:
"""Select a policy version by effective date and pin it write-once."""
def __init__(self, versions: list[VersionRecord]) -> None:
# Sort descending so the first match is the latest applicable version.
self._versions = sorted(
versions, key=lambda v: v.effective_from, reverse=True
)
self._pins: dict[str, str] = {} # report_id -> manifest_id
def _version_in_force(self, at: date) -> str:
"""The latest version whose effective_from is not after `at`."""
for record in self._versions:
if record.effective_from <= at:
return record.manifest_id
raise LookupError(f"no policy version in force on {at.isoformat()}")
def pin(self, report_id: str, submitted_at: datetime) -> str:
"""Resolve and store the version for a report — idempotent, write-once.
Called the first time a report is seen. Re-calling with the same
report_id returns the stored pin; it never re-resolves against a
newer default, which is what keeps an open report stable.
"""
if report_id in self._pins:
return self._pins[report_id]
submitted_utc = submitted_at.astimezone(timezone.utc)
manifest_id = self._version_in_force(submitted_utc.date())
self._pins[report_id] = manifest_id
logger.info(
"report_pinned",
extra={"report_id": report_id, "manifest_id": manifest_id,
"submitted_at": submitted_utc.isoformat()},
)
return manifest_id
def pinned_version(self, report_id: str) -> str:
"""Read the pin for evaluation, migration, or rollback replay."""
try:
return self._pins[report_id]
except KeyError:
raise LookupError(f"report {report_id} was never pinned")
def guard_repin(self, report_id: str, proposed_id: str) -> None:
"""Reject any attempt to move an open report to a different version."""
current = self._pins.get(report_id)
if current is not None and current != proposed_id:
raise AlreadyPinnedError(
f"{report_id} is pinned to {current}; refusing {proposed_id}"
)
Two properties carry the design. pin is idempotent: the first call resolves against the effective date and stores it; every later call returns the stored value, so a report opened days after submission still evaluates under its original version. guard_repin makes the write-once rule enforceable — a migration script or a rollback that tries to reassign an open report raises rather than silently changing the verdict. Evaluation always flows through pinned_version, never through a “current default,” which is the single line that separates a stable pipeline from a re-scoring one.
Step-by-Step Integration
-
Pin at intake, not at evaluation. The moment a report transitions to submitted, call
resolver.pin(report_id, submitted_at)and store the returnedmanifest_idon the report record. Pinning at intake — before the report enters any approval queue — guarantees the version reflects submission time, not whenever an approver happens to open it. -
Load rules from the pin. In the evaluator, fetch the manifest by
resolver.pinned_version(report_id)and hand those rules to the engine described in Core Policy Architecture & Taxonomy Design. Never read the live default inside the evaluation path. -
Ship new versions as additive, effective-dated rows. When a rule changes, register a new
VersionRecordwith a futureeffective_fromrather than editing an existing one. Reports submitted before that date keep resolving to the prior version; the migration is non-destructive by construction. -
Verify the invariant before wiring approval. Confirm that a report pinned before a change is unaffected by it:
from datetime import date, datetime, timezone resolver = EffectiveDatedResolver([ VersionRecord("2.3.1+aa11", date(2026, 1, 1)), VersionRecord("2.4.0+bb22", date(2026, 6, 1)), ]) # Report submitted in May pins to the version in force then. pinned = resolver.pin("RPT-42", datetime(2026, 5, 20, tzinfo=timezone.utc)) assert pinned == "2.3.1+aa11" # A June rollout is now live, but the open report is untouched. assert resolver.pinned_version("RPT-42") == "2.3.1+aa11" # Any attempt to move it to the new version is rejected. try: resolver.guard_repin("RPT-42", "2.4.0+bb22") raise AssertionError("re-pin should have been blocked") except AlreadyPinnedError: pass -
Make rollback flip the default only. To withdraw a bad version, point the default back at the prior manifest so new reports pin to it. Do not touch existing pins — reports that already resolved to the withdrawn version replay against it for their full lifetime, which keeps their audit trail reconstructable.
-
Record the pin in the verdict. Emit the
manifest_idalongside every decision so downstream Automated Policy Validation & Anomaly Flagging and any auditor can see exactly which version judged the report.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| Resubmission after edits | A returned report re-enters and re-pins to a newer version | Treat a resubmission as a new report_id, or deliberately re-pin and log the version change |
| Timezone-skewed submission | submitted_at near midnight resolves to the wrong effective date |
Normalize submitted_at to UTC before comparing against effective_from |
| Backdated effective date | A new version dated in the past retroactively captures open reports | Require effective_from to be today or later; block past-dated rollouts in CI |
| Missing version for old date | A pruned early version leaves a submitted report unresolvable | Never evict a version any open or auditable report still pins |
| Draft edited across a change | Time spent in draft spans a version boundary | Pin on submit, not on draft creation, so the draft period does not fix the version |
| Rollback re-scoring | Reverting the default re-evaluates already-pinned reports | Roll back the default only; pins are immutable and replay against their own version |
FAQ
Should a resubmitted report keep its original policy version?
That is a policy decision you must make explicitly, not a default to fall into. If a report is rejected and the employee corrects and resubmits it, the cleanest model is to mint a new report_id so the corrected version pins to the rules in force at resubmission. If you keep the same identifier, the write-once pin holds it to the original version — decide which you want and log the choice, because auditors will ask why a resubmission was judged by an older rule set.
How do effective dates interact with the pinned version?
Effective dates decide which version a report pins to; the pin decides that the choice never changes. At submission the resolver picks the version whose effective_from is the latest date not after submitted_at, then freezes it. After that, publishing a new effective-dated version affects only reports submitted on or after its date — every open report already carries its own frozen answer, so the two mechanisms never fight.
What is the safe way to roll back a bad policy version?
Repoint the default to the previous manifest so new reports pin to it, and leave every existing pin untouched. Because reports that already resolved to the withdrawn version replay against that exact manifest, their verdicts stay reproducible and their audit trail intact. Rolling back by re-scoring open reports against the reverted rules is the thing to avoid — it reintroduces exactly the drift the pin exists to prevent.
Can I ever re-evaluate an open report under new rules?
Only if a human explicitly decides to, and only with a logged version change — never as a silent side effect of a deploy. The guard_repin check blocks accidental re-pinning; overriding it should be a deliberate, audited action that records the old and new manifest_id and the reason. The default behavior, and the one that keeps the pipeline defensible, is that an open report is judged once, by the version it was submitted under.
Related
- Policy Versioning & Rollout — the parent guide on semantic versioning, manifests, and staged rollout
- Core Policy Architecture & Taxonomy Design — the deterministic engine that consumes the pinned rule set
- Date Window Validation Logic — effective-date reasoning applied to transaction windows
- Automated Policy Validation & Anomaly Flagging — records the pinned policy version on every verdict it emits