From 959eba637f00224e210b93efe3f00c6a638ddf98 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 16:35:14 +0200 Subject: [PATCH] Prepare durable isolated Vergabe pilot deployment and recovery Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- charts/vergabe-teilnahme/Chart.yaml | 2 +- .../vergabe-teilnahme/templates/_helpers.tpl | 26 +++++ .../templates/deployment.yaml | 24 +++- .../vergabe-teilnahme/templates/service.yaml | 20 +++- charts/vergabe-teilnahme/values.yaml | 19 ++- .../2026-09-11-vergabe-pilot-inventory.json | 12 ++ docs/vergabe-teilnahme-pilot.md | 96 +++++++++++++++ docs/vergabe-teilnahme.md | 6 + ...ergabe-teilnahme-pilot-values.example.yaml | 20 ++++ tests/test_vergabe_pilot_chart.py | 110 ++++++++++++++++++ .../RAPPS-WP-0014-vergabe-invited-pilot.md | 82 +++++++++++++ 11 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 docs/evidence/2026-09-11-vergabe-pilot-inventory.json create mode 100644 docs/vergabe-teilnahme-pilot.md create mode 100644 helm/vergabe-teilnahme-pilot-values.example.yaml create mode 100644 tests/test_vergabe_pilot_chart.py create mode 100644 workplans/RAPPS-WP-0014-vergabe-invited-pilot.md diff --git a/charts/vergabe-teilnahme/Chart.yaml b/charts/vergabe-teilnahme/Chart.yaml index 0cab8b8..64e7c59 100644 --- a/charts/vergabe-teilnahme/Chart.yaml +++ b/charts/vergabe-teilnahme/Chart.yaml @@ -4,7 +4,7 @@ description: | Vergabe Teilnahme — internal Django tender/bid management web app. Single-instance v1 deployment; HA and canary are deferred. type: application -version: 0.1.0 +version: 0.2.0 appVersion: "0.1.0" keywords: - django diff --git a/charts/vergabe-teilnahme/templates/_helpers.tpl b/charts/vergabe-teilnahme/templates/_helpers.tpl index d044502..22b9774 100644 --- a/charts/vergabe-teilnahme/templates/_helpers.tpl +++ b/charts/vergabe-teilnahme/templates/_helpers.tpl @@ -21,8 +21,34 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} {{- define "vergabe.image" -}} +{{- if .Values.image.digest -}} +{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" .Values.image.digest) -}} +{{- fail "image.digest must be a sha256 OCI digest" -}} +{{- end -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- if not .Values.image.tag -}} {{- fail "image.tag is required — pin it in helm/vergabe-teilnahme-values.yaml" -}} {{- end -}} {{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} {{- end -}} +{{- end -}} + +{{- define "vergabe.validatePilot" -}} +{{- if .Values.pilot.enabled -}} +{{- if ne (int .Values.replicaCount) 1 -}} +{{- fail "invited pilot requires exactly one application replica" -}} +{{- end -}} +{{- if or (not .Values.persistence.media.enabled) (not .Values.persistence.appState.enabled) -}} +{{- fail "invited pilot requires persistent media and appState" -}} +{{- end -}} +{{- if not .Values.image.digest -}} +{{- fail "invited pilot requires an immutable image.digest" -}} +{{- end -}} +{{- $mediaClaim := default (printf "%s-media" (include "vergabe.fullname" .)) .Values.persistence.media.existingClaim -}} +{{- $stateClaim := default (printf "%s-app-state" (include "vergabe.fullname" .)) .Values.persistence.appState.existingClaim -}} +{{- if eq $mediaClaim $stateClaim -}} +{{- fail "media and appState must use distinct claims; operational state must not be downloadable" -}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/charts/vergabe-teilnahme/templates/deployment.yaml b/charts/vergabe-teilnahme/templates/deployment.yaml index ce55216..9be6ed5 100644 --- a/charts/vergabe-teilnahme/templates/deployment.yaml +++ b/charts/vergabe-teilnahme/templates/deployment.yaml @@ -1,3 +1,4 @@ +{{- include "vergabe.validatePilot" . }} apiVersion: apps/v1 kind: Deployment metadata: @@ -8,10 +9,14 @@ spec: selector: matchLabels: {{- include "vergabe.selectorLabels" . | nindent 6 }} strategy: + {{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }} + type: Recreate + {{- else }} type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 + {{- end }} template: metadata: labels: {{- include "vergabe.selectorLabels" . | nindent 8 }} @@ -59,16 +64,29 @@ spec: failureThreshold: {{ .Values.probes.liveness.failureThreshold }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} - {{- if .Values.persistence.media.enabled }} + {{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }} volumeMounts: + {{- if .Values.persistence.media.enabled }} - name: media mountPath: /app/media + {{- end }} + {{- if .Values.persistence.appState.enabled }} + - name: app-state + mountPath: /app/.issue-facade + {{- end }} {{- end }} - {{- if .Values.persistence.media.enabled }} + {{- if or .Values.persistence.media.enabled .Values.persistence.appState.enabled }} volumes: + {{- if .Values.persistence.media.enabled }} - name: media persistentVolumeClaim: - claimName: {{ include "vergabe.fullname" . }}-media + claimName: {{ default (printf "%s-media" (include "vergabe.fullname" .)) .Values.persistence.media.existingClaim }} + {{- end }} + {{- if .Values.persistence.appState.enabled }} + - name: app-state + persistentVolumeClaim: + claimName: {{ default (printf "%s-app-state" (include "vergabe.fullname" .)) .Values.persistence.appState.existingClaim }} + {{- end }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/vergabe-teilnahme/templates/service.yaml b/charts/vergabe-teilnahme/templates/service.yaml index b08fb10..5797b47 100644 --- a/charts/vergabe-teilnahme/templates/service.yaml +++ b/charts/vergabe-teilnahme/templates/service.yaml @@ -11,12 +11,14 @@ spec: protocol: TCP name: http selector: {{- include "vergabe.selectorLabels" . | nindent 4 }} -{{- if .Values.persistence.media.enabled }} +{{- if and .Values.persistence.media.enabled (not .Values.persistence.media.existingClaim) }} --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: {{ include "vergabe.fullname" . }}-media + annotations: + helm.sh/resource-policy: keep labels: {{- include "vergabe.labels" . | nindent 4 }} spec: storageClassName: {{ .Values.persistence.media.storageClass }} @@ -25,3 +27,19 @@ spec: requests: storage: {{ .Values.persistence.media.size }} {{- end }} +{{- if and .Values.persistence.appState.enabled (not .Values.persistence.appState.existingClaim) }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "vergabe.fullname" . }}-app-state + annotations: + helm.sh/resource-policy: keep + labels: {{- include "vergabe.labels" . | nindent 4 }} +spec: + storageClassName: {{ .Values.persistence.appState.storageClass }} + accessModes: [{{ .Values.persistence.appState.accessMode }}] + resources: + requests: + storage: {{ .Values.persistence.appState.size }} +{{- end }} diff --git a/charts/vergabe-teilnahme/values.yaml b/charts/vergabe-teilnahme/values.yaml index 284ffa1..5e415d0 100644 --- a/charts/vergabe-teilnahme/values.yaml +++ b/charts/vergabe-teilnahme/values.yaml @@ -1,10 +1,15 @@ image: repository: forgejo.coulomb.social/coulomb/vergabe-teilnahme tag: "" # required; pinned via helm/vergabe-teilnahme-values.yaml + digest: "" # preferred; takes precedence over tag pullPolicy: IfNotPresent replicaCount: 1 # v1 is single-instance; HA is deferred (RAILIANCE-WP-0002 Notes) +# An invited company pilot requires an immutable image and both data volumes. +pilot: + enabled: false + service: type: ClusterIP port: 80 @@ -19,8 +24,7 @@ resources: memory: 1Gi # Env from the K8s Secret created out-of-band (vergabe-teilnahme-env). -# Holds SECRET_KEY + DATABASE_URL. Created by the operator with kubectl -# create secret generic vergabe-teilnahme-env --from-literal=... +# Holds SECRET_KEY + DATABASE_URL. Deliver via the admitted platform custody lane. envSecretName: vergabe-teilnahme-env # Non-secret env injected directly into the Deployment. @@ -45,14 +49,21 @@ probes: timeoutSeconds: 5 failureThreshold: 3 -# PVC for media uploads is deferred — Django MEDIA is in-pod ephemeral -# for v1. Switch to true + a storageClass once media uploads land. +# Existing installations remain opt-in. Pilot mode refuses ephemeral state. +# PVCs are retained on Helm uninstall; a verified off-host backup is still required. persistence: media: enabled: false storageClass: local-path size: 5Gi accessMode: ReadWriteOnce + existingClaim: "" + appState: + enabled: false + storageClass: local-path + size: 1Gi + accessMode: ReadWriteOnce + existingClaim: "" podSecurityContext: runAsNonRoot: true diff --git a/docs/evidence/2026-09-11-vergabe-pilot-inventory.json b/docs/evidence/2026-09-11-vergabe-pilot-inventory.json new file mode 100644 index 0000000..c46edf4 --- /dev/null +++ b/docs/evidence/2026-09-11-vergabe-pilot-inventory.json @@ -0,0 +1,12 @@ +{ + "schema": "railiance.vergabe-pilot-inventory.v1", + "observed_at": "2026-09-11T14:27:23.565171+00:00", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "historical_namespace_present": false, + "matching_deployments": [], + "query_scope": "Deployment names/images on connected cluster only; databases and other hosts not inventoried", + "live_mutations": 0, + "customer_data_read": false, + "credentials_read": false, + "claim": "Historical runbook does not establish current deployment; no data-loss conclusion." +} diff --git a/docs/vergabe-teilnahme-pilot.md b/docs/vergabe-teilnahme-pilot.md new file mode 100644 index 0000000..97726f0 --- /dev/null +++ b/docs/vergabe-teilnahme-pilot.md @@ -0,0 +1,96 @@ +# Invited company pilot + +User decision, 2026-09-11: one company, several users, manual onboarding; pricing +later. VERGABE-WP-0019 owns product acceptance. RAPPS-WP-0014 owns placement, +release and recovery. This is a review and execution contract, not a claim that +the historical deployment is still live. + +## Exact release packet + +Before native admission, record the following non-secret values in the company +binding. Company/host/data selection is pending; do not treat example values as +an assigned tenant. + +| Binding | Required evidence | +| --- | --- | +| Company and data | Named company contact, expected users, empty workspace or explicit import source/owner | +| Placement | Cluster UID, dedicated namespace/release, admitted operator and resource capacity | +| Release | Login-protected source commit, live CI result, published OCI digest, chart commit/version | +| Public route | Assigned HTTPS hostname, TLS receipt, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, edge login abuse control | +| Custody | Dedicated database/role, runtime Secret reference and platform delivery receipt; no values in the packet | +| Durable data | Media PVC and distinct issue-facade state PVC, database backup owner, storage class/capacity | +| Recovery/support | Consistent off-host backup, isolated restore and rollback receipt, cadence/retention, incident contact | + +Use `helm/vergabe-teilnahme-pilot-values.example.yaml` as the starting point. +It intentionally has no image digest and cannot render until one is supplied. +The old published image `main-fa9f082` does not contain the new access gate. +Do not promote it merely because the older source suite passed. + +```sh +helm lint charts/vergabe-teilnahme -f path/to/reviewed-company-values.yaml +helm template vergabe-teilnahme charts/vergabe-teilnahme \ + --namespace reviewed-company-namespace -f path/to/reviewed-company-values.yaml +python3 -m unittest discover -s tests -p 'test_vergabe_pilot_chart.py' +``` + +The default resource names are intentionally unchanged; company isolation is by +separate namespace/database/claims. Do not install two company releases in the +same namespace. Resolve the registry digest before setting `image.digest`. +Run the existing owner-approved server dry-run and deployment lane against the +reviewed placement after its packet is complete. + +## Persistence and recovery + +Mount media at `/app/media` and issue state at `/app/.issue-facade` on distinct +claims. Never expose the issue database through MEDIA_ROOT, ingress file serving +or an object bucket used for public assets. Production document downloads route +through Django authentication. All active users belong to this single company; +there is no per-tender ACL or shared-application tenant discriminator in v1. + +The chart uses Recreate with persistence to avoid overlapping old/new pods and +RWO attachment conflicts. UID/GID/fsGroup 999 match the runtime image. Helm +uninstall retains claims; namespace deletion or node loss can still destroy +local-path data. Retention annotations are not a backup. + +Before customer data is admitted, create a synthetic tender, lot, task, uploaded +document and issue-facade record; capture identifiers/content checksums. Replace +the application pod and verify all data and both user accounts remain usable. +For a coherent backup, quiesce application writes through the operator's +maintenance procedure, capture PostgreSQL plus media and a consistent SQLite +backup (including any required journal state), and retain the matched recovery +point off-host. Restore to an isolated database and new claims; `existingClaim` +can select those restored claims. Repeat login, document checksum and workflow +checks there. Record elapsed recovery time, backup age, image/schema revision +and operator. Never rehearse by overwriting historical `vergabe_db`. + +Inspect every release's migrations before rollback; reverting an image alone +cannot undo an incompatible schema or restore lost data. Record the previously +accepted digest and demonstrated data recovery path. Short planned interruption +is acceptable for the invited pilot once the operator and company contact agree; +HA is not claimed. + +## Manual onboarding and acceptance + +Use the existing Django administration via the admitted operator path to create +ordinary active members. Keep staff/superuser access with the designated +operator. Supply initial credentials over the existing private human channel; +never paste them into Git, State Hub, command arguments or chat. Members can +change passwords in the UI; operators handle reset and deactivation manually. +Do not run `seed_dev` on a pilot/customer database. + +Before admitting the first users, verify HTTPS login, CSRF failure behavior, +anonymous document refusal, two separate user sessions and deactivation of an +already logged-in account. Complete the tender → lot → task/document → domain +approval → submission workflow and feedback with the company contact. Customer +support contact, incident routing and backup responsibility must be recorded. +Pricing, automated invitation email, SSO and shared tenancy can be considered +later; none is implied by this initial pilot contract. + +## Current inventory — 2026-09-11 + +Read-only checks on the connected Railiance cluster found no namespace +`vergabe-teilnahme` and no Deployment whose name/image contains vergabe or +teilnahme. The older `docs/vergabe-teilnahme.md` is historical deployment and +recovery evidence, not proof of current placement. Database contents and other +hosts have not been inventoried; no data-loss conclusion follows from namespace +absence. RAPPS-WP-0014-T02 retains that exact inventory/admission step. diff --git a/docs/vergabe-teilnahme.md b/docs/vergabe-teilnahme.md index 4166b09..d85e9fa 100644 --- a/docs/vergabe-teilnahme.md +++ b/docs/vergabe-teilnahme.md @@ -1,3 +1,9 @@ +> Current status, 2026-09-11: this is historical deployment evidence. The checked +> Railiance cluster has no `vergabe-teilnahme` namespace or matching Deployment. +> Use [the invited-pilot contract](vergabe-teilnahme-pilot.md) and RAPPS-WP-0014 +> for fresh placement, release, custody and recovery. Do not execute the older +> credential/bootstrap recipes against an unverified target. + # vergabe-teilnahme — operator runbook Production deployment of the Django tender-management app, shipped diff --git a/helm/vergabe-teilnahme-pilot-values.example.yaml b/helm/vergabe-teilnahme-pilot-values.example.yaml new file mode 100644 index 0000000..1d94977 --- /dev/null +++ b/helm/vergabe-teilnahme-pilot-values.example.yaml @@ -0,0 +1,20 @@ +# Review-only template. Copy into the exact admitted company binding, fill the +# tested image digest/host/Secret, and use a dedicated namespace and database. +# This does not upgrade or reuse the historical vergabe_db installation. +pilot: + enabled: true +image: + digest: "" # Required; the old published image lacks the pilot access gate. +replicaCount: 1 +envSecretName: vergabe-pilot-env +env: + DJANGO_SETTINGS_MODULE: vergabe_teilnahme.settings.prod + ALLOWED_HOSTS: pilot.example.invalid,localhost + CSRF_TRUSTED_ORIGINS: https://pilot.example.invalid +probes: + hostHeader: pilot.example.invalid +persistence: + media: + enabled: true + appState: + enabled: true diff --git a/tests/test_vergabe_pilot_chart.py b/tests/test_vergabe_pilot_chart.py new file mode 100644 index 0000000..8d5b23f --- /dev/null +++ b/tests/test_vergabe_pilot_chart.py @@ -0,0 +1,110 @@ +"""Render-level checks for data durability and separation; no cluster mutations. + +Run with: python3 -m unittest discover -s tests -p 'test_vergabe_pilot_chart.py' +Requires Helm and PyYAML. +""" + +import subprocess +import unittest +from pathlib import Path + +import yaml + +CHART = Path(__file__).resolve().parents[1] / 'charts/vergabe-teilnahme' +DIGEST = 'sha256:' + 'a' * 64 + + +def render(values): + return subprocess.run( + ['helm', 'template', 'pilot', str(CHART), '-n', 'pilot-test', '-f', '-'], + input=yaml.safe_dump(values), text=True, capture_output=True, check=False, + ) + + +def pilot(): + return { + 'pilot': {'enabled': True}, + 'image': {'digest': DIGEST}, + 'persistence': {'media': {'enabled': True}, 'appState': {'enabled': True}}, + } + + +class PilotChartTests(unittest.TestCase): + def objects(self, values): + result = render(values) + self.assertEqual(result.returncode, 0, result.stderr) + return list(yaml.safe_load_all(result.stdout)) + + def test_existing_tag_profile_retains_original_rollout(self): + objects = self.objects({'image': {'tag': 'legacy-test'}}) + deployment = next(o for o in objects if o['kind'] == 'Deployment') + self.assertEqual(deployment['spec']['strategy']['type'], 'RollingUpdate') + self.assertFalse(any(o['kind'] == 'PersistentVolumeClaim' for o in objects)) + + def test_pilot_pins_image_and_separates_durable_state(self): + objects = self.objects(pilot()) + deployment = next(o for o in objects if o['kind'] == 'Deployment') + claims = {o['metadata']['name']: o for o in objects + if o['kind'] == 'PersistentVolumeClaim'} + self.assertEqual(len(claims), 2) + self.assertEqual(deployment['spec']['strategy'], {'type': 'Recreate'}) + self.assertEqual(deployment['spec']['replicas'], 1) + pod = deployment['spec']['template']['spec'] + app = pod['containers'][0] + self.assertTrue(app['image'].endswith('@' + DIGEST)) + self.assertEqual({v['name']: v['mountPath'] for v in app['volumeMounts']}, + {'media': '/app/media', 'app-state': '/app/.issue-facade'}) + for volume in pod['volumes']: + claim = claims[volume['persistentVolumeClaim']['claimName']] + self.assertEqual(claim['metadata']['annotations']['helm.sh/resource-policy'], 'keep') + self.assertEqual(pod['securityContext']['fsGroup'], 999) + + def test_pilot_refuses_each_ephemeral_store(self): + for store in ('media', 'appState'): + with self.subTest(store=store): + values = pilot() + values['persistence'][store]['enabled'] = False + result = render(values) + self.assertNotEqual(result.returncode, 0) + self.assertIn('persistent media and appState', result.stderr) + + def test_pilot_refuses_mutable_image_and_bad_digest(self): + for image in ({'tag': 'latest', 'digest': ''}, {'digest': 'sha256:invalid'}): + with self.subTest(image=image): + values = pilot() + values['image'] = image + self.assertNotEqual(render(values).returncode, 0) + + def test_pilot_refuses_multiple_replicas(self): + values = pilot() + values['replicaCount'] = 2 + result = render(values) + self.assertNotEqual(result.returncode, 0) + self.assertIn('exactly one', result.stderr) + + def test_restored_claims_are_reused_without_recreation(self): + values = pilot() + for store in ('media', 'appState'): + values['persistence'][store]['existingClaim'] = 'restored-' + store.lower() + objects = self.objects(values) + self.assertFalse(any(o['kind'] == 'PersistentVolumeClaim' for o in objects)) + deployment = next(o for o in objects if o['kind'] == 'Deployment') + volumes = deployment['spec']['template']['spec']['volumes'] + self.assertEqual({v['persistentVolumeClaim']['claimName'] for v in volumes}, + {'restored-media', 'restored-appstate'}) + + def test_pilot_refuses_operational_state_in_download_volume(self): + for media, state in (('shared-data', 'shared-data'), + ('vergabe-teilnahme-app-state', ''), + ('', 'vergabe-teilnahme-media')): + with self.subTest(media=media, state=state): + values = pilot() + values['persistence']['media']['existingClaim'] = media + values['persistence']['appState']['existingClaim'] = state + result = render(values) + self.assertNotEqual(result.returncode, 0) + self.assertIn('distinct claims', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md new file mode 100644 index 0000000..a21576c --- /dev/null +++ b/workplans/RAPPS-WP-0014-vergabe-invited-pilot.md @@ -0,0 +1,82 @@ +--- +id: RAPPS-WP-0014 +type: workplan +title: "Deploy and recover the first invited Vergabe company pilot" +domain: financials +repo: railiance-apps +status: active +owner: the-custodian +topic_slug: railiance +created: "2026-09-11" +updated: "2026-09-11" +related: [VERGABE-WP-0019, VERGABE-WP-0018, HFACT-WP-0001] +--- + +# Invited Vergabe pilot on Railiance + +## Prepare a durable and immutable single-company chart + +```task +id: RAPPS-WP-0014-T01 +status: done +priority: high +assignee: the-custodian +``` + +Extend the existing media PVC support with a distinct issue-facade state PVC, +optional existing claims for restore, retained claims on Helm uninstall and +Recreate rollout when persistent local state is mounted. Pilot mode requires +one replica, both durable stores, distinct claims and a valid OCI image digest. +The legacy opt-in behavior remains available for non-pilot installations. +Seven render regression tests and Helm lint pass. Chart 0.2.0 and the review-only +values template prepare the deployment; no live resources were changed. + +## Bind the exact company, release, placement, data and access + +```task +id: RAPPS-WP-0014-T02 +status: progress +priority: high +assignee: the-custodian +``` + +Consume VERGABE-WP-0019-T02's login-protected release after live CI/publication. +Record exact image/chart revision, company, user count, host/TLS, dedicated +namespace, database/role, both PVCs and admitted runtime Secret custody. Obtain +the user's empty-vs-existing-data disposition. Existing vergabe_db is not test +data. Resolve target inventory before using historical runbook names: the +checked Railiance cluster has no vergabe-teilnahme namespace or matching +Deployment on 2026-09-11. Do not infer data loss or authorization to recreate it. + +Use `docs/vergabe-teilnahme-pilot.md` for the review packet. Secret creation, +operator access and placement consume existing platform lanes; they do not +create a parallel identity framework. The public edge needs an admitted login +abuse-control policy and TLS; Django's authentication gate alone is not a rate +limiter. Keep `/media/` behind the app gate. Exact customer identity/host/data +selection is pending user input; source preparation can proceed meanwhile. + +## Demonstrate restart, isolated restore, rollback and operating ownership + +```task +id: RAPPS-WP-0014-T03 +status: wait +priority: high +assignee: the-custodian +depends_on: [RAPPS-WP-0014-T02] +blocking_reason: "Await exact placement/release/data binding before native rehearsal and admission." +``` + +With synthetic fixture data on the admitted deployment, prove login and health, +two-user collaboration, document upload/download and issue state across pod +replacement. Establish a consistent recovery point for PostgreSQL, media and +issue-facade SQLite; rehearse recovery into a separate database and separate +claims, repeat the workflow, and record recovery time and checksums without +customer content. Record backup owner/cadence/retention/off-host destination, +restore command revision and monitoring/incident owner. Retained local-path +PVCs are neither off-host backup nor node-failure protection. + +Review upgrade/migration effects and exact rollback image/data handling. A +single-writer Recreate release has a short service interruption; do not promise +HA. Return evidence to VERGABE-WP-0019-T03/T04 before customer invitations. +Native factory-produced delivery remains VERGABE-WP-0018/HFACT-WP-0001's separate +claim. Pricing and shared-app tenancy are outside this invited-pilot milestone.