Final integration piece for OpenovaFlow infrastructure path —
catalyst-api proxy + cloud-init substitution for SOVEREIGN_DEPLOYMENT_ID
+ SOVEREIGN_REGION_KEY, so bp-openova-flow-emitter (slot 57) emits
distinct region tags on every FlowNode and the snapshot returns 2× per
HR on a multi-region Sovereign.
Builds on PR #1389 (TS core + canvas packages on disk), PR #1390 (Go
server + flux adapter + bootstrap-kit slots 56/57), PR #1394 (catalyst-
ui temporary revert until npm workspaces land), PR #1395 (chart no-op).
## Scope vs original Agent #3 brief
The brief planned a 4-section PR (proxy + cloud-init + FlowPage rewire +
runbook). Section 3 (catalyst-ui rewire of @openova/flow-*) is deferred:
PR #1394 reverted Agent #1's UI wiring because the Docker UI build has
no node_modules for the cross-workspace canvas source. Founder note on
#1394: "Agent #3 (or a follow-up) will re-wire them properly once npm
workspaces are configured at repo root."
This PR ships the infrastructure half (proxy + cloud-init + runbook).
The canvas-side rewire is a separate follow-up PR that needs npm
workspaces, not surgical edits to FlowPage.
## What ships
### 1. catalyst-api proxy /api/v1/flows/{deploymentId}/{snapshot,stream,events}
products/catalyst/bootstrap/api/internal/handler/openova_flow_proxy.go:
- GET /snapshot — JSON pass-through, headers + status forwarded
- GET /stream — unbuffered SSE pass-through using http.Flusher (NOT
httputil.ReverseProxy; that buffers and breaks text/event-stream)
- POST /events — body forwarded byte-for-byte
- Upstream URL from env OPENOVA_FLOW_SERVER_URL (default Sovereign
in-cluster Service DNS)
Routes registered in cmd/api/main.go inside the auth-gated chi.Group.
11 table-driven tests cover snapshot/events/stream pass-through, upstream
404/400/unreachable propagation, empty-deploymentId guard, SSE frames
arrive AS EMITTED, and env-default fallback.
### 2. Cloud-init threads SOVEREIGN_DEPLOYMENT_ID + SOVEREIGN_REGION_KEY
- infra/hetzner/cloudinit-control-plane.tftpl — two new postBuild.
substitute keys alongside SOVEREIGN_FQDN/SOVEREIGN_LB_IP
- infra/hetzner/main.tf — primary CP renders var.region as region key;
secondary CP renders each.key (e.g. "hel1-1") from for_each over
local.secondary_regions
- infra/hetzner/variables.tf — new sovereign_deployment_id var (string,
default "" for tofu mocks)
- provisioner.go writeTfvars — writes vars["sovereign_deployment_id"]
= req.DeploymentID
- bootstrap-kit slot 57 — swap placeholder ${SOVEREIGN_FQDN} / literal
"primary" for the new ${SOVEREIGN_DEPLOYMENT_ID} / ${SOVEREIGN_REGION_KEY}
envsubst keys
### 3. Deployment record flag
handler/deployments.go State() — emits `openovaFlowEnabled: true` on
every deployment. The catalyst-ui rewire (follow-up PR) will read this
to enable the openova-flow-server adapter; legacy provisions without
the flag will keep the bridge once the rewire lands.
### 4. Verification runbook
docs/runbooks/openova-flow-multi-region-verify.md — prov #34 POST body
(multi-region cpx42 fsn1+hel1, qaTestEnabled=true,
sovereignFQDN=omantel.biz), step-by-step kubectl/curl gates, visual
canvas checks (gated on the follow-up UI rewire), and a failure-class
triage table.
## Canonical-seam citations
1. SSE pattern — products/catalyst/bootstrap/api/internal/handler/
deployments.go:1244-1287 (StreamLogs): identical Content-Type +
Cache-Control + X-Accel-Buffering header set; identical
http.Flusher.Flush() after each write; identical r.Context().Done()
cancel path.
2. postBuild.substitute pattern — infra/hetzner/cloudinit-control-plane.tftpl:884-893
(SOVEREIGN_FQDN + SOVEREIGN_LB_IP): same indentation, same KEY: ${var}
form, dual emission at primary + secondary CP for_each in main.tf.
## Verification
```
$ go build ./...
(clean)
$ go vet ./...
(clean)
$ go test ./internal/handler/ -run TestFlowProxy -count=1 -race
ok github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/handler 1.410s
$ go test ./internal/provisioner/... -count=1
ok github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner 0.025s
```
3 pre-existing test failures (TestHandleWhoami_NoRBACOmitsFields,
TestHandleWhoami_PinSessionRBACClaims,
TestUnstructuredToUserAccess_NilApplicationsBecomesEmpty) reproduce on
main HEAD without this PR — unrelated baseline state.
Co-authored-by: hatiyildiz <269457768+hatiyildiz@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>