diff --git a/docs/omantel-handover-wbs.md b/docs/omantel-handover-wbs.md index b01f2a62..cbe5bec7 100644 --- a/docs/omantel-handover-wbs.md +++ b/docs/omantel-handover-wbs.md @@ -240,8 +240,7 @@ flowchart TB T456 --> DOD class PH0,PH1,PH2,PH3,PH4,PH5,PH6,PH7,PH8,SCAF phase - class T316,T317,T319,T327,T331,T338,T370,T371,T373,T374,T375,T376,T377,T378,T379,T380,T381,T382,T383,T384,T385,T387,T392,T425,T428,T429,T430,T438 done - class T453 wip + class T316,T317,T319,T327,T331,T338,T370,T371,T373,T374,T375,T376,T377,T378,T379,T380,T381,T382,T383,T384,T385,T387,T392,T425,T428,T429,T430,T438,T453 done class T454,T455,T456 blocked %% Clickable ticket numbers @@ -285,7 +284,7 @@ flowchart TB **Honest read:** - Phases 0-7 are **green at chart-level** — code is shipped, individual blueprints smoke-installed on contabo. Reconcile-chain status across all 23 is **❓ unknown** (see §2 right column). -- Phase 7 is **incomplete** — #453 (#317↔#319 contract reconciliation) is a known bug discovered during #319 implementation. The post-handover redirect is broken until #453 lands. +- Phase 7 is **complete at chart-level** — #453 (#317↔#319 contract reconciliation) merged: handover-finalisation now preserves the slim record so the post-handover redirect from console.openova.io/sovereign/ → console. fires correctly. Live verification still pending Phase 8b (#455). - Phase 8 is **the actual handover gate**. Three sub-tickets: - **#454 (8a)**: live provision dry run on `test.omani.works` — surfaces every reconcile-chain bug - **#455 (8b)**: handover + decommission cycle on `test.omani.works` @@ -450,7 +449,7 @@ If founder wants to amend ADR-0001 with §13 formalised (S3 vs SeaweedFS rule), | #323 | 🟢 done (epic #320 IAM — POST-OMANTEL scope; here for cross-reference only) | [#452](https://github.com/openova-io/openova/pull/452) merged `783f7713` | UserAccess REST + UI editor | | #324 | ⏸ parked (epic #320 IAM — POST-OMANTEL; agent stopped 2026-05-01 per scope rewrite) | — | — | | #325 | ⏸ parked (epic #320 IAM — POST-OMANTEL; agent stopped 2026-05-01 per scope rewrite) | — | — | -| **#453** | 🟡 **in flight — Phase 7 cleanup; #317↔#319 contract reconciliation. #317's `FinaliseHandover` deletes the deployment record entirely, so #319's `AdoptedAt` field is dormant and the post-handover redirect at `console.openova.io/sovereign/` is broken. Fix: preserve the slim record with `AdoptedAt`+`SovereignFQDN` populated; zero out operational fields. BLOCKS Phase 8b.** | (PR pending) | gates omantel handover loop | +| **#453** | 🟢 done — handover-finalisation now preserves slim record (id, sovereignFQDN, createdAt, createdBy, AdoptedAt) instead of deleting it; operational fields (tofuState, kubeconfig, Result, error, credentials) zeroed; redirect contract from #319 PR #451 now actually fires post-handover. New `Deployment.SlimForHandover(adoptedAt)` seam swaps the in-memory + on-disk record from `status: ready` → `status: adopted`. Tests: `TestFinaliseHandover_PreservesRedirectContract` (drives FinaliseHandover then GET /api/v1/deployments/{id}, asserts adoptedAt + sovereignFQDN survive on JSON response and on disk via store.Load round-trip) + `TestSlimForHandover` (table-driven full-record/minimal-record transform; asserts audit fields kept, redirect field set, operational fields/credentials zeroed, channels closed) + `TestSlimForHandover_StoreRecordRoundTrip` (JSON encode/decode survives Pod restart). All `go test ./...` green; `bash scripts/check-vendor-coupling.sh` exit 0 (HARD-FAIL mode). | (this PR) | catalyst-api `internal/handler/{handover.go,deployments.go,handover_test.go}` | | **#454** | 🔒 **blocked — Phase 8a · live provision dry run on `test.omani.works`. Operator-driven (real Hetzner credit). Provisions a Sovereign via wizard; watches all 23 bp-* HelmReleases reach Ready=True; surfaces every reconcile-chain bug as a follow-up ticket. THIS is the integration-test gate for the chart-released → integration-tested → DoD-met progression.** | (depends on #453) | gates Phase 8b | | **#455** | 🔒 **blocked — Phase 8b · handover + decommission cycle on `test.omani.works`. Runs handover-finalisation, verifies redirect (post-#453), runs customer-side decommission, verifies wipe + re-provision idempotency.** | (depends on #454) | gates Phase 8c | | **#456** | 🔒 **blocked — Phase 8c · production `omantel.omani.works` run. DoD-met when this closes cleanly: omantel runs self-sufficient on Hetzner, killing contabo for 5 min has zero effect, customer admin kubectl works via Keycloak.** | (depends on #455) | THE DoD GATE | @@ -461,7 +460,7 @@ These are bugs we already know exist but cannot fix until Phase 8a exposes them | # | Gap | Likely surfaces in | Fix vector | |---|---|---|---| -| R1 | **#317↔#319 contract bug** — handover-finalisation deletes the deployment record; redirect can never read `AdoptedAt` | Phase 8b (redirect 404s instead of 301-ing) | [#453](https://github.com/openova-io/openova/issues/453) — IN FLIGHT, blocks Phase 8b | +| R1 | **#317↔#319 contract bug** — handover-finalisation deletes the deployment record; redirect can never read `AdoptedAt` | Phase 8b (redirect 404s instead of 301-ing) | [#453](https://github.com/openova-io/openova/issues/453) — DONE; live verification pending Phase 8b | | R2 | **Crossplane `provider-hcloud` Healthy=True never observed** | Phase 8a — Provider may fail to install if RBAC / image pull issues | Surfaces as a Phase 8a sub-bug; fix in same iteration | | R3 | **Cilium Gateway HTTPRoute admission untested** — bp-catalyst-platform smoke skipped HTTPRoute on contabo (no Gateway present) | Phase 8a (console.test.omani.works returns 404 / 502) | Likely a Gateway-class or sectionName mismatch; fix in same iteration | | R4 | **bootstrap-kit reconcile order under load** — never run all 23 HRs together with real `dependsOn` chain | Phase 8a (some HRs stuck in dependency-wait or InstallFailed) | Iterate on chart `dependsOn` + `disableWait` flags | @@ -578,7 +577,7 @@ Per founder corrective 2026-05-01: **stop dispatching capacity-fill agents on po **The only work that matters between now and DoD:** -1. **#453** (in flight) — fix #317↔#319 contract so the redirect works +1. **#453** ✅ done — #317↔#319 contract reconciled; FinaliseHandover preserves slim record so the redirect works 2. **#454** Phase 8a — operator runs the live test.omani.works provision (real Hetzner credit) 3. **Iterate on whatever 8a surfaces** (expect 3-5 bugs from §9a Risk register) 4. **#455** Phase 8b — handover + decommission cycle on test.omani.works diff --git a/products/catalyst/bootstrap/api/internal/handler/deployments.go b/products/catalyst/bootstrap/api/internal/handler/deployments.go index ced37dd8..0d81d03f 100644 --- a/products/catalyst/bootstrap/api/internal/handler/deployments.go +++ b/products/catalyst/bootstrap/api/internal/handler/deployments.go @@ -164,6 +164,67 @@ type Deployment struct { liveWatcher *helmwatch.Watcher } +// SlimForHandover returns a copy of the receiver retaining ONLY the +// fields a post-handover record needs: +// +// - id, sovereignFQDN, createdAt (StartedAt), createdBy (Request.OrgEmail) +// for audit; +// - AdoptedAt (= adoptedAt) for the redirect contract consumed by +// UI/router.tsx::maybeRedirectToCustomerConsole (issue #319). +// +// All operational/runtime state (Result, TofuState, kubeconfig path on +// Result.KubeconfigPath, PDM bearer hash, pdm reservation token, +// component states, error string, finishedAt) is zeroed. The slim +// shape is what is persisted by FinaliseHandover (issue #453) instead +// of deleting the record entirely — deletion would 404 the redirect +// fetch and the customer console would never be reached. +// +// Per docs/INVIOLABLE-PRINCIPLES.md §0 minimum-retention: Catalyst-Zero +// keeps ONLY the structural breadcrumb required for the redirect to +// fire; everything else lives on the Sovereign side post-handover. +// +// The returned Deployment carries closed eventsCh + done channels so +// any leftover SSE consumer attempts on the slim record terminate +// immediately (matching the fromRecord rehydration shape). +func (d *Deployment) SlimForHandover(adoptedAt time.Time) *Deployment { + closedCh := make(chan provisioner.Event) + closedDone := make(chan struct{}) + close(closedCh) + close(closedDone) + + stamped := adoptedAt.UTC() + + d.mu.Lock() + id := d.ID + startedAt := d.StartedAt + orgName := d.Request.OrgName + orgEmail := d.Request.OrgEmail + sovereignFQDN := d.Request.SovereignFQDN + d.mu.Unlock() + + return &Deployment{ + ID: id, + Status: "adopted", + // Request retains ONLY the audit-minimum context (orgName, + // orgEmail, sovereignFQDN). All credentials + sizing + region + // detail are intentionally zeroed — they belong to the + // Sovereign side now. + Request: provisioner.Request{ + OrgName: orgName, + OrgEmail: orgEmail, + SovereignFQDN: sovereignFQDN, + }, + StartedAt: startedAt, + // FinishedAt is also "adoptedAt" semantically — set to the same + // stamp so the wizard's terminal-state surfaces show a coherent + // timeline. + FinishedAt: stamped, + AdoptedAt: &stamped, + eventsCh: closedCh, + done: closedDone, + } +} + // recordEvent appends ev to the durable history under the mutex, evicting // the oldest entry when the buffer is at cap. Returns the event back so // callers can fluently send it down the live channel. diff --git a/products/catalyst/bootstrap/api/internal/handler/handover.go b/products/catalyst/bootstrap/api/internal/handler/handover.go index 8be8c8f4..4bc45bce 100644 --- a/products/catalyst/bootstrap/api/internal/handler/handover.go +++ b/products/catalyst/bootstrap/api/internal/handler/handover.go @@ -22,7 +22,11 @@ // `secret/catalyst/tofu-phase0-archive`. On 200 OK from the new // Sovereign, delete `/var/lib/catalyst/tofu//` // locally. -// 4. Delete the kubeconfig file on the PVC + the deployment record JSON. +// 4. Delete the kubeconfig file on the PVC; transform the deployment +// record JSON in-place to a slim post-handover state (id + +// sovereignFQDN + audit timestamps + AdoptedAt; operational fields +// zeroed) — the slim record is what fires the post-handover +// console redirect (issue #319 PR #451 contract; #453 reconciles). // // Step 4's "Hetzner token rotate" mentioned in the issue body is deferred // to Crossplane Provider rotation (per #425, the canonical Day-2 IaC @@ -54,9 +58,12 @@ // - OpenBao writes go through internal/openbao.Client (canonical seam). // - Tofu workdir cleanup uses os.RemoveAll on the existing // provisioner.Provisioner.WorkDir/ path. -// - Deployment record + kubeconfig deletion uses the existing -// store.Store.Delete + os.Remove on h.kubeconfigsDir paths (same +// - Kubeconfig deletion uses os.Remove on h.kubeconfigsDir paths (same // paths the wipe.go finalisation uses). +// - Deployment record post-handover is transformed to slim shape via +// Deployment.SlimForHandover + persisted with the existing +// store.Store.Save seam; the older Delete-then-redirect-404 path +// was the bug fixed by #453. package handler import ( @@ -227,26 +234,39 @@ func (h *Handler) FinaliseHandover(w http.ResponseWriter, r *http.Request) { } } - // Step 4b — delete the on-disk deployment record. After this, GET - // /api/v1/deployments/{id} returns 404 (the in-memory entry is also - // dropped via h.deployments.Delete after a brief grace window so - // open SSE consumers can flush). + // Step 4b — slim the deployment record instead of deleting (#453, + // reconciles #317↔#319 contract). + // + // Issue #319 (PR #451) shipped the post-handover redirect: when the + // browser hits console.openova.io/sovereign/, the + // provisionRoute.beforeLoad fetches /api/v1/deployments/, reads + // `adoptedAt` + `sovereignFQDN`, and 301s to https://console./. + // Deleting the record (the previous behaviour) made that fetch 404 + // and the redirect could never fire — broken-by-design contract. + // + // The fix is to retain a slim post-handover record: + // - Audit minimum: id, sovereignFQDN, orgName, orgEmail, startedAt + // - Redirect contract: adoptedAt = now() + // All operational fields (Result/tofuState, kubeconfig hash, PDM + // reservation token, error, component states) are zeroed via + // SlimForHandover. The on-disk record after this Save is the + // structural breadcrumb required by §0 minimum-retention; nothing + // more. + adopted := dep.SlimForHandover(now) + h.deployments.Store(id, adopted) if h.store != nil { - if err := h.store.Delete(id); err != nil { - resp.Errors = append(resp.Errors, "delete deployment record: "+err.Error()) + if err := h.store.Save(adopted.toRecord()); err != nil { + resp.Errors = append(resp.Errors, "persist adopted record: "+err.Error()) } else { resp.Steps.DeploymentRecordRemoved = true } + } else { + // In-memory-only handler (test path) — slim shape was swapped + // in via Store above, that satisfies the redirect contract on + // its own. + resp.Steps.DeploymentRecordRemoved = true } - // In-memory cleanup runs in a goroutine so any open SSE stream - // receives the `handover` event before the deployment id 404s. The - // 60s grace mirrors the wipe.go pattern. - go func() { - time.Sleep(60 * time.Second) - h.deployments.Delete(id) - }() - writeJSON(w, http.StatusOK, resp) } diff --git a/products/catalyst/bootstrap/api/internal/handler/handover_test.go b/products/catalyst/bootstrap/api/internal/handler/handover_test.go index e9aa426d..3f6ba84c 100644 --- a/products/catalyst/bootstrap/api/internal/handler/handover_test.go +++ b/products/catalyst/bootstrap/api/internal/handler/handover_test.go @@ -14,11 +14,13 @@ import ( "strings" "sync" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/openbao" "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner" + "github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/store" ) // fakeReceiver simulates the new Sovereign's /api/v1/handover/tofu-archive @@ -211,6 +213,39 @@ func TestFinaliseHandover_FullFlow(t *testing.T) { if _, err := os.Stat(kcPath); !os.IsNotExist(err) { t.Errorf("kubeconfig not cleaned: %v", err) } + + // Slim-record contract (#453): the deployment must NOT be deleted. + // Audit-minimum + redirect-essentials must remain on the in-memory + // row so a follow-up GET /api/v1/deployments/{id} hands the UI the + // `adoptedAt` + `sovereignFQDN` it needs to fire the redirect from + // console.openova.io/sovereign/ → console.. + val, ok := h.deployments.Load("dep-full") + if !ok { + t.Fatalf("deployment record was deleted post-handover; #453 contract requires slim retention") + } + got := val.(*Deployment) + if got.AdoptedAt == nil { + t.Errorf("AdoptedAt nil after FinaliseHandover; redirect contract needs non-nil timestamp") + } + if got.Request.SovereignFQDN != "tenant-y.omani.works" { + t.Errorf("SovereignFQDN dropped from slim record: %q", got.Request.SovereignFQDN) + } + if got.ID != "dep-full" { + t.Errorf("ID drifted on slim record: %q", got.ID) + } + // Operational fields must be zeroed. + if got.Result != nil { + t.Errorf("Result not zeroed on slim record: %+v", got.Result) + } + if got.Error != "" { + t.Errorf("Error not zeroed on slim record: %q", got.Error) + } + if got.Request.HetznerToken != "" || got.Request.DynadotAPIKey != "" { + t.Errorf("credential fields leaked into slim record: %+v", got.Request) + } + if got.Status != "adopted" { + t.Errorf("Status not transitioned to adopted: %q", got.Status) + } } func TestFinaliseHandover_ReceiverFailureKeepsLocalState(t *testing.T) { @@ -428,6 +463,261 @@ func keysOf[K comparable, V any](m map[K]V) []K { return out } +// TestFinaliseHandover_PreservesRedirectContract is the explicit guard +// against the regression #453 fixed: handover-finalisation MUST leave a +// reachable deployment row whose JSON shape carries `adoptedAt` + +// `sovereignFQDN`, because that is what the UI router's beforeLoad +// fetches to fire the post-handover redirect (issue #319 PR #451). +// +// We drive the same FinaliseHandover handler used in production, then +// hit GET /api/v1/deployments/{id} (the canonical handler the UI +// fetches) and assert the JSON response contains the redirect +// contract. Anything else (404, missing adoptedAt, missing +// sovereignFQDN) breaks the redirect. +func TestFinaliseHandover_PreservesRedirectContract(t *testing.T) { + dir := t.TempDir() + st, err := store.New(dir) + if err != nil { + t.Fatalf("store.New: %v", err) + } + h := newTestHandler(t) + h.store = st + + dep := seedDeployment(t, h, "dep-redir", "tenant-r.omani.works") + dep.Request.OrgEmail = "ops@tenant-r.example" + dep.Request.OrgName = "Tenant R" + // Seed an initial Save so on-disk state exists pre-handover. + h.persistDeployment(dep) + + // Wire a fake receiver so the Tofu archive POST succeeds. + recvr := &fakeReceiver{} + srv := httptest.NewServer(recvr.handler()) + defer srv.Close() + h.SetHandoverTargetURL(srv.URL) + h.SetHandoverHTTPClient(srv.Client()) + + r := chi.NewRouter() + r.Post("/api/v1/handover/finalise/{id}", h.FinaliseHandover) + r.Get("/api/v1/deployments/{id}", h.GetDeployment) + + // Drive the finalise. + req := httptest.NewRequest(http.MethodPost, "/api/v1/handover/finalise/dep-redir", strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("finalise: status=%d body=%s", rec.Code, rec.Body.String()) + } + + // GET /api/v1/deployments/{id} — the path the UI router beforeLoad + // fetches. Per #319 PR #451 it expects {adoptedAt, sovereignFQDN}. + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/dep-redir", nil) + getRec := httptest.NewRecorder() + r.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("GET deployment after handover: status=%d body=%s — record was deleted! redirect cannot fire", getRec.Code, getRec.Body.String()) + } + + var body map[string]any + if err := json.Unmarshal(getRec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + adoptedAt, ok := body["adoptedAt"].(string) + if !ok || adoptedAt == "" { + t.Fatalf("adoptedAt missing/empty in GET response: %v", body) + } + if _, err := time.Parse(time.RFC3339, adoptedAt); err != nil { + t.Errorf("adoptedAt not RFC3339: %q (%v)", adoptedAt, err) + } + if got, _ := body["sovereignFQDN"].(string); got != "tenant-r.omani.works" { + t.Errorf("sovereignFQDN missing or wrong: %v", body) + } + + // On-disk record must round-trip through the store with AdoptedAt set. + rec2, err := st.Load("dep-redir") + if err != nil { + t.Fatalf("store.Load post-handover: %v — on-disk record missing breaks redirect after Pod restart", err) + } + if rec2.AdoptedAt == nil { + t.Errorf("on-disk record AdoptedAt nil") + } + if rec2.Request.SovereignFQDN != "tenant-r.omani.works" { + t.Errorf("on-disk record SovereignFQDN: %q", rec2.Request.SovereignFQDN) + } + // Operational fields must be absent on disk. + if rec2.Result != nil { + t.Errorf("on-disk Result not zeroed: %+v", rec2.Result) + } + if rec2.Request.HetznerToken != "" { + t.Errorf("on-disk credentials leaked: %q", rec2.Request.HetznerToken) + } +} + +// TestSlimForHandover is a unit-level cover for the in-memory record +// transform invoked by FinaliseHandover. We assert all four classes of +// behaviour: audit fields kept, redirect field set, operational fields +// zeroed, channels closed. +func TestSlimForHandover(t *testing.T) { + finished := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + earlier := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC) + + cases := []struct { + name string + in *Deployment + }{ + { + name: "full-record", + in: &Deployment{ + ID: "dep-1", + Status: "ready", + StartedAt: earlier, + Request: provisioner.Request{ + OrgName: "Acme", + OrgEmail: "ops@acme.io", + SovereignFQDN: "k8s.acme.io", + HetznerToken: "secret-do-not-leak", + DynadotAPIKey: "secret-too", + }, + Result: &provisioner.Result{ + LoadBalancerIP: "1.2.3.4", + KubeconfigPath: "/tmp/kc", + }, + Error: "", + kubeconfigBearerHash: "deadbeef", + pdmReservationToken: "rt", + pdmPoolDomain: "omani.works", + pdmSubdomain: "acme", + eventsCh: make(chan provisioner.Event, 4), + done: make(chan struct{}), + }, + }, + { + name: "minimal-record", + in: &Deployment{ + ID: "dep-2", + Status: "phase1-watching", + StartedAt: earlier, + Request: provisioner.Request{ + SovereignFQDN: "byo.example.com", + }, + eventsCh: make(chan provisioner.Event, 1), + done: make(chan struct{}), + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := tc.in.SlimForHandover(finished) + if out == nil { + t.Fatal("SlimForHandover returned nil") + } + // Audit-minimum kept. + if out.ID != tc.in.ID { + t.Errorf("ID dropped: in=%q out=%q", tc.in.ID, out.ID) + } + if out.Request.SovereignFQDN != tc.in.Request.SovereignFQDN { + t.Errorf("SovereignFQDN dropped: in=%q out=%q", tc.in.Request.SovereignFQDN, out.Request.SovereignFQDN) + } + if !out.StartedAt.Equal(tc.in.StartedAt) { + t.Errorf("StartedAt dropped: in=%v out=%v", tc.in.StartedAt, out.StartedAt) + } + if out.Request.OrgName != tc.in.Request.OrgName { + t.Errorf("OrgName dropped: in=%q out=%q", tc.in.Request.OrgName, out.Request.OrgName) + } + if out.Request.OrgEmail != tc.in.Request.OrgEmail { + t.Errorf("OrgEmail (createdBy) dropped: in=%q out=%q", tc.in.Request.OrgEmail, out.Request.OrgEmail) + } + // Redirect contract. + if out.AdoptedAt == nil { + t.Errorf("AdoptedAt nil") + } else if !out.AdoptedAt.Equal(finished) { + t.Errorf("AdoptedAt: got=%v want=%v", *out.AdoptedAt, finished) + } + // Status transitioned. + if out.Status != "adopted" { + t.Errorf("Status: got=%q want=adopted", out.Status) + } + // Operational fields zeroed. + if out.Result != nil { + t.Errorf("Result not zeroed: %+v", out.Result) + } + if out.Error != "" { + t.Errorf("Error not zeroed: %q", out.Error) + } + if out.Request.HetznerToken != "" { + t.Errorf("HetznerToken leaked: %q", out.Request.HetznerToken) + } + if out.Request.DynadotAPIKey != "" { + t.Errorf("DynadotAPIKey leaked: %q", out.Request.DynadotAPIKey) + } + if out.kubeconfigBearerHash != "" { + t.Errorf("kubeconfigBearerHash leaked: %q", out.kubeconfigBearerHash) + } + if out.pdmReservationToken != "" { + t.Errorf("pdmReservationToken leaked: %q", out.pdmReservationToken) + } + // Channels closed (any consumer terminates immediately). + select { + case _, open := <-out.eventsCh: + if open { + t.Errorf("eventsCh not closed") + } + default: + t.Errorf("eventsCh blocked (should be closed)") + } + select { + case <-out.done: + // closed — good + default: + t.Errorf("done not closed") + } + }) + } +} + +// TestSlimForHandover_StoreRecordRoundTrip verifies the slim record +// serialised through store.Record JSON and decoded back retains the +// AdoptedAt + SovereignFQDN contract. This is the cross-restart guard: +// after a Pod roll, the rehydrated row must still serve the redirect. +func TestSlimForHandover_StoreRecordRoundTrip(t *testing.T) { + finished := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + original := &Deployment{ + ID: "dep-rt", + Status: "ready", + StartedAt: time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC), + Request: provisioner.Request{ + OrgName: "Tenant", + OrgEmail: "ops@tenant.io", + SovereignFQDN: "tenant.omani.works", + HetznerToken: "secret-must-not-survive", + }, + } + slim := original.SlimForHandover(finished) + rec := slim.toRecord() + + raw, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded store.Record + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.AdoptedAt == nil || !decoded.AdoptedAt.Equal(finished) { + t.Errorf("AdoptedAt did not round-trip: %v", decoded.AdoptedAt) + } + if decoded.Request.SovereignFQDN != "tenant.omani.works" { + t.Errorf("SovereignFQDN did not round-trip: %q", decoded.Request.SovereignFQDN) + } + // Credentials must be redacted on disk (existing Redact contract). + if decoded.Request.HetznerToken != "" { + // Slim record sets HetznerToken to "" before redaction; redaction + // only marks present fields. So an empty token must round-trip + // as empty (not ). + t.Errorf("HetznerToken not zero on slim record after round-trip: %q", decoded.Request.HetznerToken) + } +} + // Ensure the helmwatch.Watcher.Cancel hook compiles against the existing // internal/helmwatch package without surprising the linker. var _ = context.Background