Close HUB-WP-0009 conformance gaps (C2, C7, C9, C10); mark blocked workplans
Implements the four residual conformance checks left open by the T04
minimal vertical:
- C2: GET /ports/registry/registrations/{hub_slug} resolves missing (404),
ambiguous (shared reuse_surface_id across hub_slugs), and stale
(deprecated/retired descriptor) registrations; a new .../audit route
exposes queryable registration history from the existing in-memory
history and the PostgreSQL runtime_audit_ledger.
- C7: harness proof that disabled compatibility groups deny access
(404) with no fixture credentials involved, matching the existing
fail-closed compat router behavior.
- C9: harness proof plus a dedicated test that /readyz degrades only on
an unavailable configured dependency while unrelated disabled
projections stay non-blocking.
- C10: ContractValidator now negotiates contract_version_min/max against
the runtime's contract version and rejects incompatible or inverted
ranges with an explicit 422 instead of silently accepting them.
HUB-WP-0009 is now finished. HUB-WP-0006 is marked blocked: its only open
task (T06) has no remaining hub-core code path and waits on an external
Forgejo identity/production deployment gate. HUB-WP-0011 is marked
blocked: T02/T03 already waited on external credential/deployment
review, and T01 needs a source/destination ownership and retention
decision against live message data before it can be implemented safely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 310936@bnt-lap001
Assistant-Session: 00cd9abe-09a0-416b-88e0-f907b9101629
This commit is contained in:
parent
b0e89592c6
commit
e89d621f18
11 changed files with 496 additions and 20 deletions
|
|
@ -47,6 +47,36 @@ def create_ports_router() -> APIRouter:
|
|||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.register_extension(body, x_correlation_id)
|
||||
|
||||
@router.get(
|
||||
"/registry/registrations/{hub_slug}",
|
||||
response_model=PortRecord,
|
||||
tags=["registry"],
|
||||
openapi_extra={"x-port-id": "port.registry", "x-direction": "out"},
|
||||
)
|
||||
async def resolve_registration(
|
||||
hub_slug: str,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortRecord:
|
||||
resolved = await store.resolve_registration(hub_slug)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"Registration '{hub_slug}' not found")
|
||||
return resolved
|
||||
|
||||
@router.get(
|
||||
"/registry/registrations/{hub_slug}/audit",
|
||||
response_model=PortCollection,
|
||||
tags=["registry"],
|
||||
openapi_extra={"x-port-id": "port.registry", "x-direction": "out"},
|
||||
)
|
||||
async def registration_audit(
|
||||
hub_slug: str,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortCollection:
|
||||
audit = await store.list_registration_audit(hub_slug)
|
||||
if not audit.items:
|
||||
raise HTTPException(status_code=404, detail=f"No audit history for '{hub_slug}'")
|
||||
return audit
|
||||
|
||||
@router.get(
|
||||
"/messaging/messages",
|
||||
response_model=PortCollection,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from hub_core.runtime.models import (
|
|||
RegistryRegistration,
|
||||
)
|
||||
from hub_core.runtime.repository_navigation import NavigationProjection
|
||||
from hub_core.runtime.store import _resolve_registration_record
|
||||
from hub_core.runtime.workload_projection import WorkloadProjection
|
||||
from hub_core.runtime.tables import (
|
||||
compat_api_keys,
|
||||
|
|
@ -443,6 +444,45 @@ class PostgresPortStore:
|
|||
)
|
||||
)
|
||||
|
||||
async def resolve_registration(self, hub_slug: str) -> PortRecord | None:
|
||||
async with self.sessions() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(runtime_registrations.c.hub_slug, runtime_registrations.c.package)
|
||||
)
|
||||
).all()
|
||||
registrations = {row.hub_slug: row.package for row in rows}
|
||||
value = registrations.get(hub_slug)
|
||||
if value is None:
|
||||
return None
|
||||
return _resolve_registration_record("hub-core-postgresql", hub_slug, value, registrations)
|
||||
|
||||
async def list_registration_audit(self, hub_slug: str) -> PortCollection:
|
||||
async with self.sessions() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(runtime_audit_ledger)
|
||||
.where(
|
||||
runtime_audit_ledger.c.subject_type == "registration",
|
||||
runtime_audit_ledger.c.subject_id == hub_slug,
|
||||
)
|
||||
.order_by(runtime_audit_ledger.c.recorded_at, runtime_audit_ledger.c.id)
|
||||
)
|
||||
).mappings()
|
||||
entries = [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"action": row["action"],
|
||||
"hub_slug": hub_slug,
|
||||
"correlation_id": row["correlation_id"],
|
||||
"recorded_at": _iso(row["recorded_at"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return PortCollection(
|
||||
items=[self._record("registration_audit", entry) for entry in entries]
|
||||
)
|
||||
|
||||
async def _append_event(
|
||||
self,
|
||||
command: EventCommand,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ class PortStore(Protocol):
|
|||
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||
) -> None: ...
|
||||
|
||||
async def resolve_registration(self, hub_slug: str) -> PortRecord | None: ...
|
||||
|
||||
async def list_registration_audit(self, hub_slug: str) -> PortCollection: ...
|
||||
|
||||
|
||||
class InMemoryPortStore:
|
||||
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
||||
|
|
@ -77,6 +81,7 @@ class InMemoryPortStore:
|
|||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._registrations: dict[str, dict[str, Any]] = {}
|
||||
self._registration_audit: dict[str, list[dict[str, Any]]] = {}
|
||||
self._messages: list[dict[str, Any]] = []
|
||||
self._progress_events: list[dict[str, Any]] = []
|
||||
self._interaction_events: list[dict[str, Any]] = []
|
||||
|
|
@ -96,12 +101,36 @@ class InMemoryPortStore:
|
|||
async with self._lock:
|
||||
duplicate = self._registrations.get(hub_slug) == value
|
||||
self._registrations[hub_slug] = deepcopy(value)
|
||||
self._registration_audit.setdefault(hub_slug, []).append(
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"action": "registry.duplicate" if duplicate else "registry.accepted",
|
||||
"hub_slug": hub_slug,
|
||||
"correlation_id": str(correlation_id),
|
||||
"recorded_at": _now().isoformat(),
|
||||
}
|
||||
)
|
||||
return PortAccepted(
|
||||
id=hub_slug,
|
||||
status="duplicate" if duplicate else "accepted",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def resolve_registration(self, hub_slug: str) -> PortRecord | None:
|
||||
async with self._lock:
|
||||
value = self._registrations.get(hub_slug)
|
||||
if value is None:
|
||||
return None
|
||||
registrations = deepcopy(self._registrations)
|
||||
return _resolve_registration_record("hub-core-memory", hub_slug, value, registrations)
|
||||
|
||||
async def list_registration_audit(self, hub_slug: str) -> PortCollection:
|
||||
async with self._lock:
|
||||
entries = deepcopy(self._registration_audit.get(hub_slug, []))
|
||||
return PortCollection(
|
||||
items=[self._record("registration_audit", entry) for entry in entries]
|
||||
)
|
||||
|
||||
async def send_message(self, command: MessageCommand) -> PortAccepted:
|
||||
message_id = uuid4()
|
||||
value = {
|
||||
|
|
@ -250,6 +279,48 @@ def _now() -> datetime:
|
|||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _resolve_registration_record(
|
||||
source_system: str,
|
||||
hub_slug: str,
|
||||
value: Mapping[str, Any],
|
||||
registrations: Mapping[str, Mapping[str, Any]],
|
||||
) -> PortRecord:
|
||||
descriptor = value["descriptor"]
|
||||
reuse_surface_id = descriptor.get("reuse_surface_id")
|
||||
ambiguous_with = sorted(
|
||||
other_slug
|
||||
for other_slug, other_value in registrations.items()
|
||||
if other_slug != hub_slug
|
||||
and other_value["descriptor"].get("reuse_surface_id") == reuse_surface_id
|
||||
)
|
||||
stale = descriptor.get("status") in {"deprecated", "retired"}
|
||||
if ambiguous_with:
|
||||
resolution = "ambiguous"
|
||||
elif stale:
|
||||
resolution = "stale"
|
||||
else:
|
||||
resolution = "ok"
|
||||
data = {
|
||||
"hub_slug": hub_slug,
|
||||
"resolution": resolution,
|
||||
"ambiguous_with": ambiguous_with,
|
||||
"descriptor": deepcopy(dict(descriptor)),
|
||||
"manifest": deepcopy(dict(value["manifest"])),
|
||||
}
|
||||
encoded = json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
|
||||
return PortRecord(
|
||||
id=hub_slug,
|
||||
data=data,
|
||||
provenance=Provenance(
|
||||
source_system=source_system,
|
||||
source_ref=f"{source_system.replace('hub-core-', '')}://registration/{hub_slug}",
|
||||
schema_version=CONTRACT_VERSION,
|
||||
content_hash=hashlib.sha256(encoded).hexdigest(),
|
||||
indexed_at=_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_sources(projection_id: str) -> list[str]:
|
||||
return {
|
||||
"hub_registry": ["hub_descriptors", "hub_manifests"],
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ from typing import Any
|
|||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from hub_core.contracts import extension_contract_root
|
||||
from hub_core.contracts import CONTRACT_VERSION, extension_contract_root
|
||||
from hub_core.runtime.models import RegistryRegistration
|
||||
|
||||
|
||||
class ContractValidator:
|
||||
"""Validate runtime registration input against the packaged contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, runtime_contract_version: str = CONTRACT_VERSION) -> None:
|
||||
self._runtime_contract_version = _parse_semver(runtime_contract_version)
|
||||
contract_root = extension_contract_root()
|
||||
schema_root = contract_root.joinpath("schemas")
|
||||
self._descriptor = _validator(schema_root.joinpath("hub-descriptor.schema.json"))
|
||||
|
|
@ -31,6 +32,21 @@ class ContractValidator:
|
|||
manifest_id = registration.manifest.get("reuse_surface_id")
|
||||
if descriptor_id != manifest_id:
|
||||
raise ValueError("descriptor and manifest reuse_surface_id must match")
|
||||
self._negotiate_contract_version(registration.descriptor)
|
||||
|
||||
def _negotiate_contract_version(self, descriptor: dict[str, Any]) -> None:
|
||||
version_min = _parse_semver(descriptor["contract_version_min"])
|
||||
version_max = _parse_semver(descriptor["contract_version_max"])
|
||||
if version_min > version_max:
|
||||
raise ValueError(
|
||||
"descriptor contract_version_min must not exceed contract_version_max"
|
||||
)
|
||||
if not (version_min <= self._runtime_contract_version <= version_max):
|
||||
raise ValueError(
|
||||
"descriptor requires contract version range "
|
||||
f"{descriptor['contract_version_min']}-{descriptor['contract_version_max']}, "
|
||||
f"incompatible with runtime contract version {CONTRACT_VERSION}"
|
||||
)
|
||||
|
||||
def validate_event_family(self, event_type: str, expected_family: str) -> None:
|
||||
actual_family = self._event_families.get(event_type)
|
||||
|
|
@ -46,3 +62,9 @@ def _validator(resource: Any) -> Draft202012Validator:
|
|||
schema = json.loads(resource.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
|
||||
|
||||
def _parse_semver(value: str) -> tuple[int, int, int]:
|
||||
core = value.split("+", 1)[0].split("-", 1)[0]
|
||||
major, minor, patch = core.split(".")
|
||||
return (int(major), int(minor), int(patch))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue