fix(campaign): align adaptive handbook with shipped UI

This commit is contained in:
2026-07-21 19:15:52 +02:00
parent c05bb8e474
commit bfbb86564c
5 changed files with 145 additions and 62 deletions

View File

@@ -26,6 +26,7 @@ _CAMPAIGN_USER_SCOPES = (
"campaigns:recipient:export",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:report:send",
)
_CAMPAIGN_HELP_CONTEXTS = (
@@ -108,10 +109,10 @@ CAMPAIGN_USER_DOCUMENTATION = (
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
order=30,
audience=("campaign_manager", "campaign_author"),
required_scopes=("campaigns:campaign:create",),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
route="/campaigns",
screen="Campaigns",
help_contexts=("campaigns.list", "campaign.overview"),
help_contexts=("campaigns.list",),
prerequisites=("You may create campaigns in the active tenant.",),
steps=(
"Open Campaigns and select New campaign.",
@@ -244,7 +245,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Save, validate, and build, then inspect the resolved attachment evidence in Review and send.",
),
outcome="Governed file versions linked to the campaign and frozen into the exact build evidence.",
verification="Attachment preview and the built-message evidence show the expected file versions, matches, checksums, and archive behavior.",
verification="Confirm the expected filenames, paths, matches, and link state in the Campaign UI. Exact managed versions and checksums remain retained in build evidence for authorized supporting tools.",
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
@@ -269,7 +270,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
route="/campaigns/{campaign_id}/review",
screen="Review and send",
help_contexts=("campaign.review-send",),
help_contexts=(),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"Its Mail profile remains available and authorized for the current campaign context.",
@@ -277,20 +278,21 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
steps=(
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
"Run the queue estimate or dry run when offered and resolve every blocker.",
"Queue the build for background delivery; use the synchronous path only for an intentionally small controlled run.",
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
"Invoke the Queue action through that client so background workers can process the build.",
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
),
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail", "notifications"),
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
),
_workflow_topic(
topic_id="campaigns.workflow.send-small-controlled-run",
title="Send a small controlled campaign run",
summary="Use the synchronous delivery action only for a deliberately small, fully reviewed campaign build.",
body="The synchronous action is intentionally bounded and does not replace the worker queue for ordinary batches. It preserves the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
title="Send a campaign immediately",
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
body="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
order=36,
audience=("campaign_sender", "campaign_operator"),
required_modules=("campaigns", "mail"),
@@ -306,7 +308,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
help_contexts=("campaign.review-send",),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"The run is small enough for the configured synchronous limit.",
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
"Its Mail profile remains available and authorized for the current campaign context.",
),
steps=(
@@ -315,19 +317,20 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Select Send now and confirm the protected delivery action.",
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
),
outcome="A bounded real delivery run with recipient-level attempt and outcome evidence.",
outcome="A synchronous real delivery run with recipient-level attempt and outcome evidence.",
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail",),
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
),
_workflow_topic(
topic_id="campaigns.workflow.view-delivery-report",
title="Review a campaign delivery report",
summary="Inspect aggregate delivery outcomes and, when separately authorized, recipient-level job evidence.",
body="Report access does not imply access to recipient details or diagnostics. Aggregate business state remains useful for readers, while recipient rows and infrastructure diagnostics require separate authority.",
title="Review campaign delivery details",
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
body="The current Report UI requires recipient-read authority in addition to report access. Infrastructure diagnostics remain separately authorized, and every report action is still checked by the server even where the current UI has not yet hidden an unavailable control.",
order=37,
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:report:read"),
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
route="/campaigns/{campaign_id}/report",
screen="Campaign Report",
help_contexts=("campaign.report",),
@@ -335,12 +338,13 @@ CAMPAIGN_USER_DOCUMENTATION = (
steps=(
"Open the campaign and select Report.",
"Review totals for queued, accepted, failed, cancelled, unattempted, and outcome-unknown jobs.",
"Inspect recipient-level rows and attempt history only when those details are available to your role.",
"Inspect the authorized recipient-level rows and attempt history.",
"Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.",
),
outcome="A role-appropriate view of delivery progress and outcomes without exposing system internals.",
verification="The report totals match the selected campaign version, and recipient or diagnostic details are absent unless separately authorized.",
outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.",
verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.",
related_topic_ids=("campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
limitations=("An aggregate-only Report Web UI for readers without recipient-read authority is not implemented yet.",),
),
_workflow_topic(
topic_id="campaigns.workflow.export-delivery-report",
@@ -351,7 +355,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
audience=("campaign_report_exporter", "campaign_auditor"),
required_scopes=(
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:recipient:read",
"campaigns:recipient:export",
),
route="/campaigns/{campaign_id}/report",
@@ -418,9 +424,12 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Reopen or query the campaign and confirm its state is Archived.",
),
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
verification="The campaign state is Archived and its report and audit evidence remain available to authorized readers.",
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
limitations=("The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",),
limitations=(
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
),
),
_workflow_topic(
topic_id="campaigns.workflow.delete-untouched-draft",
@@ -444,9 +453,12 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
),
outcome="An eligible untouched draft removed from active Campaigns with attributable deletion evidence.",
verification="The draft is no longer returned as an active campaign, while an authorized audit reader can verify who deleted it and when.",
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
related_topic_ids=("campaigns.workflow.archive-campaign",),
limitations=("The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",),
limitations=(
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
),
),
)
@@ -465,19 +477,21 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
return ()
current_configuration = list(_actor_capabilities(principal))
mail_available = _integration_available(context.registry, _MAIL_INTEGRATION)
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
current_configuration.extend(integration_configuration)
limitations = [
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
*integration_limitations,
]
return (
DocumentationTopic(
id="campaigns.current-composition",
title="Campaign capabilities available to you",
summary="This guide reflects your current Campaign actions and the connected services available in this installation.",
title="Your Campaign role and installed composition",
summary="This guide separates actions authorized by your role from optional services connected to Campaign in this installation.",
body=_composition_body(current_configuration, limitations),
layer="configured",
documentation_types=("user",),
@@ -504,7 +518,7 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
)
def _actor_capabilities(principal: object) -> tuple[str, ...]:
def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str, ...]:
capabilities: list[str] = []
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
@@ -516,12 +530,14 @@ def _actor_capabilities(principal: object) -> tuple[str, ...]:
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
if mail_available:
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
if mail_available:
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
_append_if(
capabilities,
@@ -533,6 +549,14 @@ def _actor_capabilities(principal: object) -> tuple[str, ...]:
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
if mail_available:
_append_if(
capabilities,
principal,
("campaigns:report:send", "campaigns:report:read", "mail:profile:use"),
"Send an authorized campaign report through a Mail profile.",
require_all=True,
)
return tuple(capabilities)
@@ -541,8 +565,18 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
limitations: list[str] = []
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
if mail_available and _has_scope(principal, "mail:profile:use"):
configured.append("Mail profile selection is available for Campaign delivery steps you are authorized to perform.")
can_select_mail_profile = _has_all_scopes(
principal,
("campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"),
)
can_deliver_with_mail = _has_scope(principal, "mail:profile:use") and _has_any_scope(
principal,
("campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry"),
)
if mail_available and can_select_mail_profile:
configured.append("Installed composition: Campaign can open Mail's actor-filtered profile picker while you edit; eligible profiles are resolved in the selected campaign context.")
elif mail_available and can_deliver_with_mail:
configured.append("Installed composition: Mail-backed Campaign delivery is connected, and the selected profile is re-authorized for each delivery action.")
elif mail_available:
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
else:
@@ -555,12 +589,19 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
)
can_link_files = _has_all_scopes(
principal,
("campaigns:campaign:validate", "campaigns:recipient:read", "files:file:read", "files:file:share"),
(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:campaign:validate",
"campaigns:recipient:read",
"files:file:read",
"files:file:share",
),
)
if files_available and can_link_files:
configured.append("Managed file versions can be selected, previewed, and linked as governed campaign attachments.")
configured.append("Installed composition: Managed file versions can be selected, previewed, and linked as governed campaign attachments when the selected campaign and files pass their resource checks.")
elif files_available and can_preview_files:
configured.append("Managed campaign attachments can be previewed; linking a version requires separate attachment-sharing authority.")
configured.append("Installed composition: Managed campaign attachments can be previewed; linking a version requires campaign-update, validation, and attachment-sharing authority.")
elif files_available:
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
else:
@@ -569,20 +610,30 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
configured.append("Address records can be looked up while preparing recipients.")
configured.append("Installed composition: Address records can be looked up while preparing recipients.")
elif lookup_available:
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
else:
limitations.append("Connected address lookup is not available in the current composition.")
if source_available and _has_scope(principal, "campaigns:recipient:import"):
configured.append("An authorized address source can be copied into a campaign as a traceable recipient snapshot.")
can_import_source = _has_all_scopes(
principal,
(
"campaigns:campaign:read",
"campaigns:campaign:update",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
),
)
if source_available and can_import_source:
configured.append("Installed composition: The actor-filtered Addresses source picker can copy a selected source into a campaign as a traceable recipient snapshot.")
elif source_available:
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
else:
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
configured.append("In-app notifications can report important Campaign delivery status changes.")
configured.append("Installed composition: In-app notifications can report important Campaign delivery status changes.")
else:
limitations.append("Automatic in-app Campaign status notifications are not configured.")
@@ -599,7 +650,7 @@ def _append_if(
) -> None:
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
if allowed:
target.append(text)
target.append(f"Role authorization: {text}")
def _has_scope(principal: object, scope: str) -> bool:
@@ -650,6 +701,5 @@ def _registry_has_capability(registry: object, capability_name: str) -> bool:
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
configured_lines = "\n".join(f"- {item}" for item in current_configuration)
limitation_lines = "\n".join(f"- {item}" for item in limitations)
return f"Available in your current Campaign workspace:\n{configured_lines}\n\nLimits that still apply:\n{limitation_lines}"
del current_configuration, limitations
return "The facts below are calculated for the current actor and installed module contracts. They do not bypass campaign ownership, sharing, state, or operation-time resource and policy checks."