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.
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+withpydantic2.xon 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, andstore_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_revisionon 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_revisionto the wall-clock window it was in force for a givenstore_id, so acapture_timestampcan be resolved back to a revision after the fact. - A capture clock you trust:
capture_timestampmust 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 payloadA 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, headersTrack 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:
- Old capture reads the old contract. Resolve a record with
capture_timestampinside ther14window against a ledger that has since advanced tor16, and assertrevision_in_forcereturns14. Backfill the same capture “today” and assert it still returns14. - Additive field is ignored by an old client. Serialize a
2.4payload carryingfacing_confidence, parse it with the2.3CompliancePayloadV2model, and assert it validates with the unknown field dropped rather than raising. - Breaking change is gated behind the new version. Assert
classify_changelabels a reshapedcompliance_percentageasBREAKING, thatnext_version("2.4", BREAKING)returns"3.0", and that a2.xconsumer never receives the v3 shape. - Stamp and ledger must agree. Feed
read_effectivea record stampedr15whosecapture_timestampfalls in ther14window and assert it raisesValueError— a mismatch is surfaced, not silently served. - Deprecation headers appear only on the sunset major. Assert
select_contract(2)returns aSunsetheader andselect_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 |
Related Jump to heading
- Compliance Score APIs & Payload Contracts — the parent cluster defining the payload contract this page versions across resets
- Designing a Compliance Score REST API — the sibling that designs the base endpoints and record this scheme wraps versioning around
- Planogram Sync & SKU Mapping Strategies — the upstream pillar whose versioned mapping registry is the origin of
registry_revision