Consume governed recipients and add operational checks

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent fa4eb39e0b
commit 82ddc0c34c
15 changed files with 354 additions and 14 deletions
+39
View File
@@ -0,0 +1,39 @@
# Campaign Accessibility Review
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
alert, and segmented-control components. Core owns focus trapping, focus return,
Escape handling, labels, disabled state, and keyboard behavior for those shared
primitives.
## Repeatable review matrix
Run this matrix for Campaign overview, wizard, sender and recipients,
attachments, template editing, review and send, operator queue, and reports:
1. Navigate all actions with Tab and Shift+Tab; focus must remain visible and
follow the visual reading order.
2. Activate buttons and links with Enter, and native buttons with Space.
3. Open every dialog, verify initial focus remains within it, close with Escape,
and verify focus returns to the opener.
4. Use DataGrid sorting, filtering, pagination, row selection, and action menus
without a pointer.
5. At 200 percent browser zoom and a 320 CSS-pixel viewport, verify that content
reflows or scrolls without hiding actions.
6. With reduced motion enabled, verify that workflow state does not depend on
animation.
7. With a screen reader, verify page headings, field labels, validation errors,
workflow states, message navigation, and attachment evidence.
## Automated structural guard
`npm run test:accessibility-contract` rejects non-semantic click handlers,
unlabelled icon-only buttons in the message preview, and Campaign-local modal
implementations that bypass Core's `Dialog`. It complements rather than replaces
browser and assistive-technology testing.
## Known boundary
Translation keys may be visible in source because Core resolves them at runtime.
The review must use a built application with current language packages. WCAG
conformance is a release-level claim and still requires a bounded manual audit
of the release candidate.
+9 -3
View File
@@ -135,9 +135,15 @@ just the authoring form:
5. Confirm attachment behavior when a rule matches no files, ZIP/password
behavior, and any recipient-specific files.
6. Record review completion and the inspected message keys through the review
surface. The current baseline does not persist a distinct approve/reject
decision or review reason. If content, recipients, attachment inputs, owner
context, or non-secret transport identity changes, revalidate and rebuild.
surface. Validation, build, review, and exception evidence records the actor,
timestamp, and immutable build token/message digest where applicable. If
content, recipients, attachment inputs, owner context, or non-secret
transport identity changes, revalidate and rebuild.
This evidence is the Campaign input to separation-of-duties policy. Generic
approve/reject chains, delegation, substitutions, escalation, and signatures
belong to the optional Approvals capability. Campaign must not claim an
approval merely because validation, building, or message review completed.
Normal readers and reviewers see business state and safe evidence. Process-local
paths, storage keys, worker claim tokens, and raw provider diagnostics require
+13 -1
View File
@@ -27,6 +27,7 @@ from govoplan_core.core.modules import (
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_core.core.postbox import (
@@ -254,7 +255,7 @@ manifest = ModuleManifest(
),
ModuleInterfaceRequirement(
name="addresses.recipient_source",
version_min="0.1.0",
version_min="0.1.9",
version_max_exclusive="0.2.0",
optional=True,
),
@@ -750,6 +751,17 @@ manifest = ModuleManifest(
fromlist=["retention_capability"],
).retention_capability(context),
},
operational_check_providers=(
OperationalCheckProviderRegistration(
module_id="campaigns",
check_id="campaign.generated_eml_storage",
provider=lambda: __import__(
"govoplan_campaign.backend.operational_checks",
fromlist=["generated_eml_storage_check"],
).generated_eml_storage_check(),
cache_seconds=60,
),
),
)
@@ -0,0 +1,55 @@
from __future__ import annotations
import os
import secrets
from pathlib import Path
from govoplan_core.core.operations import OperationalCheck
from govoplan_campaign.backend.persistence.campaigns import BUILD_OUTPUT_DIR
def generated_eml_storage_check() -> OperationalCheck:
"""Verify the current EML evidence path and report its node-local boundary."""
root = Path(BUILD_OUTPUT_DIR)
probe = root / ".govoplan-health" / f"{secrets.token_hex(16)}.probe"
payload = secrets.token_bytes(64)
try:
probe.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
with probe.open("xb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
if probe.read_bytes() != payload:
raise OSError("generated EML persistence returned different bytes")
except OSError as exc:
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="error",
detail=(
"The generated EML evidence path failed a bounded durable-write probe "
f"({type(exc).__name__})."
),
readiness_critical=True,
metrics={"backend": "node_local_filesystem"},
)
finally:
try:
probe.unlink(missing_ok=True)
probe.parent.rmdir()
except OSError:
pass
return OperationalCheck(
id="campaign.generated_eml_storage",
label="Generated Campaign EML evidence",
state="warning",
detail=(
"Generated EML passed a local write/fsync/read probe, but remains node-local. "
"Use one shared runtime volume and include it in coordinated backups until "
"Campaign evidence is migrated to managed object storage."
),
metrics={"backend": "node_local_filesystem"},
)
@@ -356,7 +356,15 @@ def validate_campaign_version(
postbox_available=postbox_integration().available,
)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
report_json.update(
{
"ok": report.ok,
"error_count": report.error_count,
"warning_count": report.warning_count,
"validated_at": datetime.now(UTC).isoformat(),
"validated_by_user_id": user_id,
}
)
version.validation_summary = report_json
# Replace version-level semantic issues from previous validations.
@@ -642,6 +650,7 @@ def build_campaign_version(
tenant_id: str,
version_id: str,
write_eml: bool = True,
user_id: str | None = None,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
@@ -687,6 +696,7 @@ def build_campaign_version(
entries_by_index=entries_by_index,
)
report_json = _campaign_build_report(result, files)
report_json["built_by_user_id"] = user_id
version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
@@ -19,6 +19,7 @@ from govoplan_campaign.backend.schemas import (
CampaignRecipientAddressSourcesResponse,
CampaignRecipientAddressSourceSnapshotRequest,
CampaignRecipientAddressSourceSnapshotResponse,
CampaignRecipientSnapshotExcludedItem,
CampaignRecipientSnapshotItem,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
@@ -764,7 +765,11 @@ def snapshot_campaign_recipient_address_source(
)
try:
snapshot = getattr(capability, "snapshot")(
session, principal, source_id=payload.source_id
session,
principal,
source_id=payload.source_id,
purpose=payload.purpose,
requested_channels=("email",),
)
except ValueError as exc:
raise HTTPException(
@@ -775,6 +780,15 @@ def snapshot_campaign_recipient_address_source(
CampaignRecipientSnapshotItem.model_validate(_capability_payload(item))
for item in snapshot_payload.get("recipients", [])
]
excluded = [
CampaignRecipientSnapshotExcludedItem.model_validate(_capability_payload(item))
for item in snapshot_payload.get("excluded", [])
]
provenance = (
snapshot_payload.get("provenance")
if isinstance(snapshot_payload.get("provenance"), dict)
else {}
)
return CampaignRecipientAddressSourceSnapshotResponse(
source_id=str(snapshot_payload.get("source_id") or ""),
source_label=str(snapshot_payload.get("source_label") or ""),
@@ -782,9 +796,16 @@ def snapshot_campaign_recipient_address_source(
source_revision=str(snapshot_payload.get("source_revision") or ""),
generated_at=str(snapshot_payload.get("generated_at") or ""),
recipients=recipients,
provenance=snapshot_payload.get("provenance")
if isinstance(snapshot_payload.get("provenance"), dict)
else {},
excluded=excluded,
included_count=len(recipients),
excluded_count=len(excluded),
purpose=str(snapshot_payload.get("purpose") or payload.purpose),
provenance={
**provenance,
"campaign_id": campaign_id,
"purpose": payload.purpose,
"requested_channels": ["email"],
},
)
@@ -658,6 +658,7 @@ def build_version(
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
user_id=principal.user.id,
)
audit_from_principal(
session,
+17
View File
@@ -378,6 +378,7 @@ class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120)
class CampaignRecipientSnapshotItem(BaseModel):
@@ -389,6 +390,18 @@ class CampaignRecipientSnapshotItem(BaseModel):
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientSnapshotExcludedItem(BaseModel):
contact_id: str
display_name: str
channel: str
target: str
contact_point_id: str | None = None
status: str
reason_code: str | None = None
explanation: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
@@ -396,6 +409,10 @@ class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list)
included_count: int = 0
excluded_count: int = 0
purpose: str = "campaign_delivery"
provenance: dict[str, Any] = Field(default_factory=dict)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from unittest.mock import patch
from govoplan_campaign.backend.operational_checks import generated_eml_storage_check
def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None:
with patch(
"govoplan_campaign.backend.operational_checks.BUILD_OUTPUT_DIR",
tmp_path,
):
result = generated_eml_storage_check()
assert result.state == "warning"
assert "node-local" in result.detail
assert list(tmp_path.rglob("*.probe")) == []
+52
View File
@@ -78,6 +78,58 @@ class CampaignPartialValidationTests(unittest.TestCase):
self.assertTrue(response.available)
self.assertEqual(response.candidates[0].email, "ada@example.local")
def test_address_source_snapshot_preserves_governance_exclusions(self) -> None:
class RecipientSourceCapability:
def snapshot(self, _session, _principal, **kwargs):
self.kwargs = kwargs
return {
"source_id": "addresses:address_book:book-1",
"source_label": "Residents",
"source_kind": "local",
"source_revision": "revision-2",
"generated_at": "2026-07-31T10:00:00+00:00",
"purpose": "campaign_delivery",
"recipients": [
{
"contact_id": "contact-1",
"display_name": "Ada Lovelace",
"email": "ada@example.local",
"fields": {},
"provenance": {"channel_decision": {"governance_state": "opted_in"}},
}
],
"excluded": [
{
"contact_id": "contact-2",
"display_name": "Grace Hopper",
"channel": "email",
"target": "grace@example.local",
"status": "suppressed",
"reason_code": "addresses.channel.opted_out",
"provenance": {"channel_decision": {"governance_state": "opted_out"}},
}
],
"provenance": {"governance_applied": True},
}
capability = RecipientSourceCapability()
payload = router.CampaignRecipientAddressSourceSnapshotRequest(
source_id="addresses:address_book:book-1"
)
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(
router, "_registry_capability", return_value=capability
):
response = router.snapshot_campaign_recipient_address_source(
"campaign-1", payload, session=object(), principal=object()
)
self.assertEqual(capability.kwargs["purpose"], "campaign_delivery")
self.assertEqual(capability.kwargs["requested_channels"], ("email",))
self.assertEqual(response.included_count, 1)
self.assertEqual(response.excluded_count, 1)
self.assertEqual(response.excluded[0].reason_code, "addresses.channel.opted_out")
self.assertTrue(response.provenance["governance_applied"])
class CampaignSemanticValidationTests(unittest.TestCase):
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
+2 -1
View File
@@ -32,7 +32,8 @@
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs"
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
+17 -1
View File
@@ -256,6 +256,18 @@ export type CampaignRecipientSnapshotItem = {
provenance: Record<string, unknown>;
};
export type CampaignRecipientSnapshotExcludedItem = {
contact_id: string;
display_name: string;
channel: string;
target: string;
contact_point_id?: string | null;
status: string;
reason_code?: string | null;
explanation?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = {
source_id: string;
source_label: string;
@@ -263,6 +275,10 @@ export type CampaignRecipientAddressSourceSnapshot = {
source_revision: string;
generated_at: string;
recipients: CampaignRecipientSnapshotItem[];
excluded: CampaignRecipientSnapshotExcludedItem[];
included_count: number;
excluded_count: number;
purpose: string;
provenance: Record<string, unknown>;
};
@@ -714,7 +730,7 @@ sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST",
body: JSON.stringify({ source_id: sourceId })
body: JSON.stringify({ source_id: sourceId, purpose: "campaign_delivery" })
});
}
@@ -74,6 +74,14 @@ export default function AddressSourceImportDialog({
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
const exclusionCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const item of snapshot?.excluded ?? []) {
const reason = item.reason_code || item.status || "excluded";
counts.set(reason, (counts.get(reason) ?? 0) + 1);
}
return [...counts.entries()].sort((left, right) => right[1] - left[1]);
}, [snapshot]);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
@@ -276,14 +284,26 @@ export default function AddressSourceImportDialog({
</dd>
</div>
<div>
<dt>Recipients</dt>
<dd>{snapshot.recipients.length}</dd>
<dt>Included</dt>
<dd>{snapshot.included_count}</dd>
</div>
<div>
<dt>Excluded</dt>
<dd>{snapshot.excluded_count}</dd>
</div>
<div>
<dt>Revision</dt>
<dd className="mono-small">{snapshot.source_revision}</dd>
</div>
</dl>
{snapshot.excluded_count > 0 && (
<DismissibleAlert tone="warning" compact dismissible={false}>
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
{exclusionCounts.length > 0 && (
<span> {exclusionCounts.map(([reason, count]) => `${reason}: ${count}`).join(" · ")}</span>
)}
</DismissibleAlert>
)}
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
{snapshot.recipients.length > 20 && (
<p className="muted small-note">
@@ -98,7 +98,27 @@ export function createAddressSourceImportProvenance(
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
source_provenance: {
...snapshot.provenance,
generated_at: snapshot.generated_at,
purpose: snapshot.purpose,
included_count: snapshot.included_count,
excluded_count: snapshot.excluded_count,
included_decisions: snapshot.recipients.map((recipient) => ({
contact_id: recipient.contact_id,
email: recipient.email,
provenance: recipient.provenance
})),
exclusions: snapshot.excluded.map((item) => ({
contact_id: item.contact_id,
channel: item.channel,
target: item.target,
status: item.status,
reason_code: item.reason_code,
explanation: item.explanation,
provenance: item.provenance
}))
},
filename: null,
sheet_name: null,
encoding: null,
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const sourceRoot = path.resolve("src");
function sourceFiles(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(target);
return /\.(tsx|ts)$/.test(entry.name) ? [target] : [];
});
}
const sources = sourceFiles(sourceRoot).map((file) => ({
file,
value: fs.readFileSync(file, "utf8"),
}));
const nonSemanticClicks = sources.flatMap(({ file, value }) =>
[...value.matchAll(/<(div|span|li|tr)\b[^>]*\bonClick\s*=/g)].map(
(match) => `${path.relative(sourceRoot, file)}:${match.index}`,
),
);
assert.deepEqual(
nonSemanticClicks,
[],
`Use a semantic button/link instead of clickable layout elements: ${nonSemanticClicks.join(", ")}`,
);
const preview = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
"utf8",
);
for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
const buttonLine = preview
.split("\n")
.find((line) => line.includes(`<button`) && line.includes(`navigation.${handler}`));
assert.ok(
buttonLine?.includes("aria-label="),
`${handler} must remain an explicitly labelled button`,
);
}
for (const { file, value } of sources) {
if (!value.includes('role="dialog"')) continue;
assert.fail(
`${path.relative(sourceRoot, file)} implements a local dialog; use Core's Dialog component`,
);
}
console.log("Campaign accessibility structural contract passed.");