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.

Resolving and pinning a policy version for an open report A report event carrying a report id and a submitted-at timestamp enters a decision: is it already pinned? If yes, the stored manifest identifier is loaded unchanged. If it is seen for the first time, the resolver selects the policy version whose effective date was in force at the submitted-at timestamp and writes a write-once pin. Both paths converge on evaluation under the pinned version. A band across the bottom states the invariant: a policy version shipping mid-flight changes only which version new reports pin to, while reports already open keep their pinned rules through approval and rollback. Pin at submission time, evaluate by that version for life Report event report_id submitted_at pinned? already pinned Load stored manifest_id unchanged first sight Resolve by effective date version live at submitted_at Write pin write-once Evaluate under pinned version deterministic verdict A version shipping mid-flight changes only which version new reports pin — open reports keep their rules through approval and rollback.

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_id with 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

  1. Pin at intake, not at evaluation. The moment a report transitions to submitted, call resolver.pin(report_id, submitted_at) and store the returned manifest_id on 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.

  2. 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.

  3. Ship new versions as additive, effective-dated rows. When a rule changes, register a new VersionRecord with a future effective_from rather than editing an existing one. Reports submitted before that date keep resolving to the prior version; the migration is non-destructive by construction.

  4. 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
  5. 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.

  6. Record the pin in the verdict. Emit the manifest_id alongside 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.