diff --git a/README.md b/README.md
index 67dbbbf..be22f3a 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,9 @@ Distribution Lists owns:
- German-administration `Verteiler` semantics
- static, parameterized, and Dataflow-backed dynamic audience definitions
- expansion of mixed entries into concrete delivery/recipient targets
-- snapshot DTOs with source revision and provenance
+- purpose-aware Addresses contact-point resolution for email and postal targets
+- snapshot DTOs with exact rendered targets, contact-point IDs, source revision,
+ fingerprint, and decision provenance
- stale-source detection for expanded entries
- eligibility and policy explanations for disabled recipient entries
- audit evidence for who was resolved at execution time
diff --git a/docs/ADREMA_CAPABILITY_ASSESSMENT.md b/docs/ADREMA_CAPABILITY_ASSESSMENT.md
index b68c81f..a5cb077 100644
--- a/docs/ADREMA_CAPABILITY_ASSESSMENT.md
+++ b/docs/ADREMA_CAPABILITY_ASSESSMENT.md
@@ -180,7 +180,7 @@ typed output template, and Report/Campaign run configuration.
| Multiple email, phone, and postal contact points | Implemented | Addresses |
| Static address lists | Implemented | Addresses |
| vCard import/export and CardDAV sync | Implemented | Addresses |
-| Read-only lookup and immutable email-recipient snapshots | Implemented | Addresses |
+| Read-only lookup and immutable email/postal recipient snapshots | Implemented | Addresses/Distribution Lists |
| Source provenance and sync conflicts | Implemented | Addresses |
| Organization units and function definitions | Implemented | Organizations |
| Effective-dated identity-to-function assignments | Implemented first slice | IDM |
@@ -190,10 +190,10 @@ typed output template, and Report/Campaign run configuration.
| Campaign snapshots, review, digital delivery, and evidence | Implemented first channel | Campaign |
| Typed template library and document rendering | Scaffold/issue only | Templates |
| BI reports and spreadsheet-grade exports | Scaffold/issue only | Reporting |
-| Mixed operational distribution lists and governed segments | Contract/implementation issues only | Distribution Lists |
-| Contact-point consent, suppression, and preferences | Open issue | Addresses/Policy |
-| Deduplication, merge, quality, and complete history | Open issue | Addresses |
-| Channel-neutral expansion and frozen postal targets | Missing | Distribution Lists/Addresses |
+| Mixed operational distribution lists and governed segments | Implemented broad first slice | Distribution Lists |
+| Contact-point consent, suppression, and preferences | Implemented | Addresses/Policy |
+| Deduplication, merge, quality, and complete history | Implemented first slice | Addresses |
+| Channel-neutral expansion and frozen postal targets | Implemented | Distribution Lists/Addresses |
| Printable label, envelope, and letter output | Open issue | Templates |
| End-to-end guided AdreMa workflow | Missing | Workflow plus consuming modules |
diff --git a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md
index 783d1a9..52cfc46 100644
--- a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md
+++ b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md
@@ -83,6 +83,16 @@ The first capabilities should be:
- `dist_lists.expand`: expand a distribution list into immutable recipient rows.
- `dist_lists.writer`: explain whether a caller can create or change a list.
+When `addresses.contact_point_resolution` is available, address-contact and
+address-list entries are resolved through that versioned contract. Entry and
+expansion channel constraints are intersected, and purpose, effective time,
+address purpose, fallback rule, locale, and postal format are passed to the
+provider. Selected and rejected contact points retain stable reason codes and
+provider provenance. The frozen Distribution Lists snapshot stores the exact
+rendered target and contact-point ID, so later address changes do not rewrite
+delivery evidence. Legacy Addresses lookup/email-source capabilities are used
+only as a compatibility fallback.
+
Consumers must resolve those capabilities through core and must not import
distribution-list ORM or service internals.
@@ -98,9 +108,9 @@ Likely consumers:
## Relationship To Address Lists
-`govoplan-addresses` should implement classical address lists first. A
-distribution list may later include an address list as an entry, but the
-address module remains the owner of contact records and address-only grouping.
+`govoplan-addresses` implements classical address lists. A distribution list
+may include an address list as an entry, but the address module remains the
+owner of contact records and address-only grouping.
The distribution-list module owns mixed operational routing. This prevents
campaign-specific recipient logic from becoming the platform's de facto
diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
index 55db05e..2f2db25 100644
--- a/docs/IMPLEMENTATION_PLAN.md
+++ b/docs/IMPLEMENTATION_PLAN.md
@@ -44,13 +44,17 @@ Goal: allow distribution lists to resolve through installed modules.
Tasks:
-- [x] integrate with `addresses.lookup` and address-list source capability
+- [x] integrate with versioned, channel-neutral Addresses contact-point
+ resolution; retain lookup/address-list source capabilities as compatibility
+ fallbacks
- [x] integrate with IDM identity and effective function-assignment resolution
- [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract;
until then the selector and expansion return an explicit unavailable outcome
- [x] integrate with Organizations unit/function resolution
- [x] consume governed Datasource/Connector states through pinned Dataflow outputs
- [x] apply Addresses preference/consent facts and explainable Policy decisions
+- [x] freeze exact postal/email targets, contact-point IDs, revisions,
+ fingerprints, and decision provenance in expansion snapshots
- [x] preserve module independence when optional providers are absent
## Milestone 4: UI
diff --git a/src/govoplan_dist_lists/backend/expansion.py b/src/govoplan_dist_lists/backend/expansion.py
index 24affe3..fc35a51 100644
--- a/src/govoplan_dist_lists/backend/expansion.py
+++ b/src/govoplan_dist_lists/backend/expansion.py
@@ -16,6 +16,14 @@ from govoplan_core.core.dataflows import (
DataflowDatasetRequest,
dataflow_dataset_output,
)
+from govoplan_core.core.contact_points import (
+ CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
+ ContactPointCandidate,
+ ContactPointResolution,
+ ContactPointResolutionProvider,
+ ContactPointResolutionRequest,
+ ContactPointSourceRequest,
+)
from govoplan_core.core.distribution_lists import (
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
@@ -566,6 +574,199 @@ def _nested_list_recipients(
def _address_recipients(
context: _ExpansionContext,
entry: DistributionListEntryRef,
+) -> list[DistributionRecipientRef]:
+ provider = _typed_capability(
+ context.registry,
+ CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
+ ContactPointResolutionProvider,
+ )
+ if provider is not None:
+ return _contact_point_recipients(context, entry, provider)
+ return _legacy_address_recipients(context, entry)
+
+
+def _contact_point_recipients(
+ context: _ExpansionContext,
+ entry: DistributionListEntryRef,
+ provider: ContactPointResolutionProvider,
+) -> list[DistributionRecipientRef]:
+ requested_channels, constrained = _requested_channel_filter(context, entry)
+ if constrained and not requested_channels:
+ return [
+ _unresolved(
+ entry,
+ "channel.no_common_selection",
+ "The entry and expansion request do not allow a common delivery channel.",
+ status="suppressed",
+ )
+ ]
+ if entry.kind == "address_list":
+ return _contact_point_source_recipients(
+ context,
+ entry,
+ provider,
+ requested_channels=requested_channels,
+ )
+ try:
+ resolution = provider.resolve_contact_points(
+ context.session,
+ context.principal,
+ request=ContactPointResolutionRequest(
+ tenant_id=context.principal.tenant_id,
+ subject=entry.source,
+ effective_at=context.effective_at,
+ purpose=entry.purpose or context.request.purpose,
+ requested_channels=requested_channels,
+ address_purpose=_text(entry.configuration.get("address_purpose")),
+ fallback_rule=_contact_point_fallback_rule(entry),
+ locale=_text(entry.configuration.get("locale")),
+ postal_format=_postal_format(entry),
+ context={
+ "distribution_list_id": context.request.list_id,
+ "distribution_list_entry_id": entry.id,
+ },
+ ),
+ )
+ except (LookupError, PermissionError, ValueError) as exc:
+ return [_unresolved(entry, "addresses.contact_point_failed", str(exc))]
+ contact_revision = _contact_record_revision(resolution)
+ stale = _is_stale(
+ entry.source,
+ actual_revision=contact_revision,
+ actual_fingerprint=resolution.source_fingerprint,
+ )
+ context.evidence.append(
+ _contact_point_evidence(
+ entry,
+ actual_revision=contact_revision or resolution.source_revision,
+ actual_fingerprint=resolution.source_fingerprint,
+ stale=stale,
+ details={
+ "contract_version": resolution.contract_version,
+ "contact_id": resolution.contact_id,
+ "contact_point_revision": resolution.source_revision,
+ "contact_point_fingerprint": resolution.source_fingerprint,
+ "provenance": dict(resolution.provenance),
+ },
+ )
+ )
+ return [
+ _contact_point_recipient(
+ entry,
+ resolution,
+ stale=stale,
+ expected_email=(
+ _text(entry.configuration.get("email"))
+ or _text(entry.source.metadata.get("email"))
+ if entry.kind == "address_email"
+ else None
+ ),
+ )
+ ]
+
+
+def _contact_point_source_recipients(
+ context: _ExpansionContext,
+ entry: DistributionListEntryRef,
+ provider: ContactPointResolutionProvider,
+ *,
+ requested_channels: tuple[str, ...],
+) -> list[DistributionRecipientRef]:
+ source_id = _contact_point_source_id(entry)
+ source_request = ContactPointSourceRequest(
+ tenant_id=context.principal.tenant_id,
+ source_id=source_id,
+ effective_at=context.effective_at,
+ purpose=entry.purpose or context.request.purpose,
+ requested_channels=requested_channels, # type: ignore[arg-type]
+ address_purpose=_text(entry.configuration.get("address_purpose")),
+ fallback_rule=_contact_point_fallback_rule(entry),
+ locale=_text(entry.configuration.get("locale")),
+ postal_format=_postal_format(entry),
+ max_items=20_000,
+ context={
+ "distribution_list_id": context.request.list_id,
+ "distribution_list_entry_id": entry.id,
+ },
+ )
+ resolutions: list[ContactPointResolution] = []
+ preview = None
+ source_revision: str | None = None
+ source_fingerprint: str | None = None
+ offset = 0
+ provider_limit = context.request.limits.max_provider_results
+ try:
+ while len(resolutions) < provider_limit:
+ page_limit = min(500, provider_limit - len(resolutions))
+ preview = provider.preview_source(
+ context.session,
+ context.principal,
+ request=source_request,
+ offset=offset,
+ limit=page_limit,
+ )
+ if source_revision is None:
+ source_revision = preview.source_revision
+ source_fingerprint = preview.source_fingerprint
+ elif (
+ preview.source_revision != source_revision
+ or preview.source_fingerprint != source_fingerprint
+ ):
+ return [
+ _unresolved(
+ entry,
+ "addresses.source_changed",
+ "The Addresses source changed while it was being expanded; retry the expansion.",
+ status="stale",
+ )
+ ]
+ resolutions.extend(preview.resolutions)
+ offset += len(preview.resolutions)
+ if not preview.has_more or not preview.resolutions:
+ break
+ except (LookupError, PermissionError, ValueError) as exc:
+ return [_unresolved(entry, "addresses.source_failed", str(exc))]
+ if preview is None:
+ return []
+ stale = _is_stale(
+ entry.source,
+ actual_revision=source_revision,
+ actual_fingerprint=source_fingerprint,
+ )
+ context.evidence.append(
+ _contact_point_evidence(
+ entry,
+ actual_revision=source_revision,
+ actual_fingerprint=source_fingerprint,
+ stale=stale,
+ generated_at=preview.generated_at,
+ details={
+ "contract_version": preview.contract_version,
+ "source_id": source_id,
+ "total_count": preview.total_count,
+ "provenance": dict(preview.provenance),
+ },
+ )
+ )
+ if preview.total_count > len(resolutions):
+ context.candidate_truncated = True
+ context.diagnostics.append(
+ _explanation(
+ "provider.result_limit",
+ "Addresses results were truncated by the provider limit.",
+ provider="addresses",
+ source=entry.source,
+ )
+ )
+ return [
+ _contact_point_recipient(entry, resolution, stale=stale)
+ for resolution in resolutions
+ ]
+
+
+def _legacy_address_recipients(
+ context: _ExpansionContext,
+ entry: DistributionListEntryRef,
) -> list[DistributionRecipientRef]:
if entry.kind == "address_list":
provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE)
@@ -631,6 +832,194 @@ def _address_recipients(
return [_address_lookup_recipient(entry, item) for item in exact]
+def _contact_point_recipient(
+ entry: DistributionListEntryRef,
+ resolution: ContactPointResolution,
+ *,
+ stale: bool,
+ expected_email: str | None = None,
+) -> DistributionRecipientRef:
+ accepted = list(resolution.candidates)
+ rejected = list(resolution.excluded)
+ if expected_email is not None:
+ matching: list[ContactPointCandidate] = []
+ for candidate in accepted:
+ if candidate.channel == "email" and candidate.target.casefold() == expected_email.casefold():
+ matching.append(candidate)
+ else:
+ rejected.append(
+ replace(
+ candidate,
+ status="suppressed",
+ reason_code="addresses.email.not_selected",
+ explanation="This contact point is not the email address selected by the list entry.",
+ )
+ )
+ accepted = matching
+ channels = tuple(
+ _distribution_channel_candidate(item, entry=entry, stale=stale)
+ for item in (*accepted, *rejected)
+ )
+ selected_channels = channels[: len(accepted)]
+ if any(item.status == "usable" for item in selected_channels):
+ status = "usable"
+ elif any(item.status == "stale" for item in selected_channels):
+ status = "stale"
+ else:
+ status = _recipient_outcome(channels, resolution.status)
+ explanations = [*resolution.explanations]
+ explanations.extend(
+ DistributionExplanation(
+ code=item.reason_code,
+ message=item.explanation,
+ severity="warning",
+ provider="addresses",
+ source=item.source or entry.source,
+ provenance=dict(item.provenance),
+ )
+ for item in rejected
+ if item.reason_code and item.explanation
+ )
+ contact_id = resolution.contact_id
+ return DistributionRecipientRef(
+ recipient_key=f"contact:{contact_id}" if contact_id else f"unresolved:{entry.id}",
+ display_name=(
+ resolution.display_name
+ or entry.label
+ or entry.source.label
+ or contact_id
+ or entry.source.resource_id
+ ),
+ status=status, # type: ignore[arg-type]
+ channels=channels,
+ contact_id=contact_id,
+ source_entry_ids=(entry.id,),
+ explanations=tuple(_unique_explanations(explanations)),
+ attributes={
+ "contact_points": [
+ {
+ "channel": item.channel,
+ "contact_point_id": item.contact_point_id,
+ "address_purpose": item.address_purpose,
+ "value": dict(item.value),
+ }
+ for item in (*accepted, *rejected)
+ ],
+ },
+ provenance={
+ **_entry_provenance(entry),
+ "contact_point_resolution": {
+ "contract_version": resolution.contract_version,
+ "source_revision": resolution.source_revision,
+ "source_fingerprint": resolution.source_fingerprint,
+ "provenance": dict(resolution.provenance),
+ },
+ "channel_facts_resolved": True,
+ },
+ )
+
+
+def _distribution_channel_candidate(
+ candidate: ContactPointCandidate,
+ *,
+ entry: DistributionListEntryRef,
+ stale: bool,
+) -> DistributionChannelCandidate:
+ status = candidate.status
+ reason_code = candidate.reason_code
+ explanation = candidate.explanation
+ if stale and status == "usable":
+ status = "stale"
+ reason_code = "source.stale"
+ explanation = "The source revision changed."
+ return DistributionChannelCandidate(
+ channel=candidate.channel,
+ target=candidate.target,
+ target_key=candidate.target_key,
+ status=status,
+ contact_point_id=candidate.contact_point_id,
+ locale=candidate.locale,
+ preferred=candidate.preferred,
+ reason_code=reason_code,
+ explanation=explanation,
+ source=candidate.source or entry.source,
+ decision_provenance={
+ **dict(candidate.provenance),
+ "address_purpose": candidate.address_purpose,
+ "preference_rank": candidate.preference_rank,
+ "source_revision": candidate.source_revision,
+ "preference_revision": candidate.preference_revision,
+ "consent_revision": candidate.consent_revision,
+ "value": dict(candidate.value),
+ },
+ )
+
+
+def _contact_point_evidence(
+ entry: DistributionListEntryRef,
+ *,
+ actual_revision: str | None,
+ actual_fingerprint: str | None,
+ stale: bool,
+ details: Mapping[str, object],
+ generated_at: datetime | None = None,
+) -> DistributionProviderEvidence:
+ return DistributionProviderEvidence(
+ provider="addresses",
+ source=entry.source,
+ actual_revision=actual_revision,
+ actual_fingerprint=actual_fingerprint,
+ stale=stale,
+ generated_at=generated_at,
+ details=details,
+ )
+
+
+def _contact_record_revision(resolution: ContactPointResolution) -> str | None:
+ revisions = {
+ str(revision).strip()
+ for candidate in (*resolution.candidates, *resolution.excluded)
+ if (revision := candidate.provenance.get("source_revision")) is not None
+ and str(revision).strip()
+ }
+ return next(iter(revisions)) if len(revisions) == 1 else None
+
+
+def _contact_point_source_id(entry: DistributionListEntryRef) -> str:
+ resource_id = entry.source.resource_id
+ if resource_id.startswith("addresses:"):
+ return resource_id
+ resource_type = entry.source.resource_type
+ if resource_type in {"address_book", "book"}:
+ return f"addresses:address_book:{resource_id}"
+ return f"addresses:address_list:{resource_id}"
+
+
+def _contact_point_fallback_rule(entry: DistributionListEntryRef):
+ value = _text(entry.configuration.get("fallback_rule")) or "primary"
+ return value if value in {"none", "primary", "any"} else "primary"
+
+
+def _postal_format(entry: DistributionListEntryRef):
+ value = _text(entry.configuration.get("postal_format")) or "domestic"
+ return value if value in {"domestic", "international"} else "domestic"
+
+
+def _requested_channel_filter(
+ context: _ExpansionContext,
+ entry: DistributionListEntryRef,
+) -> tuple[tuple[str, ...], bool]:
+ entry_channels = set(entry.requested_channels)
+ if entry.kind == "address_email":
+ entry_channels = {"email"}
+ caller_channels = set(context.request.requested_channels)
+ if entry_channels and caller_channels:
+ selected = entry_channels.intersection(caller_channels)
+ else:
+ selected = entry_channels or caller_channels
+ return tuple(sorted(selected)), bool(entry_channels or caller_channels)
+
+
def _address_snapshot_recipient(
entry: DistributionListEntryRef,
item: object,
@@ -1063,7 +1452,11 @@ def _apply_channel_decisions(
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
RecipientChannelFactsProvider,
)
- if facts_provider is not None and recipient.source_entry_ids:
+ if (
+ facts_provider is not None
+ and recipient.source_entry_ids
+ and not recipient.provenance.get("channel_facts_resolved")
+ ):
source = channels[0].source if channels else None
if source is not None:
facts = facts_provider.resolve_channel_facts(
@@ -1097,12 +1490,13 @@ def _apply_channel_decisions(
else set()
)
requested_by_caller = set(context.request.requested_channels)
+ has_channel_constraint = bool(requested_by_entry or requested_by_caller)
requested_channels = (
requested_by_entry.intersection(requested_by_caller)
if requested_by_entry and requested_by_caller
else requested_by_entry or requested_by_caller
)
- if requested_channels:
+ if has_channel_constraint:
channels = [
item
if item.channel in requested_channels
diff --git a/src/govoplan_dist_lists/backend/manifest.py b/src/govoplan_dist_lists/backend/manifest.py
index 9e0614f..12e3f6a 100644
--- a/src/govoplan_dist_lists/backend/manifest.py
+++ b/src/govoplan_dist_lists/backend/manifest.py
@@ -7,6 +7,9 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT
+from govoplan_core.core.contact_points import (
+ CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
+)
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
CAPABILITY_DISTRIBUTION_LIST_SOURCE,
@@ -30,6 +33,7 @@ from govoplan_core.core.modules import (
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
+ ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
@@ -95,6 +99,24 @@ DOCUMENTATION = (
related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"),
metadata={"seed": True},
),
+ DocumentationTopic(
+ id=f"{MODULE_ID}.address-contact-resolution",
+ title="Address contact-point resolution",
+ summary="Resolve address contacts and lists into purpose-aware email and postal candidates.",
+ body=(
+ "When Addresses is enabled, Distribution Lists uses its versioned contact-point contract "
+ "to resolve communication purpose, requested channels, address purpose, fallback, locale, "
+ "quality, preference, consent, and suppression. Expansion previews explain rejected points. "
+ "Frozen Distribution List snapshots retain the exact rendered email or postal target, contact-"
+ "point ID, source revision and fingerprint, and decision provenance. If the new contract is "
+ "unavailable, older Addresses lookup and email-source capabilities remain a compatibility fallback."
+ ),
+ layer="available",
+ documentation_types=("admin", "user"),
+ audience=("operator", "module_admin", "product_owner"),
+ related_modules=("addresses", "campaigns", "policy"),
+ metadata={"seed": True},
+ ),
)
@@ -162,6 +184,7 @@ manifest = ModuleManifest(
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_DATAFLOW_DATASET_OUTPUT,
+ CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
CAPABILITY_RECIPIENT_CHANNEL_FACTS,
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS,
),
@@ -179,6 +202,20 @@ manifest = ModuleManifest(
version=MODULE_VERSION,
),
),
+ requires_interfaces=(
+ ModuleInterfaceRequirement(
+ name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION,
+ version_min="1.0.0",
+ version_max_exclusive="2.0.0",
+ optional=True,
+ ),
+ ModuleInterfaceRequirement(
+ name=CAPABILITY_RECIPIENT_CHANNEL_FACTS,
+ version_min="0.1.0",
+ version_max_exclusive="0.2.0",
+ optional=True,
+ ),
+ ),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 7329200..0c78926 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -15,6 +15,17 @@ class DistributionListsManifestTests(unittest.TestCase):
self.assertFalse(manifest.dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
+ self.assertIn(
+ "addresses.contact_point_resolution",
+ manifest.optional_capabilities,
+ )
+ requirements = {item.name: item for item in manifest.requires_interfaces}
+ self.assertTrue(requirements["addresses.contact_point_resolution"].optional)
+ self.assertEqual(
+ "1.0.0",
+ requirements["addresses.contact_point_resolution"].version_min,
+ )
+ self.assertTrue(requirements["addresses.channel_facts"].optional)
self.assertIn("dist_lists.expand", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions})
self.assertIsNotNone(manifest.route_factory)
diff --git a/tests/test_service.py b/tests/test_service.py
index c99a2be..7aaa4ed 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -6,6 +6,12 @@ import unittest
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.concurrency import RevisionConflictError
+from govoplan_core.core.contact_points import (
+ CONTACT_POINT_CONTRACT_VERSION,
+ ContactPointCandidate,
+ ContactPointResolution,
+ ContactPointSourcePreview,
+)
from govoplan_core.core.dataflows import (
DataflowDatasetDescriptor,
DataflowDatasetResult,
@@ -14,7 +20,9 @@ from govoplan_core.core.distribution_lists import (
DistributionChannelPolicyDecision,
DistributionExpansionLimits,
DistributionExpansionRequest,
+ DistributionExplanation,
DistributionListConflictError,
+ DistributionSourceReference,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
@@ -151,6 +159,120 @@ class _Policy:
)
+class _ContactPoints:
+ def __init__(self) -> None:
+ self.resolve_request = None
+ self.source_requests = []
+ source = DistributionSourceReference(
+ provider="addresses",
+ resource_type="contact",
+ resource_id="contact-1",
+ revision="contact-revision-1",
+ fingerprint="contact-fingerprint-1",
+ label="Ada Example",
+ )
+ self.resolution = ContactPointResolution(
+ contract_version=CONTACT_POINT_CONTRACT_VERSION,
+ subject=source,
+ status="usable",
+ contact_id="contact-1",
+ display_name="Ada Example",
+ candidates=(
+ ContactPointCandidate(
+ channel="postal",
+ target="Ada Example\nMain Street 1\n10115 Berlin\nGermany",
+ target_key="postal:main street 1|10115|berlin||germany",
+ status="usable",
+ contact_point_id="postal-1",
+ address_purpose="official",
+ locale="de-DE",
+ preferred=True,
+ preference_rank=1,
+ source=source,
+ source_revision="facts-revision-1",
+ preference_revision="preference-revision-1",
+ consent_revision="consent-revision-1",
+ value={
+ "street": "Main Street 1",
+ "postal_code": "10115",
+ "locality": "Berlin",
+ "country": "Germany",
+ },
+ provenance={
+ "rule_ids": ["rule-1"],
+ "source_revision": "contact-revision-1",
+ },
+ ),
+ ),
+ excluded=(
+ ContactPointCandidate(
+ channel="email",
+ target="ada@example.test",
+ target_key="email:ada@example.test",
+ status="suppressed",
+ contact_point_id="email-1",
+ reason_code="addresses.channel.opted_out",
+ explanation="The contact opted out of email delivery.",
+ source=source,
+ source_revision="facts-revision-1",
+ provenance={
+ "rule_ids": ["rule-2"],
+ "source_revision": "contact-revision-1",
+ },
+ ),
+ ),
+ explanations=(
+ DistributionExplanation(
+ code="addresses.channel_fact.expired",
+ message="An older preference expired.",
+ severity="info",
+ provider="addresses",
+ source=source,
+ ),
+ ),
+ source_revision="facts-revision-1",
+ source_fingerprint="facts-fingerprint-1",
+ provenance={"active_rule_ids": ["rule-1", "rule-2"]},
+ )
+
+ def resolve_contact_points(self, session, principal, *, request):
+ del session, principal
+ self.resolve_request = request
+ return self.resolution
+
+ def preview_source(self, session, principal, *, request, offset=0, limit=100):
+ del session, principal
+ self.source_requests.append(request)
+ resolutions = (self.resolution, self.resolution)[offset : offset + limit]
+ return ContactPointSourcePreview(
+ contract_version=CONTACT_POINT_CONTRACT_VERSION,
+ source=DistributionSourceReference(
+ provider="addresses",
+ resource_type="address_list",
+ resource_id="list-1",
+ ),
+ request=request,
+ resolutions=resolutions,
+ total_count=2,
+ usable_count=len(resolutions),
+ excluded_count=len(resolutions),
+ offset=offset,
+ limit=limit,
+ has_more=offset + len(resolutions) < 2,
+ source_revision="list-revision-1",
+ source_fingerprint="list-fingerprint-1",
+ generated_at=datetime(2026, 1, 1, tzinfo=UTC),
+ provenance={"bounded": True},
+ )
+
+ def freeze_source(self, session, principal, *, request):
+ raise AssertionError("Distribution Lists freezes its complete expansion snapshot.")
+
+ def get_snapshot(self, session, principal, *, snapshot_id):
+ del session, principal, snapshot_id
+ return None
+
+
class _Registry:
def __init__(self, capabilities=None) -> None:
self.capabilities = dict(capabilities or {})
@@ -494,6 +616,134 @@ class DistributionListServiceTests(unittest.TestCase):
self.assertEqual("channel.not_requested", by_channel["email"].reason_code)
self.assertEqual("usable", by_channel["postal"].status)
+ def test_address_contact_resolution_is_purpose_aware_and_frozen(self) -> None:
+ contact_points = _ContactPoints()
+ registry = _Registry(
+ {"addresses.contact_point_resolution": contact_points}
+ )
+ with self.database.session() as session:
+ item, _ = create_distribution_list(
+ session,
+ principal(),
+ DistributionListCreateRequest.model_validate(
+ {
+ "name": "Official postal recipients",
+ "entries": [
+ {
+ "entry_key": "ada",
+ "kind": "address_contact",
+ "purpose": "official_notice",
+ "requested_channels": ["postal"],
+ "source": {
+ "provider": "addresses",
+ "resource_type": "contact",
+ "resource_id": "contact-1",
+ "revision": "contact-revision-1",
+ },
+ "configuration": {
+ "address_purpose": "official",
+ "fallback_rule": "none",
+ "locale": "de-DE",
+ "postal_format": "international",
+ },
+ }
+ ],
+ }
+ ),
+ )
+ result = expand_distribution_list(
+ session,
+ principal(),
+ registry=registry,
+ request=DistributionExpansionRequest(
+ list_id=item.id,
+ effective_at=datetime(2026, 1, 1, tzinfo=UTC),
+ requested_channels=("postal",),
+ freeze=True,
+ idempotency_key="official-postal-1",
+ ),
+ )
+ session.commit()
+
+ self.assertEqual(1, len(result.recipients))
+ recipient = result.recipients[0]
+ self.assertEqual("contact-1", recipient.contact_id)
+ self.assertEqual("postal-1", recipient.channels[0].contact_point_id)
+ self.assertIn("Main Street 1", recipient.channels[0].target)
+ self.assertEqual("suppressed", recipient.channels[1].status)
+ self.assertEqual(
+ "addresses.channel.opted_out",
+ recipient.channels[1].reason_code,
+ )
+ self.assertEqual("official_notice", contact_points.resolve_request.purpose)
+ self.assertEqual("official", contact_points.resolve_request.address_purpose)
+ self.assertEqual("international", contact_points.resolve_request.postal_format)
+ self.assertEqual(
+ "1.0",
+ recipient.provenance["contact_point_resolution"]["contract_version"],
+ )
+ snapshot = session.get(DistributionListSnapshot, result.snapshot_id)
+ self.assertIn("Main Street 1", snapshot.recipients[0]["channels"][0]["target"])
+ self.assertEqual(
+ "postal-1",
+ snapshot.recipients[0]["channels"][0]["contact_point_id"],
+ )
+ self.assertEqual(
+ "facts-fingerprint-1",
+ snapshot.provider_evidence[0]["actual_fingerprint"],
+ )
+
+ def test_address_list_resolution_is_bounded_and_channel_neutral(self) -> None:
+ contact_points = _ContactPoints()
+ registry = _Registry(
+ {"addresses.contact_point_resolution": contact_points}
+ )
+ with self.database.session() as session:
+ item, _ = create_distribution_list(
+ session,
+ principal(),
+ DistributionListCreateRequest.model_validate(
+ {
+ "name": "Address source",
+ "entries": [
+ {
+ "entry_key": "address-list",
+ "kind": "address_list",
+ "source": {
+ "provider": "addresses",
+ "resource_type": "address_list",
+ "resource_id": "addresses:address_list:list-1",
+ },
+ }
+ ],
+ }
+ ),
+ )
+ result = expand_distribution_list(
+ session,
+ principal(),
+ registry=registry,
+ request=DistributionExpansionRequest(
+ list_id=item.id,
+ effective_at=datetime(2026, 1, 1, tzinfo=UTC),
+ requested_channels=("postal",),
+ limits=DistributionExpansionLimits(max_provider_results=1),
+ ),
+ )
+
+ self.assertEqual(1, len(result.recipients))
+ self.assertTrue(result.truncated)
+ self.assertEqual(
+ "addresses:address_list:list-1",
+ contact_points.source_requests[0].source_id,
+ )
+ self.assertEqual(("postal",), contact_points.source_requests[0].requested_channels)
+ self.assertEqual("list-revision-1", result.provider_evidence[0].actual_revision)
+ self.assertIn(
+ "provider.result_limit",
+ {item.code for item in result.diagnostics},
+ )
+
def _nested_entry(list_id: str, key: str) -> dict[str, object]:
return {
diff --git a/webui/src/features/distributionLists/DistributionListsPage.tsx b/webui/src/features/distributionLists/DistributionListsPage.tsx
index fbab567..6d7bc4a 100644
--- a/webui/src/features/distributionLists/DistributionListsPage.tsx
+++ b/webui/src/features/distributionLists/DistributionListsPage.tsx
@@ -930,6 +930,55 @@ function EntryEditorDialog({