feat: add hub runtime and extension contract
This commit is contained in:
parent
fce19f193f
commit
7e1ec03f0c
44 changed files with 3875 additions and 84 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Reusable primitives for FOS hub services."""
|
||||
"""Contracts, reusable primitives, and runtime surfaces for HelixForge hubs."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
|
|
|
|||
15
hub_core/conformance/__init__.py
Normal file
15
hub_core/conformance/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Reusable conformance entrypoints for HelixForge hub runtimes."""
|
||||
|
||||
from hub_core.conformance.harness import (
|
||||
CheckResult,
|
||||
ConformanceHarness,
|
||||
ConformanceReport,
|
||||
find_secret_violations,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CheckResult",
|
||||
"ConformanceHarness",
|
||||
"ConformanceReport",
|
||||
"find_secret_violations",
|
||||
]
|
||||
354
hub_core/conformance/harness.py
Normal file
354
hub_core/conformance/harness.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from hub_core.contracts import CONTRACT_VERSION, extension_contract_root
|
||||
|
||||
|
||||
class ResponseLike(Protocol):
|
||||
status_code: int
|
||||
|
||||
def json(self) -> Any: ...
|
||||
|
||||
|
||||
class ConformanceTarget(Protocol):
|
||||
"""Small HTTP surface shared by httpx.Client and FastAPI TestClient."""
|
||||
|
||||
def get(self, url: str, **kwargs: Any) -> ResponseLike: ...
|
||||
|
||||
def post(self, url: str, **kwargs: Any) -> ResponseLike: ...
|
||||
|
||||
|
||||
CheckStatus = Literal["pass", "fail"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckResult:
|
||||
check_id: str
|
||||
tier: int
|
||||
status: CheckStatus
|
||||
summary: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConformanceReport:
|
||||
contract_version: str
|
||||
checks: tuple[CheckResult, ...]
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return all(check.status == "pass" for check in self.checks)
|
||||
|
||||
@property
|
||||
def passed_count(self) -> int:
|
||||
return sum(check.status == "pass" for check in self.checks)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"contract_version": self.contract_version,
|
||||
"passed": self.passed,
|
||||
"summary": {"passed": self.passed_count, "total": len(self.checks)},
|
||||
"checks": [asdict(check) for check in self.checks],
|
||||
}
|
||||
|
||||
|
||||
class ConformanceHarness:
|
||||
"""Run the implemented Tier 2/3 profile against an isolated HTTP target.
|
||||
|
||||
The target should be disposable or use a dedicated test namespace. The
|
||||
harness writes the packaged ops-hub fixture plus one message and one event
|
||||
from each implemented framework event family.
|
||||
"""
|
||||
|
||||
def __init__(self, target: ConformanceTarget) -> None:
|
||||
self.target = target
|
||||
root = extension_contract_root()
|
||||
self.package = _load_json(root.joinpath("fixtures", "ops-hub.extension.json"))
|
||||
self.scenario = _load_json(root.joinpath("fixtures", "projection-rebuild.json"))
|
||||
self.catalog = _load_json(root.joinpath("catalogs", "event-types.json"))
|
||||
self.schema_root = root.joinpath("schemas")
|
||||
|
||||
def run(self) -> ConformanceReport:
|
||||
results = [
|
||||
self._check("C1", 2, "descriptor and manifest validate", self._schema_validate),
|
||||
self._check("C6", 2, "fixtures contain no secret material", self._no_secrets),
|
||||
self._check("C3", 2, "health endpoint passes", self._health_probe),
|
||||
]
|
||||
|
||||
correlation_id = str(uuid4())
|
||||
registration = self.target.post(
|
||||
"/ports/registry/registrations",
|
||||
headers={"X-Correlation-ID": correlation_id},
|
||||
json=self.package,
|
||||
)
|
||||
duplicate = self.target.post(
|
||||
"/ports/registry/registrations",
|
||||
headers={"X-Correlation-ID": correlation_id},
|
||||
json=self.package,
|
||||
)
|
||||
results.append(
|
||||
self._check(
|
||||
"C4",
|
||||
2,
|
||||
"manifest activation is idempotent",
|
||||
lambda: _assert_registration_idempotent(registration, duplicate),
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
self._check(
|
||||
"C8",
|
||||
2,
|
||||
"registry write propagates correlation id",
|
||||
lambda: _assert_correlation(registration, correlation_id),
|
||||
)
|
||||
)
|
||||
|
||||
progress = _materialize_event(self.scenario["authority"]["progress_events"][0])
|
||||
interaction = _materialize_event(self.scenario["authority"]["interaction_events"][0])
|
||||
progress_response = self.target.post("/ports/events/progress", json=progress)
|
||||
interaction_response = self.target.post("/ports/events/interaction", json=interaction)
|
||||
wrong_family = self.target.post("/ports/events/progress", json=interaction)
|
||||
unknown_event = {**interaction, "event_type": "hub.interaction.uncataloged"}
|
||||
unknown_response = self.target.post("/ports/events/interaction", json=unknown_event)
|
||||
results.append(
|
||||
self._check(
|
||||
"C5",
|
||||
2,
|
||||
"cataloged events are accepted and invalid types rejected",
|
||||
lambda: _assert_event_validation(
|
||||
progress_response,
|
||||
interaction_response,
|
||||
wrong_family,
|
||||
unknown_response,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
message = _materialize_message(self.scenario["authority"]["messages"][0])
|
||||
message_response = self.target.post("/ports/messaging/messages", json=message)
|
||||
projections = {
|
||||
projection_id: self.target.get(f"/ports/projections/{projection_id}")
|
||||
for projection_id in self.scenario["expected_projections"]
|
||||
}
|
||||
results.append(
|
||||
self._check(
|
||||
"F2",
|
||||
3,
|
||||
"progress and interaction event families remain separate",
|
||||
lambda: _assert_family_separation(
|
||||
projections,
|
||||
progress["correlation_id"],
|
||||
interaction["correlation_id"],
|
||||
),
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
self._check(
|
||||
"F3",
|
||||
3,
|
||||
"projections rebuild from authority fixture with provenance",
|
||||
lambda: _assert_projection_rebuild(
|
||||
projections,
|
||||
self.scenario["expected_projections"],
|
||||
message_response,
|
||||
message["correlation_id"],
|
||||
),
|
||||
)
|
||||
)
|
||||
return ConformanceReport(contract_version=CONTRACT_VERSION, checks=tuple(results))
|
||||
|
||||
def _schema_validate(self) -> None:
|
||||
_validator(self.schema_root.joinpath("hub-descriptor.schema.json")).validate(
|
||||
self.package["descriptor"]
|
||||
)
|
||||
_validator(self.schema_root.joinpath("hub-manifest.schema.json")).validate(
|
||||
self.package["manifest"]
|
||||
)
|
||||
_validator(self.schema_root.joinpath("event-type-catalog.schema.json")).validate(
|
||||
self.catalog
|
||||
)
|
||||
if self.package["descriptor"]["reuse_surface_id"] != self.package["manifest"][
|
||||
"reuse_surface_id"
|
||||
]:
|
||||
raise AssertionError("descriptor and manifest reuse_surface_id differ")
|
||||
|
||||
def _no_secrets(self) -> None:
|
||||
violations = find_secret_violations(
|
||||
{"package": self.package, "scenario": self.scenario, "catalog": self.catalog}
|
||||
)
|
||||
if violations:
|
||||
raise AssertionError("secret-like material: " + ", ".join(violations))
|
||||
|
||||
def _health_probe(self) -> None:
|
||||
response = self.target.get("/healthz")
|
||||
_expect_status(response, 200, "health probe")
|
||||
if response.json().get("status") != "ok":
|
||||
raise AssertionError("health response status is not ok")
|
||||
|
||||
@staticmethod
|
||||
def _check(
|
||||
check_id: str,
|
||||
tier: int,
|
||||
summary: str,
|
||||
operation: Any,
|
||||
) -> CheckResult:
|
||||
try:
|
||||
operation()
|
||||
except Exception as exc: # each check must produce a complete report
|
||||
return CheckResult(check_id, tier, "fail", f"{summary}: {exc}")
|
||||
return CheckResult(check_id, tier, "pass", summary)
|
||||
|
||||
|
||||
SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:api_?(?:key|token)|access_?token|auth_?token|client_?secret|credential|passwd|password|private_?key|secret)(?:$|_)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SECRET_VALUE = re.compile(
|
||||
r"(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis)://[^\s/:]+:[^\s/@]+@|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def find_secret_violations(value: Any, path: str = "$") -> list[str]:
|
||||
"""Return JSON paths containing credential-shaped keys or values."""
|
||||
|
||||
violations: list[str] = []
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
child_path = f"{path}.{key}"
|
||||
if SECRET_KEY.search(str(key)):
|
||||
violations.append(child_path)
|
||||
violations.extend(find_secret_violations(child, child_path))
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
for index, child in enumerate(value):
|
||||
violations.extend(find_secret_violations(child, f"{path}[{index}]"))
|
||||
elif isinstance(value, str) and SECRET_VALUE.search(value):
|
||||
violations.append(path)
|
||||
return violations
|
||||
|
||||
|
||||
def _assert_registration_idempotent(
|
||||
registration: ResponseLike,
|
||||
duplicate: ResponseLike,
|
||||
) -> None:
|
||||
_expect_status(registration, 202, "initial activation")
|
||||
_expect_status(duplicate, 202, "duplicate activation")
|
||||
if registration.json().get("status") not in {"accepted", "duplicate"}:
|
||||
raise AssertionError("initial activation did not return an activation status")
|
||||
if duplicate.json().get("status") != "duplicate":
|
||||
raise AssertionError("second activation was not reported as duplicate")
|
||||
|
||||
|
||||
def _assert_correlation(response: ResponseLike, expected: str) -> None:
|
||||
_expect_status(response, 202, "correlated registry write")
|
||||
if response.json().get("correlation_id") != expected:
|
||||
raise AssertionError("response correlation_id does not match request")
|
||||
|
||||
|
||||
def _assert_event_validation(
|
||||
progress: ResponseLike,
|
||||
interaction: ResponseLike,
|
||||
wrong_family: ResponseLike,
|
||||
unknown: ResponseLike,
|
||||
) -> None:
|
||||
_expect_status(progress, 202, "progress event")
|
||||
_expect_status(interaction, 202, "interaction event")
|
||||
_expect_status(wrong_family, 422, "wrong-family event")
|
||||
_expect_status(unknown, 422, "uncataloged event")
|
||||
|
||||
|
||||
def _assert_family_separation(
|
||||
projections: Mapping[str, ResponseLike],
|
||||
progress_correlation: str,
|
||||
interaction_correlation: str,
|
||||
) -> None:
|
||||
progress_items = _projection_items(projections["progress_events"], "progress_events")
|
||||
interaction_items = _projection_items(
|
||||
projections["interaction_events"], "interaction_events"
|
||||
)
|
||||
progress_correlations = {item.get("correlation_id") for item in progress_items}
|
||||
interaction_correlations = {item.get("correlation_id") for item in interaction_items}
|
||||
if progress_correlation not in progress_correlations:
|
||||
raise AssertionError("progress fixture missing from progress projection")
|
||||
if interaction_correlation not in interaction_correlations:
|
||||
raise AssertionError("interaction fixture missing from interaction projection")
|
||||
if progress_correlation in interaction_correlations or interaction_correlation in progress_correlations:
|
||||
raise AssertionError("event correlation crossed family projection boundary")
|
||||
if any(item.get("family") != "progress" for item in progress_items):
|
||||
raise AssertionError("progress projection contains another event family")
|
||||
if any(item.get("family") != "interaction" for item in interaction_items):
|
||||
raise AssertionError("interaction projection contains another event family")
|
||||
|
||||
|
||||
def _assert_projection_rebuild(
|
||||
projections: Mapping[str, ResponseLike],
|
||||
expectations: Mapping[str, Any],
|
||||
message_response: ResponseLike,
|
||||
message_correlation: str,
|
||||
) -> None:
|
||||
_expect_status(message_response, 202, "fixture message")
|
||||
for projection_id, expected in expectations.items():
|
||||
response = projections[projection_id]
|
||||
_expect_status(response, 200, f"{projection_id} projection")
|
||||
body = response.json()
|
||||
data = body.get("data", {})
|
||||
if data.get("projection_id") != projection_id:
|
||||
raise AssertionError(f"{projection_id} identity was not preserved")
|
||||
if data.get("rebuild_from") != expected["rebuild_from"]:
|
||||
raise AssertionError(f"{projection_id} rebuild sources differ")
|
||||
provenance = body.get("provenance", {})
|
||||
if not provenance.get("source_ref") or not provenance.get("schema_version"):
|
||||
raise AssertionError(f"{projection_id} lacks provenance")
|
||||
if not provenance.get("content_hash"):
|
||||
raise AssertionError(f"{projection_id} lacks content hash")
|
||||
|
||||
registry_items = _projection_items(projections["hub_registry"], "hub_registry")
|
||||
if not any(item.get("descriptor", {}).get("hub_slug") == "ops-hub" for item in registry_items):
|
||||
raise AssertionError("ops-hub authority fixture missing from registry projection")
|
||||
message_items = _projection_items(projections["messages"], "messages")
|
||||
if not any(item.get("correlation_id") == message_correlation for item in message_items):
|
||||
raise AssertionError("message authority fixture missing from message projection")
|
||||
|
||||
|
||||
def _projection_items(response: ResponseLike, projection_id: str) -> list[dict[str, Any]]:
|
||||
_expect_status(response, 200, f"{projection_id} projection")
|
||||
items = response.json().get("data", {}).get("items")
|
||||
if not isinstance(items, list):
|
||||
raise AssertionError(f"{projection_id} items are not a list")
|
||||
return items
|
||||
|
||||
|
||||
def _materialize_event(template: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
**template,
|
||||
"correlation_id": str(uuid4()),
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _materialize_message(template: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {**template, "correlation_id": str(uuid4()), "conversation_id": str(uuid4())}
|
||||
|
||||
|
||||
def _expect_status(response: ResponseLike, expected: int, purpose: str) -> None:
|
||||
if response.status_code != expected:
|
||||
detail = json.dumps(response.json(), sort_keys=True)
|
||||
raise AssertionError(f"{purpose} returned {response.status_code}, expected {expected}: {detail}")
|
||||
|
||||
|
||||
def _validator(resource: Any) -> Draft202012Validator:
|
||||
schema = _load_json(resource)
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
|
||||
|
||||
def _load_json(resource: Any) -> Any:
|
||||
return json.loads(resource.read_text(encoding="utf-8"))
|
||||
16
hub_core/contracts/__init__.py
Normal file
16
hub_core/contracts/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Packaged HelixForge hub contract artifacts."""
|
||||
|
||||
from importlib.resources import files
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
CONTRACT_ID = "helixforge.hub-extension"
|
||||
CONTRACT_VERSION = "0.1.0"
|
||||
|
||||
|
||||
def extension_contract_root() -> Traversable:
|
||||
"""Return the packaged root for the current hub-extension contract."""
|
||||
|
||||
return files("hub_core.contracts.helixforge_hub_extension.v0_1_0")
|
||||
|
||||
|
||||
__all__ = ["CONTRACT_ID", "CONTRACT_VERSION", "extension_contract_root"]
|
||||
1
hub_core/contracts/helixforge_hub_extension/__init__.py
Normal file
1
hub_core/contracts/helixforge_hub_extension/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Versioned ``helixforge.hub-extension`` contract packages."""
|
||||
26
hub_core/contracts/helixforge_hub_extension/v0_1_0/README.md
Normal file
26
hub_core/contracts/helixforge_hub_extension/v0_1_0/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# helixforge.hub-extension 0.1.0
|
||||
|
||||
Tier 1 contract artifacts implementing `SHR-ARCH-HUB-0001` and
|
||||
`SHR-ARCH-IA-0001`:
|
||||
|
||||
- `schemas/hub-descriptor.schema.json` — hub identity and version negotiation;
|
||||
- `schemas/hub-manifest.schema.json` — provided/consumed capabilities, ports,
|
||||
events, endpoints, policies, and operator surfaces;
|
||||
- `schemas/event-type-catalog.schema.json` — catalog entries with event family,
|
||||
ownership, sensitivity, and payload schema references;
|
||||
- `openapi/ports.openapi.json` — OpenAPI 3.1 fragments for every v0.1 named port;
|
||||
- `catalogs/event-types.json` — initial progress and interaction event catalog;
|
||||
- `fixtures/ops-hub.extension.json` — non-secret domain/aspect hub package;
|
||||
- `fixtures/projection-rebuild.json` — deterministic authority inputs and
|
||||
expected rebuild sources for framework projections;
|
||||
- `compatibility-matrix.json` — version and Core Hub migration compatibility.
|
||||
|
||||
The package is descriptive and versioned. Runtime conformance belongs to Tier
|
||||
2/3 tests exposed through `hub_core.conformance` and `hub-core conformance`.
|
||||
Contract changes are additive within a minor line; breaking field,
|
||||
enum, or semantic changes require a new major version and a dual-run window.
|
||||
|
||||
Manifest and event payloads must never contain credential material. Endpoint
|
||||
entries carry only discovery keys or non-secret URLs. Authorization decisions,
|
||||
repository work authority, secrets, and schedules remain behind their assigned
|
||||
ports.
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Contract artifacts for ``helixforge.hub-extension`` version 0.1.0."""
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"catalog_version": "0.1.0",
|
||||
"event_types": [
|
||||
{
|
||||
"type": "hub.progress.recorded",
|
||||
"display_name": "Hub Progress Recorded",
|
||||
"description": "Coordination audit evidence linked to work or another governed subject.",
|
||||
"family": "progress",
|
||||
"schema_version": "0.1.0",
|
||||
"owner": "hub-core",
|
||||
"sensitivity": "operational",
|
||||
"payload_schema_ref": "urn:helixforge:event:hub.progress.recorded:0.1.0",
|
||||
"correlation_required": true,
|
||||
"retention_class": "audit"
|
||||
},
|
||||
{
|
||||
"type": "hub.interaction.recorded",
|
||||
"display_name": "Hub Interaction Recorded",
|
||||
"description": "Framework or operator interaction evidence distinct from coordination progress.",
|
||||
"family": "interaction",
|
||||
"schema_version": "0.1.0",
|
||||
"owner": "hub-core",
|
||||
"sensitivity": "operational",
|
||||
"payload_schema_ref": "urn:helixforge:event:hub.interaction.recorded:0.1.0",
|
||||
"correlation_required": true,
|
||||
"retention_class": "audit"
|
||||
},
|
||||
{
|
||||
"type": "repository.change.observed",
|
||||
"display_name": "Repository Change Observed",
|
||||
"description": "A repository revision or governed mutation observed by repo-manager.",
|
||||
"family": "repository_change",
|
||||
"schema_version": "0.1.0",
|
||||
"owner": "repo-manager",
|
||||
"sensitivity": "operational",
|
||||
"payload_schema_ref": "urn:helixforge:event:repository.change.observed:0.1.0",
|
||||
"correlation_required": false,
|
||||
"retention_class": "audit"
|
||||
},
|
||||
{
|
||||
"type": "ops.endpoint.verified",
|
||||
"display_name": "Operations Endpoint Verified",
|
||||
"description": "A domain-owned operations endpoint passed its declared verification.",
|
||||
"family": "domain",
|
||||
"schema_version": "0.1.0",
|
||||
"owner": "ops-hub",
|
||||
"sensitivity": "operational",
|
||||
"payload_schema_ref": "urn:helixforge:event:ops.endpoint.verified:0.1.0",
|
||||
"correlation_required": true,
|
||||
"retention_class": "domain_policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"contract_id": "helixforge.hub-extension",
|
||||
"current_version": "0.1.0",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"status": "current",
|
||||
"compatible_min": "0.1.0",
|
||||
"compatible_max": "0.1.0",
|
||||
"breaking": false
|
||||
}
|
||||
],
|
||||
"migration_adapters": [
|
||||
{
|
||||
"source": "core-hub.hub-manifest",
|
||||
"source_version": "0.1",
|
||||
"target_version": "0.1.0",
|
||||
"status": "required-during-dual-run",
|
||||
"mapping": {
|
||||
"hub_slug": "descriptor.hub_slug",
|
||||
"manifest_version": "manifest.manifest_version",
|
||||
"capabilities": "manifest.provides",
|
||||
"endpoints": "manifest.endpoints"
|
||||
},
|
||||
"notes": "The adapter must supply descriptor identity/version fields and explicit consumes/event lists; it must not infer credentials from endpoint metadata."
|
||||
}
|
||||
],
|
||||
"change_policy": {
|
||||
"patch": "Clarifications and compatible constraint corrections only.",
|
||||
"minor": "Additive optional fields, ports, event types, and enum values.",
|
||||
"major": "Removed or renamed fields, narrowed enums, changed authority semantics, or incompatible port behavior; requires a dual-run window."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"descriptor": {
|
||||
"contract_id": "helixforge.hub-extension",
|
||||
"contract_version": "0.1.0",
|
||||
"hub_slug": "ops-hub",
|
||||
"display_name": "Operations Hub",
|
||||
"description": "Operations aspect hub fixture for contract and composition tests.",
|
||||
"domain": "operations",
|
||||
"hub_kind": "aspect",
|
||||
"status": "active",
|
||||
"reuse_surface_id": "capability.operations.ops-hub",
|
||||
"contract_version_min": "0.1.0",
|
||||
"contract_version_max": "0.1.0",
|
||||
"vsm_system": "operations",
|
||||
"vsm_function": "coordination"
|
||||
},
|
||||
"manifest": {
|
||||
"manifest_version": "0.1.0",
|
||||
"schema_version": "0.1.0",
|
||||
"reuse_surface_id": "capability.operations.ops-hub",
|
||||
"provides": [
|
||||
"capability.operations.ops-hub",
|
||||
"capability.operations.service-catalog"
|
||||
],
|
||||
"consumes": [
|
||||
"port.registry",
|
||||
"port.messaging",
|
||||
"port.events.interaction",
|
||||
"port.projection.query",
|
||||
"port.policy"
|
||||
],
|
||||
"events_emitted": [
|
||||
"ops.endpoint.verified"
|
||||
],
|
||||
"events_consumed": [
|
||||
"hub.progress.recorded"
|
||||
],
|
||||
"endpoints": [
|
||||
{
|
||||
"id": "api",
|
||||
"discovery_key": "service.ops-hub.http"
|
||||
},
|
||||
{
|
||||
"id": "docs",
|
||||
"url": "https://ops-hub.example.invalid/docs"
|
||||
}
|
||||
],
|
||||
"policy_scopes": [
|
||||
"ops.catalog.read",
|
||||
"ops.evidence.write"
|
||||
],
|
||||
"widgets": [
|
||||
{
|
||||
"id": "ops-overview",
|
||||
"kind": "widget",
|
||||
"description": "Non-secret operations overview surface."
|
||||
}
|
||||
],
|
||||
"operator_surfaces": [
|
||||
{
|
||||
"id": "ops-console",
|
||||
"kind": "console",
|
||||
"description": "Operator-facing operations console."
|
||||
},
|
||||
{
|
||||
"id": "ops-mcp",
|
||||
"kind": "mcp",
|
||||
"description": "Policy-bound operations MCP surface."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"contract_version": "0.1.0",
|
||||
"extension_fixture": "ops-hub.extension.json",
|
||||
"authority": {
|
||||
"messages": [
|
||||
{
|
||||
"schema_version": "0.1.0",
|
||||
"from_address": "hub:ops-hub",
|
||||
"to_addresses": ["agent:conformance"],
|
||||
"body": "Projection rebuild conformance fixture.",
|
||||
"subject_refs": {"hub": "ops-hub", "fixture": "projection-rebuild"}
|
||||
}
|
||||
],
|
||||
"progress_events": [
|
||||
{
|
||||
"schema_version": "0.1.0",
|
||||
"event_type": "hub.progress.recorded",
|
||||
"subject_refs": {"hub": "ops-hub", "fixture": "projection-rebuild"},
|
||||
"payload": {"result": "fixture-progress"}
|
||||
}
|
||||
],
|
||||
"interaction_events": [
|
||||
{
|
||||
"schema_version": "0.1.0",
|
||||
"event_type": "hub.interaction.recorded",
|
||||
"subject_refs": {"hub": "ops-hub", "fixture": "projection-rebuild"},
|
||||
"payload": {"result": "fixture-interaction"}
|
||||
}
|
||||
]
|
||||
},
|
||||
"expected_projections": {
|
||||
"hub_registry": {"rebuild_from": ["hub_descriptors", "hub_manifests"]},
|
||||
"messages": {"rebuild_from": ["messages"]},
|
||||
"progress_events": {"rebuild_from": ["progress_events"]},
|
||||
"interaction_events": {"rebuild_from": ["interaction_events"]}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,669 @@
|
|||
{
|
||||
"openapi": "3.1.0",
|
||||
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
|
||||
"info": {
|
||||
"title": "HelixForge Hub Extension Ports",
|
||||
"version": "0.1.0",
|
||||
"description": "Implementation-neutral HTTP fragments for helixforge.hub-extension named ports. Implementations may mount compatibility aliases while these operations remain the stable contract."
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/ports/registry/registrations": {
|
||||
"post": {
|
||||
"operationId": "registerHubExtension",
|
||||
"summary": "Register or idempotently update a hub descriptor and manifest",
|
||||
"tags": ["registry"],
|
||||
"x-port-id": "port.registry",
|
||||
"x-direction": "in",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/CorrelationHeader"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistryRegistration"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/InvalidRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/addressing/resolve/{address}": {
|
||||
"get": {
|
||||
"operationId": "resolveAddress",
|
||||
"summary": "Resolve a qualified agent, hub, domain, or component address",
|
||||
"tags": ["addressing"],
|
||||
"x-port-id": "port.addressing",
|
||||
"x-direction": "out",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "address",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Record"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/messaging/messages": {
|
||||
"get": {
|
||||
"operationId": "listAddressedMessages",
|
||||
"summary": "Read retained messages addressed to a participant",
|
||||
"tags": ["messaging"],
|
||||
"x-port-id": "port.messaging",
|
||||
"x-direction": "out",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "address",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "conversation_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Collection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "sendAddressedMessage",
|
||||
"summary": "Append an addressed message to a conversation",
|
||||
"tags": ["messaging"],
|
||||
"x-port-id": "port.messaging",
|
||||
"x-direction": "in",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/events/progress": {
|
||||
"post": {
|
||||
"operationId": "appendProgressEvent",
|
||||
"summary": "Append coordination progress evidence",
|
||||
"tags": ["events"],
|
||||
"x-port-id": "port.events.progress",
|
||||
"x-direction": "in",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EventCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/events/interaction": {
|
||||
"post": {
|
||||
"operationId": "appendInteractionEvent",
|
||||
"summary": "Append framework or domain interaction evidence",
|
||||
"tags": ["events"],
|
||||
"x-port-id": "port.events.interaction",
|
||||
"x-direction": "in",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EventCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/projections/{projection_id}": {
|
||||
"get": {
|
||||
"operationId": "queryProjection",
|
||||
"summary": "Read a rebuildable projection with provenance",
|
||||
"tags": ["projections"],
|
||||
"x-port-id": "port.projection.query",
|
||||
"x-direction": "out",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projection_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Record"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/repos/{repo_slug}": {
|
||||
"get": {
|
||||
"operationId": "getRepositoryReference",
|
||||
"summary": "Resolve repository metadata through repo-manager",
|
||||
"tags": ["repositories"],
|
||||
"x-port-id": "port.repo",
|
||||
"x-direction": "out",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "repo_slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*$"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Record"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/work/{record_id}": {
|
||||
"get": {
|
||||
"operationId": "getWorkRecordProjection",
|
||||
"summary": "Read a repository-authoritative work-record projection",
|
||||
"tags": ["work"],
|
||||
"x-port-id": "port.work",
|
||||
"x-direction": "out",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "record_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Record"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/policy/evaluations": {
|
||||
"post": {
|
||||
"operationId": "evaluatePolicy",
|
||||
"summary": "Request an authorization or policy decision from its authority",
|
||||
"tags": ["policy"],
|
||||
"x-port-id": "port.policy",
|
||||
"x-direction": "out",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PolicyEvaluation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Record"
|
||||
},
|
||||
"503": {
|
||||
"description": "Policy authority unavailable; callers fail closed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/telemetry/samples": {
|
||||
"post": {
|
||||
"operationId": "submitTelemetrySample",
|
||||
"summary": "Submit correlated cost or usage telemetry",
|
||||
"tags": ["telemetry"],
|
||||
"x-port-id": "port.telemetry",
|
||||
"x-direction": "in",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TelemetryCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ports/schedule/requests": {
|
||||
"post": {
|
||||
"operationId": "requestScheduledExecution",
|
||||
"summary": "Request activity-core execution without embedding a scheduler",
|
||||
"tags": ["schedule"],
|
||||
"x-port-id": "port.schedule",
|
||||
"x-direction": "out",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ScheduleCommand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Accepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"CorrelationHeader": {
|
||||
"name": "X-Correlation-ID",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"description": "UUIDv7 correlation identifier for the registration action.",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"RegistryRegistration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["descriptor", "manifest"],
|
||||
"properties": {
|
||||
"descriptor": {
|
||||
"$ref": "../schemas/hub-descriptor.schema.json"
|
||||
},
|
||||
"manifest": {
|
||||
"$ref": "../schemas/hub-manifest.schema.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessageCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "correlation_id", "from_address", "to_addresses", "body"],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"from_address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"to_addresses": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"subject_refs": {
|
||||
"$ref": "#/components/schemas/SubjectRefs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EventCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "correlation_id", "event_type", "occurred_at", "payload"],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9][a-z0-9-]*)+$"
|
||||
},
|
||||
"occurred_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"subject_refs": {
|
||||
"$ref": "#/components/schemas/SubjectRefs"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PolicyEvaluation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["correlation_id", "subject", "action", "resource"],
|
||||
"properties": {
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"resource": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TelemetryCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "correlation_id", "metric", "value", "recorded_at"],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
},
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"recorded_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"subject_refs": {
|
||||
"$ref": "#/components/schemas/SubjectRefs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScheduleCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["correlation_id", "capability", "requested_for", "input"],
|
||||
"properties": {
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
},
|
||||
"capability": {
|
||||
"type": "string",
|
||||
"pattern": "^capability\\.[a-z0-9][a-z0-9-]*\\.[a-z0-9][a-z0-9-]*$"
|
||||
},
|
||||
"requested_for": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"input": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CorrelationId": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "UUIDv7 correlation identifier for one action spanning information kinds."
|
||||
},
|
||||
"SubjectRefs": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"PortAccepted": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "status", "correlation_id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status": {
|
||||
"enum": ["accepted", "duplicate"]
|
||||
},
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PortRecord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "data", "provenance"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object"
|
||||
},
|
||||
"provenance": {
|
||||
"type": "object",
|
||||
"required": ["source_system", "source_ref", "schema_version"],
|
||||
"properties": {
|
||||
"source_system": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_ref": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"content_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"indexed_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PortCollection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PortRecord"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PortError": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"correlation_id": {
|
||||
"$ref": "#/components/schemas/CorrelationId"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Accepted": {
|
||||
"description": "Command accepted idempotently",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Record": {
|
||||
"description": "Projected record with provenance",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortRecord"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Collection": {
|
||||
"description": "Projected collection",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortCollection"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"InvalidRequest": {
|
||||
"description": "Contract validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"description": "Requested projection or authority reference was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.helixforge.local/helixforge.hub-extension/0.1.0/event-type-catalog.schema.json",
|
||||
"title": "HelixForge Event Type Catalog",
|
||||
"description": "Catalog of versioned, non-secret event types with distinct semantic families.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["catalog_version", "event_types"],
|
||||
"properties": {
|
||||
"catalog_version": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"event_types": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/eventType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"semver": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"eventType": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"display_name",
|
||||
"description",
|
||||
"family",
|
||||
"schema_version",
|
||||
"owner",
|
||||
"sensitivity",
|
||||
"payload_schema_ref",
|
||||
"correlation_required"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9][a-z0-9-]*)+$",
|
||||
"maxLength": 180
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"family": {
|
||||
"enum": ["progress", "interaction", "repository_change", "domain"]
|
||||
},
|
||||
"schema_version": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
||||
"maxLength": 80
|
||||
},
|
||||
"sensitivity": {
|
||||
"enum": ["public_internal", "operational", "personal"]
|
||||
},
|
||||
"payload_schema_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"correlation_required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"retention_class": {
|
||||
"enum": ["audit", "operational", "domain_policy"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.helixforge.local/helixforge.hub-extension/0.1.0/hub-descriptor.schema.json",
|
||||
"title": "HelixForge Hub Descriptor",
|
||||
"description": "Identity and contract-version declaration for a domain or aspect hub.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"contract_id",
|
||||
"contract_version",
|
||||
"hub_slug",
|
||||
"display_name",
|
||||
"domain",
|
||||
"hub_kind",
|
||||
"status",
|
||||
"reuse_surface_id",
|
||||
"contract_version_min",
|
||||
"contract_version_max"
|
||||
],
|
||||
"properties": {
|
||||
"contract_id": {
|
||||
"const": "helixforge.hub-extension"
|
||||
},
|
||||
"contract_version": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"hub_slug": {
|
||||
"$ref": "#/$defs/slug"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"domain": {
|
||||
"$ref": "#/$defs/slug"
|
||||
},
|
||||
"hub_kind": {
|
||||
"enum": ["domain", "aspect"]
|
||||
},
|
||||
"status": {
|
||||
"enum": ["draft", "active", "deprecated", "retired"]
|
||||
},
|
||||
"reuse_surface_id": {
|
||||
"$ref": "#/$defs/capabilityId"
|
||||
},
|
||||
"contract_version_min": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"contract_version_max": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"vsm_system": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"vsm_function": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*$",
|
||||
"maxLength": 80
|
||||
},
|
||||
"semver": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"pattern": "^capability\\.[a-z0-9][a-z0-9-]*\\.[a-z0-9][a-z0-9-]*$",
|
||||
"maxLength": 160
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.helixforge.local/helixforge.hub-extension/0.1.0/hub-manifest.schema.json",
|
||||
"title": "HelixForge Hub Capability Manifest",
|
||||
"description": "Versioned capabilities, ports, events, and non-secret integration metadata for a hub.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"manifest_version",
|
||||
"schema_version",
|
||||
"reuse_surface_id",
|
||||
"provides",
|
||||
"consumes",
|
||||
"events_emitted",
|
||||
"events_consumed"
|
||||
],
|
||||
"properties": {
|
||||
"manifest_version": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"schema_version": {
|
||||
"$ref": "#/$defs/semver"
|
||||
},
|
||||
"reuse_surface_id": {
|
||||
"$ref": "#/$defs/capabilityId"
|
||||
},
|
||||
"provides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/capabilityId"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"consumes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/portId"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"events_emitted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/eventType"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"events_consumed": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/eventType"
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/endpoint"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"policy_scopes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_.:-]*$",
|
||||
"maxLength": 160
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/surface"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"operator_surfaces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/surface"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"semver": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"capabilityId": {
|
||||
"type": "string",
|
||||
"pattern": "^capability\\.[a-z0-9][a-z0-9-]*\\.[a-z0-9][a-z0-9-]*$",
|
||||
"maxLength": 160
|
||||
},
|
||||
"eventType": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9]*(?:[.-][a-z0-9][a-z0-9-]*)+$",
|
||||
"maxLength": 180
|
||||
},
|
||||
"portId": {
|
||||
"enum": [
|
||||
"port.registry",
|
||||
"port.addressing",
|
||||
"port.messaging",
|
||||
"port.events.progress",
|
||||
"port.events.interaction",
|
||||
"port.projection.query",
|
||||
"port.repo",
|
||||
"port.work",
|
||||
"port.policy",
|
||||
"port.telemetry",
|
||||
"port.schedule"
|
||||
]
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
},
|
||||
"discovery_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https?://"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{"required": ["discovery_key"]},
|
||||
{"required": ["url"]}
|
||||
]
|
||||
},
|
||||
"surface": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
},
|
||||
"kind": {
|
||||
"enum": ["widget", "console", "dashboard", "mcp", "cli"]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
hub_core/runtime/__init__.py
Normal file
21
hub_core/runtime/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Primary hub-core runtime composition surfaces.
|
||||
|
||||
Exports are lazy so CLI commands such as migrations do not construct the ASGI
|
||||
application as an import side effect.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["InMemoryPortStore", "PortStore", "create_app"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "create_app":
|
||||
from hub_core.runtime.app import create_app
|
||||
|
||||
return create_app
|
||||
if name in {"InMemoryPortStore", "PortStore"}:
|
||||
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
||||
|
||||
return {"InMemoryPortStore": InMemoryPortStore, "PortStore": PortStore}[name]
|
||||
raise AttributeError(name)
|
||||
54
hub_core/runtime/app.py
Normal file
54
hub_core/runtime/app.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Response, status
|
||||
|
||||
from hub_core import __version__
|
||||
from hub_core.runtime.config import RuntimeSettings
|
||||
from hub_core.runtime.models import HealthResponse, ReadinessResponse
|
||||
from hub_core.runtime.ports import create_ports_router
|
||||
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
||||
from hub_core.runtime.validation import ContractValidator
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
settings: RuntimeSettings | None = None,
|
||||
port_store: PortStore | None = None,
|
||||
) -> FastAPI:
|
||||
resolved_settings = settings or RuntimeSettings.from_env()
|
||||
resolved_store = port_store or _create_store(resolved_settings)
|
||||
|
||||
app = FastAPI(
|
||||
title="Hub Core Runtime",
|
||||
version=__version__,
|
||||
description="HelixForge hub framework and named-port runtime.",
|
||||
)
|
||||
app.state.settings = resolved_settings
|
||||
app.state.port_store = resolved_store
|
||||
app.state.contract_validator = ContractValidator()
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||
async def healthz() -> HealthResponse:
|
||||
return HealthResponse(version=__version__)
|
||||
|
||||
@app.get("/readyz", response_model=ReadinessResponse, tags=["system"])
|
||||
async def readyz(response: Response) -> ReadinessResponse:
|
||||
ready = resolved_settings.is_ready(resolved_store.backend_name)
|
||||
if not ready:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return ReadinessResponse(
|
||||
status="ok" if ready else "degraded",
|
||||
checks=resolved_settings.readiness_checks(resolved_store.backend_name),
|
||||
)
|
||||
|
||||
app.include_router(create_ports_router())
|
||||
return app
|
||||
|
||||
|
||||
def _create_store(settings: RuntimeSettings) -> PortStore:
|
||||
if settings.backend == "memory":
|
||||
return InMemoryPortStore()
|
||||
raise RuntimeError(f"Unsupported HUB_CORE_BACKEND '{settings.backend}'")
|
||||
|
||||
|
||||
app = create_app()
|
||||
100
hub_core/runtime/cli.py
Normal file
100
hub_core/runtime/cli.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from importlib.resources import files
|
||||
from typing import Sequence
|
||||
|
||||
from hub_core.mcp import HubCoreMCPServer
|
||||
from hub_core.runtime.config import RuntimeSettings
|
||||
|
||||
|
||||
def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentParser:
|
||||
resolved = settings or RuntimeSettings.from_env()
|
||||
parser = argparse.ArgumentParser(prog="hub-core", description="Hub Core runtime commands")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
api = commands.add_parser("api", help="Run the HTTP API and named ports")
|
||||
api.add_argument("--host", default=resolved.api_host)
|
||||
api.add_argument("--port", type=int, default=resolved.api_port)
|
||||
|
||||
mcp = commands.add_parser("mcp", help="Run the Hub Core MCP process")
|
||||
mcp.add_argument("--host", default=resolved.mcp_host)
|
||||
mcp.add_argument("--port", type=int, default=resolved.mcp_port)
|
||||
mcp.add_argument("--transport", default=resolved.mcp_transport)
|
||||
mcp.add_argument("--api-base", default=resolved.api_base)
|
||||
|
||||
migrate = commands.add_parser("migrate", help="Run packaged Alembic migrations")
|
||||
migrate.add_argument("revision", nargs="?", default="head")
|
||||
migrate.add_argument("--database-url", default=resolved.database_url)
|
||||
|
||||
conformance = commands.add_parser(
|
||||
"conformance",
|
||||
help="Run the implemented Tier 2/3 profile against an HTTP runtime",
|
||||
)
|
||||
conformance.add_argument("--base-url", default=resolved.api_base)
|
||||
conformance.add_argument("--timeout", type=float, default=10.0)
|
||||
conformance.add_argument("--json", action="store_true", dest="as_json")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
settings = RuntimeSettings.from_env()
|
||||
args = build_parser(settings).parse_args(argv)
|
||||
|
||||
if args.command == "api":
|
||||
_run_api(args.host, args.port)
|
||||
return 0
|
||||
if args.command == "mcp":
|
||||
_run_mcp(args.host, args.port, args.transport, args.api_base)
|
||||
return 0
|
||||
if args.command == "migrate":
|
||||
if not args.database_url:
|
||||
raise SystemExit("hub-core migrate requires --database-url or HUB_CORE_DATABASE_URL")
|
||||
_run_migrations(args.database_url, args.revision)
|
||||
return 0
|
||||
if args.command == "conformance":
|
||||
return _run_conformance(args.base_url, args.timeout, args.as_json)
|
||||
raise AssertionError(f"Unhandled command {args.command}")
|
||||
|
||||
|
||||
def _run_api(host: str, port: int) -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("hub_core.runtime.app:app", host=host, port=port)
|
||||
|
||||
|
||||
def _run_mcp(host: str, port: int, transport: str, api_base: str) -> None:
|
||||
server = HubCoreMCPServer(name="hub-core", api_base=api_base)
|
||||
server.mcp.run(transport=transport, host=host, port=port)
|
||||
|
||||
|
||||
def _run_migrations(database_url: str, revision: str) -> None:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
migration_root = files("hub_core.migrations")
|
||||
config = Config()
|
||||
config.set_main_option("script_location", str(migration_root))
|
||||
config.set_main_option("sqlalchemy.url", _sync_database_url(database_url))
|
||||
command.upgrade(config, revision)
|
||||
|
||||
|
||||
def _run_conformance(base_url: str, timeout: float, as_json: bool) -> int:
|
||||
import httpx
|
||||
|
||||
from hub_core.conformance import ConformanceHarness
|
||||
|
||||
with httpx.Client(base_url=base_url, timeout=timeout) as target:
|
||||
report = ConformanceHarness(target).run()
|
||||
if as_json:
|
||||
print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
||||
else:
|
||||
for check in report.checks:
|
||||
print(f"{check.status.upper():4} Tier {check.tier} {check.check_id}: {check.summary}")
|
||||
print(f"{report.passed_count}/{len(report.checks)} implemented checks passed")
|
||||
return 0 if report.passed else 1
|
||||
|
||||
|
||||
def _sync_database_url(database_url: str) -> str:
|
||||
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
59
hub_core/runtime/config.py
Normal file
59
hub_core/runtime/config.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeSettings:
|
||||
environment: str = "development"
|
||||
backend: str = "memory"
|
||||
allow_ephemeral: bool = True
|
||||
api_host: str = "127.0.0.1"
|
||||
api_port: int = 8010
|
||||
api_base: str = "http://127.0.0.1:8010"
|
||||
mcp_host: str = "127.0.0.1"
|
||||
mcp_port: int = 8011
|
||||
mcp_transport: str = "http"
|
||||
database_url: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> RuntimeSettings:
|
||||
environment = os.getenv("HUB_CORE_ENV", "development")
|
||||
default_ephemeral = environment in {"development", "test"}
|
||||
api_host = os.getenv("HUB_CORE_API_HOST", "127.0.0.1")
|
||||
api_port = int(os.getenv("HUB_CORE_API_PORT", "8010"))
|
||||
return cls(
|
||||
environment=environment,
|
||||
backend=os.getenv("HUB_CORE_BACKEND", "memory"),
|
||||
allow_ephemeral=_env_bool("HUB_CORE_ALLOW_EPHEMERAL", default_ephemeral),
|
||||
api_host=api_host,
|
||||
api_port=api_port,
|
||||
api_base=os.getenv("HUB_CORE_API_BASE", f"http://127.0.0.1:{api_port}"),
|
||||
mcp_host=os.getenv("HUB_CORE_MCP_HOST", "127.0.0.1"),
|
||||
mcp_port=int(os.getenv("HUB_CORE_MCP_PORT", "8011")),
|
||||
mcp_transport=os.getenv("HUB_CORE_MCP_TRANSPORT", "http"),
|
||||
database_url=os.getenv("HUB_CORE_DATABASE_URL") or os.getenv("DATABASE_URL"),
|
||||
)
|
||||
|
||||
def readiness_checks(self, store_backend: str) -> dict[str, str]:
|
||||
ephemeral_allowed = store_backend != "memory" or self.allow_ephemeral
|
||||
return {
|
||||
"environment": self.environment,
|
||||
"configured_backend": self.backend,
|
||||
"active_backend": store_backend,
|
||||
"ephemeral_backend": "allowed" if ephemeral_allowed else "not_allowed",
|
||||
"contract": "helixforge.hub-extension/0.1.0",
|
||||
}
|
||||
|
||||
def is_ready(self, store_backend: str) -> bool:
|
||||
return self.backend == store_backend and (
|
||||
store_backend != "memory" or self.allow_ephemeral
|
||||
)
|
||||
72
hub_core/runtime/models.py
Normal file
72
hub_core/runtime/models.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RuntimeModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RegistryRegistration(RuntimeModel):
|
||||
descriptor: dict[str, Any]
|
||||
manifest: dict[str, Any]
|
||||
|
||||
|
||||
class MessageCommand(RuntimeModel):
|
||||
schema_version: str
|
||||
correlation_id: UUID
|
||||
conversation_id: UUID | None = None
|
||||
from_address: str = Field(min_length=1)
|
||||
to_addresses: list[str] = Field(min_length=1)
|
||||
body: str = Field(min_length=1)
|
||||
subject_refs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EventCommand(RuntimeModel):
|
||||
schema_version: str
|
||||
correlation_id: UUID
|
||||
event_type: str = Field(pattern=r"^[a-z][a-z0-9]*(?:[.-][a-z0-9][a-z0-9-]*)+$")
|
||||
occurred_at: datetime
|
||||
subject_refs: dict[str, str] = Field(default_factory=dict)
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class Provenance(RuntimeModel):
|
||||
source_system: str
|
||||
source_ref: str
|
||||
schema_version: str
|
||||
content_hash: str | None = None
|
||||
indexed_at: datetime
|
||||
|
||||
|
||||
class PortRecord(RuntimeModel):
|
||||
id: str
|
||||
data: dict[str, Any]
|
||||
provenance: Provenance
|
||||
|
||||
|
||||
class PortCollection(RuntimeModel):
|
||||
items: list[PortRecord]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class PortAccepted(RuntimeModel):
|
||||
id: str
|
||||
status: Literal["accepted", "duplicate"]
|
||||
correlation_id: UUID
|
||||
|
||||
|
||||
class HealthResponse(RuntimeModel):
|
||||
service: str = "hub-core"
|
||||
status: Literal["ok"] = "ok"
|
||||
version: str
|
||||
|
||||
|
||||
class ReadinessResponse(RuntimeModel):
|
||||
service: str = "hub-core"
|
||||
status: Literal["ok", "degraded"]
|
||||
checks: dict[str, str]
|
||||
127
hub_core/runtime/ports.py
Normal file
127
hub_core/runtime/ports.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from jsonschema import ValidationError
|
||||
|
||||
from hub_core.runtime.models import (
|
||||
EventCommand,
|
||||
MessageCommand,
|
||||
PortAccepted,
|
||||
PortCollection,
|
||||
PortRecord,
|
||||
RegistryRegistration,
|
||||
)
|
||||
from hub_core.runtime.store import PortStore
|
||||
from hub_core.runtime.validation import ContractValidator
|
||||
|
||||
|
||||
def get_port_store(request: Request) -> PortStore:
|
||||
return request.app.state.port_store
|
||||
|
||||
|
||||
def get_contract_validator(request: Request) -> ContractValidator:
|
||||
return request.app.state.contract_validator
|
||||
|
||||
|
||||
def create_ports_router() -> APIRouter:
|
||||
router = APIRouter(prefix="/ports")
|
||||
|
||||
@router.post(
|
||||
"/registry/registrations",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["registry"],
|
||||
openapi_extra={"x-port-id": "port.registry", "x-direction": "in"},
|
||||
)
|
||||
async def register_extension(
|
||||
body: RegistryRegistration,
|
||||
x_correlation_id: UUID = Header(alias="X-Correlation-ID"),
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_registration(body)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.register_extension(body, x_correlation_id)
|
||||
|
||||
@router.get(
|
||||
"/messaging/messages",
|
||||
response_model=PortCollection,
|
||||
tags=["messaging"],
|
||||
openapi_extra={"x-port-id": "port.messaging", "x-direction": "out"},
|
||||
)
|
||||
async def list_messages(
|
||||
address: str,
|
||||
conversation_id: UUID | None = None,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortCollection:
|
||||
return await store.list_messages(address, conversation_id)
|
||||
|
||||
@router.post(
|
||||
"/messaging/messages",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["messaging"],
|
||||
openapi_extra={"x-port-id": "port.messaging", "x-direction": "in"},
|
||||
)
|
||||
async def send_message(
|
||||
body: MessageCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortAccepted:
|
||||
return await store.send_message(body)
|
||||
|
||||
@router.post(
|
||||
"/events/progress",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["events"],
|
||||
openapi_extra={"x-port-id": "port.events.progress", "x-direction": "in"},
|
||||
)
|
||||
async def append_progress(
|
||||
body: EventCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_event_family(body.event_type, "progress")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.append_progress(body)
|
||||
|
||||
@router.post(
|
||||
"/events/interaction",
|
||||
response_model=PortAccepted,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
tags=["events"],
|
||||
openapi_extra={"x-port-id": "port.events.interaction", "x-direction": "in"},
|
||||
)
|
||||
async def append_interaction(
|
||||
body: EventCommand,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
validator: ContractValidator = Depends(get_contract_validator),
|
||||
) -> PortAccepted:
|
||||
try:
|
||||
validator.validate_event_family(body.event_type, "interaction")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return await store.append_interaction(body)
|
||||
|
||||
@router.get(
|
||||
"/projections/{projection_id}",
|
||||
response_model=PortRecord,
|
||||
tags=["projections"],
|
||||
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
|
||||
)
|
||||
async def query_projection(
|
||||
projection_id: str,
|
||||
store: PortStore = Depends(get_port_store),
|
||||
) -> PortRecord:
|
||||
projection = await store.query_projection(projection_id)
|
||||
if projection is None:
|
||||
raise HTTPException(status_code=404, detail=f"Projection '{projection_id}' not found")
|
||||
return projection
|
||||
|
||||
return router
|
||||
179
hub_core/runtime/store.py
Normal file
179
hub_core/runtime/store.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from hub_core.contracts import CONTRACT_VERSION
|
||||
from hub_core.runtime.models import (
|
||||
EventCommand,
|
||||
MessageCommand,
|
||||
PortAccepted,
|
||||
PortCollection,
|
||||
PortRecord,
|
||||
Provenance,
|
||||
RegistryRegistration,
|
||||
)
|
||||
|
||||
|
||||
class PortStore(Protocol):
|
||||
"""Persistence boundary for the initial hub-core runtime ports."""
|
||||
|
||||
backend_name: str
|
||||
|
||||
async def register_extension(
|
||||
self,
|
||||
registration: RegistryRegistration,
|
||||
correlation_id: UUID,
|
||||
) -> PortAccepted: ...
|
||||
|
||||
async def send_message(self, command: MessageCommand) -> PortAccepted: ...
|
||||
|
||||
async def list_messages(self, address: str, conversation_id: UUID | None) -> PortCollection: ...
|
||||
|
||||
async def append_progress(self, command: EventCommand) -> PortAccepted: ...
|
||||
|
||||
async def append_interaction(self, command: EventCommand) -> PortAccepted: ...
|
||||
|
||||
async def query_projection(self, projection_id: str) -> PortRecord | None: ...
|
||||
|
||||
|
||||
class InMemoryPortStore:
|
||||
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
||||
|
||||
Production readiness rejects this backend unless explicitly allowed. The
|
||||
store deliberately keeps progress and interaction event families separate.
|
||||
"""
|
||||
|
||||
backend_name = "memory"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._registrations: dict[str, dict[str, Any]] = {}
|
||||
self._messages: list[dict[str, Any]] = []
|
||||
self._progress_events: list[dict[str, Any]] = []
|
||||
self._interaction_events: list[dict[str, Any]] = []
|
||||
|
||||
async def register_extension(
|
||||
self,
|
||||
registration: RegistryRegistration,
|
||||
correlation_id: UUID,
|
||||
) -> PortAccepted:
|
||||
hub_slug = str(registration.descriptor["hub_slug"])
|
||||
value = registration.model_dump(mode="json")
|
||||
async with self._lock:
|
||||
duplicate = self._registrations.get(hub_slug) == value
|
||||
self._registrations[hub_slug] = deepcopy(value)
|
||||
return PortAccepted(
|
||||
id=hub_slug,
|
||||
status="duplicate" if duplicate else "accepted",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
async def send_message(self, command: MessageCommand) -> PortAccepted:
|
||||
message_id = uuid4()
|
||||
value = {
|
||||
"id": str(message_id),
|
||||
"created_at": _now().isoformat(),
|
||||
**command.model_dump(mode="json"),
|
||||
}
|
||||
async with self._lock:
|
||||
self._messages.append(value)
|
||||
return PortAccepted(
|
||||
id=str(message_id),
|
||||
status="accepted",
|
||||
correlation_id=command.correlation_id,
|
||||
)
|
||||
|
||||
async def list_messages(self, address: str, conversation_id: UUID | None) -> PortCollection:
|
||||
async with self._lock:
|
||||
values = [
|
||||
deepcopy(message)
|
||||
for message in self._messages
|
||||
if address in message["to_addresses"]
|
||||
and (
|
||||
conversation_id is None
|
||||
or message.get("conversation_id") == str(conversation_id)
|
||||
)
|
||||
]
|
||||
return PortCollection(items=[self._record("message", value) for value in values])
|
||||
|
||||
async def append_progress(self, command: EventCommand) -> PortAccepted:
|
||||
return await self._append_event(command, self._progress_events, "progress")
|
||||
|
||||
async def append_interaction(self, command: EventCommand) -> PortAccepted:
|
||||
return await self._append_event(command, self._interaction_events, "interaction")
|
||||
|
||||
async def query_projection(self, projection_id: str) -> PortRecord | None:
|
||||
async with self._lock:
|
||||
sources: Mapping[str, Any] = {
|
||||
"hub_registry": list(self._registrations.values()),
|
||||
"messages": self._messages,
|
||||
"progress_events": self._progress_events,
|
||||
"interaction_events": self._interaction_events,
|
||||
}
|
||||
if projection_id not in sources:
|
||||
return None
|
||||
items = deepcopy(sources[projection_id])
|
||||
return self._record(
|
||||
projection_id,
|
||||
{
|
||||
"projection_id": projection_id,
|
||||
"items": items,
|
||||
"rebuild_from": _rebuild_sources(projection_id),
|
||||
},
|
||||
)
|
||||
|
||||
async def _append_event(
|
||||
self,
|
||||
command: EventCommand,
|
||||
target: list[dict[str, Any]],
|
||||
family: str,
|
||||
) -> PortAccepted:
|
||||
event_id = uuid4()
|
||||
value = {
|
||||
"id": str(event_id),
|
||||
"family": family,
|
||||
"recorded_at": _now().isoformat(),
|
||||
**command.model_dump(mode="json"),
|
||||
}
|
||||
async with self._lock:
|
||||
target.append(value)
|
||||
return PortAccepted(
|
||||
id=str(event_id),
|
||||
status="accepted",
|
||||
correlation_id=command.correlation_id,
|
||||
)
|
||||
|
||||
def _record(self, kind: str, value: dict[str, Any]) -> PortRecord:
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
record_id = str(value.get("id") or kind)
|
||||
return PortRecord(
|
||||
id=record_id,
|
||||
data=deepcopy(value),
|
||||
provenance=Provenance(
|
||||
source_system="hub-core-memory",
|
||||
source_ref=f"memory://{kind}/{record_id}",
|
||||
schema_version=CONTRACT_VERSION,
|
||||
content_hash=hashlib.sha256(encoded).hexdigest(),
|
||||
indexed_at=_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _rebuild_sources(projection_id: str) -> list[str]:
|
||||
return {
|
||||
"hub_registry": ["hub_descriptors", "hub_manifests"],
|
||||
"messages": ["messages"],
|
||||
"progress_events": ["progress_events"],
|
||||
"interaction_events": ["interaction_events"],
|
||||
}[projection_id]
|
||||
48
hub_core/runtime/validation.py
Normal file
48
hub_core/runtime/validation.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from hub_core.contracts import extension_contract_root
|
||||
from hub_core.runtime.models import RegistryRegistration
|
||||
|
||||
|
||||
class ContractValidator:
|
||||
"""Validate runtime registration input against the packaged contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
contract_root = extension_contract_root()
|
||||
schema_root = contract_root.joinpath("schemas")
|
||||
self._descriptor = _validator(schema_root.joinpath("hub-descriptor.schema.json"))
|
||||
self._manifest = _validator(schema_root.joinpath("hub-manifest.schema.json"))
|
||||
catalog = json.loads(
|
||||
contract_root.joinpath("catalogs", "event-types.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self._event_families = {
|
||||
entry["type"]: entry["family"] for entry in catalog["event_types"]
|
||||
}
|
||||
|
||||
def validate_registration(self, registration: RegistryRegistration) -> None:
|
||||
self._descriptor.validate(registration.descriptor)
|
||||
self._manifest.validate(registration.manifest)
|
||||
descriptor_id = registration.descriptor.get("reuse_surface_id")
|
||||
manifest_id = registration.manifest.get("reuse_surface_id")
|
||||
if descriptor_id != manifest_id:
|
||||
raise ValueError("descriptor and manifest reuse_surface_id must match")
|
||||
|
||||
def validate_event_family(self, event_type: str, expected_family: str) -> None:
|
||||
actual_family = self._event_families.get(event_type)
|
||||
if actual_family is None:
|
||||
raise ValueError(f"event type '{event_type}' is not cataloged")
|
||||
if actual_family != expected_family:
|
||||
raise ValueError(
|
||||
f"event type '{event_type}' belongs to '{actual_family}', not '{expected_family}'"
|
||||
)
|
||||
|
||||
|
||||
def _validator(resource: Any) -> Draft202012Validator:
|
||||
schema = json.loads(resource.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
Loading…
Add table
Add a link
Reference in a new issue