Deprecating a Spatial Data Product Safely

Most deprecations overrun because nobody knew who the consumers were. An announcement goes out, a sunset date is set, and on the date half the callers are still calling — because the consumer list came from a wiki rather than from telemetry, and the window came from a template rather than from the slowest affected release cycle. This guide runs a deprecation that lands: identify callers from access records, derive the window from their cadence, enforce in graduated stages, and retire only when the caller set is empty. It implements the deprecation discipline in Spatial Product Lifecycle Management within Geospatial Data Mesh Fundamentals, and it is the transition automated in Automating Lifecycle State Transitions.

Prerequisites

Requirement Value / Assumption Notes
Tools Platform CLI, Prometheus, jq Progress is a metric, not a status report
Identity Every request carries an authenticated caller Anonymous traffic cannot be migrated
Successor A named successor version with a published diff “Migrate to v2” is not actionable
Telemetry window ≥ 90 days A monthly consumer is invisible in 30
Access roles domain-owner initiates; the guard enforces Retirement is gated, not scheduled
Environment PRODUCT, VERSION, SUCCESSOR Exported before running

Step-by-Step Implementation

1. Identify the callers from telemetry

bash
# Every identity that called this version in 90 days, with volume and last-seen.
curl -sS "$PROM/api/v1/query" --data-urlencode \
  'query=sum by (caller) (increase(product_requests_total{product="'"$PRODUCT"'",version="'"$VERSION"'"}[90d]))' \
  | jq -r '.data.result[] | "\(.metric.caller)\t\(.value[1]|tonumber|floor)"' \
  | sort -k2 -rn | tee /tmp/callers.tsv

Verify the window is long enough by comparing caller counts at three windows — a set that keeps growing means slower consumers exist:

bash
for d in 30 90 180; do
  printf '%3dd: ' "$d"
  curl -sS "$PROM/api/v1/query" --data-urlencode \
    "query=count(count by (caller) (increase(product_requests_total{product=\"$PRODUCT\",version=\"$VERSION\"}[${d}d])))" \
    | jq -r '.data.result[0].value[1]'
done

2. Derive the window from the slowest affected consumer

python
# window.py — a sunset date the affected teams can actually meet.
CADENCE_DAYS = {          # published by each consuming team, not guessed
    "logistics/router": 7,
    "planning/reporting": 90,
    "public/portal": 14,
}
CYCLES_REQUIRED = 2       # one to plan, one to ship


def sunset_days(callers: list[str]) -> tuple[int, str]:
    """Two release cycles of the slowest affected consumer. Publishing the
    derivation is what makes the date credible rather than negotiable."""
    known = {c: CADENCE_DAYS[c] for c in callers if c in CADENCE_DAYS}
    if not known:
        return 90, "no cadence published for any caller; defaulting to 90 days"
    slowest, days = max(known.items(), key=lambda kv: kv[1])
    return days * CYCLES_REQUIRED, f"{CYCLES_REQUIRED} cycles of {slowest} ({days}d)"

Deriving the window from consumer cadence rather than from a templateFour candidate sunset windows in days, against the 180 days that two release cycles of the slowest affected consumer actually requires. A 30-day window is unmeetable for a consumer shipping quarterly under change control. The 90-day template default is still short. The derived 180-day window is meetable and therefore holdable. A 365-day window is achievable and leaves the producer maintaining two versions far longer than necessary. The derived figure is usually longer than the template and, unlike it, is met.30 (arbitrary)30 dunmeetable90 (template default)90 dstill short180 (derived)180 dmeetable, holdable365365 dtwo versions maintained2 cycles of the slowest

3. Enforce in graduated stages

A hard cutover concentrates all the risk into one moment. Each stage below is announced, reversible, and impossible to ignore in turn.

python
# stages.py — escalating enforcement across the window.
def stage_for(day: int, window: int) -> dict:
    """Header first, so a consumer reading their own logs finds out unprompted.
    Throttling next, which is impossible to ignore and breaks nothing. Errors last."""
    if day < 7:
        return {"header": True, "throttle": None, "error": False}
    if day < window * 0.6:
        return {"header": True, "throttle": None, "error": False}
    if day < window:
        # Deliberate, well-labelled throttling: latency, never failure.
        return {"header": True, "throttle": {"delay_ms": 2000}, "error": False}
    return {"header": True, "throttle": None, "error": True}


def response_headers(version: str, successor: str, sunset_iso: str) -> dict:
    return {
        "Deprecation": "true",
        "Sunset": sunset_iso,                       # RFC 8594
        "Link": f'</products/{successor}>; rel="successor-version"',
        "X-Spatial-Deprecated-Version": version,
    }

Graduated enforcement spreads the risk across the windowFive stages across a derived deprecation window. From day zero every response carries a deprecation header, a sunset date and a link to the named successor, so a consumer reading its own logs finds out unprompted. At about thirty percent of the window, per-caller volume is reviewed against the deadline and the window extended if the slowest consumer cannot make it. From about sixty percent the old version is deliberately and visibly throttled, which is impossible to ignore and breaks nothing. Only after the window does it return an error, and only with an empty caller set.day 0 · headersDeprecation · Sunset · Link30% · reviewcallers vs deadline60% · throttle2s delay, no failureswindow endserrors beginretireguard: callers = 0The window is two release cycles of the slowest affected consumer, and it is published

Verify the header reaches consumers before the throttle stage begins:

bash
curl -sS -o /dev/null -D - "https://api.internal/products/$PRODUCT/$VERSION/items?limit=1" \
  | grep -iE '^(deprecation|sunset|link):'

4. Track progress and retire on an empty caller set

bash
# The progress signal: call volume per caller against the deadline.
watch -n 3600 'curl -sS "$PROM/api/v1/query" --data-urlencode \
  "query=sum by (caller) (rate(product_requests_total{product=\"'"$PRODUCT"'\",version=\"'"$VERSION"'\"}[24h]))" \
  | jq -r ".data.result[] | \"\(.metric.caller) \(.value[1])\""'

# Retirement is a guarded transition: the guard fails while any caller remains.
mesh-platform transition --product "$PRODUCT" --version "$VERSION" --to Archived
# BLOCKED: 1 caller in the last 7 days: planning/reporting

Configuration Reference

Element Value Rationale
Telemetry window 90d minimum A monthly consumer is invisible in 30 days
Sunset window 2 cycles of the slowest caller A window nobody can meet is not a deadline
Deprecation header From day 0 Consumers reading their logs find out unprompted
Sunset header RFC 8594 date Machine-readable; tooling can alert on it
Link successor Required “Migrate to v2” without a target is not actionable
Throttle stage From ~60% of the window Impossible to ignore, breaks nothing
Error stage After the window Only with an empty caller set
Retirement gate Caller set empty over 7 days A date is not a precondition

The weak form of each deprecation element, and the form that worksSix elements of a deprecation compared between the form that overruns and the form that lands. Consumers identified by announcement become consumers identified by a query over caller identity. A fixed window becomes one derived from the slowest consumer cadence. A migration target of use the new version becomes a named successor with a published diff. No progress signal becomes per-consumer volume against the deadline. A hard cutover becomes escalating enforcement. A date-based retirement becomes a guard on an empty caller set.OverrunsLandsConsumersan announcementa telemetry queryWindowfixed 90 daysderived from cadenceTarget"use v2"named successor + diffProgressnothingvolume vs deadlineEnforcementhard cutoverheader → throttle → errorRetirementa datean empty caller set

Common Failure Modes & Fixes

The sunset date passes with callers still active. Root cause: the window came from a template rather than from consumer cadence, or progress was never visible. Fix: derive and publish the window; publish per-caller volume against the deadline where both sides see it.

A consumer appears on the last day, having never been seen. Root cause: a telemetry window shorter than their cadence. Fix: 90 days minimum, and check the 180-day set before the error stage.

Throttling breaks a consumer. Root cause: added latency exceeding a client timeout, which turns a warning into an outage. Fix: keep the added delay well inside typical client timeouts — two seconds is noticeable and safe; twenty is a failure.

Consumers ignore the deprecation header. Root cause: nothing consumes it. Fix: this is why the throttle stage exists; also worth adding a platform-side alert that fires for any consumer calling a version with a Sunset header inside 30 days.

Retirement blocks indefinitely on one caller. Root cause: the deprecation is working; a consumer genuinely cannot move. Fix: this is a conversation, not a technical problem — and it is happening before the outage rather than during it, which is the entire purpose.

FAQ

Why not just set a date and hold it?

Because a date held against a consumer who cannot meet it produces either a breach or an extension, and the extension teaches everyone that sunset dates are negotiable — which makes the next deprecation harder. Deriving the window from the slowest affected consumer’s actual release cadence, and publishing that derivation, produces a date that is defensible and therefore holdable. It is usually longer than the template would have given, and it is met.

What if a consumer will not migrate?

Then the deprecation surfaces a real dependency that needs a decision, which is far better than discovering it at cutover. The options are genuine: extend for that consumer specifically, help them migrate, or accept the breakage with their agreement. What the gated retirement prevents is the fourth option — breaking them by accident, on a date, because a guard was a calendar entry rather than a precondition.

Should the successor’s diff really be mandatory?

Yes, because it is the difference between a request and a work item. “Migrate to v2” cannot be estimated, prioritised, or scheduled; “v2 renames parcel_id to parcel_ref, adds a required survey_date, and changes the storage CRS from EPSG:32633 to EPSG:4326” can be. Where the contract diff is generated automatically at release, publishing it costs nothing and is the single cheapest thing a producer can do to make a deprecation land on time.

Does throttling count as breaking the contract?

It is a deliberate, announced degradation within a declared deprecation, which is why it belongs in the contract’s deprecation clause rather than being invented at the time. A contract that states what happens during deprecation — headers from day one, latency from a stated point, errors after the sunset — makes every stage something the consumer agreed to in advance. A throttle applied without that clause is a surprise, however well-intentioned.

What about consumers who are not services — an analyst with a saved query?

They are the hardest case and the reason the deprecation header alone is insufficient. An interactive consumer does not read response headers and does not appear as a stable service identity, so telemetry shows an identity that may be a shared gateway or a notebook environment. The workable approach is to treat those callers as one named consumer with a human owner, contact that owner directly, and rely on the throttle stage to reach anyone the contact missed — a two-second delay is noticed by a person in a way a header never is.

Should a deprecated version keep receiving data updates?

Yes, for the duration of the window, and stopping is a common mistake. A deprecated version that stops updating becomes stale while consumers are still legitimately using it, which converts an orderly migration into a data-quality incident affecting exactly the people who have not moved yet. The lifecycle state should reduce the cadence if the cost is material, and it should never freeze the data outright before the sunset — a product that is still serving is still a product.