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.
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:
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:
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:
#!/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:
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:
# .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.
Related
- Spatial Product Lifecycle Management — the parent concern this automation implements.
- Data Contracts for Spatial Products — the contract the guard reads its preconditions from.
- Versioning Spatial Data Contracts with SemVer — the version bump that accompanies a promotion.
- Defining SLAs for Spatial Data Products — the SLA the Experimental → Production guard checks.
- Data Mesh vs Traditional GIS Architecture — where policy-as-code replaces the governance review meeting.