feat(docs): define semantic subject contract

This commit is contained in:
2026-08-21 14:55:58 +02:00
parent 4b0737e1cd
commit 925dc33696
6 changed files with 1176 additions and 0 deletions
+47
View File
@@ -61,6 +61,11 @@ from govoplan_core.core.search import (
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX,
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
from govoplan_core.core.tasks import (
RegisteredWorkItemProvider,
WorkItemProvider,
@@ -1215,6 +1220,48 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
"documentation contract version must not be empty"
)
semantic_capabilities = tuple(
capability
for capability in manifest.capability_factories
if capability.startswith(
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
)
)
for capability in semantic_capabilities:
expected = semantic_documentation_subject_capability(manifest.id)
if capability != expected:
raise RegistryError(
f"Module {manifest.id!r} semantic-documentation capability "
f"must be {expected!r}, not {capability!r}"
)
metadata = manifest.capability_documentation.get(capability)
if metadata is None:
raise RegistryError(
f"Module {manifest.id!r} semantic-documentation capability "
"must declare capability documentation"
)
if (
metadata.contract_version
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
):
raise RegistryError(
f"Module {manifest.id!r} semantic-documentation capability "
f"must declare contract version "
f"{SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION!r}"
)
static_types = {
documentation_type
for topic in manifest.documentation
for documentation_type in topic.documentation_types
}
missing_types = {"admin", "user"} - static_types
if missing_types:
raise RegistryError(
f"Module {manifest.id!r} semantic-documentation provider must "
"retain static user and administrator documentation baselines; "
f"missing: {', '.join(sorted(missing_types))}"
)
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
@@ -0,0 +1,621 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX = (
"documentation.semantic_subjects."
)
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION = "1"
SemanticDocumentationSubjectAvailability = Literal[
"available",
"changed",
"superseded",
"missing",
"temporarily_unavailable",
]
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,119}$")
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,254}$")
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
_REASON_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
_SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
class SemanticDocumentationContractError(ValueError):
"""Raised when a semantic-documentation subject violates the Core contract."""
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectAnchor:
kind: str
id: str
def __post_init__(self) -> None:
_require_match(self.kind, _KIND_RE, "Semantic subject anchor kind")
_require_match(self.id, _IDENTIFIER_RE, "Semantic subject anchor id")
def to_dict(self) -> dict[str, str]:
return {"kind": self.kind, "id": self.id}
@classmethod
def from_mapping(
cls, value: Mapping[str, object]
) -> SemanticDocumentationSubjectAnchor:
_require_keys(value, {"kind", "id"}, "Semantic subject anchor")
return cls(kind=_required_text(value, "kind"), id=_required_text(value, "id"))
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectReference:
module_id: str
tenant_id: str
subject_kind: str
subject_id: str
anchor: SemanticDocumentationSubjectAnchor | None = None
observed_revision: str | None = None
observed_fingerprint: str | None = None
def __post_init__(self) -> None:
_require_match(self.module_id, _MODULE_ID_RE, "Semantic subject module id")
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic subject tenant id")
_require_match(self.subject_kind, _KIND_RE, "Semantic subject kind")
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic subject id")
_optional_text(self.observed_revision, "Semantic subject observed revision", 255)
if self.observed_fingerprint is not None and not _SHA256_RE.fullmatch(
self.observed_fingerprint
):
raise SemanticDocumentationContractError(
"Semantic subject observed fingerprint must be a SHA-256 digest."
)
@property
def stable_key(self) -> str:
identity = {
"anchor": self.anchor.to_dict() if self.anchor else None,
"module_id": self.module_id,
"subject_id": self.subject_id,
"subject_kind": self.subject_kind,
"tenant_id": self.tenant_id,
}
encoded = json.dumps(
identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"tenant_id": self.tenant_id,
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"anchor": self.anchor.to_dict() if self.anchor else None,
"observed_revision": self.observed_revision,
"observed_fingerprint": self.observed_fingerprint,
}
@classmethod
def from_mapping(
cls, value: Mapping[str, object]
) -> SemanticDocumentationSubjectReference:
_require_keys(
value,
{
"module_id",
"tenant_id",
"subject_kind",
"subject_id",
"anchor",
"observed_revision",
"observed_fingerprint",
},
"Semantic subject reference",
)
raw_anchor = value.get("anchor")
if raw_anchor is not None and not isinstance(raw_anchor, Mapping):
raise SemanticDocumentationContractError(
"Semantic subject anchor must be an object."
)
return cls(
module_id=_required_text(value, "module_id"),
tenant_id=_required_text(value, "tenant_id"),
subject_kind=_required_text(value, "subject_kind"),
subject_id=_required_text(value, "subject_id"),
anchor=(
SemanticDocumentationSubjectAnchor.from_mapping(raw_anchor)
if isinstance(raw_anchor, Mapping)
else None
),
observed_revision=_mapping_optional_text(value, "observed_revision"),
observed_fingerprint=_mapping_optional_text(
value, "observed_fingerprint"
),
)
@dataclass(frozen=True, slots=True)
class SemanticDocumentationBreadcrumb:
label: str
subject_kind: str
subject_id: str
anchor: SemanticDocumentationSubjectAnchor | None = None
def __post_init__(self) -> None:
_required_bounded_text(self.label, "Semantic subject breadcrumb label", 300)
_require_match(self.subject_kind, _KIND_RE, "Semantic breadcrumb kind")
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic breadcrumb id")
def to_dict(self) -> dict[str, object]:
return {
"label": self.label,
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"anchor": self.anchor.to_dict() if self.anchor else None,
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectDescriptor:
reference: SemanticDocumentationSubjectReference
labels: Mapping[str, str]
descriptions: Mapping[str, str] = field(default_factory=dict)
breadcrumbs: tuple[SemanticDocumentationBreadcrumb, ...] = ()
route: str | None = None
route_anchor: str | None = None
audience: tuple[str, ...] = ()
classification: str = "internal"
required_scopes: tuple[str, ...] = ()
def __post_init__(self) -> None:
if not self.reference.observed_revision:
raise SemanticDocumentationContractError(
"Semantic subject descriptors require a current revision."
)
if not self.reference.observed_fingerprint:
raise SemanticDocumentationContractError(
"Semantic subject descriptors require a current fingerprint."
)
_localized_text(self.labels, "Semantic subject labels", required=True, limit=300)
_localized_text(
self.descriptions,
"Semantic subject descriptions",
required=False,
limit=2_000,
)
if len(self.breadcrumbs) > 32:
raise SemanticDocumentationContractError(
"Semantic subject breadcrumbs are limited to 32 items."
)
if self.route is not None:
_optional_text(self.route, "Semantic subject route", 2_000)
if not self.route.startswith("/") or self.route.startswith("//"):
raise SemanticDocumentationContractError(
"Semantic subject routes must be local absolute paths."
)
if self.route_anchor is not None:
_require_match(
self.route_anchor,
_IDENTIFIER_RE,
"Semantic subject route anchor",
)
_text_tuple(self.audience, "Semantic subject audience", maximum=32)
_required_bounded_text(
self.classification, "Semantic subject classification", 120
)
_text_tuple(
self.required_scopes, "Semantic subject required scopes", maximum=64
)
def to_dict(self) -> dict[str, object]:
return {
"reference": self.reference.to_dict(),
"labels": dict(self.labels),
"descriptions": dict(self.descriptions),
"breadcrumbs": [item.to_dict() for item in self.breadcrumbs],
"route": self.route,
"route_anchor": self.route_anchor,
"audience": list(self.audience),
"classification": self.classification,
"required_scopes": list(self.required_scopes),
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectResolution:
requested_reference: SemanticDocumentationSubjectReference
availability: SemanticDocumentationSubjectAvailability
subject: SemanticDocumentationSubjectDescriptor | None = None
superseded_by: SemanticDocumentationSubjectReference | None = None
reason_code: str | None = None
def __post_init__(self) -> None:
if self.reason_code is not None:
_require_match(
self.reason_code, _REASON_CODE_RE, "Semantic resolution reason code"
)
if self.availability in {"available", "changed"}:
if self.subject is None:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions require a descriptor."
)
if (
self.subject.reference.stable_key
!= self.requested_reference.stable_key
):
raise SemanticDocumentationContractError(
"Semantic subject resolution changed the requested identity."
)
changed = _reference_changed(
self.requested_reference, self.subject.reference
)
if self.availability == "available" and changed:
raise SemanticDocumentationContractError(
"Changed semantic subjects must use the changed availability."
)
if self.availability == "changed" and not changed:
raise SemanticDocumentationContractError(
"Changed semantic subject resolutions require a revision or fingerprint change."
)
elif self.subject is not None:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions cannot include a descriptor."
)
if self.availability == "superseded":
if self.superseded_by is None:
raise SemanticDocumentationContractError(
"Superseded semantic subjects require a replacement reference."
)
elif self.superseded_by is not None:
raise SemanticDocumentationContractError(
"Only superseded semantic subjects may declare a replacement."
)
if self.availability in {"missing", "temporarily_unavailable"} and not self.reason_code:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions require a reason code."
)
def to_dict(self) -> dict[str, object]:
return {
"requested_reference": self.requested_reference.to_dict(),
"availability": self.availability,
"subject": self.subject.to_dict() if self.subject else None,
"superseded_by": (
self.superseded_by.to_dict() if self.superseded_by else None
),
"reason_code": self.reason_code,
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectQuery:
tenant_id: str
query: str = ""
subject_kinds: tuple[str, ...] = ()
limit: int = 50
cursor: str | None = None
def __post_init__(self) -> None:
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic query tenant id")
if not isinstance(self.query, str) or len(self.query) > 300:
raise SemanticDocumentationContractError(
"Semantic subject query must be text of at most 300 characters."
)
if self.query:
_required_bounded_text(self.query, "Semantic subject query", 300)
if not 1 <= self.limit <= 200:
raise SemanticDocumentationContractError(
"Semantic subject query limit must be between 1 and 200."
)
_text_tuple(self.subject_kinds, "Semantic query subject kinds", maximum=100)
for kind in self.subject_kinds:
_require_match(kind, _KIND_RE, "Semantic query subject kind")
_optional_text(self.cursor, "Semantic query cursor", 1_000)
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectPage:
subjects: tuple[SemanticDocumentationSubjectDescriptor, ...] = ()
next_cursor: str | None = None
has_more: bool = False
def __post_init__(self) -> None:
keys = tuple(item.reference.stable_key for item in self.subjects)
if len(keys) != len(set(keys)):
raise SemanticDocumentationContractError(
"Semantic subject pages cannot contain duplicate identities."
)
_optional_text(self.next_cursor, "Semantic subject page cursor", 1_000)
if self.has_more and not self.next_cursor:
raise SemanticDocumentationContractError(
"Semantic subject pages with more results require a cursor."
)
@runtime_checkable
class SemanticDocumentationSubjectProvider(Protocol):
provider_id: str
module_id: str
contract_version: str
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage: ...
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
"""Return None when the principal may not know whether a subject exists."""
def semantic_documentation_subject_capability(module_id: str) -> str:
_require_match(module_id, _MODULE_ID_RE, "Semantic subject module id")
return f"{SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX}{module_id}"
def semantic_documentation_subject_provider_names(
registry: object | None,
) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
str(name)
for name in registry.capability_names()
if str(name).startswith(SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX)
)
def semantic_documentation_subject_providers(
registry: object | None,
) -> tuple[tuple[str, SemanticDocumentationSubjectProvider], ...]:
if registry is None or not hasattr(registry, "capability"):
return ()
providers: list[tuple[str, SemanticDocumentationSubjectProvider]] = []
for capability_name in semantic_documentation_subject_provider_names(registry):
module_id = capability_name.removeprefix(
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
)
provider = registry.capability(capability_name)
if not isinstance(provider, SemanticDocumentationSubjectProvider):
raise TypeError(
f"Invalid semantic-documentation provider capability: {capability_name}"
)
if provider.module_id != module_id:
raise SemanticDocumentationContractError(
f"Semantic provider module {provider.module_id!r} does not match "
f"capability {capability_name!r}."
)
if (
provider.contract_version
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
):
raise SemanticDocumentationContractError(
f"Unsupported semantic-documentation provider contract: "
f"{provider.contract_version!r}."
)
providers.append((module_id, provider))
return tuple(providers)
def list_semantic_documentation_subjects(
registry: object | None,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> tuple[tuple[str, SemanticDocumentationSubjectPage], ...]:
if _principal_tenant_id(principal) != request.tenant_id:
return ()
pages: list[tuple[str, SemanticDocumentationSubjectPage]] = []
for module_id, provider in semantic_documentation_subject_providers(registry):
page = provider.list_subjects(
session,
principal,
request=request,
)
if any(
subject.reference.module_id != module_id
or subject.reference.tenant_id != request.tenant_id
for subject in page.subjects
):
raise SemanticDocumentationContractError(
f"Semantic provider {module_id!r} returned a foreign subject."
)
pages.append((module_id, page))
return tuple(pages)
def resolve_semantic_documentation_subject(
registry: object | None,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
if _principal_tenant_id(principal) != reference.tenant_id:
return None
capability_name = semantic_documentation_subject_capability(reference.module_id)
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(capability_name)
):
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="temporarily_unavailable",
reason_code="provider_unavailable",
)
provider = registry.capability(capability_name)
if not isinstance(provider, SemanticDocumentationSubjectProvider):
raise TypeError(
f"Invalid semantic-documentation provider capability: {capability_name}"
)
if (
provider.module_id != reference.module_id
or provider.contract_version
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
):
raise SemanticDocumentationContractError(
f"Semantic provider {capability_name!r} does not match the Core contract."
)
result = provider.resolve_subject(
session,
principal,
reference=reference,
)
if result is None:
return None
if result.requested_reference != reference:
raise SemanticDocumentationContractError(
"Semantic provider returned a resolution for another reference."
)
return result
def semantic_documentation_fingerprint(value: object) -> str:
try:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise SemanticDocumentationContractError(
"Semantic fingerprint input must be canonical JSON data."
) from exc
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def _reference_changed(
requested: SemanticDocumentationSubjectReference,
current: SemanticDocumentationSubjectReference,
) -> bool:
comparisons = (
(requested.observed_revision, current.observed_revision),
(requested.observed_fingerprint, current.observed_fingerprint),
)
return any(expected is not None and expected != actual for expected, actual in comparisons)
def _principal_tenant_id(principal: object) -> str:
return str(getattr(principal, "tenant_id", "") or "")
def _localized_text(
values: Mapping[str, str],
label: str,
*,
required: bool,
limit: int,
) -> None:
if required and not values:
raise SemanticDocumentationContractError(f"{label} are required.")
if len(values) > 20:
raise SemanticDocumentationContractError(f"{label} are limited to 20 locales.")
for locale, value in values.items():
if not _LOCALE_RE.fullmatch(str(locale)):
raise SemanticDocumentationContractError(
f"{label} contain an invalid locale: {locale!r}."
)
_required_bounded_text(value, f"{label} value", limit)
def _text_tuple(values: Sequence[str], label: str, *, maximum: int) -> None:
if len(values) > maximum:
raise SemanticDocumentationContractError(
f"{label} are limited to {maximum} items."
)
normalized = tuple(str(value).strip() for value in values)
if any(not value or len(value) > 255 for value in normalized):
raise SemanticDocumentationContractError(
f"{label} must contain non-empty bounded text."
)
if len(normalized) != len(set(normalized)):
raise SemanticDocumentationContractError(f"{label} must be unique.")
def _require_match(value: str, pattern: re.Pattern[str], label: str) -> None:
if not isinstance(value, str) or not pattern.fullmatch(value):
raise SemanticDocumentationContractError(f"{label} is invalid.")
def _required_bounded_text(value: str, label: str, limit: int) -> None:
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise SemanticDocumentationContractError(
f"{label} must be non-empty and at most {limit} characters."
)
if any(ord(character) < 32 and character not in "\n\t" for character in value):
raise SemanticDocumentationContractError(f"{label} contains control characters.")
def _optional_text(value: str | None, label: str, limit: int) -> None:
if value is not None:
_required_bounded_text(value, label, limit)
def _require_keys(
value: Mapping[str, object], allowed: set[str], label: str
) -> None:
unexpected = sorted(str(key) for key in value if str(key) not in allowed)
if unexpected:
raise SemanticDocumentationContractError(
f"{label} contains unsupported fields: {', '.join(unexpected)}."
)
def _required_text(value: Mapping[str, object], key: str) -> str:
result = value.get(key)
if not isinstance(result, str) or not result.strip():
raise SemanticDocumentationContractError(
f"Semantic subject field {key} is required."
)
return result
def _mapping_optional_text(value: Mapping[str, object], key: str) -> str | None:
result = value.get(key)
if result is None:
return None
if not isinstance(result, str):
raise SemanticDocumentationContractError(
f"Semantic subject field {key} must be text."
)
return result
__all__ = [
"SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX",
"SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION",
"SemanticDocumentationBreadcrumb",
"SemanticDocumentationContractError",
"SemanticDocumentationSubjectAnchor",
"SemanticDocumentationSubjectAvailability",
"SemanticDocumentationSubjectDescriptor",
"SemanticDocumentationSubjectPage",
"SemanticDocumentationSubjectProvider",
"SemanticDocumentationSubjectQuery",
"SemanticDocumentationSubjectReference",
"SemanticDocumentationSubjectResolution",
"list_semantic_documentation_subjects",
"resolve_semantic_documentation_subject",
"semantic_documentation_fingerprint",
"semantic_documentation_subject_capability",
"semantic_documentation_subject_provider_names",
"semantic_documentation_subject_providers",
]