Shuttle ↔ Starbase API Contract¶
All requests include Authorization: Bearer <token> and Content-Type: application/json.
(Design record: specs/2026-07-02-status-contract-and-tombstone-gc-design.md.)
§25.1 Pull Desired State¶
Load-bearing — the desired-state payload
This response body is the desired-state contract Shuttle obeys ("Starbase decides, Shuttle
applies"). The payload shape is load-bearing; the canonical model lives at
§32. Never reintroduce customer_id — identity
is project_id + environment + service_id, with workspace_id as the billing-boundary
label.
GET /api/v1/clusters/{cluster_id}/desired-state
If-None-Match: "<last applied sequence>" → 304 Not Modified when unchanged (no-op tick, ~free)
Each cluster's payload contains exactly the entities placed on that cluster (FR-077) — Shuttle never sees or reasons about other clusters. Response:
{
"sequence": 1234,
"version": "abc123",
"force_gc": false,
"allow_cross_env_networking": false,
"cluster_config": {
"gateway_service_annotations": {
"service.beta.kubernetes.io/do-loadbalancer-size-unit": "3"
}
},
"services": [
{
"workspace_id": "...",
"project_id": "...",
"service_id": "...",
"deployment_id": "dep_...",
"environment": "production",
"type": "web",
"tier": "nova",
"image": "registry.digitalocean.com/starform/...",
"replicas": 1,
"autoscaling": { "enabled": true, "min_replicas": 1, "max_replicas": 4, "target_cpu_percent": 75, "target_memory_percent": null },
"env": {"KEY": "value", "API_URL": "http://api-production:8080"},
"ports": [{"container_port": 3000, "protocol": "TCP"}],
"public": true,
"hostname": "myapp-p9vq.starform.app",
"health_check": { "path": "/readyz", "port": 3000, "timeout_seconds": 300 },
"pre_deploy": { "command": "npx prisma migrate deploy" },
"deleted": false
}
],
"databases": [
{
"workspace_id": "...",
"project_id": "...",
"database_id": "...",
"environment": "production",
"engine": "postgres",
"tier": "mininova",
"mode": "containerized",
"image": "postgres:16-alpine",
"resources": { "cpu": "250m", "memory": "512Mi" },
"volume_gb": 1,
"deleted": false
},
{
"workspace_id": "...",
"project_id": "...",
"database_id": "...",
"environment": "production",
"engine": "valkey",
"tier": "supernova",
"mode": "dedicated",
"endpoint": "private-db-valkey-nyc3-....db.ondigitalocean.com:25061",
"secret_ref": "sys-db-<database_id>",
"deleted": false
}
]
}
Field semantics:
sequence— int64, monotonic per cluster; Starbase increments it on every desired-state change. Shuttle refuses a payload whosesequenceis lower than the last one it applied (stale-read / split-brain guard, §27) and keeps the last-applied state. Restore recovery: the counter lives in the control-plane Postgres, so a DR restore (§39.3 #34) would otherwise re-issue lower sequences and freeze the whole fleet. On control-plane recovery Starbase re-derives each cluster's counter from the maxsequence_observedthat cluster reports (§25.4) and resumes one above it — automatic and per-cluster (the fleet, not Postgres, holds the high-water mark). For total loss (a cluster and Postgres both gone) an operator-authenticated, per-clustersequence_reseedoverride re-baselines that cluster — a break-glass analogue offorce_gc.versionstays the opaque content hash used for no-op detection — two fields, two jobs.gateway_service_annotations— an opaque, adapter-rendered map applied to the Envoy Gateway Service. Starbase's region adapter decides the keys and values (on DO, the CCM's LB-sizing annotation shown above; other clouds render their own CCM's vocabulary); Shuttle applies the map without interpreting it (§34.2) — the contract stays cloud-neutral.deployment_id— the deployment that produced this service revision; Shuttle stamps it into the §24B.1 provenance annotations and echoes it in §25.4 status reports.type—web|worker(cron post-MVP). Web renders a ClusterIP Service and, whenpublic, an HTTPRoute; worker renders neither and gets no probes (§20.2). Carried explicitly so Shuttle never infers exposure fromports/hostname.public— web only; default true.false= internal-only: the Service is rendered, no HTTPRoute, no DNS record; siblings still reach it athttp://<service-name>-<environment>:<port>(Services › Networking). Worker/cron ignore it.env— Starform-injected env key/values for the pod, including a linked service's connection URL (default<TARGET>_URL, Connections). Customer Var Group values mount separately as SecretenvFrom(§38.4).autoscaling— when present withenabled: true, Shuttle renders a HorizontalPodAutoscaler and the HPA ownsDeployment.spec.replicas(§20.3);replicasis then only the creation-time seed.suspended— billing suspension (§36). Whentrue, Shuttle scales the Deployment to 0 and removes any HPA, overridingautoscalingand thereplicasseed — so suspension halts autoscaled services too, not just fixed-replica ones. The storedautoscalingconfig is untouched; clearingsuspendedrestores the HPA automatically. Default false.health_check— optional single check{path?, port, timeout_seconds}. Withpath, ready = HTTP GET returns 2xx; when null and the service is web, Shuttle defaults to a TCP-connect check on the first container port (never ready-at-process-start); workers get no probes. Rendered as startup/readiness/liveness per the §20.3 mapping.pre_deploy— optional{command}. When present anddeployment_idhas changed, Shuttle runs the §20.3 pre-deploy Job (new image + the environment's Var Groups) and gates the Deployment update on Job success; failure ⇒ §25.4rollout: "failed"with reasonPreDeployFailed— the previous version keeps serving. A superseding deployment deletes a running Job first.databases— the DB/cache entities for this cluster (Managed Databases). The customer-chosentierdeterminesmodeserver-side (Starbase decides; Shuttle never infers):containerized(Mininova) entries carry image/resources/volume and Shuttle renders Deployment + Service + PVC + system Secret;dedicated(Micronova+) entries carry only the endpoint +secret_ref— Shuttle renders the system Secret and the per-environment egress allowlist (FR-075), never the instance itself. Database endpoints are region-local (FR-078): they appear only in payloads of clusters in the instance's region.allow_cross_env_networking— per-project opt-in (FR-079); Shuttle renders the relaxed (same-namespace) NetworkPolicy scope when true (§20.4). Default false.- Conditional GET — Shuttle sends
If-None-Matchwith the last appliedsequence; Starbase answers304when unchanged, so no-op ticks transfer nothing. (Long-polling on this endpoint is a seeded upgrade, §39.3 #55.) - Tombstones — deletion is explicit: an entity being deleted appears with
"deleted": trueuntil Shuttle confirms via §25.4 (deletion_confirmed), plus a 7-day Starbase-side cleanup backstop for permanently offline clusters. Absence is never a deletion instruction (FR-073). force_gc— operator-set override that bypasses the §20.3 delete breaker for one tick's tombstones; auto-expires on the Starbase side and is audit-logged once §39.2 #19 lands.
§25.2 Post Snapshot Batch¶
{
"snapshots": [
{
"snapshot_id": "hash(project_id+service_id+pod_id+ts)",
"workspace_id": "...",
"project_id": "...",
"environment": "production",
"service_id": "...",
"pod_id": "...",
"tier": "nova",
"phase": "Running",
"snapshot_timestamp": "2026-04-08T12:34:56Z"
}
]
}
§25.3 Post Capacity Report¶
{
"cluster_id": "...",
"current_pod_count": 423,
"soft_limit": 1000,
"timestamp": "2026-04-08T12:34:56Z"
}
§25.4 Post Status Report¶
Load-bearing — the upward half of the loop
Desired state flows down (§25.1); observations flow up here. Starbase derives the
deploying → deployed | failed build-state transitions
(§16.12) from these reports
(FR-072 /
SC-019), and tombstone acks + orphan reports +
breaker state ride along (FR-073).
Status is derived from the Informer cache — a dropped POST costs nothing; the next tick
regenerates it.
{
"sequence_observed": 1234,
"reported_at": "2026-07-02T12:34:56Z",
"services": [
{
"workspace_id": "...", "project_id": "...", "environment": "production", "service_id": "...",
"deployment_id": "dep_...",
"observed_image": "registry.digitalocean.com/starform/app@sha256:...",
"rollout": "failed",
"replicas": { "desired": 3, "updated": 1, "ready": 2, "available": 2 },
"reason": "CrashLoopBackOff",
"message": "new-template pod restarted 4 times in 2m; 2 previous-version pods still serving",
"first_ready_at": null,
"deletion_confirmed": false
}
],
"databases": [
{ "database_id": "...", "environment": "production", "ready": true, "reason": null }
],
"orphans": [ { "kind": "Deployment", "namespace": "proj-1f2e3d4c5b6a79880011223344556677", "name": "..." } ],
"gc_breaker": { "tripped": false, "requested_deletes": 0, "threshold": 10 }
}
sequence_observed— the highest desired-statesequencethis cluster has applied. Beyond the304conditional-GET check, it is the fleet-held high-water mark Starbase reads to re-derive its per-clustersequencecounter after a control-plane Postgres restore (§25.1) — so the fleet, not Postgres, is the source of truth for what has been applied.rollout∈progressing | complete | failed, derived with the standard rollout-status logic:completewhenobservedGeneration == generationand updated == ready ==spec.replicas;failedon the DeploymentProgressingcondition reasonProgressDeadlineExceeded, on a failed/timed-out pre-deploy Job (reasonPreDeployFailed, §20.3), or ≥1 new-template pod waiting withCrashLoopBackOff/ImagePullBackOff/CreateContainerConfigError; otherwiseprogressing. (Shuttle setsprogressDeadlineSeconds: 600on every Deployment it renders — §20.3.)first_ready_at— earliest readinesslastTransitionTimeamong ready pods whose image matches the desired digest; stateless across Shuttle restarts (read from pod conditions, not memory). This is the §16.12deployedtrigger: ≥1 healthy pod on the new image.replicas.desiredreads the live Deployment spec — HPA-mutated when autoscaling is enabled (§20.3) — so status stays truthful under autoscaling.deletion_confirmed: trueacks a tombstone (§25.1); Starbase then drops it. Re-seeing a tombstone for an already-gone entity re-acks as a no-op.databases— readiness of containerized (Mininova) instances, derived from their pods like service status; dedicated instances are Starbase-side (Worker job state) and never appear here.orphans— managed-labeled resources in the cluster with no corresponding payload entry. Shuttle never deletes them (§20.3); it reports them and a platform alert fires.- Delivery — the Status Runnable (§19.3) POSTs only when the batch's content hash changed, plus an unconditional resend every 5 minutes so Starbase can detect staleness; retry 3× then drop (regenerated next tick).
Starbase handling (API binary, §13):
- Upsert last-known status per service; flip
deploying → deployedwhenfirst_ready_atis set withobserved_imagematching the desired digest,deploying → failedonrollout: "failed"(previous version stays live, §16.12). - Stardeck's Deploy stage and service-health chips read this state.
- Customer-facing only — the platform plane (Grafana Cloud, §35.5) keeps watching Shuttle's own health, not customer apps.
Cross-references
The HTTP client that speaks this contract → §19.3 · the
desired-state model behind the §25.1 payload → §32
· the snapshot fields' identity tuple → §24.1 · the resources the
§25.1 services array drives → §20.2 · snapshot
delivery semantics & loss bounds → §19.4 · the Status
Runnable that speaks §25.4 → §19.3 · tombstone GC + the
delete breaker → §20.3 · sequence-regression and
breaker failure rows → §27.