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:

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:

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.