Add institutional provenance and approval gates

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent cec3d17bff
commit 2afdd38128
16 changed files with 2472 additions and 509 deletions
+43
View File
@@ -406,6 +406,16 @@ export type CampaignDeliveryOptions = {
version_id: string;
worker_queue_available: boolean;
postbox_available: boolean;
approval_gate: {
configured?: boolean;
available?: boolean;
approved?: boolean;
state?: string;
explanation?: string | null;
request_id?: string;
request_revision?: number;
subject_digest?: string;
};
synchronous_send: {
allowed?: boolean;
reason?: string | null;
@@ -420,6 +430,26 @@ export type CampaignDeliveryOptions = {
};
};
export type CampaignApprovalRequestPayload = {
title: string;
description?: string | null;
steps: Array<{
key: string;
label: string;
selectors: Array<{
kind: "account" | "group" | "role" | "function_assignment" | "any_account";
value: string;
}>;
required_approvals: number;
rejection_policy: "fail_fast" | "collect";
signature_required: boolean;
forbidden_evidence_roles: Array<"author" | "owner" | "validator" | "builder" | "reviewer">;
}>;
unique_actors_across_steps: boolean;
policy_refs: string[];
idempotency_key: string;
};
export type CampaignSendNowPayload = {
version_id?: string | null;
include_warnings?: boolean;
@@ -1148,6 +1178,19 @@ versionId?: string | null)
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
}
export async function requestCampaignApproval(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignApprovalRequestPayload)
: Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function sendCampaignNow(
settings: ApiSettings,
campaignId: string,
+130 -7
View File
@@ -22,6 +22,7 @@ import {
pauseCampaign,
previewCampaignAttachments,
queueCampaign,
requestCampaignApproval,
resumeCampaign,
retryCampaignJobs,
sendCampaignJob,
@@ -35,7 +36,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -106,7 +107,7 @@ import {
synchronousSendReason
} from "./review/reviewPresentation";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "approval" | "send" | "queue" | "control" | "retry" | "imap" | "";
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
@@ -138,6 +139,7 @@ export default function ReviewSendPage({
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
const canRequestApproval = hasScope(auth, "campaigns:campaign:review");
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
@@ -186,6 +188,12 @@ export default function ReviewSendPage({
const [attachmentLinking, setAttachmentLinking] = useState(false);
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
const [approvalDialogOpen, setApprovalDialogOpen] = useState(false);
const [approvalSelectorKind, setApprovalSelectorKind] = useState<"account" | "group" | "role" | "function_assignment" | "any_account">("role");
const [approvalSelectorValue, setApprovalSelectorValue] = useState("");
const [approvalRequiredCount, setApprovalRequiredCount] = useState(1);
const [approvalSignatureRequired, setApprovalSignatureRequired] = useState(false);
const [approvalExcludedRoles, setApprovalExcludedRoles] = useState<Set<"author" | "owner" | "validator" | "builder" | "reviewer">>(() => new Set());
const [dryRun, setDryRun] = useState(false);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
@@ -215,6 +223,7 @@ export default function ReviewSendPage({
setAttachmentPreview(null);
setAttachmentPreviewError("");
setAttachmentLockConfirmOpen(false);
setApprovalDialogOpen(false);
setSendResult(null);
setQueueResult(null);
setImapAppendResult(null);
@@ -343,6 +352,11 @@ export default function ReviewSendPage({
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
const synchronousSendAllowed = synchronousSendOption.allowed === true;
const workerQueueAvailable = deliveryOptions?.worker_queue_available === true;
const approvalGate = asRecord(deliveryOptions?.approval_gate);
const approvalGateConfigured = approvalGate.configured === true;
const approvalGateAvailable = approvalGate.available === true;
const approvalGateApproved = approvalGate.approved === true;
const approvalGateBlocksLiveDelivery = approvalGateConfigured && !approvalGateApproved;
const persistedDeliveryMode = version?.delivery_mode ?? null;
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
const selectedDryRun = dryRun && !directQueuedSendAllowed;
@@ -523,7 +537,7 @@ export default function ReviewSendPage({
const mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState);
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity);
const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState);
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !singleMessageSendWorkflowBlocked);
const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !approvalGateBlocksLiveDelivery && !singleMessageSendWorkflowBlocked);
const sendLockReason = !inspectionSatisfied ?
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
mockWorkflowRequired ?
@@ -837,9 +851,43 @@ export default function ReviewSendPage({
}
}
async function createApprovalGate() {
if (!version || busy || !canRequestApproval || !approvalGateAvailable || approvalGateConfigured || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim()) return;
setBusy("approval");
setError("");
setMessage("Requesting approval for the exact built execution...");
try {
await requestCampaignApproval(settings, campaignId, version.id, {
title: `Approve delivery of ${data.campaign?.name ?? "Campaign"}`,
description: "Approval is bound to this version's immutable execution snapshot.",
steps: [{
key: "release",
label: "Approve delivery",
selectors: [{ kind: approvalSelectorKind, value: approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue.trim() }],
required_approvals: approvalRequiredCount,
rejection_policy: "fail_fast",
signature_required: approvalSignatureRequired,
forbidden_evidence_roles: [...approvalExcludedRoles]
}],
unique_actors_across_steps: true,
policy_refs: [],
idempotency_key: crypto.randomUUID()
});
setApprovalDialogOpen(false);
setMessage("Approval requested. Real delivery remains unavailable until the request is approved.");
await reload();
await refreshDeliveryOptions(false);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function runSendNow() {
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return;
setBusy("send");
setMessage(effectiveDryRun ?
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
@@ -881,7 +929,7 @@ export default function ReviewSendPage({
}
async function runQueueForWorkers() {
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
setBusy("queue");
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
setQueueResult(null);
@@ -1259,6 +1307,17 @@ export default function ReviewSendPage({
detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.",
state: messagesPerMinute > 0 ? "ready" : "warning"
},
{
label: "Approval",
detail: approvalGateConfigured ?
approvalGateApproved ?
`Approval ${String(approvalGate.request_id ?? "")} covers this exact execution snapshot.` :
String(approvalGate.explanation ?? "The configured approval request is not approved yet.") :
approvalGateAvailable ?
"No approval gate is configured. Add one when this delivery requires independent release authority." :
"The optional Approvals module is not active; no Campaign approval gate is configured.",
state: approvalGateConfigured ? approvalGateApproved ? "ready" : "blocked" : "info"
},
{
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
detail: deliveryOptionsLoading ?
@@ -1527,6 +1586,18 @@ export default function ReviewSendPage({
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
</div>
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
<div className="review-send-controls">
<span>Approval gate</span>
<StatusBadge
status={approvalGateConfigured ? approvalGateApproved ? "approved" : String(approvalGate.state ?? "pending") : "not_required"}
label={approvalGateConfigured ? approvalGateApproved ? "Approved" : humanize(String(approvalGate.state ?? "pending")) : "Not required"} />
{!approvalGateConfigured && approvalGateAvailable && canRequestApproval &&
<Button disabled={Boolean(busy) || readOnlyVersion || !hasBuild} onClick={() => setApprovalDialogOpen(true)}>
<ShieldCheck size={16} aria-hidden="true" />Request approval
</Button>}
{approvalGateConfigured &&
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
</div>
<div className="review-flow-fact-grid">
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({
<Button
variant="primary"
onClick={() => setQueueConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
</Button>
<Button
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || !selectedDryRun && approvalGateBlocksLiveDelivery || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
{busy === "send" ?
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
@@ -1770,6 +1841,58 @@ export default function ReviewSendPage({
}
<Dialog
open={approvalDialogOpen}
title="Request Campaign approval"
portal
closeDisabled={busy === "approval"}
onClose={() => setApprovalDialogOpen(false)}
footer={<>
<Button disabled={busy === "approval"} onClick={() => setApprovalDialogOpen(false)}>Cancel</Button>
<Button
variant="primary"
disabled={busy === "approval" || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim() || approvalRequiredCount < 1}
onClick={() => void createApprovalGate()}>
{busy === "approval" ? "Requesting..." : "Request approval"}
</Button>
</>}>
<div className="form-grid">
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
<FormField label="Approver type">
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
<option value="account">Account</option>
<option value="group">Group</option>
<option value="role">Role</option>
<option value="function_assignment">Function assignment</option>
<option value="any_account">Any account</option>
</select>
</FormField>
<FormField label="Approver reference">
<input value={approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue} disabled={busy === "approval" || approvalSelectorKind === "any_account"} onChange={(event) => setApprovalSelectorValue(event.target.value)} />
</FormField>
<FormField label="Required approvals">
<input type="number" min={1} max={500} value={approvalRequiredCount} disabled={busy === "approval"} onChange={(event) => setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} />
</FormField>
<ToggleSwitch label="Require signature evidence" checked={approvalSignatureRequired} disabled={busy === "approval"} onChange={setApprovalSignatureRequired} />
<div className="form-grid-wide">
<span className="field-label">Actors excluded by four-eyes policy</span>
<div className="button-row compact-actions">
{(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) =>
<ToggleSwitch
key={role}
label={humanize(role)}
checked={approvalExcludedRoles.has(role)}
disabled={busy === "approval"}
onChange={(checked) => setApprovalExcludedRoles((current) => {
const next = new Set(current);
if (checked) next.add(role); else next.delete(role);
return next;
})} />)}
</div>
</div>
</div>
</Dialog>
<ConfirmDialog
open={attachmentLockConfirmOpen}
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"