Versioning Compliance Payloads Across Store Resets

This walkthrough sits under Compliance Score APIs & Payload Contracts and solves one precise task: keeping the compliance payload contract stable while the shelf underneath it keeps changing. Stores reset — a category is re-laid, a promotional bay swaps in, a planogram is redrawn — and every reset advances the versioned mapping registry that the scoring stage grades against. A compliance_percentage of 0.71 scored on last week’s planogram is not the same measurement as 0.71 scored on today’s, and a naive consumer that reads the latest schema against an old score will silently mis-report it. The failure is quiet and expensive: a dashboard shows a store recovering when it was actually graded against a layout that no longer exists. The fix is to treat the registry_revision as an effective-dating key, to separate additive schema changes from breaking ones with an explicit compatibility rule, and to make every read effective-dated so a capture_timestamp resolves against the revision that was in force then — not the newest one. This page builds that scheme step by step, and each step is independently verifiable.

Effective-dated reads: each capture_timestamp resolves to the registry_revision and schema in force when the shelf was photographed A left-to-right time axis carries three registry-revision bands. The first band is registry revision r14 on schema version v2.3. A store reset ends it and starts registry revision r15 on schema version v2.4, which adds a field additively so old clients still parse it. A second store reset ends r15 and starts registry revision r16 on schema version v3.0, a breaking reshape of the compliance field gated behind a new major version. Below the bands sit three capture events, each with a capture_timestamp arrow pointing up to the band that was in force at that moment: an early capture resolves to r14 and v2.3, a middle capture to r15 and v2.4, and a recent capture to r16 and v3.0. A backfilled read of the early capture, run today, still resolves to r14. A caption states the rule: the revision in force at the capture_timestamp wins, never the latest revision. Each capture reads the contract in force at its capture_timestamp registry_revision r14 schema_version v2.3 registry_revision r15 schema_version v2.4 + facing_confidence (additive) registry_revision r16 schema_version v3.0 compliance_percentage reshaped (breaking) store reset store reset time capture @ t₁ resolves → r14 · v2.3 capture @ t₂ resolves → r15 · v2.4 capture @ t₃ resolves → r16 · v3.0 Effective-dated read: the registry_revision in force at capture_timestamp wins — a backfill of t₁ run today still reads the r14 contract, never the latest.

Prerequisites & Context Jump to heading

Before applying this page, confirm the following are already in place. Versioning is a contract concern layered on top of an already-working scoring pipeline; it does not change how a score is computed, only how it is labelled and read.

  • Runtime: Python 3.11+ with pydantic 2.x on the API host for typed payload models and validation.
  • A stable base payload: the compliance record your scoring stage emits, carrying compliance_percentage, out_of_stock_flags, misplaced_sku_list, price_tag_mismatch_count, capture_timestamp, and store_id. Designing that record and its endpoints is the sibling task in Designing a Compliance Score REST API; this page assumes it exists and adds versioning around it.
  • A monotonic registry revision: every planogram reset must bump a registry_revision on the versioned mapping registry that lives upstream in Planogram Sync & SKU Mapping Strategies. A score is only meaningful relative to the revision it was graded against, so that revision must be stamped onto every record at scoring time.
  • An immutable revision ledger: a small append-only table mapping each registry_revision to the wall-clock window it was in force for a given store_id, so a capture_timestamp can be resolved back to a revision after the fact.
  • A capture clock you trust: capture_timestamp must be the moment the shelf was photographed, not the moment the record was ingested. Effective-dating hangs entirely on that distinction.

A note on terms: the contract is the shape of the payload (its schema_version); the registry_revision is the shape of the shelf it describes. They advance independently, and this page keeps both legible to a consumer at once.

Step 1 — Version the Payload With Both a Schema Version and a Registry Revision Jump to heading

Two independent axes change, so the payload carries two independent version keys. The schema_version describes the shape of the JSON — which fields exist and what they mean. The registry_revision describes the planogram the score was graded against — the ground truth that a reset advances. A consumer needs both: schema_version tells it how to parse, and registry_revision tells it what the number is comparable to. Conflating them is the original sin here; a store reset must never look like a schema change, and vice versa.

Stamp both at emit time, and make them non-optional so a record can never be ambiguous about either axis.

from datetime import datetime
from pydantic import BaseModel, Field


class CompliancePayloadV2(BaseModel):
    """The stable v2 compliance contract. schema_version and registry_revision
    are the two version axes; everything else is the measurement itself."""

    schema_version: str = Field(pattern=r"^2\.\d+$")
    registry_revision: int = Field(ge=1)
    store_id: str
    capture_timestamp: datetime
    compliance_percentage: float = Field(ge=0.0, le=1.0)
    out_of_stock_flags: list[str] = Field(default_factory=list)
    misplaced_sku_list: list[str] = Field(default_factory=list)
    price_tag_mismatch_count: int = Field(ge=0, default=0)

The schema_version pattern pins the major version to 2 while leaving the minor free, which is exactly the compatibility contract Step 2 formalizes. The registry_revision is an integer that only ever moves forward.

Step 2 — Classify Additive vs Breaking Changes With a Compatibility Rule Jump to heading

Every proposed change to the contract is either additive or breaking, and the classification is mechanical rather than a matter of taste. An additive change adds an optional field, widens an enum, or relaxes a constraint — an old client that ignores unknown keys keeps working untouched. A breaking change removes or renames a field, changes a field’s type or units, tightens a constraint, or reshapes a value (for example turning a scalar compliance_percentage into a per-slot object). The rule: additive changes bump the minor version; breaking changes bump the major version and ship as a parallel contract. A consumer pinned to major version 2 must be able to read every 2.x payload ever emitted.

Encode the rule so it is enforced in code review and CI, not remembered in a wiki.

from enum import Enum


class ChangeKind(Enum):
    ADDITIVE = "additive"   # optional field, widened enum, relaxed bound -> minor bump
    BREAKING = "breaking"   # removed/renamed/retyped/reshaped field -> major bump


def classify_change(old: set[str], new: set[str], retyped: set[str]) -> ChangeKind:
    """Classify a schema delta. Removed, renamed, or retyped fields are breaking;
    purely added optional fields are additive."""
    removed = old - new
    if removed or retyped:
        return ChangeKind.BREAKING
    return ChangeKind.ADDITIVE


def next_version(current: str, kind: ChangeKind) -> str:
    major, minor = (int(p) for p in current.split("."))
    if kind is ChangeKind.BREAKING:
        return f"{major + 1}.0"
    return f"{major}.{minor + 1}"

So adding facing_confidence as an optional field takes 2.3 to 2.4 and old clients ignore it. Reshaping compliance_percentage into a per-slot breakdown takes 2.4 to 3.0, ships as CompliancePayloadV3 alongside — not in place of — the v2 model, and only clients that have opted into major version 3 ever see it.

Step 3 — Effective-Date Reads So a Capture Resolves Against the Revision in Force Then Jump to heading

This is the crux. A read must resolve a record’s registry_revision against the ledger as of its capture_timestamp, never against the latest revision. A capture taken at t₁ under registry_revision 14 must always be interpreted through revision 14, even when it is backfilled or re-read weeks later after two resets have advanced the registry to 16. Anchoring on capture time is what stops a stale score from being silently compared against a planogram it was never graded against.

Look the effective revision up from the immutable ledger by store_id and capture_timestamp, and reject any record whose stamped revision disagrees with the one the ledger says was in force — that disagreement is a data-integrity bug, not something to paper over.

from dataclasses import dataclass


@dataclass(frozen=True)
class RevisionWindow:
    store_id: str
    registry_revision: int
    effective_from: datetime
    effective_to: datetime | None   # None => still in force


class RevisionLedger:
    def __init__(self, windows: list[RevisionWindow]) -> None:
        self._windows = sorted(windows, key=lambda w: w.effective_from)

    def revision_in_force(self, store_id: str, at: datetime) -> int:
        """The registry_revision effective for a store at a given capture time."""
        for w in self._windows:
            if w.store_id != store_id:
                continue
            if w.effective_from <= at and (w.effective_to is None or at < w.effective_to):
                return w.registry_revision
        raise LookupError(f"no registry_revision in force for {store_id} at {at.isoformat()}")


def read_effective(payload: CompliancePayloadV2, ledger: RevisionLedger) -> CompliancePayloadV2:
    """Validate that a record's stamped revision matches the one in force at capture."""
    in_force = ledger.revision_in_force(payload.store_id, payload.capture_timestamp)
    if payload.registry_revision != in_force:
        raise ValueError(
            f"record stamped r{payload.registry_revision} but r{in_force} "
            f"was in force at {payload.capture_timestamp.isoformat()}"
        )
    return payload

A dashboard querying “compliance for store S-118 on the week of the reset” now returns two comparable series — one under r14, one under r15 — instead of one misleading blended line. Trend and drift consumers such as Time-Series Compliance Drift Analysis depend on this resolution to avoid reading a reset as a real compliance cliff.

Step 4 — Migrate Consumers With a Deprecation Window Jump to heading

A breaking bump to 3.0 does not retire 2.x on the same day. Serve both contracts in parallel through a negotiated deprecation window: honour the consumer’s requested major version (via an Accept-Version header or query param), default to the current stable major, and advertise the sunset date on every legacy response so integrators see it in their logs. Only after the window closes and telemetry shows zero traffic on the old major do you remove the v2 model.

DEFAULT_MAJOR = 2
SUPPORTED_MAJORS = {2, 3}
SUNSET = {2: "2026-10-01"}   # v2 retires once traffic drains


def select_contract(requested_major: int | None) -> tuple[int, dict[str, str]]:
    """Resolve which contract major to serve and any deprecation headers."""
    major = requested_major if requested_major is not None else DEFAULT_MAJOR
    if major not in SUPPORTED_MAJORS:
        raise ValueError(f"unsupported major version {major}")
    headers: dict[str, str] = {}
    if major in SUNSET:
        headers["Deprecation"] = "true"
        headers["Sunset"] = SUNSET[major]
    return major, headers

Track per-major request volume as a first-class metric; the window closes on evidence, not on the calendar alone.

Verification & Testing Jump to heading

Confirm each guarantee deterministically rather than trusting the version strings by eye:

  1. Old capture reads the old contract. Resolve a record with capture_timestamp inside the r14 window against a ledger that has since advanced to r16, and assert revision_in_force returns 14. Backfill the same capture “today” and assert it still returns 14.
  2. Additive field is ignored by an old client. Serialize a 2.4 payload carrying facing_confidence, parse it with the 2.3 CompliancePayloadV2 model, and assert it validates with the unknown field dropped rather than raising.
  3. Breaking change is gated behind the new version. Assert classify_change labels a reshaped compliance_percentage as BREAKING, that next_version("2.4", BREAKING) returns "3.0", and that a 2.x consumer never receives the v3 shape.
  4. Stamp and ledger must agree. Feed read_effective a record stamped r15 whose capture_timestamp falls in the r14 window and assert it raises ValueError — a mismatch is surfaced, not silently served.
  5. Deprecation headers appear only on the sunset major. Assert select_contract(2) returns a Sunset header and select_contract(3) returns none.

A healthy rollout shows legacy-major request volume decaying toward zero across the window, with no record ever resolving to a revision other than the one the ledger says was in force at its capture.

Troubleshooting Jump to heading

Symptom Likely root cause Remediation
A store’s score jumps at a reset boundary and a dashboard reads it as recovery Records blended across registry_revision values without effective-dating Resolve every record through revision_in_force and split series by revision before charting; never compare scores across a reset boundary
Old clients start throwing parse errors after a minor release A change classified additive was actually breaking (a field retyped or reshaped) Re-run classify_change; if BREAKING, pull the change from the 2.x line and reship it as 3.0 in parallel
read_effective raises “no registry_revision in force” Ledger window missing or capture_timestamp outside every window for the store Backfill the ledger window for that reset; confirm capture_timestamp is capture time, not ingest time
Backfilled captures resolve to the latest revision instead of the historical one Read path keyed on “current” revision rather than capture_timestamp Route all reads through the ledger lookup; forbid any code path that reads the newest revision directly
v2 traffic never drains so the contract can’t be retired No per-major telemetry or the Sunset header isn’t surfaced to integrators Emit per-major request counts and attach Deprecation/Sunset headers so consumers see the deadline in their logs
Back to top