Automating Lifecycle State Transitions for Spatial Products

Manual promotion of a spatial product from experiment to production is where governance quietly breaks: a tile set reaches consumers before it meets its SLA, or a deprecated layer disappears without notice. This page automates the Experimental → Production → Deprecated → Archived path using catalog metadata, policy checks, and CI, with guards that block a transition until its preconditions hold. It operationalizes Spatial Product Lifecycle Management inside Geospatial Data Mesh Fundamentals, and every transition is evaluated against the product’s Data Contracts for Spatial Products. The state lives in one field — lifecycle_stage — and only a guarded, idempotent transition may change it.

Encoding the lifecycle as a state machine with explicit guards turns governance from a review meeting into a policy decision a machine can make and audit. The diagram below shows the four states and the guard that gates each edge.

Figure — A guarded lifecycle state machine advancing a spatial product from Experimental to Archived.

Spatial product lifecycle state machine with transition guards Four states run left to right: Experimental (unstable, no SLA), Production (SLA enforced), Deprecated (read-only, consumer notice), and Archived (cold storage). An arrow from Experimental to Production is guarded by SLA met; an arrow from Production to Deprecated is guarded by consumer notice sent; an arrow from Deprecated to Archived is guarded by a 90-day sunset elapsed. Each transition is evaluated by an OPA policy and applied in CI. SLA met notice sent sunset elapsed Experimental unstable · no SLA Production SLA enforced Deprecated read-only · notice Archived cold storage each edge: guard evaluated by opa eval, applied in CI, written back to catalog lifecycle_stage

Prerequisites

Requirement Version / assumption Notes
opa (CLI) >= 0.60 opa eval evaluates the transition guard as policy-as-code
Catalog API STAC-compatible, writable Holds lifecycle_stage and transition history per product
CI runner GitHub Actions / GitLab CI Applies transitions on merge; zero-trust default deny on manual edits
jq, curl any Read current state and post the new state atomically
CRS assumption Lifecycle is CRS-agnostic; the SLA guard reads accuracy in the product’s declared CRS (EPSG:4326) Guard reuses the product’s SLA contract
Role lifecycle-bot (CI identity, catalog write) No human writes lifecycle_stage directly
Env PRODUCT_ID, CATALOG_API, TARGET_STAGE Drives the transition script

Step-by-Step Implementation

1. Model the transition as a policy guard

Write the guard as Rego so the same rule runs locally, in CI, and at the catalog. The policy is deny-by-default and only allows a transition when its precondition holds — zero-trust applied to lifecycle changes:

rego
package spatial.lifecycle.transition

import rego.v1

default allow := false

# Experimental -> Production requires a met SLA and a released contract
allow if {
    input.from == "experimental"
    input.to == "production"
    input.sla.availability >= 0.999
    input.sla.freshness_ok == true
    input.contract.version_released == true
}

# Production -> Deprecated requires a consumer notice on record
allow if {
    input.from == "production"
    input.to == "deprecated"
    input.notice.sent_at != ""
}

# Deprecated -> Archived requires the sunset window to have elapsed
allow if {
    input.from == "deprecated"
    input.to == "archived"
    input.sunset.days_elapsed >= 90
}

2. Evaluate the guard for a proposed transition

Build the decision input from the catalog and SLA metrics, then let opa eval render the allow/deny. This is the gate that keeps a product out of production before its SLA is met:

The preconditions a transition guard evaluates, and which one blocks retirementA proposed transition is checked against four preconditions: the transition is legal for the current state, the CI gates for the target state have passed, a named successor version exists where the target requires one, and the set of active callers of this version is empty. Only when all four hold does the catalog record the new state. Each precondition drops onto a rail ending in an unchanged state, because a transition is atomic — a failure leaves the product exactly where it was rather than in an intermediate state no rule describes.Legal transitionfor current stateCI gatestarget-state checksSuccessor namedwhere requiredCallers = 0from access telemetryState recordedatomic catalog writelegalpassednamedquietillegal hopno successorstill in useState unchangedno intermediate state exists

bash
cat > input.json <<'JSON'
{
  "from": "experimental", "to": "production",
  "sla": {"availability": 0.9992, "freshness_ok": true},
  "contract": {"version_released": true},
  "notice": {"sent_at": ""},
  "sunset": {"days_elapsed": 0}
}
JSON

opa eval -i input.json -d transition.rego \
  'data.spatial.lifecycle.transition.allow' --format raw
# expect: true

3. Write a guarded, idempotent transition script

The script reads the current stage, evaluates the guard, and only writes when the guard passes and the state is not already the target — re-running it never double-applies or skips the guard:

bash
#!/usr/bin/env bash
set -euo pipefail
CURRENT=$(curl -sf "${CATALOG_API}/products/${PRODUCT_ID}" | jq -r '.properties.lifecycle_stage')

# Idempotency: no-op if already at target
if [ "$CURRENT" = "$TARGET_STAGE" ]; then
  echo "NOOP: ${PRODUCT_ID} already ${TARGET_STAGE}"; exit 0
fi

DECISION=$(opa eval -i input.json -d transition.rego \
  'data.spatial.lifecycle.transition.allow' --format raw)
if [ "$DECISION" != "true" ]; then
  echo "GUARD_DENIED: ${CURRENT} -> ${TARGET_STAGE}"; exit 1
fi

# Atomic state write with an idempotency key on the from/to pair
curl -sf -X PATCH "${CATALOG_API}/products/${PRODUCT_ID}" \
  -H "Idempotency-Key: ${PRODUCT_ID}:${CURRENT}->${TARGET_STAGE}" \
  -H "Content-Type: application/json" \
  -d "{\"properties\":{\"lifecycle_stage\":\"${TARGET_STAGE}\"}}"
echo "TRANSITION_OK: ${CURRENT} -> ${TARGET_STAGE}"

Verify the catalog reflects the new state:

bash
curl -sf "${CATALOG_API}/products/${PRODUCT_ID}" | jq -r '.properties.lifecycle_stage'
# expect: production

4. Wire the transition into CI

Run the guarded script on merge so a stage change is a reviewed, audited pull request rather than a console edit. The version bump that accompanies a promotion follows Versioning Spatial Data Contracts with SemVer:

Reading a deprecation from caller volume rather than from an announcementWeekly call volume against a deprecated version across a sunset window, in thousands of requests. Week one carries 412 thousand calls, week four 288, week eight 96, week twelve 31, and week sixteen 4. The dashed line marks the threshold below which retirement is considered, but the decision is made on the caller count reaching zero rather than on volume alone — a single remaining integration issuing four thousand calls a week is still an integration that breaks on sunset day. The curve is the progress signal a mailing list cannot provide.week 1 · deprecated412kweek 4 · header live288kweek 8 · review96kweek 12 · throttled31kweek 16 · sunset4k1 caller leftreview threshold

yaml
# .github/workflows/lifecycle.yml
name: lifecycle-transition
on:
  pull_request:
    paths: ["products/**/lifecycle.json"]
jobs:
  transition:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Evaluate guard and apply
        env:
          CATALOG_API: ${{ secrets.CATALOG_API }}
          PRODUCT_ID: ${{ vars.PRODUCT_ID }}
          TARGET_STAGE: ${{ vars.TARGET_STAGE }}
        run: |
          opa eval -i input.json -d transition.rego \
            'data.spatial.lifecycle.transition.allow' --fail-defined --format raw
          ./transition.sh

Configuration Reference

Field Location Required Example
lifecycle_stage catalog product properties Yes experimental | production | deprecated | archived
sla.availability guard input Yes (→ production) 0.999
contract.version_released guard input Yes (→ production) true
notice.sent_at guard input Yes (→ deprecated) ISO-8601 timestamp
sunset.days_elapsed guard input Yes (→ archived) 90
Idempotency-Key PATCH header Yes product:from->to
TARGET_STAGE CI variable Yes production

Common Failure Modes & Fixes

Symptom: a product reaches production while its availability SLO is still failing. Root cause: the guard input was built from a stale metrics snapshot, so sla.availability reflected a passing window that has since regressed. Fix: compute the guard input from a live query at transition time and add for:-style dwell so a momentary pass cannot promote; re-run opa eval immediately before the PATCH.

Symptom: the CI job flips the stage back and forth on every run. Root cause: the script writes unconditionally instead of no-oping when already at the target, so a re-run re-applies the transition. Fix: keep the idempotency guard from step 3 that exits early when CURRENT == TARGET_STAGE, and use the Idempotency-Key so the catalog rejects a duplicate apply.

Symptom: consumers were surprised by a deprecation. Root cause: the Production → Deprecated transition ran without notice.sent_at, because the guard input defaulted the field to a non-empty placeholder. Fix: treat an empty or placeholder sent_at as deny in the Rego rule, and require the notice webhook to stamp a real timestamp before the guard can pass.

Symptom: opa eval returns true for a transition that should be blocked. Root cause: the input from/to pair did not match any rule, and a stray default allow := true elsewhere leaked through. Fix: keep a single default allow := false and run opa eval --fail-defined in CI so an undefined decision fails the job rather than silently allowing.

FAQ

Why keep lifecycle state in the catalog instead of in Git alone?

Git records intent; the catalog records the state consumers actually resolve against. Storing lifecycle_stage in the catalog means a consumer’s discovery query sees the live stage, while the CI pull request provides the reviewed, auditable trail of how it got there. The two are kept in sync by the guarded transition, never edited independently.

Can a product skip a state — go straight from Experimental to Deprecated?

No path in the state machine allows it, and that is deliberate. A product that never reached Production has no consumers to notify and should simply be deleted from the experimental namespace. Restricting transitions to defined edges prevents nonsensical states and keeps the audit trail linear.

What happens to in-flight consumers when a product moves to Deprecated?

Deprecated is read-only, not gone: the product keeps serving so pinned consumers do not break, but no new versions are published and the consumer notice starts the 90-day sunset clock. Only after sunset.days_elapsed >= 90 does the guard permit the move to Archived, where the data goes to cold storage.

How do I roll a transition back if a promotion was wrong?

Run the script with TARGET_STAGE set to the previous stage; the same guard evaluates the reverse edge, and the idempotency key on the reversed from->to pair makes the rollback safe to retry. Because every transition is a catalog write with a key, the lineage of stage changes is fully reconstructable for audit.

How does the transition job know whether anyone is still using the version it is about to retire?

From access telemetry, not from a record of who was notified. Every request to an output port carries a caller identity from the zero-trust layer, so the set of active consumers of a specific version is a query over the last N days rather than an estimate — and that query belongs in the transition guard as a hard precondition. A move to Archived with a non-empty caller set should fail the guard and report the callers, so retirement cannot proceed on the assumption that an announcement was read. The same query, run continuously through the deprecation window, is the progress signal that tells you whether the sunset date is realistic while there is still time to change it.

Should a failed transition leave the product in its old state or an intermediate one?

Its old state, always. A transition is a single guarded step with no partial outcome: either every precondition passed and the catalog now records the new state, or nothing changed and the product remains exactly where it was. Introducing an intermediate promoting or deprecating state to represent a transition in flight is tempting and creates a class of failure with no owner — a product stuck mid-transition that neither the old rules nor the new ones describe, and that no guard is written to move. Keep the state machine’s states meaningful to consumers, keep transitions atomic, and let a failed transition be an ordinary retry rather than a recovery procedure.