diff --git a/landing-zone-configurator/app/README.de.md b/landing-zone-configurator/app/README.de.md
index 8d4f1eb..da6faf4 100644
--- a/landing-zone-configurator/app/README.de.md
+++ b/landing-zone-configurator/app/README.de.md
@@ -408,6 +408,33 @@ weder Runner-Stillstand noch Widerruf bereits ausgegebener Cloud-Credentials.
Cloud-Abgleich, Runner-Stillstand, State-Wiederherstellung und Ausfuehrungsfreigabe
bleiben separate Vorgänge mit eigenen Nachweisen und Freigaben.
+
+
+### Application nach fehlgeschlagenem Apply neu planen
+
+Migration 064 ergänzt einen separaten unveränderlichen Abschlussbeleg, unabhängig
+vom obigen Flag für beratende Prüfbelege. Der ursprüngliche Application Owner
+kann einen abschließend als `apply_failed` gemeldeten Job mit verbrauchtem,
+paketgebundenem Runner-Ticket, gebundenem Ausgabenachweis und ohne Recovery-Beleg
+prüfen und für einen neuen Plan freigeben. Die Prüfung liest mit serverseitigen
+Backend-Zugangsdaten ausschließlich den exakten Application-S3-Schlüssel,
+kontrolliert Sperren vor und nach dem Lesen, prüft `terraform_data.contract` und
+liefert Ressourcenanzahlen sowie Checkpoint-Metadaten. Rohe States oder
+Zugangsdaten werden nicht ausgegeben. Das fünf Minuten gültige verschlüsselte
+Prüftoken ist an Tenant, Sitzung, Job, Backend und beobachteten Checkpoint
+gebunden; die Bestätigung prüft alle Bindungen und den S3-Inhalt erneut.
+
+Dies protokolliert das gemeldete Befehlsende, keinen unabhängig verifizierten
+Runner-/Cloud-Abgleich. Ein frischer Plan mit normalem Refresh und die normale
+Apply-Prüfung bleiben erforderlich. Bei unklarem Abschluss bleibt der separate
+Operator-Abgleich durch den ursprünglich freigebenden PE nach Ablauf der
+Ausführungsfenster nötig. State-Schreibfehler, Recovery-Belege, Sperren oder
+abweichende States sind von diesem Weg ausgeschlossen. Der Beleg verändert
+keinen historischen Dispatch oder State und startet keine Ausführung. SQL erzwingt
+den frischen Plan bei der Artefaktabfrage und beim Anlegen eines Apply. Ein neuer
+fehlgeschlagener Apply benötigt einen eigenen Beleg. Der ursprüngliche
+Accelerator-Quellstand bleibt unverändert.
+
### Lokale Plan-/Apply-Ausführung
diff --git a/landing-zone-configurator/app/README.md b/landing-zone-configurator/app/README.md
index f7e5752..89ddbfb 100644
--- a/landing-zone-configurator/app/README.md
+++ b/landing-zone-configurator/app/README.md
@@ -408,6 +408,27 @@ neither Runner termination nor revocation of cloud credentials already issued.
Cloud reconciliation, Runner termination, state recovery and execution approval
remain separate operations with their own evidence and approvals.
+### Application replan after failed Apply
+
+Migration 064 adds a separate immutable completion-clearance receipt. It is
+independent of the advisory review flag above. The original Application Owner
+can check and clear a terminal `apply_failed` job with a consumed, package-bound
+Runner ticket, bound output evidence and no recovery record. The check reads
+only the exact Application S3 key using server-held backend credentials, checks
+locks before and after reads, verifies `terraform_data.contract`, and returns
+resource counts plus checkpoint metadata. It never returns raw State or secrets.
+The five-minute encrypted review token is bound to the tenant, session, job,
+backend and observed checkpoint; approval rechecks all bindings and S3 content.
+
+This records reported command completion, not independently verified Runner or
+cloud reconciliation. A fresh, normally refreshed Plan and normal Apply review
+remain mandatory. Unknown final completion retains the separate original-PE
+operator-attestation path with expired execution windows; State write failures,
+recovery records, locks or mismatched State cannot use this path. Clearance
+changes no historical dispatch or State and starts no execution. SQL enforces
+the fresh-Plan boundary at saved-plan lookup and Apply insertion, and a new failed
+Apply requires its own clearance. The original Accelerator pin is unchanged.
+
### Local Plan/Apply execution
diff --git a/landing-zone-configurator/app/apps/api/db/064_application_recovery_replan.sql b/landing-zone-configurator/app/apps/api/db/064_application_recovery_replan.sql
new file mode 100644
index 0000000..ded7562
--- /dev/null
+++ b/landing-zone-configurator/app/apps/api/db/064_application_recovery_replan.sql
@@ -0,0 +1,211 @@
+-- Historical completion clearance; never an Apply authorization or State mutation.
+CREATE TABLE lzc.application_recovery_replans (
+ job_id uuid PRIMARY KEY REFERENCES lzc.application_dispatches(job_id),
+ tenant_id uuid NOT NULL REFERENCES lzc.tenants(id),
+ owner_user_id uuid NOT NULL REFERENCES lzc_auth.users(id),
+ reviewed_by uuid NOT NULL REFERENCES lzc_auth.users(id),
+ review_session_id uuid NOT NULL,
+ mode text NOT NULL CHECK(mode IN ('reported-failure','operator-attested')),
+ reviewed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ binding_sha256 text NOT NULL CHECK(binding_sha256 ~ '^[a-f0-9]{64}$'),
+ backend_id uuid NOT NULL REFERENCES lzc.state_backends(id),
+ backend_sha256 text NOT NULL CHECK(backend_sha256 ~ '^[a-f0-9]{64}$'),
+ state_key text NOT NULL,
+ state_sha256 text NOT NULL CHECK(state_sha256 ~ '^[a-f0-9]{64}$'),
+ state_serial bigint NOT NULL CHECK(state_serial>=0),
+ state_version text CHECK(state_version IS NULL OR length(state_version) BETWEEN 1 AND 1024),
+ attested_runner_stopped boolean NOT NULL,
+ attested_cloud_state_compared boolean NOT NULL,
+ CHECK(attested_runner_stopped=(mode='operator-attested') AND attested_cloud_state_compared=(mode='operator-attested'))
+);
+ALTER TABLE lzc.application_recovery_replans ENABLE ROW LEVEL SECURITY;
+ALTER TABLE lzc.application_recovery_replans FORCE ROW LEVEL SECURITY;
+CREATE POLICY application_recovery_replan_read ON lzc.application_recovery_replans FOR SELECT TO configurator_app
+ USING(tenant_id=lzc.current_tenant_id() AND ((owner_user_id=lzc.current_user_id() AND lzc.application_role('application-owner')) OR (reviewed_by=lzc.current_user_id() AND lzc.application_role('platform-engineer'))));
+CREATE POLICY application_recovery_replan_migration ON lzc.application_recovery_replans TO configurator_migration USING(true) WITH CHECK(true);
+GRANT SELECT ON lzc.application_recovery_replans TO configurator_app;
+CREATE TRIGGER immutable_application_recovery_replan BEFORE UPDATE OR DELETE ON lzc.application_recovery_replans
+ FOR EACH ROW EXECUTE FUNCTION lzc.immutable_application_version();
+
+CREATE FUNCTION lzc_auth.application_recovery_replan_context(p_session uuid,p_job uuid)
+RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
+DECLARE context jsonb; job lzc.application_jobs; dispatched lzc.application_dispatches;
+ grant_row lzc.application_job_grants; bound lzc.application_job_backends; registered lzc.state_backends; descriptor_hash text; review_mode text;
+BEGIN
+ PERFORM 1 FROM lzc_auth.sessions WHERE id=p_session FOR SHARE;
+ PERFORM 1 FROM lzc.memberships WHERE tenant_id=lzc.current_tenant_id() AND user_id=lzc.current_user_id() FOR SHARE;
+ PERFORM lzc_auth.authorize_application(p_session,lzc.current_tenant_id(),'order');
+ PERFORM 1 FROM lzc.stackit_identities human WHERE human.user_id=lzc.current_user_id()
+ AND human.issuer='https://accounts.stackit.cloud' AND human.revoked_at IS NULL AND human.valid_until>now() FOR SHARE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'application_recovery_replan_access_denied' USING ERRCODE='42501'; END IF;
+ SELECT * INTO job FROM lzc.application_jobs WHERE id=p_job AND tenant_id=lzc.current_tenant_id();
+ IF job.id IS NULL OR NOT ((job.owner_user_id=lzc.current_user_id() AND (lzc.application_role('application-owner') OR lzc.application_role('platform-engineer')))
+ OR lzc.application_role('platform-engineer')) THEN
+ RAISE EXCEPTION 'application_recovery_replan_access_denied' USING ERRCODE='42501';
+ END IF;
+ -- The same instance lock serializes Plan, archive and review.
+ PERFORM lzc_auth.require_active_application_order(p_session,job.instance_id);
+ SELECT * INTO dispatched FROM lzc.application_dispatches WHERE job_id=p_job FOR UPDATE;
+ SELECT * INTO grant_row FROM lzc.application_job_grants WHERE job_id=p_job FOR SHARE;
+ SELECT * INTO bound FROM lzc.application_job_backends WHERE job_id=p_job FOR SHARE;
+ PERFORM 1 FROM lzc.application_runner_tickets WHERE job_id=p_job FOR SHARE;
+ PERFORM lzc_auth.assert_application_job_group(p_job);
+ IF job.owner_user_id=lzc.current_user_id() AND dispatched.error_code='apply_failed'
+ AND EXISTS(SELECT 1 FROM lzc.application_runner_records WHERE job_id=p_job AND kind='output') THEN
+ review_mode:='reported-failure';
+ IF NOT EXISTS(SELECT 1 FROM lzc.application_instances i JOIN lzc.stackit_identities human ON human.user_id=i.requested_by
+ AND human.issuer='https://accounts.stackit.cloud' AND human.revoked_at IS NULL AND human.valid_until>now()
+ AND human.email=i.resolved_settings->>'owner_email' WHERE i.id=job.instance_id AND i.tenant_id=job.tenant_id) THEN
+ RAISE EXCEPTION 'application_recovery_replan_access_denied' USING ERRCODE='42501';
+ END IF;
+ ELSE
+ review_mode:='operator-attested';
+ context:=lzc_auth.application_recovery_review_context(p_session,p_job);
+ IF NOT lzc.application_role('platform-engineer') OR NOT EXISTS(
+ SELECT 1 FROM lzc.application_instances i
+ JOIN lzc.application_template_versions v ON v.id=i.version_id AND v.tenant_id=i.tenant_id
+ JOIN lzc.application_platform_contracts c ON c.revision=v.platform_revision AND c.tenant_id=v.tenant_id
+ WHERE i.id=job.instance_id AND i.tenant_id=job.tenant_id AND c.approved_by=lzc.current_user_id()
+ ) THEN RAISE EXCEPTION 'application_recovery_replan_access_denied' USING ERRCODE='42501'; END IF;
+ END IF;
+ SELECT * INTO registered FROM lzc.state_backends b WHERE b.id=bound.backend_id AND b.tenant_id=job.tenant_id;
+ IF job.operation IS DISTINCT FROM 'apply' OR dispatched.status IS DISTINCT FROM 'reconciliation_required'
+ OR dispatched.finished_at IS NULL OR dispatched.runner_app_id IS NULL OR dispatched.error_code IS NOT DISTINCT FROM 'state_failed'
+ OR grant_row.job_id IS NULL OR bound.job_id IS NULL OR registered.id IS NULL
+ OR grant_row.binding_sha256 IS DISTINCT FROM job.binding_sha256
+ OR bound.tenant_id IS DISTINCT FROM job.tenant_id OR bound.owner_user_id IS DISTINCT FROM job.owner_user_id
+ OR bound.approved_by IS DISTINCT FROM grant_row.approved_by
+ OR bound.descriptor->>'key' IS DISTINCT FROM grant_row.state_key
+ OR grant_row.state_key IS DISTINCT FROM 'applications/'||job.tenant_id::text||'/'||job.instance_id::text||'/terraform.tfstate'
+ OR bound.descriptor->>'useLockfile' IS DISTINCT FROM 'true'
+ OR (review_mode='operator-attested' AND ((grant_row.revoked_at IS NULL AND grant_row.expires_at>now())
+ OR EXISTS(SELECT 1 FROM lzc.application_runner_tickets WHERE job_id=p_job AND expires_at>now())))
+ OR (review_mode='reported-failure' AND (NOT EXISTS(SELECT 1 FROM lzc.application_runner_tickets ticket
+ WHERE ticket.job_id=p_job AND ticket.tenant_id=job.tenant_id AND ticket.consumed_at IS NOT NULL AND ticket.runner_package_id=dispatched.runner_package_id)
+ OR NOT EXISTS(SELECT 1 FROM lzc.application_runner_records output WHERE output.job_id=p_job AND output.kind='output'
+ AND output.tenant_id=job.tenant_id AND output.owner_user_id=job.owner_user_id AND output.binding_sha256=job.binding_sha256
+ AND output.state_key=grant_row.state_key AND output.runner_package_id=dispatched.runner_package_id)))
+ OR EXISTS(SELECT 1 FROM lzc.application_runner_records WHERE job_id=p_job AND kind='recovery')
+ OR EXISTS(SELECT 1 FROM lzc.application_dispatches other WHERE other.instance_id=job.instance_id AND other.tenant_id=job.tenant_id
+ AND other.job_id<>p_job AND other.status NOT IN ('succeeded','failed','reconciliation_required'))
+ OR (registered.descriptor - 'key' - 'useLockfile') IS DISTINCT FROM (bound.descriptor - 'key' - 'useLockfile')
+ OR NOT EXISTS(SELECT 1 FROM lzc.tenants t WHERE t.id=job.tenant_id AND t.organization_id=grant_row.organization_id AND t.organization_verified AND t.archived_at IS NULL)
+ THEN RAISE EXCEPTION 'application_recovery_replan_unavailable' USING ERRCODE='40001'; END IF;
+ descriptor_hash:=encode(sha256(convert_to(bound.descriptor::text,'UTF8')),'hex');
+ -- Ciphertext is internal broker material; never serialize it into UI or proof tokens.
+ RETURN jsonb_build_object('jobId',job.id,'instanceId',job.instance_id,'bindingSha256',job.binding_sha256,
+ 'status',dispatched.status,'operation',job.operation,'finishedAt',dispatched.finished_at,'recoverySha256',NULL,
+ 'finding','completion-review-required','mode',review_mode,
+ 'backendId',bound.backend_id,'backendDescriptor',bound.descriptor,'runnerAppId',dispatched.runner_app_id,
+ 'registeredBackendDescriptor',registered.descriptor,'backendCiphertext',encode(registered.credentials_ciphertext,'hex'),
+ 'stateKey',grant_row.state_key,'backendSha256',descriptor_hash,
+ 'existingReceipt',(SELECT to_jsonb(r) FROM lzc.application_recovery_replans r WHERE r.job_id=p_job));
+END $$;
+REVOKE ALL ON FUNCTION lzc_auth.application_recovery_replan_context(uuid,uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION lzc_auth.application_recovery_replan_context(uuid,uuid) TO configurator_app;
+
+CREATE FUNCTION lzc_auth.record_application_recovery_replan(p_session uuid,p_job uuid,p_binding text,p_backend_sha text,p_state_sha text,p_state_serial bigint,p_state_version text,p_attested boolean)
+RETURNS lzc.application_recovery_replans LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
+DECLARE context jsonb; job lzc.application_jobs; receipt lzc.application_recovery_replans;
+BEGIN
+ context:=lzc_auth.application_recovery_replan_context(p_session,p_job);
+ IF p_attested IS DISTINCT FROM (context->>'mode'='operator-attested') OR p_state_sha IS NULL OR p_state_sha !~ '^[a-f0-9]{64}$'
+ OR p_state_serial IS NULL OR p_state_serial<0 OR (p_state_version IS NOT NULL AND length(p_state_version) NOT BETWEEN 1 AND 1024)
+ THEN RAISE EXCEPTION 'application_recovery_replan_unavailable' USING ERRCODE='40001'; END IF;
+ IF context->>'bindingSha256' IS DISTINCT FROM p_binding OR context->>'backendSha256' IS DISTINCT FROM p_backend_sha THEN
+ RAISE EXCEPTION 'application_recovery_replan_changed' USING ERRCODE='40001';
+ END IF;
+ SELECT * INTO job FROM lzc.application_jobs WHERE id=p_job;
+ SELECT * INTO receipt FROM lzc.application_recovery_replans WHERE job_id=p_job;
+ IF FOUND THEN
+ IF receipt.reviewed_by<>lzc.current_user_id() OR receipt.mode IS DISTINCT FROM context->>'mode' OR receipt.binding_sha256 IS DISTINCT FROM p_binding
+ OR receipt.backend_sha256 IS DISTINCT FROM p_backend_sha OR receipt.state_sha256 IS DISTINCT FROM p_state_sha
+ OR receipt.state_serial IS DISTINCT FROM p_state_serial OR receipt.state_version IS DISTINCT FROM p_state_version THEN
+ RAISE EXCEPTION 'application_recovery_replan_changed' USING ERRCODE='40001';
+ END IF;
+ RETURN receipt;
+ END IF;
+ INSERT INTO lzc.application_recovery_replans(job_id,tenant_id,owner_user_id,reviewed_by,review_session_id,mode,binding_sha256,backend_id,backend_sha256,state_key,state_sha256,state_serial,state_version,attested_runner_stopped,attested_cloud_state_compared)
+ VALUES(p_job,job.tenant_id,job.owner_user_id,lzc.current_user_id(),p_session,context->>'mode',p_binding,(context->>'backendId')::uuid,p_backend_sha,context->>'stateKey',p_state_sha,p_state_serial,p_state_version,p_attested,p_attested)
+ RETURNING * INTO receipt;
+ RETURN receipt;
+END $$;
+REVOKE ALL ON FUNCTION lzc_auth.record_application_recovery_replan(uuid,uuid,text,text,text,bigint,text,boolean) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION lzc_auth.record_application_recovery_replan(uuid,uuid,text,text,text,bigint,text,boolean) TO configurator_app;
+
+-- Revalidate every historical clearance at every execution gate. New recovery
+-- evidence, renewed capabilities or changed bindings close the gate again.
+CREATE FUNCTION lzc_auth.application_recovery_replan_valid(p_job uuid) RETURNS boolean
+LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$
+ SELECT EXISTS(SELECT 1 FROM lzc.application_recovery_replans r
+ JOIN lzc.application_jobs j ON j.id=r.job_id AND j.tenant_id=r.tenant_id AND j.owner_user_id=r.owner_user_id AND j.operation='apply' AND j.binding_sha256=r.binding_sha256
+ JOIN lzc.application_dispatches d ON d.job_id=j.id AND d.status='reconciliation_required' AND d.finished_at IS NOT NULL AND d.error_code IS DISTINCT FROM 'state_failed'
+ JOIN lzc.application_job_grants g ON g.job_id=j.id AND g.binding_sha256=r.binding_sha256 AND g.state_key=r.state_key
+ JOIN lzc.application_job_backends b ON b.job_id=j.id AND b.backend_id=r.backend_id AND b.tenant_id=r.tenant_id
+ AND encode(sha256(convert_to(b.descriptor::text,'UTF8')),'hex')=r.backend_sha256
+ WHERE r.job_id=p_job AND (
+ (r.mode='operator-attested' AND (g.revoked_at IS NOT NULL OR g.expires_at<=now())
+ AND NOT EXISTS(SELECT 1 FROM lzc.application_runner_tickets WHERE job_id=p_job AND expires_at>now()))
+ OR (r.mode='reported-failure' AND d.error_code='apply_failed'
+ AND EXISTS(SELECT 1 FROM lzc.application_runner_tickets ticket WHERE ticket.job_id=p_job AND ticket.tenant_id=j.tenant_id
+ AND ticket.consumed_at IS NOT NULL AND ticket.runner_package_id=d.runner_package_id)
+ AND EXISTS(SELECT 1 FROM lzc.application_runner_records output WHERE output.job_id=p_job AND output.kind='output'
+ AND output.tenant_id=j.tenant_id AND output.owner_user_id=j.owner_user_id AND output.binding_sha256=j.binding_sha256
+ AND output.state_key=g.state_key AND output.runner_package_id=d.runner_package_id)))
+ AND NOT EXISTS(SELECT 1 FROM lzc.application_runner_records WHERE job_id=p_job AND kind='recovery'));
+$$;
+REVOKE ALL ON FUNCTION lzc_auth.application_recovery_replan_valid(uuid) FROM PUBLIC;
+
+-- Public listing exposes only a caller-visible historical clearance boolean.
+CREATE FUNCTION lzc_auth.application_recovery_replan_approved(p_job uuid) RETURNS boolean
+LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$
+ SELECT EXISTS(SELECT 1 FROM lzc.application_jobs j
+ JOIN lzc.application_job_grants g ON g.job_id=j.id AND g.tenant_id=j.tenant_id
+ WHERE j.id=p_job AND j.tenant_id=lzc.current_tenant_id()
+ AND ((j.owner_user_id=lzc.current_user_id() AND (lzc.application_role('application-owner') OR lzc.application_role('platform-engineer')))
+ OR (g.approved_by=lzc.current_user_id() AND lzc.application_role('platform-engineer')))
+ AND lzc_auth.application_recovery_replan_valid(j.id));
+$$;
+REVOKE ALL ON FUNCTION lzc_auth.application_recovery_replan_approved(uuid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION lzc_auth.application_recovery_replan_approved(uuid) TO configurator_app;
+
+CREATE OR REPLACE FUNCTION lzc_auth.application_execution_blocked(p_job uuid,p_instance uuid,p_tenant uuid,p_purpose text,p_apply_only boolean DEFAULT false) RETURNS boolean
+LANGUAGE sql SECURITY DEFINER SET search_path=pg_catalog AS $$
+ SELECT EXISTS(
+ SELECT 1 FROM lzc.application_dispatches d WHERE d.tenant_id=p_tenant AND d.instance_id=p_instance AND d.job_id<>p_job
+ AND (NOT p_apply_only OR EXISTS(SELECT 1 FROM lzc.application_jobs job WHERE job.id=d.job_id AND job.operation='apply'))
+ AND (d.status NOT IN ('succeeded','failed','reconciliation_required') OR (d.status='reconciliation_required' AND NOT (
+ (p_purpose='standard' AND lzc_auth.application_recovery_replan_valid(d.job_id)) OR
+ (p_purpose IN ('destroy','drift') AND d.finished_at IS NOT NULL
+ AND NOT EXISTS(SELECT 1 FROM lzc.application_runner_tickets t WHERE t.job_id=d.job_id AND t.expires_at>now())
+ AND NOT EXISTS(SELECT 1 FROM lzc.application_runner_records r WHERE r.job_id=d.job_id AND r.kind='recovery'))
+ )))
+ );
+$$;
+
+CREATE FUNCTION lzc_auth.assert_application_replan_fresh(p_plan uuid) RETURNS void
+LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
+BEGIN
+ IF EXISTS(SELECT 1 FROM lzc.application_jobs planned
+ JOIN lzc.application_recovery_replans r ON r.tenant_id=planned.tenant_id
+ JOIN lzc.application_dispatches d ON d.job_id=r.job_id AND d.instance_id=planned.instance_id
+ WHERE planned.id=p_plan AND planned.created_at<=greatest(r.reviewed_at,d.finished_at)) THEN
+ RAISE EXCEPTION 'application_recovery_fresh_plan_required' USING ERRCODE='40001';
+ END IF;
+END $$;
+REVOKE ALL ON FUNCTION lzc_auth.assert_application_replan_fresh(uuid) FROM PUBLIC;
+
+DO $$
+DECLARE definition text; signature text; anchor text;
+BEGIN
+ FOREACH signature IN ARRAY ARRAY['lzc_auth.application_apply_plan(uuid,uuid,text)','lzc_auth.application_running_apply_plan(uuid,uuid,text,uuid)'] LOOP
+ definition:=pg_get_functiondef(signature::regprocedure);
+ anchor:=' RETURN jsonb_build_object(''planId'',p_plan';
+ IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application recovery saved plan source mismatch'; END IF;
+ EXECUTE replace(definition,anchor,' PERFORM lzc_auth.assert_application_replan_fresh(p_plan);'||chr(10)||anchor);
+ END LOOP;
+ definition:=pg_get_functiondef('lzc.validate_application_apply()'::regprocedure);
+ anchor:=' context:=lzc_auth.application_apply_plan(NEW.issuer_session_id,NEW.plan_id,NEW.artifact_sha256);';
+ IF strpos(definition,anchor)=0 THEN RAISE EXCEPTION 'application recovery apply source mismatch'; END IF;
+ EXECUTE replace(definition,anchor,' PERFORM lzc_auth.assert_application_replan_fresh(NEW.plan_id);'||chr(10)||anchor);
+END $$;
diff --git a/landing-zone-configurator/app/apps/api/src/applications/recovery-replan.ts b/landing-zone-configurator/app/apps/api/src/applications/recovery-replan.ts
new file mode 100644
index 0000000..a113a24
--- /dev/null
+++ b/landing-zone-configurator/app/apps/api/src/applications/recovery-replan.ts
@@ -0,0 +1,329 @@
+import {
+ applicationRecoveryReplanCheckSchema,
+ applicationRecoveryReplanReceiptSchema,
+ applicationRecoveryReplanRequestSchema,
+ s3BackendDescriptorSchema,
+ s3RunnerBackendSchema,
+} from "@lzc/contracts";
+import type pg from "pg";
+import { z } from "zod";
+import type { Session } from "../auth/store.js";
+import { CredentialError } from "../credentials/profiles.js";
+import type { Backends } from "../deployments/backends.js";
+import { type ArtifactCrypto, canonicalBase64 } from "../plans/crypto.js";
+import { canonicalJson, sha256 } from "../plans/execution.js";
+
+// A separate error type avoids a circular import of Applications.
+export class RecoveryReplanError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ ) {
+ super(code);
+ }
+}
+const hash = z.string().regex(/^[a-f0-9]{64}$/);
+const contextSchema = z.object({
+ mode: z.enum(["reported-failure", "operator-attested"]),
+ jobId: z.uuid(),
+ runnerAppId: z.uuid(),
+ instanceId: z.uuid(),
+ bindingSha256: hash,
+ backendId: z.uuid(),
+ backendSha256: hash,
+ backendDescriptor: s3BackendDescriptorSchema,
+ stateKey: z.string(),
+});
+const proofSchema = z.strictObject({
+ context: contextSchema.strict(),
+ stateSha256: hash,
+ stateSerial: applicationRecoveryReplanCheckSchema.shape.stateSerial,
+ stateVersion: z.string().min(1).max(1024),
+ expiresAt: z.number().int(),
+});
+export type RecoveryReplanBackends = Pick &
+ Partial>;
+type Dependencies = {
+ backends: RecoveryReplanBackends;
+ crypto: ArtifactCrypto;
+};
+const changed = () =>
+ new RecoveryReplanError(409, "application_recovery_replan_changed");
+async function context(client: pg.PoolClient, session: Session, jobId: string) {
+ const row = (
+ await client.query<{ context: unknown }>(
+ "SELECT lzc_auth.application_recovery_replan_context($1,$2) AS context",
+ [session.id, jobId],
+ )
+ ).rows[0];
+ const bound = contextSchema.parse(row?.context);
+ if (
+ bound.jobId !== jobId ||
+ bound.stateKey !==
+ `applications/${session.tenantId}/${bound.instanceId}/terraform.tfstate` ||
+ bound.backendDescriptor.key !== bound.stateKey ||
+ !bound.backendDescriptor.useLockfile
+ )
+ throw changed();
+ const backendCiphertext = z
+ .object({
+ backendCiphertext: z
+ .string()
+ .regex(/^[a-f0-9]+$/)
+ .optional(),
+ })
+ .parse(row?.context).backendCiphertext;
+ return { bound, backendCiphertext };
+}
+async function snapshot(
+ client: pg.PoolClient,
+ session: Session,
+ bound: z.infer,
+ deps: Dependencies,
+ backendCiphertext?: string,
+) {
+ if (!deps.backends.current)
+ throw new RecoveryReplanError(
+ 503,
+ "application_recovery_replan_unavailable",
+ );
+ // The authorized SQL context supplies ciphertext for delegated owners whose
+ // tenant role deliberately cannot enumerate backend credentials or objects.
+ const registered = backendCiphertext
+ ? s3RunnerBackendSchema.parse({
+ kind: "s3",
+ descriptor: bound.backendDescriptor,
+ credentials: JSON.parse(
+ deps.crypto
+ .decrypt(
+ Buffer.from(backendCiphertext, "hex"),
+ session.tenantId,
+ bound.backendId,
+ `backend:${bound.backendId}`,
+ )
+ .toString("utf8"),
+ ),
+ })
+ : await deps.backends.runner(client, session, bound.backendId);
+ const {
+ key: _registeredKey,
+ useLockfile: _registeredLock,
+ ...registeredLocation
+ } = registered.descriptor;
+ const {
+ key: _boundKey,
+ useLockfile: _boundLock,
+ ...boundLocation
+ } = bound.backendDescriptor;
+ if (canonicalJson(registeredLocation) !== canonicalJson(boundLocation))
+ throw changed();
+ const backend = s3RunnerBackendSchema.parse({
+ ...registered,
+ descriptor: bound.backendDescriptor,
+ });
+ try {
+ // current() checks the lock before reading; a second read also checks for a
+ // newly appeared lock and a changed object. This is observation, no unlock.
+ const first = await deps.backends.current(backend, true);
+ const last = await deps.backends.current(backend, true);
+ const stateSha256 = sha256(first.bytes);
+ if (first.identity !== last.identity || stateSha256 !== sha256(last.bytes))
+ throw changed();
+ const roots = first.document.resources.filter(
+ (r) =>
+ !r.module &&
+ r.mode === "managed" &&
+ r.type === "terraform_data" &&
+ r.name === "contract",
+ );
+ const instances = roots[0]?.instances;
+ const input = instances?.[0]?.attributes?.input;
+ const instanceId =
+ typeof input === "string"
+ ? input
+ : input && typeof input === "object" && "value" in input
+ ? input.value
+ : undefined;
+ if (
+ roots.length !== 1 ||
+ instances?.length !== 1 ||
+ instanceId !== bound.instanceId ||
+ first.document.resources.some((r) => r.instances?.some((i) => i.deposed))
+ )
+ throw new RecoveryReplanError(
+ 409,
+ "application_recovery_replan_state_invalid",
+ );
+ const counts = new Map<
+ string,
+ { type: string; mode: "managed" | "data"; count: number }
+ >();
+ for (const r of first.document.resources) {
+ if (
+ !r.type ||
+ !/^[a-zA-Z0-9_]+$/.test(r.type) ||
+ (r.mode !== "managed" && r.mode !== "data")
+ )
+ throw new RecoveryReplanError(
+ 409,
+ "application_recovery_replan_state_invalid",
+ );
+ const key = `${r.mode}:${r.type}`;
+ const item = counts.get(key) ?? { type: r.type, mode: r.mode, count: 0 };
+ item.count += r.instances?.length ?? 0;
+ counts.set(key, item);
+ }
+ return {
+ stateSha256,
+ stateSerial: first.document.serial,
+ stateVersion: first.identity,
+ resources: [...counts.values()].sort((a, b) =>
+ `${a.mode}:${a.type}`.localeCompare(`${b.mode}:${b.type}`),
+ ),
+ };
+ } catch (error) {
+ if (error instanceof RecoveryReplanError) throw error;
+ if (error instanceof CredentialError && error.code === "state_locked")
+ throw new RecoveryReplanError(
+ 423,
+ "application_recovery_replan_state_locked",
+ );
+ throw new RecoveryReplanError(
+ 409,
+ "application_recovery_replan_state_unavailable",
+ );
+ }
+}
+export async function checkRecoveryReplan(
+ client: pg.PoolClient,
+ session: Session,
+ jobId: string,
+ deps: Dependencies,
+) {
+ const { bound, backendCiphertext } = await context(client, session, jobId);
+ const state = await snapshot(client, session, bound, deps, backendCiphertext);
+ const observedAt = new Date();
+ const expiresAt = observedAt.getTime() + 5 * 60_000;
+ const proof = proofSchema.parse({
+ context: bound,
+ stateSha256: state.stateSha256,
+ stateSerial: state.stateSerial,
+ stateVersion: state.stateVersion,
+ expiresAt,
+ });
+ return applicationRecoveryReplanCheckSchema.parse({
+ jobId,
+ runnerAppId: bound.runnerAppId,
+ instanceId: bound.instanceId,
+ observedAt: observedAt.toISOString(),
+ expiresAt: new Date(expiresAt).toISOString(),
+ stateSha256: state.stateSha256,
+ stateSerial: state.stateSerial,
+ resources: state.resources,
+ stateLock: "free",
+ reviewToken: deps.crypto
+ .encrypt(
+ Buffer.from(JSON.stringify(proof)),
+ session.tenantId,
+ session.id,
+ `application-replan:${jobId}`,
+ )
+ .toString("base64"),
+ mode: bound.mode,
+ runnerStop:
+ bound.mode === "reported-failure"
+ ? "reported-command-completed"
+ : "operator-attestation-required",
+ cloudState:
+ bound.mode === "reported-failure"
+ ? "requires-fresh-plan-review"
+ : "operator-attestation-required",
+ startsExecution: false,
+ });
+}
+export async function approveRecoveryReplan(
+ client: pg.PoolClient,
+ session: Session,
+ jobId: string,
+ input: unknown,
+ deps: Dependencies,
+) {
+ const request = applicationRecoveryReplanRequestSchema.parse(input);
+ let proof: z.infer;
+ try {
+ proof = proofSchema.parse(
+ JSON.parse(
+ deps.crypto
+ .decrypt(
+ canonicalBase64(request.reviewToken, 6144),
+ session.tenantId,
+ session.id,
+ `application-replan:${jobId}`,
+ )
+ .toString("utf8"),
+ ),
+ );
+ } catch {
+ throw changed();
+ }
+ if (proof.expiresAt <= Date.now()) throw changed();
+ const { bound, backendCiphertext } = await context(client, session, jobId);
+ if (
+ bound.mode === "operator-attested" &&
+ (!request.confirmRunnerStopped || !request.confirmCloudStateCompared)
+ )
+ throw changed();
+ if (canonicalJson(bound) !== canonicalJson(proof.context)) throw changed();
+ const state = await snapshot(client, session, bound, deps, backendCiphertext);
+ if (
+ state.stateSha256 !== proof.stateSha256 ||
+ state.stateSerial !== proof.stateSerial ||
+ state.stateVersion !== proof.stateVersion ||
+ proof.expiresAt <= Date.now()
+ )
+ throw changed();
+ const row = (
+ await client.query<{
+ job_id: string;
+ reviewed_by: string;
+ reviewed_at: Date;
+ state_sha256: string;
+ state_serial: string;
+ }>(
+ "SELECT * FROM lzc_auth.record_application_recovery_replan($1,$2,$3,$4,$5,$6,$7,$8)",
+ [
+ session.id,
+ jobId,
+ bound.bindingSha256,
+ bound.backendSha256,
+ state.stateSha256,
+ state.stateSerial,
+ state.stateVersion,
+ bound.mode === "operator-attested",
+ ],
+ )
+ ).rows[0];
+ if (!row)
+ throw new RecoveryReplanError(
+ 503,
+ "application_recovery_replan_unavailable",
+ );
+ return applicationRecoveryReplanReceiptSchema.parse({
+ jobId: row.job_id,
+ reviewedBy: row.reviewed_by,
+ reviewedAt: row.reviewed_at.toISOString(),
+ stateSha256: row.state_sha256,
+ stateSerial: Number(row.state_serial),
+ mode: bound.mode,
+ runnerStop:
+ bound.mode === "reported-failure"
+ ? "reported-command-completed"
+ : "operator-attested",
+ cloudState:
+ bound.mode === "reported-failure"
+ ? "requires-fresh-plan-review"
+ : "operator-attested",
+ requiresFreshPlan: true,
+ startsExecution: false,
+ });
+}
diff --git a/landing-zone-configurator/app/apps/api/src/applications/routes.ts b/landing-zone-configurator/app/apps/api/src/applications/routes.ts
index 8747a33..114dd5b 100644
--- a/landing-zone-configurator/app/apps/api/src/applications/routes.ts
+++ b/landing-zone-configurator/app/apps/api/src/applications/routes.ts
@@ -1,6 +1,7 @@
import {
applicationOrderDecisionSchema,
applicationPlanPurposeSchema,
+ applicationRecoveryReplanRequestSchema,
applicationRecoveryReviewRequestSchema,
} from "@lzc/contracts";
import type { FastifyInstance, FastifyRequest } from "fastify";
@@ -11,6 +12,7 @@ import {
validMutation,
} from "../auth/routes.js";
import type { Session } from "../auth/store.js";
+import { RecoveryReplanError } from "./recovery-replan.js";
import {
ApplicationError,
type Applications,
@@ -151,6 +153,8 @@ export function registerApplications(
| "diagnoseRecovery"
| "recoveryReviewContext"
| "recordRecoveryReview"
+ | "checkRecoveryReplan"
+ | "approveRecoveryReplan"
| "startApply"
| "listGroups"
| "createGroup"
@@ -167,7 +171,10 @@ export function registerApplications(
const sessions = new WeakMap();
app.register(async (routes) => {
routes.setErrorHandler((error, _request, reply) => {
- if (error instanceof ApplicationError)
+ if (
+ error instanceof ApplicationError ||
+ error instanceof RecoveryReplanError
+ )
return reply.code(error.status).send({ error: error.code });
if (error instanceof z.ZodError)
return reply.code(400).send({ error: "invalid_application_request" });
@@ -185,6 +192,9 @@ export function registerApplications(
"application_execution_binding_conflict",
"application_apply_plan_unavailable",
"application_apply_package_changed",
+ "application_recovery_fresh_plan_required",
+ "application_recovery_replan_changed",
+ "application_recovery_replan_unavailable",
"application_recovery_review_changed",
"application_recovery_review_unavailable",
].includes(error.message)
@@ -552,6 +562,39 @@ export function registerApplications(
);
},
);
+ routes.post(
+ "/api/v1/applications/jobs/:id/recovery-replan/check",
+ { bodyLimit: 1024 },
+ async (request, reply) => {
+ const session = sessions.get(request);
+ if (!session) return reply.code(401).send();
+ if (!applications.checkRecoveryReplan)
+ return reply
+ .code(503)
+ .send({ error: "application_recovery_replan_unavailable" });
+ const { id } = z.strictObject({ id: z.uuid() }).parse(request.params);
+ z.strictObject({}).parse(request.body);
+ return applications.checkRecoveryReplan(session, id);
+ },
+ );
+ routes.post(
+ "/api/v1/applications/jobs/:id/recovery-replan",
+ { bodyLimit: 16384 },
+ async (request, reply) => {
+ const session = sessions.get(request);
+ if (!session) return reply.code(401).send();
+ if (!applications.approveRecoveryReplan)
+ return reply
+ .code(503)
+ .send({ error: "application_recovery_replan_unavailable" });
+ const { id } = z.strictObject({ id: z.uuid() }).parse(request.params);
+ return applications.approveRecoveryReplan(
+ session,
+ id,
+ applicationRecoveryReplanRequestSchema.parse(request.body),
+ );
+ },
+ );
routes.post(
"/api/v1/applications/jobs/:id/apply",
{ bodyLimit: 1024 },
diff --git a/landing-zone-configurator/app/apps/api/src/applications/service.ts b/landing-zone-configurator/app/apps/api/src/applications/service.ts
index ed458eb..8dddba2 100644
--- a/landing-zone-configurator/app/apps/api/src/applications/service.ts
+++ b/landing-zone-configurator/app/apps/api/src/applications/service.ts
@@ -39,13 +39,18 @@ import type { Session } from "../auth/store.js";
import { tokenHash } from "../auth/store.js";
import type { PostgresCredentialProfiles } from "../credentials/profiles.js";
import type { CredentialSecrets } from "../credentials/secrets.js";
-import type { Backends } from "../deployments/backends.js";
import type { PlanRunner } from "../plans/cloud-foundry.js";
import { type ArtifactCrypto, canonicalBase64 } from "../plans/crypto.js";
import { canonicalJson, sha256 } from "../plans/execution.js";
import type { Plans } from "../plans/service.js";
import { withTenant } from "../storage/database.js";
+import {
+ approveRecoveryReplan,
+ checkRecoveryReplan,
+ type RecoveryReplanBackends,
+} from "./recovery-replan.js";
+
export class ApplicationError extends Error {
constructor(
readonly status: number,
@@ -195,7 +200,7 @@ export class Applications {
> &
Partial>,
private readonly secrets?: Pick,
- private readonly backends?: Pick,
+ private readonly backends?: RecoveryReplanBackends,
private readonly dispatch?: { runner: PlanRunner; origin: string },
private readonly artifactCrypto?: ArtifactCrypto,
private readonly platforms?: Pick,
@@ -1156,6 +1161,7 @@ export class Applications {
plan_id: string | null;
artifact_sha256: string | null;
can_apply: boolean;
+ recovery_replan_approved: boolean;
}>(
`SELECT j.id,j.instance_id,j.owner_user_id,g.approved_by,j.created_at,j.expires_at,
coalesce(d.status,j.status) AS status,g.revoked_at IS NULL AND g.expires_at>now() AS grant_active,
@@ -1166,6 +1172,7 @@ export class Applications {
jd.job_id IS NOT NULL AS delegated_execution,
j.operation,coalesce(j.inputs->>'purpose','standard') AS purpose,j.plan_id,a.sha256 AS artifact_sha256,
CASE WHEN j.operation='plan' AND a.sha256 IS NOT NULL AND j.owner_user_id=$3 THEN lzc_auth.application_apply_available($4,j.id,a.sha256) ELSE false END AS can_apply,
+ lzc_auth.application_recovery_replan_approved(j.id) AS recovery_replan_approved,
d.summary,d.error_code FROM lzc.application_jobs j
JOIN lzc.application_job_grants g ON g.job_id=j.id AND g.tenant_id=j.tenant_id
LEFT JOIN lzc.application_job_backends b ON b.job_id=j.id AND b.tenant_id=j.tenant_id
@@ -1189,6 +1196,7 @@ export class Applications {
planId: row.plan_id,
artifactSha256: row.artifact_sha256,
canApply: this.executionCapabilities().applyEnabled && row.can_apply,
+ recoveryReplanApproved: row.recovery_replan_approved ?? false,
status: row.status,
grantActive: row.grant_active,
backendId: row.backend_id,
@@ -1318,6 +1326,34 @@ export class Applications {
});
}
+ async checkRecoveryReplan(session: Session, jobId: string) {
+ const id = z.uuid().parse(jobId);
+ const backends = this.backends;
+ const crypto = this.artifactCrypto;
+ if (!backends?.current || !crypto)
+ throw new ApplicationError(
+ 503,
+ "application_recovery_replan_unavailable",
+ );
+ return this.work(session, "read", (client) =>
+ checkRecoveryReplan(client, session, id, { backends, crypto }),
+ );
+ }
+
+ async approveRecoveryReplan(session: Session, jobId: string, input: unknown) {
+ const id = z.uuid().parse(jobId);
+ const backends = this.backends;
+ const crypto = this.artifactCrypto;
+ if (!backends?.current || !crypto)
+ throw new ApplicationError(
+ 503,
+ "application_recovery_replan_unavailable",
+ );
+ return this.work(session, "read", (client) =>
+ approveRecoveryReplan(client, session, id, input, { backends, crypto }),
+ );
+ }
+
async recoveryReviewContext(session: Session, jobId: string) {
const id = z.uuid().parse(jobId);
if (!this.options.recoveryReviewsEnabled)
diff --git a/landing-zone-configurator/app/apps/api/src/deployments/backends.ts b/landing-zone-configurator/app/apps/api/src/deployments/backends.ts
index c35d939..9a79e62 100644
--- a/landing-zone-configurator/app/apps/api/src/deployments/backends.ts
+++ b/landing-zone-configurator/app/apps/api/src/deployments/backends.ts
@@ -343,12 +343,17 @@ export class Backends {
throw new CredentialError(409, "backend_credentials_invalid");
}
}
- async current(backend: S3RunnerBackend) {
+ async current(backend: S3RunnerBackend, verifyUnlockedAfterRead = false) {
if (await this.read(backend, `${backend.descriptor.key}.tflock`))
throw new CredentialError(423, "state_locked");
const remote = await this.read(backend, backend.descriptor.key);
if (!remote) throw new CredentialError(409, "backend_state_missing");
const document = stateDocument(remote.bytes);
+ if (
+ verifyUnlockedAfterRead &&
+ (await this.read(backend, `${backend.descriptor.key}.tflock`))
+ )
+ throw new CredentialError(423, "state_locked");
return { ...remote, document };
}
}
diff --git a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx
index b65aca6..3e27024 100644
--- a/landing-zone-configurator/app/apps/web/src/components/Applications.tsx
+++ b/landing-zone-configurator/app/apps/web/src/components/Applications.tsx
@@ -5,6 +5,8 @@ import {
applicationPlanJobSchema,
applicationPlanPreviewSchema,
applicationRecoveryDiagnosisSchema,
+ applicationRecoveryReplanCheckSchema,
+ applicationRecoveryReplanReceiptSchema,
applicationRecoveryReviewSchema,
applicationRecoveryReviewViewSchema,
appliedPlatformSourceSchema,
@@ -43,6 +45,18 @@ import { OrganizationBinding } from "./OrganizationBinding";
import { templateObservabilityConfigurable } from "./TemplateParameters";
const errors: Record = {
+ application_recovery_fresh_plan_required:
+ "Nach der Checkpoint-Prüfung ist ein neuer Plan erforderlich. Bitte dieselbe Bestellung neu planen und den neuen Plan prüfen.",
+ application_recovery_replan_changed:
+ "Der Checkpoint oder Prüfkontext hat sich geändert oder ist abgelaufen. Bitte erneut prüfen.",
+ application_recovery_replan_unavailable:
+ "Dieser Apply ist noch nicht für einen neuen Plan freigebbar. Ausführung, Berechtigungen und Recovery-State müssen vom Platform Owner geprüft werden.",
+ application_recovery_replan_state_locked:
+ "Der Application-State ist gesperrt. Keine Freigabe möglich; die Sperre wird nicht verändert.",
+ application_recovery_replan_state_invalid:
+ "Der State passt nicht eindeutig zu dieser Bestellung oder enthält ersetzte Instanzen. Operator-Abgleich erforderlich.",
+ application_recovery_replan_state_unavailable:
+ "Der gebundene Application-State konnte nicht sicher gelesen werden. Bitte den Platform Owner kontaktieren.",
application_recovery_review_changed:
"Der Prüfstand hat sich geändert. Bitte den Prüfkontext neu laden.",
application_recovery_review_unavailable:
@@ -742,6 +756,187 @@ function RecoveryReview({
);
}
+function RecoveryReplan({
+ session,
+ jobId,
+ onApproved,
+}: {
+ session: Session;
+ jobId: string;
+ onApproved: () => Promise;
+}) {
+ const [checkpoint, setCheckpoint] = useState | null>(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [runnerStopped, setRunnerStopped] = useState(false);
+ const [cloudCompared, setCloudCompared] = useState(false);
+ const [freshPlan, setFreshPlan] = useState(false);
+ async function check() {
+ setBusy(true);
+ setError("");
+ setCheckpoint(null);
+ setRunnerStopped(false);
+ setCloudCompared(false);
+ setFreshPlan(false);
+ try {
+ setCheckpoint(
+ applicationRecoveryReplanCheckSchema.parse(
+ await request(`jobs/${jobId}/recovery-replan/check`, session, {}),
+ ),
+ );
+ } catch (cause) {
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Die Recovery-Prüfung ist derzeit nicht verfügbar.",
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function approve() {
+ if (
+ !checkpoint ||
+ !freshPlan ||
+ (checkpoint.mode === "operator-attested" &&
+ (!runnerStopped || !cloudCompared))
+ )
+ return;
+ setBusy(true);
+ setError("");
+ try {
+ applicationRecoveryReplanReceiptSchema.parse(
+ await request(`jobs/${jobId}/recovery-replan`, session, {
+ reviewToken: checkpoint.reviewToken,
+ confirmFreshPlan: true,
+ ...(checkpoint.mode === "operator-attested"
+ ? { confirmRunnerStopped: true, confirmCloudStateCompared: true }
+ : {}),
+ }),
+ );
+ await onApproved();
+ } catch (cause) {
+ setCheckpoint(null);
+ setError(
+ cause instanceof Error
+ ? cause.message
+ : "Die Recovery-Prüfung ist derzeit nicht verfügbar.",
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
{t("Neuen Plan nach Apply-Fehler ermöglichen")}
+
+ {t(
+ "Bei regulär gemeldetem Apply-Fehler prüft der Server den gespeicherten State für einen neuen Plan. Nur unklare Abschlüsse benötigen einen externen Operator-Abgleich.",
+ )}
+
+ {t(
+ "Ein vorheriger Apply ist fehlgeschlagen. Prüfe in der Apply-Ansicht den Application-Checkpoint, um dieselbe Bestellung neu zu planen. Nur bei unklarem Abschluss ist ein Operator-Abgleich nötig.",
+ )}
+