feat: compose stable product surfaces
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-24 13:21:24 +02:00
parent f98cf9ced8
commit 6e518fa6a2
25 changed files with 1012 additions and 17 deletions
+51
View File
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@@ -114,6 +115,55 @@ class ProductAreaContribution:
order: int = 100
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
ProductAvailabilityReason = Literal[
"authorization",
"policy",
"configuration",
"disabled",
"capability",
"offline",
"provider_degraded",
]
@dataclass(frozen=True, slots=True)
class ProductAvailabilityExplanation:
"""Explain a product outcome without making package topology user-facing."""
reason: ProductAvailabilityReason
title: str
description: str
resolution: str
responsible_role: str | None = None
@dataclass(frozen=True, slots=True)
class ProductSurfaceContribution:
"""Bind an owner route to a stable, cross-module product identity."""
id: str
module_id: str
label: str
icon: str
entry_path: str
route_path: str
surface_ids: tuple[str, ...]
unavailable: ProductAvailabilityExplanation
description: str | None = None
degraded: ProductAvailabilityExplanation | None = None
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
capability_ids: tuple[str, ...] = ()
search_source_ids: tuple[str, ...] = ()
help_context_ids: tuple[str, ...] = ()
documentation_topic_ids: tuple[str, ...] = ()
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
aliases: tuple[str, ...] = ()
order: int = 100
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
@dataclass(frozen=True, slots=True)
class QuickAccessTool:
"""Declare a versioned, bounded module-owned Quick Access tool."""
@@ -153,6 +203,7 @@ class FrontendModule:
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
product_areas: tuple[ProductAreaContribution, ...] = ()
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
quick_access_tools: tuple[QuickAccessTool, ...] = ()
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
"navigation",
"permission",
"product_area",
"product_surface",
"provided_interface",
"public_route",
"search_provider",
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
},
)
)
for surface in frontend.product_surfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=f"{manifest.id}.{surface.id}",
module_id=manifest.id,
kind="product_surface",
label=surface.label,
path=surface.route_path,
required_all=surface.required_all,
required_any=surface.required_any,
metadata={
"contract_version": surface.contract_version,
"product_surface_id": surface.id,
"description": surface.description,
"icon": surface.icon,
"entry_path": surface.entry_path,
"surface_ids": list(surface.surface_ids),
"presentations": list(surface.presentations),
"capability_ids": list(surface.capability_ids),
"search_source_ids": list(surface.search_source_ids),
"help_context_ids": list(surface.help_context_ids),
"documentation_topic_ids": list(
surface.documentation_topic_ids
),
"aliases": list(surface.aliases),
"order": surface.order,
"unavailable_reason": surface.unavailable.reason,
"degraded_reason": (
surface.degraded.reason
if surface.degraded is not None
else None
),
},
)
)
for tool in frontend.quick_access_tools:
declarations.append(
PlatformInterfaceDeclaration(
+198
View File
@@ -16,13 +16,16 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution,
ProductSurfaceContribution,
PublicFrontendRoute,
QuickAccessTool,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
TenantSummaryBatchProvider,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
@@ -90,6 +93,9 @@ _WILDCARD_RE = re.compile(
)
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
_PRODUCT_SURFACE_ID_RE = re.compile(
r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
)
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
)
@@ -974,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
area_definitions: dict[str, tuple[str, str]] = {}
surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
product_paths: dict[str, str] = {}
tool_owners: dict[str, str] = {}
concrete_paths = {
route.path: manifest.id
for manifest in manifests
if manifest.frontend is not None
for route in (
*manifest.frontend.routes,
*manifest.frontend.settings_routes,
*manifest.frontend.public_routes,
)
}
for manifest in manifests:
frontend = manifest.frontend
if frontend is None:
@@ -987,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
f"Product area {area.id!r} has conflicting labels or icons"
)
area_definitions[area.id] = definition
for surface in frontend.product_surfaces:
definition = (
surface.label,
surface.icon,
surface.entry_path,
surface.description,
)
previous = surface_definitions.get(surface.id)
if previous is not None and previous != definition:
raise RegistryError(
f"Product surface {surface.id!r} has conflicting product identity metadata"
)
surface_definitions[surface.id] = definition
for path in (surface.entry_path, *surface.aliases):
concrete_owner = concrete_paths.get(path)
if concrete_owner is not None:
raise RegistryError(
f"Product path {path!r} collides with a concrete route "
f"owned by module {concrete_owner!r}"
)
previous_id = product_paths.get(path)
if previous_id is not None and previous_id != surface.id:
raise RegistryError(
f"Product path {path!r} is shared by product surfaces "
f"{previous_id!r} and {surface.id!r}"
)
product_paths[path] = surface.id
for tool in frontend.quick_access_tools:
previous_owner = tool_owners.get(tool.id)
if previous_owner is not None:
@@ -1474,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
f"in module {manifest.id!r}"
)
seen_area_memberships.add(membership)
seen_product_surfaces: set[str] = set()
for surface in frontend.product_surfaces:
_validate_product_surface(manifest, surface, known_surface_ids)
if surface.id in seen_product_surfaces:
raise RegistryError(
f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
)
seen_product_surfaces.add(surface.id)
seen_tools: set[str] = set()
for tool in frontend.quick_access_tools:
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
@@ -1512,6 +1565,151 @@ def _validate_product_area(
)
def _validate_product_surface(
manifest: ModuleManifest,
surface: ProductSurfaceContribution,
known_surface_ids: set[str],
) -> None:
module_id = manifest.id
frontend = manifest.frontend
assert frontend is not None
if surface.module_id != module_id:
raise RegistryError(
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
raise RegistryError(
f"Product surface {surface.id!r} uses unsupported contract version "
f"{surface.contract_version!r}"
)
if not surface.label.strip() or not surface.icon.strip():
raise RegistryError(
f"Product surface {surface.id!r} needs a label and icon"
)
for label, path in (
("entry", surface.entry_path),
("owner", surface.route_path),
*(("alias", alias) for alias in surface.aliases),
):
if not path.startswith("/") or "?" in path or "#" in path:
raise RegistryError(
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
)
if (
surface.entry_path == surface.route_path
or surface.entry_path in surface.aliases
or surface.route_path in surface.aliases
):
raise RegistryError(
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
)
if len(set(surface.aliases)) != len(surface.aliases):
raise RegistryError(
f"Product surface {surface.id!r} contains duplicate aliases"
)
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
if surface.route_path not in route_paths:
raise RegistryError(
f"Product surface {surface.id!r} references unknown owner route "
f"{surface.route_path!r}"
)
if not surface.surface_ids:
raise RegistryError(
f"Product surface {surface.id!r} has no owner surfaces"
)
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
if unknown_surfaces:
raise RegistryError(
f"Product surface {surface.id!r} references unknown surfaces: "
+ ", ".join(sorted(unknown_surfaces))
)
allowed_presentations = {"task", "reader", "admin", "operator"}
if (
not surface.presentations
or len(set(surface.presentations)) != len(surface.presentations)
or set(surface.presentations) - allowed_presentations
):
raise RegistryError(
f"Product surface {surface.id!r} has invalid presentations"
)
declared_capabilities = {
*manifest.required_capabilities,
*manifest.optional_capabilities,
*manifest.capability_factories,
*(provider.name for provider in manifest.provides_interfaces),
*(requirement.name for requirement in manifest.requires_interfaces),
}
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
if unknown_capabilities:
raise RegistryError(
f"Product surface {surface.id!r} references undeclared capabilities: "
+ ", ".join(sorted(unknown_capabilities))
)
search_source_ids = {source.id for source in manifest.search_sources}
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
if unknown_search_sources:
raise RegistryError(
f"Product surface {surface.id!r} references unknown search sources: "
+ ", ".join(sorted(unknown_search_sources))
)
topics = {topic.id: topic for topic in manifest.documentation}
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
if unknown_topics:
raise RegistryError(
f"Product surface {surface.id!r} references unknown documentation topics: "
+ ", ".join(sorted(unknown_topics))
)
documented_help_contexts: set[str] = set()
for topic in manifest.documentation:
contexts = topic.metadata.get("help_contexts", ())
if isinstance(contexts, (list, tuple, set, frozenset)):
documented_help_contexts.update(
context for context in contexts if isinstance(context, str)
)
unknown_help = set(surface.help_context_ids) - documented_help_contexts
if unknown_help:
raise RegistryError(
f"Product surface {surface.id!r} references undocumented help contexts: "
+ ", ".join(sorted(unknown_help))
)
_validate_product_availability_explanation(surface.id, surface.unavailable)
if surface.degraded is not None:
_validate_product_availability_explanation(surface.id, surface.degraded)
def _validate_product_availability_explanation(
surface_id: str,
explanation: ProductAvailabilityExplanation,
) -> None:
allowed_reasons = {
"authorization",
"policy",
"configuration",
"disabled",
"capability",
"offline",
"provider_degraded",
}
if explanation.reason not in allowed_reasons:
raise RegistryError(
f"Product surface {surface_id!r} has an invalid availability reason"
)
if any(
not value.strip()
for value in (
explanation.title,
explanation.description,
explanation.resolution,
)
):
raise RegistryError(
f"Product surface {surface_id!r} has an incomplete availability explanation"
)
def _validate_quick_access_tool(
module_id: str,
tool: QuickAccessTool,