Add shared work-item provider contracts

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent b5f5be15f6
commit d65d7a8e5f
8 changed files with 844 additions and 140 deletions
+33 -17
View File
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.workflows import WorkflowDefinitionContribution
@@ -34,7 +35,9 @@ MigrationTaskPhase = Literal[
"post_migration_backfill",
"post_migration_verify",
]
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
MigrationTaskSafety = Literal[
"automatic", "requires_review", "forward_only", "destructive"
]
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
@@ -75,8 +78,6 @@ class NavItem:
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
class FrontendRoute:
path: str
@@ -147,7 +148,9 @@ class ModuleMigrationTaskResult:
details: Mapping[str, Any] = field(default_factory=dict)
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
ModuleMigrationTaskExecutor = Callable[
[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None
]
@dataclass(frozen=True, slots=True)
@@ -204,7 +207,9 @@ class ModuleUninstallGuardResult:
message: str
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
UninstallGuardProvider = Callable[
[object | None, str], Iterable[ModuleUninstallGuardResult]
]
@dataclass(frozen=True, slots=True)
@@ -224,7 +229,9 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationConfigurationState = Literal[
"enabled", "disabled", "inherited", "unavailable"
]
DocumentationSourceKind = Literal[
"manifest",
"route",
@@ -289,16 +296,23 @@ def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
kind = (
raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
)
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
return (
"user workflow topics must declare at least one scope-conditioned alternative",
)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
if not any(
scope.strip()
for scope in (*condition.required_scopes, *condition.any_scopes)
)
)
if not unscoped_alternatives:
return ()
@@ -370,14 +384,11 @@ class CapabilityDocumentation:
class ResourceAclProvider(Protocol):
resource_type: str
def can_read(self, principal: object, resource_id: str) -> bool:
...
def can_read(self, principal: object, resource_id: str) -> bool: ...
def can_write(self, principal: object, resource_id: str) -> bool:
...
def can_write(self, principal: object, resource_id: str) -> bool: ...
def explain(self, principal: object, resource_id: str) -> AccessDecision:
...
def explain(self, principal: object, resource_id: str) -> AccessDecision: ...
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
@@ -436,12 +447,17 @@ class ModuleManifest:
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(
default_factory=dict
)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(
default_factory=dict
)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
work_item_providers: tuple["WorkItemProviderRegistration", ...] = ()
operational_check_providers: tuple[
"OperationalCheckProviderRegistration",
...,
+351 -121
View File
@@ -48,13 +48,21 @@ from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
module_architecture_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.versioning import (
format_version_range,
version_range_is_valid,
version_satisfies_range,
)
from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.tasks import (
RegisteredWorkItemProvider,
WorkItemProvider,
)
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
@@ -69,7 +77,9 @@ from govoplan_core.core.workflows import (
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
_WILDCARD_RE = re.compile(
r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$"
)
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
@@ -90,7 +100,9 @@ class PlatformRegistry:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = (
defaultdict(list)
)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capability_factory_owners: dict[str, str] = {}
@@ -99,10 +111,10 @@ class PlatformRegistry:
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_source_registrations: list[RegisteredSearchSourceProvider] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
self._work_item_provider_registrations: list[RegisteredWorkItemProvider] = []
self._work_item_providers: dict[str, WorkItemProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
@@ -133,6 +145,13 @@ class PlatformRegistry:
registration=registration,
)
)
for registration in manifest.work_item_providers:
self._work_item_provider_registrations.append(
RegisteredWorkItemProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -148,24 +167,29 @@ class PlatformRegistry:
self._tenant_summary_batch_providers = dict(
replacement._tenant_summary_batch_providers
)
self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._delete_veto_providers = defaultdict(
list,
{
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
},
)
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._capability_factory_owners = dict(
replacement._capability_factory_owners
)
self._capability_factory_owners = dict(replacement._capability_factory_owners)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._work_item_provider_registrations = list(
replacement._work_item_provider_registrations
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
self._work_item_providers.clear()
self._tenant_entitlement_resolver.invalidate()
return snapshot
@@ -183,7 +207,10 @@ class PlatformRegistry:
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
manifest = self.require_module(module_id)
return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id))
return dependency_id in manifest.dependencies or (
dependency_id in manifest.optional_dependencies
and self.has_module(dependency_id)
)
def manifests(self) -> tuple[ModuleManifest, ...]:
return tuple(self._topologically_sorted())
@@ -216,13 +243,26 @@ class PlatformRegistry:
)
def permissions(self) -> tuple[PermissionDefinition, ...]:
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
return tuple(
permission
for manifest in self.manifests()
for permission in manifest.permissions
)
def role_templates(self) -> tuple[RoleTemplate, ...]:
return tuple(template for manifest in self.manifests() for template in manifest.role_templates)
return tuple(
template
for manifest in self.manifests()
for template in manifest.role_templates
)
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
return tuple(
sorted(
(item for manifest in self.manifests() for item in manifest.nav_items),
key=lambda item: item.order,
)
)
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return tuple(
@@ -232,7 +272,11 @@ class PlatformRegistry:
)
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
return tuple(
provider
for manifest in self.manifests()
for provider in manifest.resource_acl_providers
)
def register_ownership_provider(
self,
@@ -295,7 +339,9 @@ class PlatformRegistry:
self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
def register_capability_factory(
self, module_id: str, name: str, factory: CapabilityFactory
) -> None:
if name in self._capability_factories:
raise RegistryError(f"Duplicate capability: {name}")
self._capability_factories[name] = factory
@@ -446,9 +492,7 @@ class PlatformRegistry:
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
raise RegistryError("Search source context is not configured.")
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
@@ -463,14 +507,51 @@ class PlatformRegistry:
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
provider = registered.registration.create(self._capability_context)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
def work_item_provider_registrations(
self,
) -> tuple[RegisteredWorkItemProvider, ...]:
return tuple(
sorted(
self._work_item_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def work_item_providers(
self,
) -> tuple[tuple[RegisteredWorkItemProvider, WorkItemProvider], ...]:
if self._capability_context is None:
if self._work_item_provider_registrations:
raise RegistryError("Work-item provider context is not configured.")
return ()
providers: list[tuple[RegisteredWorkItemProvider, WorkItemProvider]] = []
for registered in self.work_item_provider_registrations():
execution = current_tenant_execution_context()
if execution is not None:
try:
execution.require_module(registered.module_id)
except TenantModuleUnavailable:
continue
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._work_item_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._work_item_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(
self, module_id: str, provider: TenantSummaryProvider
) -> None:
self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
@@ -488,37 +569,56 @@ class PlatformRegistry:
) -> Mapping[str, TenantSummaryBatchProvider]:
return dict(self._tenant_summary_batch_providers)
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
module_id=module_id,
resource_type=resource_type,
provider=provider,
))
def register_delete_veto(
self, module_id: str, resource_type: str, provider: DeleteVetoProvider
) -> None:
self._delete_veto_providers[resource_type].append(
DeleteVetoProviderRegistration(
module_id=module_id,
resource_type=resource_type,
provider=provider,
)
)
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
return tuple(registration.provider for registration in self._delete_veto_providers.get(resource_type, ()))
def delete_veto_providers(
self, resource_type: str
) -> tuple[DeleteVetoProvider, ...]:
return tuple(
registration.provider
for registration in self._delete_veto_providers.get(resource_type, ())
)
def delete_veto_provider_registrations(self, resource_type: str) -> tuple[DeleteVetoProviderRegistration, ...]:
def delete_veto_provider_registrations(
self, resource_type: str
) -> tuple[DeleteVetoProviderRegistration, ...]:
return tuple(self._delete_veto_providers.get(resource_type, ()))
def collect_delete_veto_issues(self, resource_type: str, session: object, tenant_id: str, resource_id: str) -> tuple[DeleteVetoIssue, ...]:
def collect_delete_veto_issues(
self, resource_type: str, session: object, tenant_id: str, resource_id: str
) -> tuple[DeleteVetoIssue, ...]:
issues: list[DeleteVetoIssue] = []
for registration in self.delete_veto_provider_registrations(resource_type):
try:
result = registration.provider(session, tenant_id, resource_id)
except Exception as exc:
issues.append(DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=str(exc),
module_id=registration.module_id,
details={
"resource_type": resource_type,
"resource_id": resource_id,
},
))
issues.append(
DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=str(exc),
module_id=registration.module_id,
details={
"resource_type": resource_type,
"resource_id": resource_id,
},
)
)
continue
issues.extend(_normalize_delete_veto_result(result, registration=registration, resource_id=resource_id))
issues.extend(
_normalize_delete_veto_result(
result, registration=registration, resource_id=resource_id
)
)
return tuple(issues)
def validate(self) -> RegistrySnapshot:
@@ -539,18 +639,29 @@ class PlatformRegistry:
return RegistrySnapshot(
manifests=ordered,
permissions=tuple(permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
role_templates=tuple(
template for manifest in ordered for template in manifest.role_templates
),
nav_items=self.nav_items(),
)
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()}
incoming: dict[str, set[str]] = {
module_id: set(manifest.dependencies)
for module_id, manifest in self._manifests.items()
}
dependents: dict[str, set[str]] = defaultdict(set)
for module_id, dependencies in incoming.items():
for dependency in dependencies:
dependents[dependency].add(module_id)
ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies))
ready = deque(
sorted(
module_id
for module_id, dependencies in incoming.items()
if not dependencies
)
)
ordered: list[str] = []
while ready:
module_id = ready.popleft()
@@ -561,8 +672,16 @@ class PlatformRegistry:
ready.append(dependent)
if len(ordered) != len(self._manifests):
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
unresolved = ", ".join(
sorted(
module_id
for module_id, dependencies in incoming.items()
if dependencies
)
)
raise RegistryError(
f"Module dependency cycle or unresolved dependency: {unresolved}"
)
return (self._manifests[module_id] for module_id in ordered)
@@ -622,21 +741,29 @@ def _normalize_delete_veto_result(
if result is None:
return ()
if isinstance(result, DeleteVetoIssue):
return (_attribute_delete_veto_issue(result, registration=registration, resource_id=resource_id),)
return (
_attribute_delete_veto_issue(
result, registration=registration, resource_id=resource_id
),
)
if isinstance(result, str):
return (DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=result,
module_id=registration.module_id,
details={
"resource_type": registration.resource_type,
"resource_id": resource_id,
},
),)
return (
DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=result,
module_id=registration.module_id,
details={
"resource_type": registration.resource_type,
"resource_id": resource_id,
},
),
)
if isinstance(result, Iterable):
return tuple(
_attribute_delete_veto_issue(issue, registration=registration, resource_id=resource_id)
_attribute_delete_veto_issue(
issue, registration=registration, resource_id=resource_id
)
for issue in result
if isinstance(issue, DeleteVetoIssue)
)
@@ -656,7 +783,9 @@ def _attribute_delete_veto_issue(
}
if issue.module_id == registration.module_id and issue.details == details:
return issue
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
return replace(
issue, module_id=issue.module_id or registration.module_id, details=details
)
def _validate_manifest_relationships(
@@ -667,16 +796,24 @@ def _validate_manifest_relationships(
) -> None:
for dependency in manifest.dependencies:
if dependency not in known_modules:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
raise RegistryError(
f"Module {manifest.id!r} depends on unknown module {dependency!r}"
)
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
raise RegistryError(
f"Module {manifest.id!r} requires unavailable capability {capability!r}"
)
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
raise RegistryError(
f"Module {manifest.id!r} cannot list itself as an optional dependency"
)
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
def _collect_manifest_permissions(
manifests: tuple[ModuleManifest, ...],
) -> dict[str, PermissionDefinition]:
permissions: dict[str, PermissionDefinition] = {}
for manifest in manifests:
for permission in manifest.permissions:
@@ -697,10 +834,17 @@ def _validate_manifest_permission(
f"{permission.module_id!r}; expected {permission_namespace!r}"
)
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
raise RegistryError(
f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}"
)
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if (
not permission.scope.startswith(expected_prefix)
or permission.scope.rsplit(":", 1)[-1] != permission.action
):
raise RegistryError(
f"Permission fields do not match scope {permission.scope!r}"
)
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
@@ -731,8 +875,7 @@ def _validate_role_template_scopes(
)
for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
scope in {"*", "tenant:*", "system:*"} or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
@@ -740,7 +883,9 @@ def _validate_role_template_scopes(
)
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
raise RegistryError(
f"Role template {template.slug!r} references unknown permission {scope!r}"
)
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
@@ -882,9 +1027,7 @@ def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
f"Module {manifest.id!r} provider authority modes are not declared "
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
)
unknown_target_providers = set(
architecture.target_tested_providers
) - provider_ids
unknown_target_providers = set(architecture.target_tested_providers) - provider_ids
if unknown_target_providers:
raise RegistryError(
f"Module {manifest.id!r} target-tested providers are not declared: "
@@ -961,8 +1104,7 @@ def _validate_workflow_definition_contributions(
contribution.definition_key,
):
raise RegistryError(
f"Workflow contribution key is invalid: "
f"{contribution.definition_key!r}"
f"Workflow contribution key is invalid: {contribution.definition_key!r}"
)
if contribution.definition_key in seen_keys:
raise RegistryError(
@@ -1003,7 +1145,10 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a summary"
)
if metadata.contract_version is not None and not metadata.contract_version.strip():
if (
metadata.contract_version is not None
and not metadata.contract_version.strip()
):
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation contract version must not be empty"
@@ -1046,20 +1191,24 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
raise RegistryError(
f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}"
)
if not manifest.name.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
if not manifest.version.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
if (
manifest.permission_namespace is not None
and not _MODULE_ID_RE.match(manifest.permission_namespace)
if manifest.permission_namespace is not None and not _MODULE_ID_RE.match(
manifest.permission_namespace
):
raise RegistryError(
f"Module {manifest.id!r} has invalid permission namespace "
f"{manifest.permission_namespace!r}"
)
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
if (
manifest.compatibility.manifest_contract_version
!= SUPPORTED_MANIFEST_CONTRACT_VERSION
):
raise RegistryError(
f"Module {manifest.id!r} uses unsupported manifest contract version "
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
@@ -1069,9 +1218,15 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_dependency_list(
manifest.id, "optional_dependencies", manifest.optional_dependencies
)
_validate_capability_list(
manifest.id, "required_capabilities", manifest.required_capabilities
)
_validate_capability_list(
manifest.id, "optional_capabilities", manifest.optional_capabilities
)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
@@ -1100,25 +1255,51 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
f"{registration.id!r}"
)
source_ids.add(registration.id)
work_provider_ids: set[str] = set()
for registration in manifest.work_item_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} work-item provider id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in work_provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate work-item provider "
f"{registration.id!r}"
)
work_provider_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
capability_overlap = set(manifest.required_capabilities) & set(manifest.optional_capabilities)
raise RegistryError(
f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}"
)
capability_overlap = set(manifest.required_capabilities) & set(
manifest.optional_capabilities
)
if capability_overlap:
joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
raise RegistryError(
f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}"
)
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
raise RegistryError(
f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}"
)
if (
manifest.migration_spec.metadata is None
and not manifest.migration_spec.script_location
):
raise RegistryError(
f"Module {manifest.id!r} migration spec must declare metadata or script location"
)
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
@@ -1126,15 +1307,24 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
return
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}"
)
if (
frontend.asset_manifest_contract_version
!= SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION
):
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(
frontend.package_name
):
raise RegistryError(
f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}"
)
if frontend.public_routes and manifest.public_tenant_resolver is None:
raise RegistryError(
f"Module {manifest.id!r} exposes public frontend routes without a "
@@ -1160,7 +1350,9 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
surface_id = item.surface_id or navigation_view_surface_id(
manifest.id, item.path
)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
@@ -1176,8 +1368,7 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
known_ids=known_ids,
)
parent_by_id = {
surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces
surface.id: surface.parent_id or root_id for surface in frontend.view_surfaces
}
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
@@ -1257,9 +1448,13 @@ def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
if not path.startswith("/"):
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
raise RegistryError(
f"Frontend route for module {module_id!r} must start with '/': {path!r}"
)
if not component.strip():
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
raise RegistryError(
f"Frontend route {path!r} for module {module_id!r} must declare a component"
)
def _validate_public_frontend_route_uniqueness(
@@ -1281,7 +1476,9 @@ def _validate_public_frontend_route_uniqueness(
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = (
defaultdict(list)
)
for manifest in manifests:
for provided in manifest.provides_interfaces:
providers[provided.name].append((manifest, provided))
@@ -1321,44 +1518,69 @@ def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
)
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
def _validate_dependency_list(
module_id: str, field_name: str, dependencies: tuple[str, ...]
) -> None:
if len(dependencies) != len(set(dependencies)):
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
for dependency in dependencies:
if dependency == module_id:
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
if not _MODULE_ID_RE.match(dependency):
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
raise RegistryError(
f"Module {module_id!r} has invalid dependency id {dependency!r}"
)
def _validate_capability_list(module_id: str, field_name: str, capabilities: tuple[str, ...]) -> None:
def _validate_capability_list(
module_id: str, field_name: str, capabilities: tuple[str, ...]
) -> None:
if len(capabilities) != len(set(capabilities)):
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
for capability in capabilities:
if not capability.strip() or any(part == "" for part in capability.split(".")):
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
raise RegistryError(
f"Module {module_id!r} has invalid capability name {capability!r}"
)
def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None:
def _validate_interface_providers(
module_id: str, providers: tuple[ModuleInterfaceProvider, ...]
) -> None:
names = [provider.name for provider in providers]
if len(names) != len(set(names)):
raise RegistryError(f"Module {module_id!r} has duplicate provided interface names")
raise RegistryError(
f"Module {module_id!r} has duplicate provided interface names"
)
for provider in providers:
_validate_interface_name(module_id, provider.name)
if not provider.version.strip():
raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version")
raise RegistryError(
f"Module {module_id!r} provides interface {provider.name!r} without a version"
)
def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None:
def _validate_interface_requirements(
module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]
) -> None:
names = [requirement.name for requirement in requirements]
if len(names) != len(set(names)):
raise RegistryError(f"Module {module_id!r} has duplicate required interface names")
raise RegistryError(
f"Module {module_id!r} has duplicate required interface names"
)
for requirement in requirements:
_validate_interface_name(module_id, requirement.name)
if requirement.version_min is not None and not requirement.version_min.strip():
raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}")
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}")
raise RegistryError(
f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}"
)
if (
requirement.version_max_exclusive is not None
and not requirement.version_max_exclusive.strip()
):
raise RegistryError(
f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}"
)
if not version_range_is_valid(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
@@ -1367,7 +1589,9 @@ def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleI
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}")
raise RegistryError(
f"Module {module_id!r} has invalid interface range {version_range!r}"
)
def _validate_interface_name(module_id: str, name: str) -> None:
@@ -1377,8 +1601,14 @@ def _validate_interface_name(module_id: str, name: str) -> None:
def _validate_nav_item(module_id: str, item: NavItem) -> None:
if not item.path.startswith("/"):
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
raise RegistryError(
f"Navigation item for module {module_id!r} must start with '/': {item.path!r}"
)
if not item.label.strip():
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} must declare a label")
raise RegistryError(
f"Navigation item {item.path!r} for module {module_id!r} must declare a label"
)
if item.icon is not None and not item.icon.strip():
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name")
raise RegistryError(
f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name"
)
+332
View File
@@ -0,0 +1,332 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.modules import ModuleContext
WorkItemStatus = Literal[
"open",
"in_progress",
"deferred",
"blocked",
"completed",
"cancelled",
]
WorkItemPriority = Literal["low", "normal", "high", "urgent"]
WorkAssignmentKind = Literal[
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
]
WORK_ITEM_CONTRACT_VERSION = "1"
CAPABILITY_TASK_COMMANDS = "tasks.commands"
@dataclass(frozen=True, slots=True)
class WorkAssignmentRef:
kind: WorkAssignmentKind
id: str
label: str | None = None
def __post_init__(self) -> None:
if self.kind not in {
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
}:
raise ValueError(f"Unsupported work-assignment kind: {self.kind!r}.")
if not self.id.strip():
raise ValueError("Work assignments require an id.")
if len(self.id) > 255:
raise ValueError("Work assignment ids are limited to 255 characters.")
if self.label is not None and len(self.label) > 500:
raise ValueError("Work assignment labels are limited to 500 characters.")
if self.kind == "anyone" and self.id != "*":
raise ValueError("Broad work assignments use the canonical '*' id.")
@dataclass(frozen=True, slots=True)
class WorkSourceRef:
module_id: str
resource_type: str
resource_id: str
revision: str | None = None
url: str | None = None
label: str | None = None
def __post_init__(self) -> None:
required = (self.module_id, self.resource_type, self.resource_id)
if any(not value.strip() for value in required):
raise ValueError("Work source references require module, type, and id.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"revision": 255,
"url": 1_500,
"label": 500,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Work source {field_name} is limited to {limit} characters."
)
@dataclass(frozen=True, slots=True)
class WorkItem:
id: str
provider_id: str
owner_module: str
tenant_id: str
title: str
status: WorkItemStatus = "open"
priority: WorkItemPriority = "normal"
summary: str | None = None
required_action: str | None = None
action_url: str | None = None
due_at: datetime | None = None
deferred_until: datetime | None = None
assignments: tuple[WorkAssignmentRef, ...] = ()
sources: tuple[WorkSourceRef, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
revision: str = "1"
created_at: datetime | None = None
updated_at: datetime | None = None
def __post_init__(self) -> None:
required = {
"id": self.id,
"provider_id": self.provider_id,
"owner_module": self.owner_module,
"tenant_id": self.tenant_id,
"title": self.title,
"revision": self.revision,
}
if any(not value.strip() for value in required.values()):
raise ValueError(
"Work items require stable identity, owner, tenant, and title."
)
limits = {
"id": 255,
"provider_id": 200,
"owner_module": 100,
"tenant_id": 255,
"title": 500,
"summary": 4_000,
"required_action": 500,
"action_url": 1_500,
"revision": 255,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Work item {field_name} is limited to {limit} characters."
)
if len(self.assignments) > 100 or len(self.sources) > 100:
raise ValueError("Work items support at most 100 assignments and sources.")
_validate_action_url(self.action_url)
if self.status not in {
"open",
"in_progress",
"deferred",
"blocked",
"completed",
"cancelled",
}:
raise ValueError(f"Unsupported work-item status: {self.status!r}.")
if self.priority not in {"low", "normal", "high", "urgent"}:
raise ValueError(f"Unsupported work-item priority: {self.priority!r}.")
@dataclass(frozen=True, slots=True)
class WorkItemQuery:
tenant_id: str
statuses: tuple[WorkItemStatus, ...] = (
"open",
"in_progress",
"deferred",
"blocked",
)
priorities: tuple[WorkItemPriority, ...] = ()
provider_ids: tuple[str, ...] = ()
owner_modules: tuple[str, ...] = ()
due_before: datetime | None = None
text: str = ""
limit: int = 100
def __post_init__(self) -> None:
if not self.tenant_id.strip():
raise ValueError("Work-item queries require a tenant.")
if not 1 <= self.limit <= 500:
raise ValueError("Work-item query limits must be between 1 and 500.")
normalized = self.text.strip()
if len(normalized) > 500:
raise ValueError("Work-item query text is limited to 500 characters.")
if len(self.provider_ids) > 50 or len(self.owner_modules) > 50:
raise ValueError("Work-item queries support at most 50 provider filters.")
object.__setattr__(self, "text", normalized)
@dataclass(frozen=True, slots=True)
class WorkItemPage:
items: tuple[WorkItem, ...]
total: int
truncated: bool = False
def __post_init__(self) -> None:
if self.total < len(self.items):
raise ValueError(
"Work-item totals cannot be smaller than the returned page."
)
@runtime_checkable
class WorkItemProvider(Protocol):
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
"""Return only items the current principal may discover and act on."""
@dataclass(frozen=True, slots=True)
class TaskCreateCommand:
tenant_id: str
title: str
idempotency_key: str
summary: str | None = None
priority: WorkItemPriority = "normal"
due_at: datetime | None = None
required_action: str | None = None
action_url: str | None = None
assignments: tuple[WorkAssignmentRef, ...] = ()
sources: tuple[WorkSourceRef, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.title.strip():
raise ValueError("Task commands require a tenant and title.")
if not self.idempotency_key.strip() or len(self.idempotency_key) > 255:
raise ValueError("Task commands require a bounded idempotency key.")
if not self.assignments:
raise ValueError("Explicit tasks require at least one assignment.")
if self.priority not in {"low", "normal", "high", "urgent"}:
raise ValueError(f"Unsupported task priority: {self.priority!r}.")
limits = {
"title": 500,
"summary": 4_000,
"required_action": 500,
"action_url": 1_500,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Task command {field_name} is limited to {limit} characters."
)
if len(self.assignments) > 100 or len(self.sources) > 100:
raise ValueError(
"Task commands support at most 100 assignments and sources."
)
_validate_action_url(self.action_url)
def _validate_action_url(value: str | None) -> None:
if value is None:
return
candidate = value.strip()
if not candidate:
return
if (
not candidate.startswith("/")
or candidate.startswith("//")
or "\\" in candidate
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
):
raise ValueError("Work-item action URLs must be application-relative paths.")
@runtime_checkable
class TaskCommandProvider(Protocol):
def create_task(
self,
session: object,
principal: object,
*,
command: TaskCreateCommand,
) -> WorkItem: ...
WorkItemProviderFactory = Callable[[ModuleContext], WorkItemProvider]
@dataclass(frozen=True, slots=True)
class WorkItemProviderRegistration:
id: str
factory: WorkItemProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> WorkItemProvider:
provider = self.factory(context)
if not isinstance(provider, WorkItemProvider):
raise TypeError(
f"Work-item provider {self.id!r} does not implement WorkItemProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredWorkItemProvider:
module_id: str
registration: WorkItemProviderRegistration
def task_command_provider(registry: object | None) -> TaskCommandProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_TASK_COMMANDS)
):
return None
provider = registry.capability(CAPABILITY_TASK_COMMANDS)
return provider if isinstance(provider, TaskCommandProvider) else None
__all__ = [
"CAPABILITY_TASK_COMMANDS",
"RegisteredWorkItemProvider",
"TaskCommandProvider",
"TaskCreateCommand",
"WORK_ITEM_CONTRACT_VERSION",
"WorkAssignmentKind",
"WorkAssignmentRef",
"WorkItem",
"WorkItemPage",
"WorkItemPriority",
"WorkItemProvider",
"WorkItemProviderFactory",
"WorkItemProviderRegistration",
"WorkItemQuery",
"WorkItemStatus",
"WorkSourceRef",
"task_command_provider",
]