Close HUB-WP-0009 conformance gaps (C2, C7, C9, C10); mark blocked workplans
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / pytest-smoke (push) Waiting to run

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:
tegwick 2026-09-27 23:59:35 +02:00
parent b0e89592c6
commit e89d621f18
11 changed files with 496 additions and 20 deletions

View file

@ -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

View file

@ -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")

View file

@ -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,

View file

@ -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,

View file

@ -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"],

View file

@ -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))

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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.

View file

@ -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