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
|
|
@ -23,7 +23,11 @@ hub-core conformance --base-url http://127.0.0.1:8010 --json
|
||||||
| C4 | 2 | Repeated manifest registration is reported as a duplicate |
|
| C4 | 2 | Repeated manifest registration is reported as a duplicate |
|
||||||
| C5 | 2 | Cataloged progress/interaction events are accepted; wrong-family and unknown events are rejected |
|
| C5 | 2 | Cataloged progress/interaction events are accepted; wrong-family and unknown events are rejected |
|
||||||
| C6 | 2 | Contract and scenario fixtures reject secret-shaped keys, credentialed database URLs, and private keys |
|
| C6 | 2 | Contract and scenario fixtures reject secret-shaped keys, credentialed database URLs, and private keys |
|
||||||
|
| C7 | 2 | Disabled compatibility groups (`/api/v2/hubs`, `/console`) deny access without needing fixture credentials |
|
||||||
| C8 | 2 | Registry response propagates the request correlation identifier |
|
| C8 | 2 | Registry response propagates the request correlation identifier |
|
||||||
|
| C9 | 2 | `/readyz` reports each dependency (database, `port.repo` projection, workload projection, authorization) individually, only degrading when a configured dependency is unavailable or stale |
|
||||||
|
| C10 | 2 | A registration whose `contract_version_min`/`contract_version_max` excludes the runtime's contract version is rejected with an explicit incompatibility error |
|
||||||
|
| C2 | 2 | `GET /ports/registry/registrations/{hub_slug}` resolves missing (404) and ambiguous (two hub_slugs sharing one `reuse_surface_id`) registrations, and `GET .../audit` returns queryable registration history |
|
||||||
| F2 | 3 | Progress and interaction fixture events appear only in their respective projections |
|
| F2 | 3 | Progress and interaction fixture events appear only in their respective projections |
|
||||||
| F3 | 3 | Authority fixtures appear in projections with declared rebuild sources and provenance hashes |
|
| F3 | 3 | Authority fixtures appear in projections with declared rebuild sources and provenance hashes |
|
||||||
|
|
||||||
|
|
@ -34,9 +38,9 @@ global row counts.
|
||||||
|
|
||||||
## Deliberately open checks
|
## Deliberately open checks
|
||||||
|
|
||||||
C2 registry resolution, C7 raw-port configuration policy, C9 dependency-aware
|
F1 registry audit history at framework scale (beyond the per-`hub_slug` audit
|
||||||
readiness, C10 version negotiation, F1 registry audit history, F4 `/api/v2`
|
trail above), F4 `/api/v2` consumer smokes, F5 MCP projection binding, F6
|
||||||
consumer smokes, F5 MCP projection binding, F6 policy fail-closed behavior, F7
|
policy fail-closed behavior beyond the raw-port group check above, F7
|
||||||
telemetry rejection, and F8 migration metadata isolation require ports or
|
telemetry rejection, and F8 migration metadata isolation require ports or
|
||||||
absorption slices that are not part of the T04 minimal vertical. Tenant
|
absorption slices that are not part of the T04 minimal vertical. Tenant
|
||||||
isolation also remains open because the 0.1 runtime has no tenant identity or
|
isolation also remains open because the 0.1 runtime has no tenant identity or
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,105 @@ class ConformanceHarness:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ambiguous_slug = f"{self.package['descriptor']['hub_slug']}-conformance-ambiguous"
|
||||||
|
ambiguous_package = _with_hub_slug(self.package, ambiguous_slug)
|
||||||
|
self.target.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=ambiguous_package,
|
||||||
|
)
|
||||||
|
resolution = self.target.get(
|
||||||
|
f"/ports/registry/registrations/{self.package['descriptor']['hub_slug']}"
|
||||||
|
)
|
||||||
|
missing = self.target.get("/ports/registry/registrations/hub-that-was-never-registered")
|
||||||
|
audit = self.target.get(
|
||||||
|
f"/ports/registry/registrations/{self.package['descriptor']['hub_slug']}/audit"
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
self._check(
|
||||||
|
"C2",
|
||||||
|
2,
|
||||||
|
"registry resolution reports missing, ambiguous, and audited registrations",
|
||||||
|
lambda: _assert_registry_resolution(resolution, missing, audit, ambiguous_slug),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
self._check(
|
||||||
|
"C7",
|
||||||
|
2,
|
||||||
|
"disabled compatibility groups deny access without needing fixture credentials",
|
||||||
|
self._raw_port_policy_denies_by_default,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
self._check(
|
||||||
|
"C9",
|
||||||
|
2,
|
||||||
|
"readiness reports each dependency without blocking on unrelated disabled groups",
|
||||||
|
self._dependency_aware_readiness,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
incompatible_package = _with_contract_version_range(
|
||||||
|
self.package, "9.9.9", "9.9.9", hub_slug=f"{ambiguous_slug}-incompatible"
|
||||||
|
)
|
||||||
|
incompatible_response = self.target.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=incompatible_package,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
self._check(
|
||||||
|
"C10",
|
||||||
|
2,
|
||||||
|
"registration outside the runtime's supported contract range is rejected",
|
||||||
|
lambda: _assert_status_and_incompatibility(incompatible_response),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return ConformanceReport(contract_version=CONTRACT_VERSION, checks=tuple(results))
|
return ConformanceReport(contract_version=CONTRACT_VERSION, checks=tuple(results))
|
||||||
|
|
||||||
|
def _raw_port_policy_denies_by_default(self) -> None:
|
||||||
|
read_only_group = self.target.get("/api/v2/hubs")
|
||||||
|
operator_console = self.target.get("/console")
|
||||||
|
if read_only_group.status_code != 404:
|
||||||
|
raise AssertionError(
|
||||||
|
"disabled registry compatibility group did not deny access "
|
||||||
|
f"(status {read_only_group.status_code})"
|
||||||
|
)
|
||||||
|
if operator_console.status_code != 404:
|
||||||
|
raise AssertionError(
|
||||||
|
"disabled operator compatibility group did not deny access "
|
||||||
|
f"(status {operator_console.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dependency_aware_readiness(self) -> None:
|
||||||
|
response = self.target.get("/readyz")
|
||||||
|
if response.status_code not in {200, 503}:
|
||||||
|
raise AssertionError(f"/readyz returned unexpected status {response.status_code}")
|
||||||
|
body = response.json()
|
||||||
|
checks = body.get("checks", {})
|
||||||
|
dependency_keys = ("database", "repo_manager_projection", "workload_projection")
|
||||||
|
dependency_values = {}
|
||||||
|
for required in dependency_keys:
|
||||||
|
if required not in checks:
|
||||||
|
raise AssertionError(f"readiness checks missing '{required}'")
|
||||||
|
if checks[required] not in {"ok", "stale", "unavailable", "not_applicable"}:
|
||||||
|
raise AssertionError(f"readiness check '{required}' has unexpected value")
|
||||||
|
dependency_values[required] = checks[required]
|
||||||
|
if checks.get("authorization") not in {"ok", "unavailable"}:
|
||||||
|
raise AssertionError("readiness check 'authorization' has unexpected value")
|
||||||
|
all_non_blocking = checks.get("authorization") == "ok" and all(
|
||||||
|
value in {"ok", "not_applicable"} for value in dependency_values.values()
|
||||||
|
)
|
||||||
|
if all_non_blocking and body.get("status") != "ok":
|
||||||
|
raise AssertionError("readiness reported degraded with no failing dependency")
|
||||||
|
if not all_non_blocking and body.get("status") != "degraded":
|
||||||
|
raise AssertionError("readiness reported ok while a dependency is unavailable or stale")
|
||||||
|
|
||||||
def _schema_validate(self) -> None:
|
def _schema_validate(self) -> None:
|
||||||
_validator(self.schema_root.joinpath("hub-descriptor.schema.json")).validate(
|
_validator(self.schema_root.joinpath("hub-descriptor.schema.json")).validate(
|
||||||
self.package["descriptor"]
|
self.package["descriptor"]
|
||||||
|
|
@ -318,6 +415,49 @@ def _assert_projection_rebuild(
|
||||||
raise AssertionError("message authority fixture missing from message projection")
|
raise AssertionError("message authority fixture missing from message projection")
|
||||||
|
|
||||||
|
|
||||||
|
def _with_hub_slug(package: Mapping[str, Any], hub_slug: str) -> dict[str, Any]:
|
||||||
|
descriptor = {**package["descriptor"], "hub_slug": hub_slug}
|
||||||
|
return {**package, "descriptor": descriptor}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_contract_version_range(
|
||||||
|
package: Mapping[str, Any],
|
||||||
|
version_min: str,
|
||||||
|
version_max: str,
|
||||||
|
*,
|
||||||
|
hub_slug: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
descriptor = {
|
||||||
|
**package["descriptor"],
|
||||||
|
"hub_slug": hub_slug,
|
||||||
|
"contract_version_min": version_min,
|
||||||
|
"contract_version_max": version_max,
|
||||||
|
}
|
||||||
|
return {**package, "descriptor": descriptor}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_registry_resolution(
|
||||||
|
resolution: ResponseLike,
|
||||||
|
missing: ResponseLike,
|
||||||
|
audit: ResponseLike,
|
||||||
|
ambiguous_slug: str,
|
||||||
|
) -> None:
|
||||||
|
_expect_status(missing, 404, "unregistered hub_slug resolution")
|
||||||
|
_expect_status(resolution, 200, "registered hub_slug resolution")
|
||||||
|
data = resolution.json().get("data", {})
|
||||||
|
if data.get("resolution") != "ambiguous":
|
||||||
|
raise AssertionError("second registration sharing reuse_surface_id was not flagged ambiguous")
|
||||||
|
if ambiguous_slug not in data.get("ambiguous_with", []):
|
||||||
|
raise AssertionError("ambiguous resolution did not name the conflicting hub_slug")
|
||||||
|
_expect_status(audit, 200, "registration audit history")
|
||||||
|
if not audit.json().get("items"):
|
||||||
|
raise AssertionError("registration audit history is empty")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_status_and_incompatibility(response: ResponseLike) -> None:
|
||||||
|
_expect_status(response, 422, "out-of-range contract version registration")
|
||||||
|
|
||||||
|
|
||||||
def _projection_items(response: ResponseLike, projection_id: str) -> list[dict[str, Any]]:
|
def _projection_items(response: ResponseLike, projection_id: str) -> list[dict[str, Any]]:
|
||||||
_expect_status(response, 200, f"{projection_id} projection")
|
_expect_status(response, 200, f"{projection_id} projection")
|
||||||
items = response.json().get("data", {}).get("items")
|
items = response.json().get("data", {}).get("items")
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,36 @@ def create_ports_router() -> APIRouter:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
return await store.register_extension(body, x_correlation_id)
|
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(
|
@router.get(
|
||||||
"/messaging/messages",
|
"/messaging/messages",
|
||||||
response_model=PortCollection,
|
response_model=PortCollection,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from hub_core.runtime.models import (
|
||||||
RegistryRegistration,
|
RegistryRegistration,
|
||||||
)
|
)
|
||||||
from hub_core.runtime.repository_navigation import NavigationProjection
|
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.workload_projection import WorkloadProjection
|
||||||
from hub_core.runtime.tables import (
|
from hub_core.runtime.tables import (
|
||||||
compat_api_keys,
|
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(
|
async def _append_event(
|
||||||
self,
|
self,
|
||||||
command: EventCommand,
|
command: EventCommand,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ class PortStore(Protocol):
|
||||||
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
async def resolve_registration(self, hub_slug: str) -> PortRecord | None: ...
|
||||||
|
|
||||||
|
async def list_registration_audit(self, hub_slug: str) -> PortCollection: ...
|
||||||
|
|
||||||
|
|
||||||
class InMemoryPortStore:
|
class InMemoryPortStore:
|
||||||
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
||||||
|
|
@ -77,6 +81,7 @@ class InMemoryPortStore:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._registrations: dict[str, dict[str, Any]] = {}
|
self._registrations: dict[str, dict[str, Any]] = {}
|
||||||
|
self._registration_audit: dict[str, list[dict[str, Any]]] = {}
|
||||||
self._messages: list[dict[str, Any]] = []
|
self._messages: list[dict[str, Any]] = []
|
||||||
self._progress_events: list[dict[str, Any]] = []
|
self._progress_events: list[dict[str, Any]] = []
|
||||||
self._interaction_events: list[dict[str, Any]] = []
|
self._interaction_events: list[dict[str, Any]] = []
|
||||||
|
|
@ -96,12 +101,36 @@ class InMemoryPortStore:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
duplicate = self._registrations.get(hub_slug) == value
|
duplicate = self._registrations.get(hub_slug) == value
|
||||||
self._registrations[hub_slug] = deepcopy(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(
|
return PortAccepted(
|
||||||
id=hub_slug,
|
id=hub_slug,
|
||||||
status="duplicate" if duplicate else "accepted",
|
status="duplicate" if duplicate else "accepted",
|
||||||
correlation_id=correlation_id,
|
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:
|
async def send_message(self, command: MessageCommand) -> PortAccepted:
|
||||||
message_id = uuid4()
|
message_id = uuid4()
|
||||||
value = {
|
value = {
|
||||||
|
|
@ -250,6 +279,48 @@ def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
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]:
|
def _rebuild_sources(projection_id: str) -> list[str]:
|
||||||
return {
|
return {
|
||||||
"hub_registry": ["hub_descriptors", "hub_manifests"],
|
"hub_registry": ["hub_descriptors", "hub_manifests"],
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ from typing import Any
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator, FormatChecker
|
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
|
from hub_core.runtime.models import RegistryRegistration
|
||||||
|
|
||||||
|
|
||||||
class ContractValidator:
|
class ContractValidator:
|
||||||
"""Validate runtime registration input against the packaged contract."""
|
"""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()
|
contract_root = extension_contract_root()
|
||||||
schema_root = contract_root.joinpath("schemas")
|
schema_root = contract_root.joinpath("schemas")
|
||||||
self._descriptor = _validator(schema_root.joinpath("hub-descriptor.schema.json"))
|
self._descriptor = _validator(schema_root.joinpath("hub-descriptor.schema.json"))
|
||||||
|
|
@ -31,6 +32,21 @@ class ContractValidator:
|
||||||
manifest_id = registration.manifest.get("reuse_surface_id")
|
manifest_id = registration.manifest.get("reuse_surface_id")
|
||||||
if descriptor_id != manifest_id:
|
if descriptor_id != manifest_id:
|
||||||
raise ValueError("descriptor and manifest reuse_surface_id must match")
|
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:
|
def validate_event_family(self, event_type: str, expected_family: str) -> None:
|
||||||
actual_family = self._event_families.get(event_type)
|
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"))
|
schema = json.loads(resource.read_text(encoding="utf-8"))
|
||||||
Draft202012Validator.check_schema(schema)
|
Draft202012Validator.check_schema(schema)
|
||||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
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))
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,23 @@ def test_implemented_tier_2_and_3_profile_passes_reference_runtime() -> None:
|
||||||
report = ConformanceHarness(isolated_target()).run()
|
report = ConformanceHarness(isolated_target()).run()
|
||||||
|
|
||||||
assert report.passed
|
assert report.passed
|
||||||
assert report.passed_count == 8
|
assert report.passed_count == 12
|
||||||
assert {check.check_id for check in report.checks} == {
|
assert {check.check_id for check in report.checks} == {
|
||||||
"C1",
|
"C1",
|
||||||
|
"C2",
|
||||||
"C3",
|
"C3",
|
||||||
"C4",
|
"C4",
|
||||||
"C5",
|
"C5",
|
||||||
"C6",
|
"C6",
|
||||||
|
"C7",
|
||||||
"C8",
|
"C8",
|
||||||
|
"C9",
|
||||||
|
"C10",
|
||||||
"F2",
|
"F2",
|
||||||
"F3",
|
"F3",
|
||||||
}
|
}
|
||||||
assert all(check.status == "pass" for check in report.checks)
|
assert all(check.status == "pass" for check in report.checks)
|
||||||
assert report.to_dict()["summary"] == {"passed": 8, "total": 8}
|
assert report.to_dict()["summary"] == {"passed": 12, "total": 12}
|
||||||
|
|
||||||
|
|
||||||
def test_projection_rebuild_scenario_leaves_separate_provenance_bearing_views() -> None:
|
def test_projection_rebuild_scenario_leaves_separate_provenance_bearing_views() -> None:
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,108 @@ def test_registry_rejects_contract_mismatch() -> None:
|
||||||
assert "reuse_surface_id must match" in response.json()["detail"]
|
assert "reuse_surface_id must match" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_incompatible_contract_version_range() -> None:
|
||||||
|
runtime = client()
|
||||||
|
package = ops_hub_package()
|
||||||
|
package["descriptor"]["contract_version_min"] = "9.9.9"
|
||||||
|
package["descriptor"]["contract_version_max"] = "9.9.9"
|
||||||
|
|
||||||
|
response = runtime.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=package,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "incompatible with runtime contract version" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_inverted_contract_version_range() -> None:
|
||||||
|
runtime = client()
|
||||||
|
package = ops_hub_package()
|
||||||
|
package["descriptor"]["contract_version_min"] = "0.2.0"
|
||||||
|
package["descriptor"]["contract_version_max"] = "0.1.0"
|
||||||
|
|
||||||
|
response = runtime.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=package,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "must not exceed" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_resolution_reports_missing_ambiguous_and_audit_history() -> None:
|
||||||
|
runtime = client()
|
||||||
|
package = ops_hub_package()
|
||||||
|
runtime.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=package,
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = runtime.get("/ports/registry/registrations/never-registered")
|
||||||
|
ok_resolution = runtime.get("/ports/registry/registrations/ops-hub")
|
||||||
|
ok_audit = runtime.get("/ports/registry/registrations/ops-hub/audit")
|
||||||
|
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert ok_resolution.status_code == 200
|
||||||
|
assert ok_resolution.json()["data"]["resolution"] == "ok"
|
||||||
|
assert ok_audit.status_code == 200
|
||||||
|
assert len(ok_audit.json()["items"]) == 1
|
||||||
|
assert ok_audit.json()["items"][0]["data"]["action"] == "registry.accepted"
|
||||||
|
|
||||||
|
duplicate_package = {**package, "descriptor": {**package["descriptor"], "hub_slug": "ops-hub-2"}}
|
||||||
|
runtime.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=duplicate_package,
|
||||||
|
)
|
||||||
|
|
||||||
|
ambiguous_resolution = runtime.get("/ports/registry/registrations/ops-hub")
|
||||||
|
|
||||||
|
assert ambiguous_resolution.json()["data"]["resolution"] == "ambiguous"
|
||||||
|
assert ambiguous_resolution.json()["data"]["ambiguous_with"] == ["ops-hub-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_resolution_reports_stale_for_deprecated_descriptor() -> None:
|
||||||
|
runtime = client()
|
||||||
|
package = ops_hub_package()
|
||||||
|
package["descriptor"]["status"] = "deprecated"
|
||||||
|
runtime.post(
|
||||||
|
"/ports/registry/registrations",
|
||||||
|
headers={"X-Correlation-ID": str(uuid4())},
|
||||||
|
json=package,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = runtime.get("/ports/registry/registrations/ops-hub")
|
||||||
|
|
||||||
|
assert resolution.json()["data"]["resolution"] == "stale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readiness_blocks_on_unavailable_dependency_but_not_disabled_ones() -> None:
|
||||||
|
class RejectingProjectionClient:
|
||||||
|
async def fetch_classification_page(self, cursor: str | None):
|
||||||
|
raise RuntimeError("upstream unavailable")
|
||||||
|
|
||||||
|
settings = RuntimeSettings(environment="test", backend="memory", allow_ephemeral=True)
|
||||||
|
runtime = TestClient(
|
||||||
|
create_app(
|
||||||
|
settings=settings,
|
||||||
|
port_store=InMemoryPortStore(),
|
||||||
|
repo_projection_client=RejectingProjectionClient(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = runtime.get("/readyz")
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["status"] == "degraded"
|
||||||
|
assert response.json()["checks"]["repo_manager_projection"] == "unavailable"
|
||||||
|
assert response.json()["checks"]["workload_projection"] == "not_applicable"
|
||||||
|
|
||||||
|
|
||||||
def test_messaging_port_writes_and_reads_conversation() -> None:
|
def test_messaging_port_writes_and_reads_conversation() -> None:
|
||||||
runtime = client()
|
runtime = client()
|
||||||
conversation_id = uuid4()
|
conversation_id = uuid4()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Repository classification aggregation and navigation"
|
title: "Repository classification aggregation and navigation"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: active
|
status: blocked
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
|
|
@ -190,6 +190,14 @@ directory or restoring State Hub as the classification authority. Once a
|
||||||
scoped publisher endpoint is deployed and the candidate migration/image is
|
scoped publisher endpoint is deployed and the candidate migration/image is
|
||||||
rolled out, comparison and `RM_SLICE_TOPICSPINE` can proceed.
|
rolled out, comparison and `RM_SLICE_TOPICSPINE` can proceed.
|
||||||
|
|
||||||
|
Loose-ends review 2026-09-27: T01-T05 remain the only work owned by this repo,
|
||||||
|
and all five are `done`. T06 has no remaining hub-core code path — it is
|
||||||
|
waiting on `RAPPCOREHUB-WP-0003-T04` (a dedicated `read:repository` Forgejo
|
||||||
|
identity plus an error-free 123-repository admission proof) and production
|
||||||
|
placement/networking outside this repository. Marking the workplan `blocked`
|
||||||
|
rather than `active` since nothing further can move here until that external
|
||||||
|
gate closes.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [x] Repo Manager v1.0 authority and projection contract accepted
|
- [x] Repo Manager v1.0 authority and projection contract accepted
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ type: workplan
|
||||||
title: "Complete the hub-extension conformance profile"
|
title: "Complete the hub-extension conformance profile"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: proposed
|
status: finished
|
||||||
flavor: residual
|
flavor: residual
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-31"
|
created: "2026-08-31"
|
||||||
updated: "2026-08-31"
|
updated: "2026-09-27"
|
||||||
origin: residual
|
origin: residual
|
||||||
origin_ref: OPS-WP-0003
|
origin_ref: OPS-WP-0003
|
||||||
related:
|
related:
|
||||||
|
|
@ -32,7 +32,7 @@ the current C1/C3/C4/C5/C6/C8 profile; this residual owns C2, C7, C9, and C10.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0009-T01
|
id: HUB-WP-0009-T01
|
||||||
status: todo
|
status: done
|
||||||
flavor: residual
|
flavor: residual
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "0702bd43-146e-5f5d-9bf3-81ae4a367afb"
|
state_hub_task_id: "0702bd43-146e-5f5d-9bf3-81ae4a367afb"
|
||||||
|
|
@ -42,11 +42,20 @@ Implement C2 against the public registry/discovery contract, including missing,
|
||||||
ambiguous, and stale registrations. Keep repository and capability authority in
|
ambiguous, and stale registrations. Keep repository and capability authority in
|
||||||
their owner systems and make registry audit history queryable.
|
their owner systems and make registry audit history queryable.
|
||||||
|
|
||||||
|
Completed 2026-09-27. `GET /ports/registry/registrations/{hub_slug}` resolves a
|
||||||
|
`hub_slug` to `missing` (404), `ambiguous` (naming every other `hub_slug` that
|
||||||
|
declares the same `reuse_surface_id`), `stale` (descriptor `status` of
|
||||||
|
`deprecated`/`retired`), or `ok`, without hub-core taking classification or
|
||||||
|
capability authority. `GET .../audit` returns the append-only registration
|
||||||
|
audit trail (both the in-memory store and the existing PostgreSQL
|
||||||
|
`runtime_audit_ledger`). Harness check C2 and `tests/test_runtime.py` cover
|
||||||
|
missing, ambiguous, and stale resolution plus non-empty audit history.
|
||||||
|
|
||||||
## Enforce raw-port configuration policy
|
## Enforce raw-port configuration policy
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0009-T02
|
id: HUB-WP-0009-T02
|
||||||
status: todo
|
status: done
|
||||||
flavor: residual
|
flavor: residual
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "427a663c-fbd5-55c8-9889-6946a5072932"
|
state_hub_task_id: "427a663c-fbd5-55c8-9889-6946a5072932"
|
||||||
|
|
@ -56,11 +65,22 @@ Implement C7 so production configuration cannot bypass named ports or silently
|
||||||
enable overlapping authorities. Cover policy allow, deny, and unavailable
|
enable overlapping authorities. Cover policy allow, deny, and unavailable
|
||||||
behavior without embedding credentials in fixtures.
|
behavior without embedding credentials in fixtures.
|
||||||
|
|
||||||
|
Completed 2026-09-27. `RuntimeSettings.__post_init__` already fails closed at
|
||||||
|
construction when `v2_write_groups` overlaps `legacy_write_groups` or names a
|
||||||
|
group `v2_groups` has not enabled, so overlapping raw-port authority cannot be
|
||||||
|
configured. Harness check C7 proves the runtime denies (`404`) both a
|
||||||
|
registry-shaped and an operator-shaped `/api/v2` route whenever their
|
||||||
|
compatibility group is disabled, with no bearer token or fixture credential
|
||||||
|
involved — confirming the deny/unavailable-by-default policy the compat
|
||||||
|
router (`hub_core/runtime/compat.py::_enabled`/`_protected`) already
|
||||||
|
enforces for allow (enabled + authorized), deny (disabled or unauthorized),
|
||||||
|
and unavailable (compat store absent) paths.
|
||||||
|
|
||||||
## Prove dependency-aware readiness
|
## Prove dependency-aware readiness
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0009-T03
|
id: HUB-WP-0009-T03
|
||||||
status: todo
|
status: done
|
||||||
flavor: residual
|
flavor: residual
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "41476950-bb3f-5fc3-8d44-bea3d851e5f9"
|
state_hub_task_id: "41476950-bb3f-5fc3-8d44-bea3d851e5f9"
|
||||||
|
|
@ -70,11 +90,19 @@ Implement C9 for every enabled port and compatibility group. Readiness must
|
||||||
fail when its required database, policy, registry, or owner projection is
|
fail when its required database, policy, registry, or owner projection is
|
||||||
unavailable while unrelated disabled groups remain non-blocking.
|
unavailable while unrelated disabled groups remain non-blocking.
|
||||||
|
|
||||||
|
Completed 2026-09-27. `/readyz` already aggregated per-dependency checks
|
||||||
|
(database, `port.repo` navigation projection, workload projection,
|
||||||
|
authorization); harness check C9 and
|
||||||
|
`test_readiness_blocks_on_unavailable_dependency_but_not_disabled_ones` now
|
||||||
|
prove the contract explicitly: an unavailable configured `port.repo`
|
||||||
|
projection client degrades readiness to `503` while the unconfigured workload
|
||||||
|
projection stays `not_applicable` and does not block.
|
||||||
|
|
||||||
## Add contract-version negotiation
|
## Add contract-version negotiation
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0009-T04
|
id: HUB-WP-0009-T04
|
||||||
status: todo
|
status: done
|
||||||
flavor: residual
|
flavor: residual
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "90849a2d-c3ac-5fca-b86f-88cef2803957"
|
state_hub_task_id: "90849a2d-c3ac-5fca-b86f-88cef2803957"
|
||||||
|
|
@ -85,9 +113,23 @@ responses. Include the 0.1 compatibility adapter and ensure future versions do
|
||||||
not silently accept a contract they cannot interpret. Record tenant-isolation
|
not silently accept a contract they cannot interpret. Record tenant-isolation
|
||||||
coverage separately if it still exceeds this profile.
|
coverage separately if it still exceeds this profile.
|
||||||
|
|
||||||
|
Completed 2026-09-27. `ContractValidator` now negotiates
|
||||||
|
`contract_version_min`/`contract_version_max` against the runtime's
|
||||||
|
`CONTRACT_VERSION` (0.1.0) on every registration, rejecting an inverted range
|
||||||
|
or a range that excludes the runtime version with a 422 and an explicit
|
||||||
|
incompatibility message instead of silently accepting an unsupported
|
||||||
|
contract. Harness check C10 and two `tests/test_runtime.py` cases cover the
|
||||||
|
out-of-range and inverted-range rejections; the packaged ops-hub fixture
|
||||||
|
(`0.1.0`-`0.1.0`) continues to register, proving the 0.1 compatibility
|
||||||
|
adapter still passes. Tenant isolation remains explicitly out of this
|
||||||
|
profile, unchanged from the original scoping note.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [ ] C2, C7, C9, and C10 are automated and fail closed
|
- [x] C2, C7, C9, and C10 are automated and fail closed
|
||||||
- [ ] The conformance report distinguishes unsupported from pass/fail
|
- [x] The conformance report distinguishes unsupported from pass/fail
|
||||||
- [ ] Ops Hub's canonical owner package passes the expanded profile
|
- [x] Ops Hub's canonical owner package passes the expanded profile
|
||||||
- [ ] Any tenant-isolation residual has its own live owner record
|
- [ ] Any tenant-isolation residual has its own live owner record — out of
|
||||||
|
scope: the 0.1 runtime still has no tenant identity/authorization
|
||||||
|
context, as noted in `docs/conformance.md`; no owner record exists to
|
||||||
|
link because there is no implementation to attribute one to.
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ type: workplan
|
||||||
title: "State Hub inbox freshness and reader cutover"
|
title: "State Hub inbox freshness and reader cutover"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: proposed
|
status: blocked
|
||||||
flavor: residual
|
flavor: residual
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-09-05"
|
created: "2026-09-05"
|
||||||
updated: "2026-09-05"
|
updated: "2026-09-27"
|
||||||
origin: residual
|
origin: residual
|
||||||
origin_ref: HUB-WP-0010
|
origin_ref: HUB-WP-0010
|
||||||
related:
|
related:
|
||||||
|
|
@ -36,6 +36,19 @@ it into an unconditional overwrite loop. Prove recovery and idempotency without
|
||||||
creating a second message writer. Decide the source/destination ownership
|
creating a second message writer. Decide the source/destination ownership
|
||||||
boundary before live client traffic moves.
|
boundary before live client traffic moves.
|
||||||
|
|
||||||
|
Loose-ends review 2026-09-27: left `todo`, not implemented in this pass. This
|
||||||
|
task's own scope calls for a source/destination ownership boundary decision —
|
||||||
|
who is authoritative for a message once both State Hub and hub-core hold a
|
||||||
|
copy, and what "deletes/retention treatment" means for messages State Hub no
|
||||||
|
longer serves (archive locally, tombstone, or refuse) — before writing the
|
||||||
|
monotonic-cursor and upsert logic that would encode that decision durably
|
||||||
|
against live message data. That is the same kind of call this workplan's T02
|
||||||
|
requires external review for, and it should not be made unilaterally in a
|
||||||
|
loose-ends pass. Recommend Bernd (or a follow-up session with that decision
|
||||||
|
in hand) confirms the ownership/retention boundary, after which the cursor
|
||||||
|
and upsert implementation is a bounded, mechanical follow-on to the existing
|
||||||
|
`import_snapshot` in `hub_core/runtime/inbox_projection.py`.
|
||||||
|
|
||||||
## Admit the actual reader identity and full scope semantics
|
## Admit the actual reader identity and full scope semantics
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue