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.
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.
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.