feat: transport authoritative workload projections
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
parent
a090903cfc
commit
ab936a1e98
27 changed files with 1671 additions and 15 deletions
|
|
@ -94,8 +94,8 @@ Current package surface (`hub_core/`):
|
||||||
| `schemas/` | Pydantic contracts matching core models plus DoI report shapes |
|
| `schemas/` | Pydantic contracts matching core models plus DoI report shapes |
|
||||||
| `routers/` | Factory functions: domains, repos, messages, progress, capabilities, TPSC, policy |
|
| `routers/` | Factory functions: domains, repos, messages, progress, capabilities, TPSC, policy |
|
||||||
| `mcp/` | `HubCoreMCPServer` — generic orientation, messaging, capability, repo, DoI, TPSC/GDPR, risk/alert, progress tools |
|
| `mcp/` | `HubCoreMCPServer` — generic orientation, messaging, capability, repo, DoI, TPSC/GDPR, risk/alert, progress tools |
|
||||||
| `contracts/` | Packaged `helixforge.hub-extension` schemas, named-port OpenAPI, catalogs, fixtures, compatibility |
|
| `contracts/` | Packaged hub-extension, repository-navigation, and workload-projection schemas, OpenAPI, fixtures, compatibility |
|
||||||
| `migrations/` | Alembic scaffold and `0001_core_schema` for adopters |
|
| `migrations/` | Alembic core/runtime scaffold plus durable repository and workload projection migrations |
|
||||||
| `utils/` | Slugs, pagination, repo path resolution, trailing-slash routing |
|
| `utils/` | Slugs, pagination, repo path resolution, trailing-slash routing |
|
||||||
| `events.py` | Canonical FOS §10 risk and alert event types |
|
| `events.py` | Canonical FOS §10 risk and alert event types |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ matrix, and read-only OpenAPI surface are located with
|
||||||
`repository_navigation_contract_root()`; normative rebuild and cursor rules
|
`repository_navigation_contract_root()`; normative rebuild and cursor rules
|
||||||
are in `docs/repository-navigation-contract.md`.
|
are in `docs/repository-navigation-contract.md`.
|
||||||
|
|
||||||
|
`helixforge.workload-projection` 1.0.0 transports Repo Manager's authoritative
|
||||||
|
workload index through a separate injected `port.repo` reader. The runtime
|
||||||
|
offers GET-only list and exact-reference resolution at
|
||||||
|
`/ports/projections/workloads`, plus matching MCP tools, without importing Repo
|
||||||
|
Manager internals or inferring workload identity.
|
||||||
|
|
||||||
## Runtime scaffold
|
## Runtime scaffold
|
||||||
|
|
||||||
Install the runtime extra and start the API, MCP, or migration process through
|
Install the runtime extra and start the API, MCP, or migration process through
|
||||||
|
|
|
||||||
16
SCOPE.md
16
SCOPE.md
|
|
@ -1,6 +1,6 @@
|
||||||
# SCOPE — hub-core
|
# SCOPE — hub-core
|
||||||
|
|
||||||
**Updated:** 2026-08-22
|
**Updated:** 2026-08-23
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -41,6 +41,9 @@ the Python package remains independently importable.
|
||||||
- **Cross-domain navigation projections** — rebuildable aggregation of Repo
|
- **Cross-domain navigation projections** — rebuildable aggregation of Repo
|
||||||
Manager's validated repository classifications without classification write
|
Manager's validated repository classifications without classification write
|
||||||
authority
|
authority
|
||||||
|
- **Authoritative workload transport** — rebuildable normalized distribution
|
||||||
|
and exact resolution of Repo Manager's workload index without semantic or
|
||||||
|
declaration authority
|
||||||
- **Documentation** — `README.md`, `INTENT.md`, `SCOPE.md`, pointer to
|
- **Documentation** — `README.md`, `INTENT.md`, `SCOPE.md`, pointer to
|
||||||
extraction boundary in `the-custodian`
|
extraction boundary in `the-custodian`
|
||||||
|
|
||||||
|
|
@ -76,6 +79,8 @@ After the CUST-WP-0025 first slice (2026-06-06 — 2026-06-07):
|
||||||
| Adopt shared slug/pagination/path utilities | `hub_core.utils` |
|
| Adopt shared slug/pagination/path utilities | `hub_core.utils` |
|
||||||
| Expose risk/alert progress read views | `/progress/risks`, `/progress/alerts` patterns |
|
| Expose risk/alert progress read views | `/progress/risks`, `/progress/alerts` patterns |
|
||||||
| State Hub incremental adoption | Schemas, messages, policy, TPSC, progress, domains, capability catalog routers imported |
|
| State Hub incremental adoption | Schemas, messages, policy, TPSC, progress, domains, capability catalog routers imported |
|
||||||
|
| Query repository classification navigation | `port.projection.query` HTTP routes plus MCP facets |
|
||||||
|
| Transport and resolve workload references | GET-only workload projection routes plus MCP exact resolution |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/hub-core
|
cd ~/hub-core
|
||||||
|
|
@ -89,8 +94,11 @@ python3 -m venv .venv && .venv/bin/pip install -e .
|
||||||
|
|
||||||
- **Published PyPI package** — consumed via editable path or private index only
|
- **Published PyPI package** — consumed via editable path or private index only
|
||||||
- **Complete State Hub decoupling** — dev-hub routes and models still live in `state-hub`
|
- **Complete State Hub decoupling** — dev-hub routes and models still live in `state-hub`
|
||||||
- **Repository classification navigation** — accepted contract; ingestion,
|
- **Repository classification navigation cutover** — contract, ingestion, and
|
||||||
query/MCP, and A5 cutover remain in `HUB-WP-0006`
|
query/MCP are complete; A5 production handoff remains in `HUB-WP-0006-T06`
|
||||||
|
- **Workload projection production adoption** — transport/query capability is
|
||||||
|
complete; production hosts must inject the Repo Manager workload reader and
|
||||||
|
apply migration `0004_workload_projection`
|
||||||
- **fin-hub adoption** — planned; not verified in this repo
|
- **fin-hub adoption** — planned; not verified in this repo
|
||||||
- **Capability registry entries** — scaffold only (`capabilities: []`); no registered reusable behaviors yet
|
- **Capability registry entries** — scaffold only (`capabilities: []`); no registered reusable behaviors yet
|
||||||
|
|
||||||
|
|
@ -108,7 +116,7 @@ python3 -m venv .venv && .venv/bin/pip install -e .
|
||||||
| Primary consumer | `state-hub` (editable dependency, router/schema import in progress) |
|
| Primary consumer | `state-hub` (editable dependency, router/schema import in progress) |
|
||||||
| Extraction workplan | `CUST-WP-0025` (custodian domain) |
|
| Extraction workplan | `CUST-WP-0025` (custodian domain) |
|
||||||
| Target architecture | `SHR-ARCH-IA-0001` + `SHR-ARCH-HUB-0001` |
|
| Target architecture | `SHR-ARCH-IA-0001` + `SHR-ARCH-HUB-0001` |
|
||||||
| Runtime workplans | `HUB-WP-0004` and `HUB-WP-0005` finished; `HUB-WP-0006` active |
|
| Runtime workplans | `HUB-WP-0004`, `HUB-WP-0005`, and `HUB-WP-0007` finished; `HUB-WP-0006-T06` waits on cutover gates |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
| workplan | HUB-WP-0004 | finished | — | workplans/HUB-WP-0004-runtime-and-extension-contract.md |
|
| workplan | HUB-WP-0004 | finished | — | workplans/HUB-WP-0004-runtime-and-extension-contract.md |
|
||||||
| workplan | HUB-WP-0005 | finished | — | workplans/HUB-WP-0005-core-hub-absorption-execution.md |
|
| workplan | HUB-WP-0005 | finished | — | workplans/HUB-WP-0005-core-hub-absorption-execution.md |
|
||||||
| workplan | HUB-WP-0006 | active | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
| workplan | HUB-WP-0006 | active | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
||||||
|
| workplan | HUB-WP-0007 | finished | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
| task | HUB-WP-0001-T01 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
| task | HUB-WP-0001-T01 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
||||||
| task | HUB-WP-0001-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
| task | HUB-WP-0001-T02 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
||||||
| task | HUB-WP-0001-T03 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
| task | HUB-WP-0001-T03 | done | — | workplans/HUB-WP-0001-statehub-bootstrap.md |
|
||||||
|
|
@ -44,3 +45,8 @@
|
||||||
| task | HUB-WP-0006-T04 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
| task | HUB-WP-0006-T04 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
||||||
| task | HUB-WP-0006-T05 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
| task | HUB-WP-0006-T05 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
||||||
| task | HUB-WP-0006-T06 | wait | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
| task | HUB-WP-0006-T06 | wait | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
|
||||||
|
| task | HUB-WP-0007-T01 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
|
| task | HUB-WP-0007-T02 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
|
| task | HUB-WP-0007-T03 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
|
| task | HUB-WP-0007-T04 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
|
| task | HUB-WP-0007-T05 | done | — | workplans/HUB-WP-0007-workload-projection-transport.md |
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ the API does not auto-create tables.
|
||||||
| `port.events.interaction` | `POST /ports/events/interaction` | Accepts only cataloged interaction-family events |
|
| `port.events.interaction` | `POST /ports/events/interaction` | Accepts only cataloged interaction-family events |
|
||||||
| `port.projection.query` | `GET /ports/projections/{id}` | Rebuildable registry/message/event projections with provenance |
|
| `port.projection.query` | `GET /ports/projections/{id}` | Rebuildable registry/message/event projections with provenance |
|
||||||
| `port.projection.query` | `GET /ports/projections/repository-navigation/repositories` | Snapshot-bound cross-repository classification navigation |
|
| `port.projection.query` | `GET /ports/projections/repository-navigation/repositories` | Snapshot-bound cross-repository classification navigation |
|
||||||
|
| `port.projection.query` | `GET /ports/projections/workloads` | Normalized authoritative workload projection and exact resolution |
|
||||||
|
|
||||||
Available projection ids are `hub_registry`, `messages`, `progress_events`, and
|
Available projection ids are `hub_registry`, `messages`, `progress_events`, and
|
||||||
`interaction_events`. The two event families use distinct stores and cannot be
|
`interaction_events`. The two event families use distinct stores and cannot be
|
||||||
|
|
@ -57,6 +58,14 @@ canonical content hash. MCP exposes the same surface through
|
||||||
`query_repository_navigation` and `get_repository_navigation_facet`. No
|
`query_repository_navigation` and `get_repository_navigation_facet`. No
|
||||||
classification-write endpoint exists.
|
classification-write endpoint exists.
|
||||||
|
|
||||||
|
Workload projection uses a distinct injected `WorkloadProjectionClient` and
|
||||||
|
the authoritative `helixforge.workload-reference/v1` identity pair. Migration
|
||||||
|
`0004_workload_projection` stores only normalized transport rows and source
|
||||||
|
provenance. List and `/resolve` routes require explicit rapp/name/deployable
|
||||||
|
values and return unknown instead of consulting owners, repository names,
|
||||||
|
paths, or aliases. MCP exposes `query_workloads` and
|
||||||
|
`resolve_workload_reference` over those GET-only routes.
|
||||||
|
|
||||||
## Backend boundary and readiness
|
## Backend boundary and readiness
|
||||||
|
|
||||||
The app is created with an injected `PortStore`. `InMemoryPortStore` remains
|
The app is created with an injected `PortStore`. `InMemoryPortStore` remains
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ CONTRACT_ID = "helixforge.hub-extension"
|
||||||
CONTRACT_VERSION = "0.1.0"
|
CONTRACT_VERSION = "0.1.0"
|
||||||
REPOSITORY_NAVIGATION_CONTRACT_ID = "helixforge.repository-navigation"
|
REPOSITORY_NAVIGATION_CONTRACT_ID = "helixforge.repository-navigation"
|
||||||
REPOSITORY_NAVIGATION_CONTRACT_VERSION = "1.0.0"
|
REPOSITORY_NAVIGATION_CONTRACT_VERSION = "1.0.0"
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_ID = "helixforge.workload-projection"
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
def extension_contract_root() -> Traversable:
|
def extension_contract_root() -> Traversable:
|
||||||
|
|
@ -21,11 +23,20 @@ def repository_navigation_contract_root() -> Traversable:
|
||||||
return files("hub_core.contracts.repository_navigation.v1_0_0")
|
return files("hub_core.contracts.repository_navigation.v1_0_0")
|
||||||
|
|
||||||
|
|
||||||
|
def workload_projection_contract_root() -> Traversable:
|
||||||
|
"""Return the packaged root for the workload-projection contract."""
|
||||||
|
|
||||||
|
return files("hub_core.contracts.workload_projection.v1_0_0")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CONTRACT_ID",
|
"CONTRACT_ID",
|
||||||
"CONTRACT_VERSION",
|
"CONTRACT_VERSION",
|
||||||
"REPOSITORY_NAVIGATION_CONTRACT_ID",
|
"REPOSITORY_NAVIGATION_CONTRACT_ID",
|
||||||
"REPOSITORY_NAVIGATION_CONTRACT_VERSION",
|
"REPOSITORY_NAVIGATION_CONTRACT_VERSION",
|
||||||
|
"WORKLOAD_PROJECTION_CONTRACT_ID",
|
||||||
|
"WORKLOAD_PROJECTION_CONTRACT_VERSION",
|
||||||
"extension_contract_root",
|
"extension_contract_root",
|
||||||
"repository_navigation_contract_root",
|
"repository_navigation_contract_root",
|
||||||
|
"workload_projection_contract_root",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1
hub_core/contracts/workload_projection/__init__.py
Normal file
1
hub_core/contracts/workload_projection/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Versioned authoritative workload projection contracts."""
|
||||||
13
hub_core/contracts/workload_projection/v1_0_0/README.md
Normal file
13
hub_core/contracts/workload_projection/v1_0_0/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# helixforge.workload-projection 1.0.0
|
||||||
|
|
||||||
|
This package freezes hub-core's transport boundary for Repo Manager's
|
||||||
|
`helixforge.workload-reference/v1` index. It includes strict input/output JSON
|
||||||
|
schemas, a nine-declaration fixture, the read-only query OpenAPI fragment, and
|
||||||
|
the compatibility/authority matrix.
|
||||||
|
|
||||||
|
The exact identity key is `rapp_id` plus `workload_identity.name`; optional
|
||||||
|
deployable resolution checks declared membership. Hub-core transports and
|
||||||
|
queries normalized records with source repository, declaration path, source
|
||||||
|
Git revision, and canonical UTC observation metadata. It never infers identity
|
||||||
|
from repository names, owners, paths, or aliases and never imports Repo Manager
|
||||||
|
private persistence.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Packaged workload projection contract 1.0.0."""
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"contract_id": "helixforge.workload-projection",
|
||||||
|
"current_version": "1.0.0",
|
||||||
|
"accepted_inputs": [
|
||||||
|
{
|
||||||
|
"contract_id": "helixforge.workload-projection-source",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source_contract": "helixforge.workload-reference/v1",
|
||||||
|
"status": "current",
|
||||||
|
"coercion": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"declarations": "rapp-*/declarations/rapp.yaml",
|
||||||
|
"semantic_schema": "railiance-master/schemas/rapp.schema.json",
|
||||||
|
"index_and_resolution": "repo-manager",
|
||||||
|
"transport_and_cross_repository_query": "hub-core"
|
||||||
|
},
|
||||||
|
"forbidden_fallbacks": ["repository_name", "owner_repo", "path_segment", "alias"],
|
||||||
|
"change_policy": {
|
||||||
|
"patch": "Clarifications and compatible constraint corrections only.",
|
||||||
|
"minor": "Additive optional transport metadata and diagnostics.",
|
||||||
|
"major": "Changed identity, authority, resolution, rebuild, cursor, or deletion semantics; removed or renamed fields; or narrowed constraints."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
{
|
||||||
|
"contract_id": "helixforge.workload-projection-source",
|
||||||
|
"contract_version": "1.0.0",
|
||||||
|
"source": {
|
||||||
|
"system": "repo-manager",
|
||||||
|
"workload_contract": "helixforge.workload-reference/v1",
|
||||||
|
"workload_contract_id": "RMGR-CONTRACT-WORKLOAD-REFERENCE-0001",
|
||||||
|
"producer_revision": "b36b68bc5747aaeea412079c737f9f7e9d4b7dfb"
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"snapshot_id": "9999999999999999999999999999999999999999999999999999999999999999",
|
||||||
|
"generated_at": "2026-08-22T12:00:00Z",
|
||||||
|
"source_revision": "b36b68bc5747aaeea412079c737f9f7e9d4b7dfb",
|
||||||
|
"page_cursor": null,
|
||||||
|
"next_cursor": null,
|
||||||
|
"final_page": true,
|
||||||
|
"total_workload_count": 9
|
||||||
|
},
|
||||||
|
"workloads": [
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-issue-core",
|
||||||
|
"name": "issue-core",
|
||||||
|
"declaration_repo": "rapp-issue-core",
|
||||||
|
"declaration_path": "rapp-issue-core/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "a78f38cfd8afb29ed23d150a4ea3e3dcd1f35381",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "issue-core",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "confidential",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["issue-core"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-openbao",
|
||||||
|
"name": "openbao",
|
||||||
|
"declaration_repo": "rapp-openbao",
|
||||||
|
"declaration_path": "rapp-openbao/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "6c3012f6bd12d12c88df03de69b07895e8325347",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "railiance-platform",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "restricted",
|
||||||
|
"criticality": "critical",
|
||||||
|
"deployables": ["openbao", "openbao-ui-gateway"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-policy-nexus",
|
||||||
|
"name": "policy-nexus",
|
||||||
|
"declaration_repo": "rapp-policy-nexus",
|
||||||
|
"declaration_path": "rapp-policy-nexus/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "41c42e8d1a653af666da2e5e7e008a27464d9b87",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "policy-nexus",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "public",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["policy-nexus-static"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-postgres",
|
||||||
|
"name": "postgres",
|
||||||
|
"declaration_repo": "rapp-postgres",
|
||||||
|
"declaration_path": "rapp-postgres/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "cfc252630c53e87d01eb38b91e1d5b692e72a4b2",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "railiance-platform",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "restricted",
|
||||||
|
"criticality": "critical",
|
||||||
|
"deployables": ["platform-pg"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-qonto",
|
||||||
|
"name": "qonto",
|
||||||
|
"declaration_repo": "rapp-qonto",
|
||||||
|
"declaration_path": "rapp-qonto/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "f57e60ad2a41c18f55dd9ee4ec24a56a4e0d9ff2",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "qonto-assistant",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "restricted",
|
||||||
|
"criticality": "critical",
|
||||||
|
"deployables": ["rapp-qonto"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-sbom-nexus",
|
||||||
|
"name": "sbom-nexus",
|
||||||
|
"declaration_repo": "rapp-sbom-nexus",
|
||||||
|
"declaration_path": "rapp-sbom-nexus/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "1566bcf69fc0757db5efc67f78b14af10d944c1b",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "sbom-nexus",
|
||||||
|
"readiness_state": "draft",
|
||||||
|
"data_classification": "confidential",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["sbom-nexus"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-secrets-engine",
|
||||||
|
"name": "secrets-engine",
|
||||||
|
"declaration_repo": "rapp-secrets-engine",
|
||||||
|
"declaration_path": "rapp-secrets-engine/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "f71131e76b70aad02d32ab46f2d0983f030304a9",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "secrets-engine",
|
||||||
|
"readiness_state": "retired",
|
||||||
|
"data_classification": "restricted",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["secrets-engine-cli"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-tenant-engine",
|
||||||
|
"name": "tenant-engine",
|
||||||
|
"declaration_repo": "rapp-tenant-engine",
|
||||||
|
"declaration_path": "rapp-tenant-engine/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "82bbbf78c4159aa5267af683efca75cce7a57c00",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "tenant-engine",
|
||||||
|
"readiness_state": "draft",
|
||||||
|
"data_classification": "confidential",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["tenant-engine"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rapp_id": "rapp-user-engine",
|
||||||
|
"name": "user-engine",
|
||||||
|
"declaration_repo": "rapp-user-engine",
|
||||||
|
"declaration_path": "rapp-user-engine/declarations/rapp.yaml",
|
||||||
|
"source_git_revision": "e599b4f345cdd4725a6d0ee16efd475cf98ea616",
|
||||||
|
"observed_at": "2026-08-22T12:00:00Z",
|
||||||
|
"ownership_repo": "user-engine",
|
||||||
|
"readiness_state": "verified",
|
||||||
|
"data_classification": "confidential",
|
||||||
|
"criticality": "high",
|
||||||
|
"deployables": ["user-engine", "user-engine-pg"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnostics": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {"title": "HelixForge workload projection", "version": "1.0.0"},
|
||||||
|
"paths": {
|
||||||
|
"/ports/projections/workloads": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "queryWorkloads",
|
||||||
|
"x-port-id": "port.projection.query",
|
||||||
|
"x-direction": "out",
|
||||||
|
"parameters": [
|
||||||
|
{"name": "rapp_id", "in": "query", "schema": {"type": "string"}},
|
||||||
|
{"name": "name", "in": "query", "schema": {"type": "string"}},
|
||||||
|
{"name": "deployable", "in": "query", "schema": {"type": "string"}},
|
||||||
|
{"name": "cursor", "in": "query", "schema": {"type": "string"}},
|
||||||
|
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Stable page of normalized workload records", "content": {"application/json": {"schema": {"$ref": "../schemas/workload-projection.schema.json"}}}},
|
||||||
|
"400": {"description": "Invalid explicit filter"},
|
||||||
|
"409": {"description": "Cursor belongs to another generation or filter"},
|
||||||
|
"503": {"description": "No accepted workload projection is available"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/ports/projections/workloads/resolve": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "resolveWorkloadReference",
|
||||||
|
"x-port-id": "port.projection.query",
|
||||||
|
"x-direction": "out",
|
||||||
|
"parameters": [
|
||||||
|
{"name": "rapp_id", "in": "query", "required": true, "schema": {"type": "string"}},
|
||||||
|
{"name": "name", "in": "query", "required": true, "schema": {"type": "string"}},
|
||||||
|
{"name": "deployable", "in": "query", "schema": {"type": "string"}}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {"description": "Resolved or explicitly unknown reference", "content": {"application/json": {"schema": {"$ref": "../schemas/workload-resolution.schema.json"}}}},
|
||||||
|
"400": {"description": "Invalid explicit reference"},
|
||||||
|
"503": {"description": "No accepted workload projection is available"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://schemas.helixforge.local/workload-projection/1.0.0/workload-projection-page.schema.json",
|
||||||
|
"title": "Repo Manager workload projection page",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["contract_id", "contract_version", "source", "snapshot", "workloads", "diagnostics"],
|
||||||
|
"properties": {
|
||||||
|
"contract_id": {"const": "helixforge.workload-projection-source"},
|
||||||
|
"contract_version": {"const": "1.0.0"},
|
||||||
|
"source": {"$ref": "#/$defs/source"},
|
||||||
|
"snapshot": {"$ref": "#/$defs/snapshot"},
|
||||||
|
"workloads": {"type": "array", "items": {"$ref": "#/$defs/workload"}},
|
||||||
|
"diagnostics": {"type": "array", "items": {"$ref": "#/$defs/diagnostic"}}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"source": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["system", "workload_contract", "workload_contract_id", "producer_revision"],
|
||||||
|
"properties": {
|
||||||
|
"system": {"const": "repo-manager"},
|
||||||
|
"workload_contract": {"const": "helixforge.workload-reference/v1"},
|
||||||
|
"workload_contract_id": {"const": "RMGR-CONTRACT-WORKLOAD-REFERENCE-0001"},
|
||||||
|
"producer_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["snapshot_id", "generated_at", "source_revision", "page_cursor", "next_cursor", "final_page", "total_workload_count"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_id": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||||
|
"generated_at": {"$ref": "#/$defs/utcTimestamp"},
|
||||||
|
"source_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"page_cursor": {"type": ["string", "null"], "maxLength": 1000},
|
||||||
|
"next_cursor": {"type": ["string", "null"], "maxLength": 1000},
|
||||||
|
"final_page": {"type": "boolean"},
|
||||||
|
"total_workload_count": {"type": "integer", "minimum": 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workload": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["rapp_id", "name", "declaration_repo", "declaration_path", "source_git_revision", "observed_at", "ownership_repo", "readiness_state", "data_classification", "criticality", "deployables"],
|
||||||
|
"properties": {
|
||||||
|
"rapp_id": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"name": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"declaration_repo": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"declaration_path": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*/declarations/rapp\\.yaml$", "maxLength": 260},
|
||||||
|
"source_git_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"observed_at": {"$ref": "#/$defs/utcTimestamp"},
|
||||||
|
"ownership_repo": {"type": ["string", "null"], "minLength": 1, "maxLength": 120},
|
||||||
|
"readiness_state": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"data_classification": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"criticality": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"deployables": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diagnostic": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["severity", "code", "message"],
|
||||||
|
"properties": {
|
||||||
|
"severity": {"enum": ["info", "warning", "error"]},
|
||||||
|
"code": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]*$", "maxLength": 120},
|
||||||
|
"message": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||||
|
"declaration_path": {"type": "string", "maxLength": 260}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utcTimestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "Z$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://schemas.helixforge.local/workload-projection/1.0.0/workload-projection.schema.json",
|
||||||
|
"title": "Hub workload projection",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["contract_id", "contract_version", "projection_id", "projection_status", "source_snapshot", "source_checked_at", "rebuilt_at", "content_hash", "workloads", "diagnostics", "next_cursor", "total_workload_count"],
|
||||||
|
"properties": {
|
||||||
|
"contract_id": {"const": "helixforge.workload-projection"},
|
||||||
|
"contract_version": {"const": "1.0.0"},
|
||||||
|
"projection_id": {"const": "workloads"},
|
||||||
|
"projection_status": {"enum": ["current", "stale"]},
|
||||||
|
"source_snapshot": {"$ref": "#/$defs/sourceSnapshot"},
|
||||||
|
"source_checked_at": {"$ref": "#/$defs/utcTimestamp"},
|
||||||
|
"rebuilt_at": {"$ref": "#/$defs/utcTimestamp"},
|
||||||
|
"content_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||||
|
"workloads": {"type": "array", "items": {"$ref": "#/$defs/workload"}},
|
||||||
|
"diagnostics": {"type": "array", "items": {"$ref": "#/$defs/diagnostic"}},
|
||||||
|
"next_cursor": {"type": ["string", "null"], "maxLength": 1000},
|
||||||
|
"total_workload_count": {"type": "integer", "minimum": 0}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"sourceSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["snapshot_id", "source_system", "source_contract", "source_revision", "generated_at"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_id": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||||
|
"source_system": {"const": "repo-manager"},
|
||||||
|
"source_contract": {"const": "helixforge.workload-reference/v1"},
|
||||||
|
"source_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"generated_at": {"$ref": "#/$defs/utcTimestamp"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workload": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["rapp_id", "name", "declaration_repo", "declaration_path", "source_git_revision", "observed_at", "ownership_repo", "readiness_state", "data_classification", "criticality", "deployables"],
|
||||||
|
"properties": {
|
||||||
|
"rapp_id": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"name": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"declaration_repo": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120},
|
||||||
|
"declaration_path": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*/declarations/rapp\\.yaml$", "maxLength": 260},
|
||||||
|
"source_git_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"observed_at": {"$ref": "#/$defs/utcTimestamp"},
|
||||||
|
"ownership_repo": {"type": ["string", "null"], "minLength": 1, "maxLength": 120},
|
||||||
|
"readiness_state": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"data_classification": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"criticality": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
|
||||||
|
"deployables": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 120}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diagnostic": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["severity", "code", "message"],
|
||||||
|
"properties": {
|
||||||
|
"severity": {"enum": ["info", "warning", "error"]},
|
||||||
|
"code": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]*$", "maxLength": 120},
|
||||||
|
"message": {"type": "string", "minLength": 1, "maxLength": 1000},
|
||||||
|
"declaration_path": {"type": "string", "maxLength": 260}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utcTimestamp": {"type": "string", "format": "date-time", "pattern": "Z$"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://schemas.helixforge.local/workload-projection/1.0.0/workload-resolution.schema.json",
|
||||||
|
"title": "Exact workload reference resolution",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["contract_id", "contract_version", "status", "reference", "source_snapshot", "content_hash", "projection_status"],
|
||||||
|
"properties": {
|
||||||
|
"contract_id": {"const": "helixforge.workload-projection"},
|
||||||
|
"contract_version": {"const": "1.0.0"},
|
||||||
|
"status": {"enum": ["resolved", "unknown"]},
|
||||||
|
"reference": {"$ref": "#/$defs/reference"},
|
||||||
|
"source_snapshot": {"$ref": "#/$defs/sourceSnapshot"},
|
||||||
|
"content_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||||
|
"projection_status": {"enum": ["current", "stale"]},
|
||||||
|
"reason": {"enum": ["not_found", "deployable_not_declared"]},
|
||||||
|
"workload": {"$ref": "#/$defs/workload"}
|
||||||
|
},
|
||||||
|
"oneOf": [
|
||||||
|
{"properties": {"status": {"const": "resolved"}}, "required": ["workload"], "not": {"required": ["reason"]}},
|
||||||
|
{"properties": {"status": {"const": "unknown"}}, "required": ["reason"], "not": {"required": ["workload"]}}
|
||||||
|
],
|
||||||
|
"$defs": {
|
||||||
|
"reference": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["rapp_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"rapp_id": {"type": "string", "pattern": "^rapp-[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
||||||
|
"name": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
||||||
|
"deployable": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["snapshot_id", "source_system", "source_contract", "source_revision", "generated_at"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_id": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||||
|
"source_system": {"const": "repo-manager"},
|
||||||
|
"source_contract": {"const": "helixforge.workload-reference/v1"},
|
||||||
|
"source_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"generated_at": {"type": "string", "format": "date-time", "pattern": "Z$"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workload": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["rapp_id", "name", "declaration_repo", "declaration_path", "source_git_revision", "observed_at", "ownership_repo", "readiness_state", "data_classification", "criticality", "deployables"],
|
||||||
|
"properties": {
|
||||||
|
"rapp_id": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"declaration_repo": {"type": "string"},
|
||||||
|
"declaration_path": {"type": "string"},
|
||||||
|
"source_git_revision": {"type": "string", "pattern": "^[a-f0-9]{40,64}$"},
|
||||||
|
"observed_at": {"type": "string", "format": "date-time", "pattern": "Z$"},
|
||||||
|
"ownership_repo": {"type": ["string", "null"]},
|
||||||
|
"readiness_state": {"type": ["string", "null"]},
|
||||||
|
"data_classification": {"type": ["string", "null"]},
|
||||||
|
"criticality": {"type": ["string", "null"]},
|
||||||
|
"deployables": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,8 @@ CORE_TOOL_NAMES = frozenset({
|
||||||
"list_domain_repos",
|
"list_domain_repos",
|
||||||
"query_repository_navigation",
|
"query_repository_navigation",
|
||||||
"get_repository_navigation_facet",
|
"get_repository_navigation_facet",
|
||||||
|
"query_workloads",
|
||||||
|
"resolve_workload_reference",
|
||||||
"check_repo_doi",
|
"check_repo_doi",
|
||||||
"get_doi_summary",
|
"get_doi_summary",
|
||||||
"register_service",
|
"register_service",
|
||||||
|
|
@ -344,6 +346,42 @@ class HubCoreMCPServer:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@register("query_workloads")
|
||||||
|
def query_workloads(
|
||||||
|
rapp_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
deployable: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> str:
|
||||||
|
"""Query normalized authoritative workload records without inference."""
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/ports/projections/workloads",
|
||||||
|
{
|
||||||
|
"rapp_id": rapp_id,
|
||||||
|
"name": name,
|
||||||
|
"deployable": deployable,
|
||||||
|
"cursor": cursor,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@register("resolve_workload_reference")
|
||||||
|
def resolve_workload_reference(
|
||||||
|
rapp_id: str,
|
||||||
|
name: str,
|
||||||
|
deployable: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve only an exact rapp/name/deployable reference."""
|
||||||
|
return self._json(
|
||||||
|
self._get(
|
||||||
|
"/ports/projections/workloads/resolve",
|
||||||
|
{"rapp_id": rapp_id, "name": name, "deployable": deployable},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@register("check_repo_doi")
|
@register("check_repo_doi")
|
||||||
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
|
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
|
||||||
return self._json(
|
return self._json(
|
||||||
|
|
|
||||||
55
hub_core/migrations/versions/0004_workload_projection.py
Normal file
55
hub_core/migrations/versions/0004_workload_projection.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""durable authoritative workload projection
|
||||||
|
|
||||||
|
Revision ID: 0004_workload_projection
|
||||||
|
Revises: 0003_repository_navigation
|
||||||
|
Create Date: 2026-08-23
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0004_workload_projection"
|
||||||
|
down_revision: Union[str, None] = "0003_repository_navigation"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"runtime_workload_projection_state",
|
||||||
|
sa.Column("projection_id", sa.String(80), primary_key=True),
|
||||||
|
sa.Column("projection_status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("snapshot_id", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("source_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("rebuilt_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("workload_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("diagnostics", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"runtime_workload_projection_records",
|
||||||
|
sa.Column("rapp_id", sa.String(120), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(120), primary_key=True),
|
||||||
|
sa.Column("declaration_repo", sa.String(120), nullable=False),
|
||||||
|
sa.Column("declaration_path", sa.String(260), nullable=False),
|
||||||
|
sa.Column("source_git_revision", sa.String(64), nullable=False),
|
||||||
|
sa.Column("observed_at", sa.String(40), nullable=False),
|
||||||
|
sa.Column("ownership_repo", sa.String(120), nullable=True),
|
||||||
|
sa.Column("readiness_state", sa.String(80), nullable=True),
|
||||||
|
sa.Column("data_classification", sa.String(80), nullable=True),
|
||||||
|
sa.Column("criticality", sa.String(80), nullable=True),
|
||||||
|
sa.Column("deployables", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_runtime_workload_projection_records_declaration_repo",
|
||||||
|
"runtime_workload_projection_records",
|
||||||
|
["declaration_repo"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("runtime_workload_projection_records")
|
||||||
|
op.drop_table("runtime_workload_projection_state")
|
||||||
|
|
@ -19,6 +19,12 @@ from hub_core.runtime.repository_navigation_routes import (
|
||||||
)
|
)
|
||||||
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
from hub_core.runtime.store import InMemoryPortStore, PortStore
|
||||||
from hub_core.runtime.validation import ContractValidator
|
from hub_core.runtime.validation import ContractValidator
|
||||||
|
from hub_core.runtime.workload_projection import (
|
||||||
|
WorkloadProjectionClient,
|
||||||
|
WorkloadProjectionRejected,
|
||||||
|
WorkloadProjectionService,
|
||||||
|
)
|
||||||
|
from hub_core.runtime.workload_projection_routes import create_workload_projection_router
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
|
|
@ -26,6 +32,7 @@ def create_app(
|
||||||
settings: RuntimeSettings | None = None,
|
settings: RuntimeSettings | None = None,
|
||||||
port_store: PortStore | None = None,
|
port_store: PortStore | None = None,
|
||||||
repo_projection_client: RepoProjectionClient | None = None,
|
repo_projection_client: RepoProjectionClient | None = None,
|
||||||
|
workload_projection_client: WorkloadProjectionClient | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
resolved_settings = settings or RuntimeSettings.from_env()
|
resolved_settings = settings or RuntimeSettings.from_env()
|
||||||
resolved_store = port_store or _create_store(resolved_settings)
|
resolved_store = port_store or _create_store(resolved_settings)
|
||||||
|
|
@ -34,6 +41,10 @@ def create_app(
|
||||||
client=repo_projection_client,
|
client=repo_projection_client,
|
||||||
store=resolved_store,
|
store=resolved_store,
|
||||||
)
|
)
|
||||||
|
workload_projection = WorkloadProjectionService(
|
||||||
|
client=workload_projection_client,
|
||||||
|
store=resolved_store,
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
|
|
@ -44,6 +55,11 @@ def create_app(
|
||||||
# The readiness dependency reports the rejected or absent
|
# The readiness dependency reports the rejected or absent
|
||||||
# projection while the API remains available for diagnosis.
|
# projection while the API remains available for diagnosis.
|
||||||
pass
|
pass
|
||||||
|
if workload_projection_client is not None:
|
||||||
|
try:
|
||||||
|
await workload_projection.refresh()
|
||||||
|
except WorkloadProjectionRejected:
|
||||||
|
pass
|
||||||
yield
|
yield
|
||||||
if owns_store and (closer := getattr(resolved_store, "aclose", None)):
|
if owns_store and (closer := getattr(resolved_store, "aclose", None)):
|
||||||
await closer()
|
await closer()
|
||||||
|
|
@ -63,6 +79,7 @@ def create_app(
|
||||||
)
|
)
|
||||||
app.state.contract_validator = ContractValidator()
|
app.state.contract_validator = ContractValidator()
|
||||||
app.state.repository_navigation = repository_navigation
|
app.state.repository_navigation = repository_navigation
|
||||||
|
app.state.workload_projection = workload_projection
|
||||||
|
|
||||||
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
|
||||||
async def healthz() -> HealthResponse:
|
async def healthz() -> HealthResponse:
|
||||||
|
|
@ -76,6 +93,7 @@ def create_app(
|
||||||
dependency_checks = {
|
dependency_checks = {
|
||||||
**await resolved_store.readiness_checks(),
|
**await resolved_store.readiness_checks(),
|
||||||
**await repository_navigation.readiness_checks(),
|
**await repository_navigation.readiness_checks(),
|
||||||
|
**await workload_projection.readiness_checks(),
|
||||||
}
|
}
|
||||||
ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
|
ready = resolved_settings.is_ready(resolved_store.backend_name) and all(
|
||||||
value in {"ok", "not_applicable"} for value in dependency_checks.values()
|
value in {"ok", "not_applicable"} for value in dependency_checks.values()
|
||||||
|
|
@ -90,6 +108,9 @@ def create_app(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Exact projection routes precede the generic /projections/{projection_id}
|
||||||
|
# route so Starlette dispatch cannot shadow them.
|
||||||
|
app.include_router(create_workload_projection_router())
|
||||||
app.include_router(create_ports_router())
|
app.include_router(create_ports_router())
|
||||||
app.include_router(create_repository_navigation_router())
|
app.include_router(create_repository_navigation_router())
|
||||||
app.include_router(create_compatibility_router())
|
app.include_router(create_compatibility_router())
|
||||||
|
|
|
||||||
|
|
@ -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.workload_projection import WorkloadProjection
|
||||||
from hub_core.runtime.tables import (
|
from hub_core.runtime.tables import (
|
||||||
compat_api_keys,
|
compat_api_keys,
|
||||||
compat_hubs,
|
compat_hubs,
|
||||||
|
|
@ -33,6 +34,8 @@ from hub_core.runtime.tables import (
|
||||||
runtime_repository_navigation_repositories,
|
runtime_repository_navigation_repositories,
|
||||||
runtime_repository_navigation_state,
|
runtime_repository_navigation_state,
|
||||||
runtime_registrations,
|
runtime_registrations,
|
||||||
|
runtime_workload_projection_records,
|
||||||
|
runtime_workload_projection_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,6 +64,7 @@ class PostgresPortStore:
|
||||||
compat_hubs,
|
compat_hubs,
|
||||||
compat_api_keys,
|
compat_api_keys,
|
||||||
runtime_repository_navigation_state,
|
runtime_repository_navigation_state,
|
||||||
|
runtime_workload_projection_state,
|
||||||
):
|
):
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
sa.select(sa.literal(1)).select_from(table).limit(1)
|
sa.select(sa.literal(1)).select_from(table).limit(1)
|
||||||
|
|
@ -342,6 +346,103 @@ class PostgresPortStore:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_workload_projection(self) -> WorkloadProjection | None:
|
||||||
|
async with self.sessions() as session:
|
||||||
|
state = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(runtime_workload_projection_state).where(
|
||||||
|
runtime_workload_projection_state.c.projection_id == "workloads"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).mappings().one_or_none()
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(runtime_workload_projection_records).order_by(
|
||||||
|
runtime_workload_projection_records.c.rapp_id,
|
||||||
|
runtime_workload_projection_records.c.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).mappings()
|
||||||
|
workloads = tuple(
|
||||||
|
{
|
||||||
|
"rapp_id": row["rapp_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"declaration_repo": row["declaration_repo"],
|
||||||
|
"declaration_path": row["declaration_path"],
|
||||||
|
"source_git_revision": row["source_git_revision"],
|
||||||
|
"observed_at": row["observed_at"],
|
||||||
|
"ownership_repo": row["ownership_repo"],
|
||||||
|
"readiness_state": row["readiness_state"],
|
||||||
|
"data_classification": row["data_classification"],
|
||||||
|
"criticality": row["criticality"],
|
||||||
|
"deployables": list(row["deployables"] or []),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
return WorkloadProjection(
|
||||||
|
projection_status=state["projection_status"],
|
||||||
|
source_snapshot=dict(state["source_snapshot"]),
|
||||||
|
source_checked_at=state["source_checked_at"],
|
||||||
|
rebuilt_at=state["rebuilt_at"],
|
||||||
|
content_hash=state["content_hash"],
|
||||||
|
workloads=workloads,
|
||||||
|
diagnostics=tuple(state["diagnostics"] or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def replace_workload_projection(self, projection: WorkloadProjection) -> None:
|
||||||
|
values = {
|
||||||
|
"projection_status": projection.projection_status,
|
||||||
|
"snapshot_id": projection.source_snapshot["snapshot_id"],
|
||||||
|
"source_snapshot": projection.source_snapshot,
|
||||||
|
"source_checked_at": projection.source_checked_at,
|
||||||
|
"rebuilt_at": projection.rebuilt_at,
|
||||||
|
"content_hash": projection.content_hash,
|
||||||
|
"workload_count": len(projection.workloads),
|
||||||
|
"diagnostics": list(projection.diagnostics),
|
||||||
|
}
|
||||||
|
async with self.sessions.begin() as session:
|
||||||
|
await session.execute(runtime_workload_projection_records.delete())
|
||||||
|
if projection.workloads:
|
||||||
|
await session.execute(
|
||||||
|
runtime_workload_projection_records.insert(),
|
||||||
|
[dict(workload) for workload in projection.workloads],
|
||||||
|
)
|
||||||
|
exists = (
|
||||||
|
await session.execute(
|
||||||
|
sa.select(runtime_workload_projection_state.c.projection_id).where(
|
||||||
|
runtime_workload_projection_state.c.projection_id == "workloads"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists is None:
|
||||||
|
await session.execute(
|
||||||
|
runtime_workload_projection_state.insert().values(
|
||||||
|
projection_id="workloads", **values
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
runtime_workload_projection_state.update()
|
||||||
|
.where(runtime_workload_projection_state.c.projection_id == "workloads")
|
||||||
|
.values(**values)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_workload_projection_stale(
|
||||||
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
async with self.sessions.begin() as session:
|
||||||
|
await session.execute(
|
||||||
|
runtime_workload_projection_state.update()
|
||||||
|
.where(runtime_workload_projection_state.c.projection_id == "workloads")
|
||||||
|
.values(
|
||||||
|
projection_status="stale",
|
||||||
|
source_checked_at=checked_at,
|
||||||
|
diagnostics=[dict(diagnostic)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _append_event(
|
async def _append_event(
|
||||||
self,
|
self,
|
||||||
command: EventCommand,
|
command: EventCommand,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,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.workload_projection import WorkloadProjection
|
||||||
|
|
||||||
|
|
||||||
class PortStore(Protocol):
|
class PortStore(Protocol):
|
||||||
|
|
@ -55,6 +56,14 @@ class PortStore(Protocol):
|
||||||
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
async def get_workload_projection(self) -> WorkloadProjection | None: ...
|
||||||
|
|
||||||
|
async def replace_workload_projection(self, projection: WorkloadProjection) -> None: ...
|
||||||
|
|
||||||
|
async def mark_workload_projection_stale(
|
||||||
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class InMemoryPortStore:
|
class InMemoryPortStore:
|
||||||
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
"""Deterministic ephemeral backend for local runtime and conformance tests.
|
||||||
|
|
@ -72,6 +81,7 @@ class InMemoryPortStore:
|
||||||
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]] = []
|
||||||
self._repository_navigation: NavigationProjection | None = None
|
self._repository_navigation: NavigationProjection | None = None
|
||||||
|
self._workload_projection: WorkloadProjection | None = None
|
||||||
|
|
||||||
async def readiness_checks(self) -> dict[str, str]:
|
async def readiness_checks(self) -> dict[str, str]:
|
||||||
return {"database": "not_applicable"}
|
return {"database": "not_applicable"}
|
||||||
|
|
@ -174,6 +184,31 @@ class InMemoryPortStore:
|
||||||
diagnostics=(deepcopy(dict(diagnostic)),),
|
diagnostics=(deepcopy(dict(diagnostic)),),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_workload_projection(self) -> WorkloadProjection | None:
|
||||||
|
async with self._lock:
|
||||||
|
return deepcopy(self._workload_projection)
|
||||||
|
|
||||||
|
async def replace_workload_projection(self, projection: WorkloadProjection) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._workload_projection = deepcopy(projection)
|
||||||
|
|
||||||
|
async def mark_workload_projection_stale(
|
||||||
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
current = self._workload_projection
|
||||||
|
if current is None:
|
||||||
|
return
|
||||||
|
self._workload_projection = WorkloadProjection(
|
||||||
|
projection_status="stale",
|
||||||
|
source_snapshot=deepcopy(current.source_snapshot),
|
||||||
|
source_checked_at=checked_at,
|
||||||
|
rebuilt_at=current.rebuilt_at,
|
||||||
|
content_hash=current.content_hash,
|
||||||
|
workloads=deepcopy(current.workloads),
|
||||||
|
diagnostics=(deepcopy(dict(diagnostic)),),
|
||||||
|
)
|
||||||
|
|
||||||
async def _append_event(
|
async def _append_event(
|
||||||
self,
|
self,
|
||||||
command: EventCommand,
|
command: EventCommand,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,36 @@ runtime_repository_navigation_facets = sa.Table(
|
||||||
sa.Column("repository_ids", sa.JSON(), nullable=False),
|
sa.Column("repository_ids", sa.JSON(), nullable=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
runtime_workload_projection_state = sa.Table(
|
||||||
|
"runtime_workload_projection_state",
|
||||||
|
runtime_metadata,
|
||||||
|
sa.Column("projection_id", sa.String(80), primary_key=True),
|
||||||
|
sa.Column("projection_status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("snapshot_id", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("source_snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("rebuilt_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("workload_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("diagnostics", sa.JSON(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_workload_projection_records = sa.Table(
|
||||||
|
"runtime_workload_projection_records",
|
||||||
|
runtime_metadata,
|
||||||
|
sa.Column("rapp_id", sa.String(120), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(120), primary_key=True),
|
||||||
|
sa.Column("declaration_repo", sa.String(120), nullable=False, index=True),
|
||||||
|
sa.Column("declaration_path", sa.String(260), nullable=False),
|
||||||
|
sa.Column("source_git_revision", sa.String(64), nullable=False),
|
||||||
|
sa.Column("observed_at", sa.String(40), nullable=False),
|
||||||
|
sa.Column("ownership_repo", sa.String(120), nullable=True),
|
||||||
|
sa.Column("readiness_state", sa.String(80), nullable=True),
|
||||||
|
sa.Column("data_classification", sa.String(80), nullable=True),
|
||||||
|
sa.Column("criticality", sa.String(80), nullable=True),
|
||||||
|
sa.Column("deployables", sa.JSON(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
compat_hubs = sa.Table(
|
compat_hubs = sa.Table(
|
||||||
"compat_hubs",
|
"compat_hubs",
|
||||||
runtime_metadata,
|
runtime_metadata,
|
||||||
|
|
|
||||||
421
hub_core/runtime/workload_projection.py
Normal file
421
hub_core/runtime/workload_projection.py
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker, ValidationError
|
||||||
|
|
||||||
|
from hub_core.contracts import (
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_ID,
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_VERSION,
|
||||||
|
workload_projection_contract_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadProjectionClient(Protocol):
|
||||||
|
"""Injected `port.repo` workload reader with transport owned by the host."""
|
||||||
|
|
||||||
|
async def fetch_workload_page(self, cursor: str | None) -> Mapping[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadProjectionStore(Protocol):
|
||||||
|
async def get_workload_projection(self) -> WorkloadProjection | None: ...
|
||||||
|
|
||||||
|
async def replace_workload_projection(self, projection: WorkloadProjection) -> None: ...
|
||||||
|
|
||||||
|
async def mark_workload_projection_stale(
|
||||||
|
self, *, checked_at: datetime, diagnostic: Mapping[str, Any]
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadProjectionRejected(ValueError):
|
||||||
|
"""The authoritative workload transfer cannot safely replace the projection."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadCursorMismatch(ValueError):
|
||||||
|
"""A cursor does not belong to the active workload generation and filters."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkloadProjection:
|
||||||
|
projection_status: Literal["current", "stale"]
|
||||||
|
source_snapshot: dict[str, Any]
|
||||||
|
source_checked_at: datetime
|
||||||
|
rebuilt_at: datetime
|
||||||
|
content_hash: str
|
||||||
|
workloads: tuple[dict[str, Any], ...]
|
||||||
|
diagnostics: tuple[dict[str, Any], ...] = ()
|
||||||
|
|
||||||
|
def to_contract(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"contract_id": WORKLOAD_PROJECTION_CONTRACT_ID,
|
||||||
|
"contract_version": WORKLOAD_PROJECTION_CONTRACT_VERSION,
|
||||||
|
"projection_id": "workloads",
|
||||||
|
"projection_status": self.projection_status,
|
||||||
|
"source_snapshot": deepcopy(self.source_snapshot),
|
||||||
|
"source_checked_at": _utc(self.source_checked_at),
|
||||||
|
"rebuilt_at": _utc(self.rebuilt_at),
|
||||||
|
"content_hash": self.content_hash,
|
||||||
|
"workloads": deepcopy(list(self.workloads)),
|
||||||
|
"diagnostics": deepcopy(list(self.diagnostics)),
|
||||||
|
"next_cursor": None,
|
||||||
|
"total_workload_count": len(self.workloads),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkloadRefreshResult:
|
||||||
|
status: Literal["accepted", "duplicate"]
|
||||||
|
snapshot_id: str
|
||||||
|
content_hash: str
|
||||||
|
workload_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadProjectionService:
|
||||||
|
"""Materialize and resolve normalized Repo Manager workload records."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: WorkloadProjectionClient | None,
|
||||||
|
store: WorkloadProjectionStore,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.store = store
|
||||||
|
self._validator = _input_validator()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def readiness_checks(self) -> dict[str, str]:
|
||||||
|
if self.client is None:
|
||||||
|
return {"workload_projection": "not_applicable"}
|
||||||
|
projection = await self.store.get_workload_projection()
|
||||||
|
if projection is None:
|
||||||
|
return {"workload_projection": "unavailable"}
|
||||||
|
return {
|
||||||
|
"workload_projection": (
|
||||||
|
"ok" if projection.projection_status == "current" else "stale"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refresh(self) -> WorkloadRefreshResult:
|
||||||
|
if self.client is None:
|
||||||
|
raise RuntimeError("no port.repo workload projection client is configured")
|
||||||
|
async with self._lock:
|
||||||
|
checked_at = _now()
|
||||||
|
try:
|
||||||
|
pages = await self._fetch_transfer()
|
||||||
|
return await self._apply_transfer(pages, checked_at=checked_at)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
diagnostic = {
|
||||||
|
"severity": "error",
|
||||||
|
"code": "workload_projection.rejected",
|
||||||
|
"message": str(exc)[:1000] or type(exc).__name__,
|
||||||
|
}
|
||||||
|
await self.store.mark_workload_projection_stale(
|
||||||
|
checked_at=checked_at, diagnostic=diagnostic
|
||||||
|
)
|
||||||
|
if isinstance(exc, WorkloadProjectionRejected):
|
||||||
|
raise
|
||||||
|
raise WorkloadProjectionRejected(diagnostic["message"]) from exc
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
rapp_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
deployable: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not 1 <= limit <= 500:
|
||||||
|
raise ValueError("limit must be between 1 and 500")
|
||||||
|
filters = _filters(rapp_id=rapp_id, name=name, deployable=deployable)
|
||||||
|
projection = await self.store.get_workload_projection()
|
||||||
|
if projection is None:
|
||||||
|
return None
|
||||||
|
filter_hash = _hash(filters)
|
||||||
|
generation_id = _hash(
|
||||||
|
[projection.source_snapshot["snapshot_id"], projection.content_hash]
|
||||||
|
)
|
||||||
|
offset = (
|
||||||
|
_decode_cursor(cursor, generation_id, filter_hash)
|
||||||
|
if cursor
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
matches = tuple(
|
||||||
|
record for record in projection.workloads if _matches(record, filters)
|
||||||
|
)
|
||||||
|
if offset > len(matches):
|
||||||
|
raise WorkloadCursorMismatch("cursor offset exceeds the result set")
|
||||||
|
page = matches[offset : offset + limit]
|
||||||
|
next_offset = offset + len(page)
|
||||||
|
result = projection.to_contract()
|
||||||
|
result["workloads"] = deepcopy(list(page))
|
||||||
|
result["total_workload_count"] = len(matches)
|
||||||
|
result["next_cursor"] = (
|
||||||
|
_encode_cursor(next_offset, generation_id, filter_hash)
|
||||||
|
if next_offset < len(matches)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def resolve(
|
||||||
|
self, *, rapp_id: str, name: str, deployable: str | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
filters = _filters(rapp_id=rapp_id, name=name, deployable=deployable)
|
||||||
|
projection = await self.store.get_workload_projection()
|
||||||
|
if projection is None:
|
||||||
|
return None
|
||||||
|
reference = {"rapp_id": rapp_id, "name": name}
|
||||||
|
if deployable is not None:
|
||||||
|
reference["deployable"] = deployable
|
||||||
|
exact = [
|
||||||
|
record
|
||||||
|
for record in projection.workloads
|
||||||
|
if record["rapp_id"] == rapp_id and record["name"] == name
|
||||||
|
]
|
||||||
|
metadata = {
|
||||||
|
"contract_id": WORKLOAD_PROJECTION_CONTRACT_ID,
|
||||||
|
"contract_version": WORKLOAD_PROJECTION_CONTRACT_VERSION,
|
||||||
|
"reference": reference,
|
||||||
|
"source_snapshot": deepcopy(projection.source_snapshot),
|
||||||
|
"content_hash": projection.content_hash,
|
||||||
|
"projection_status": projection.projection_status,
|
||||||
|
}
|
||||||
|
if len(exact) != 1:
|
||||||
|
return {**metadata, "status": "unknown", "reason": "not_found"}
|
||||||
|
workload = exact[0]
|
||||||
|
if deployable is not None and deployable not in workload["deployables"]:
|
||||||
|
return {
|
||||||
|
**metadata,
|
||||||
|
"status": "unknown",
|
||||||
|
"reason": "deployable_not_declared",
|
||||||
|
}
|
||||||
|
assert _matches(workload, filters)
|
||||||
|
return {**metadata, "status": "resolved", "workload": deepcopy(workload)}
|
||||||
|
|
||||||
|
async def _fetch_transfer(self) -> list[dict[str, Any]]:
|
||||||
|
assert self.client is not None
|
||||||
|
pages: list[dict[str, Any]] = []
|
||||||
|
cursor: str | None = None
|
||||||
|
seen: set[str] = set()
|
||||||
|
while True:
|
||||||
|
raw = dict(await self.client.fetch_workload_page(cursor))
|
||||||
|
try:
|
||||||
|
self._validator.validate(raw)
|
||||||
|
except ValidationError as exc:
|
||||||
|
location = ".".join(str(part) for part in exc.absolute_path) or "root"
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
f"invalid workload page at {location}: {exc.message}"
|
||||||
|
) from exc
|
||||||
|
pages.append(raw)
|
||||||
|
snapshot = raw["snapshot"]
|
||||||
|
next_cursor = snapshot["next_cursor"]
|
||||||
|
if snapshot["final_page"] != (next_cursor is None):
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
"final_page must be true exactly when next_cursor is null"
|
||||||
|
)
|
||||||
|
if snapshot["final_page"]:
|
||||||
|
return pages
|
||||||
|
if next_cursor in seen:
|
||||||
|
raise WorkloadProjectionRejected("workload projection cursor cycle detected")
|
||||||
|
seen.add(next_cursor)
|
||||||
|
cursor = next_cursor
|
||||||
|
if len(pages) >= 10000:
|
||||||
|
raise WorkloadProjectionRejected("workload transfer exceeds 10000 pages")
|
||||||
|
|
||||||
|
async def _apply_transfer(
|
||||||
|
self, pages: list[dict[str, Any]], *, checked_at: datetime
|
||||||
|
) -> WorkloadRefreshResult:
|
||||||
|
workloads = _validate_and_normalize(pages)
|
||||||
|
first = pages[0]
|
||||||
|
snapshot = first["snapshot"]
|
||||||
|
current = await self.store.get_workload_projection()
|
||||||
|
duplicate = bool(
|
||||||
|
current and current.source_snapshot["snapshot_id"] == snapshot["snapshot_id"]
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
current
|
||||||
|
and not duplicate
|
||||||
|
and _parse_time(snapshot["generated_at"])
|
||||||
|
<= _parse_time(current.source_snapshot["generated_at"])
|
||||||
|
):
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
"workload snapshot is older than the active generation"
|
||||||
|
)
|
||||||
|
content_hash = _hash(list(workloads))
|
||||||
|
if duplicate and current and content_hash != current.content_hash:
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
"snapshot_id was reused with different workload content"
|
||||||
|
)
|
||||||
|
projection = WorkloadProjection(
|
||||||
|
projection_status="current",
|
||||||
|
source_snapshot={
|
||||||
|
"snapshot_id": snapshot["snapshot_id"],
|
||||||
|
"source_system": "repo-manager",
|
||||||
|
"source_contract": first["source"]["workload_contract"],
|
||||||
|
"source_revision": snapshot["source_revision"],
|
||||||
|
"generated_at": snapshot["generated_at"],
|
||||||
|
},
|
||||||
|
source_checked_at=checked_at,
|
||||||
|
rebuilt_at=_now(),
|
||||||
|
content_hash=content_hash,
|
||||||
|
workloads=workloads,
|
||||||
|
)
|
||||||
|
await self.store.replace_workload_projection(projection)
|
||||||
|
return WorkloadRefreshResult(
|
||||||
|
status="duplicate" if duplicate else "accepted",
|
||||||
|
snapshot_id=snapshot["snapshot_id"],
|
||||||
|
content_hash=content_hash,
|
||||||
|
workload_count=len(workloads),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _input_validator() -> Draft202012Validator:
|
||||||
|
resource = workload_projection_contract_root().joinpath(
|
||||||
|
"schemas", "workload-projection-page.schema.json"
|
||||||
|
)
|
||||||
|
schema = json.loads(resource.read_text(encoding="utf-8"))
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
return Draft202012Validator(schema, format_checker=FormatChecker())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_and_normalize(
|
||||||
|
pages: list[dict[str, Any]],
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
if not pages:
|
||||||
|
raise WorkloadProjectionRejected("workload transfer contains no pages")
|
||||||
|
first = pages[0]
|
||||||
|
keys = (
|
||||||
|
"snapshot_id",
|
||||||
|
"generated_at",
|
||||||
|
"source_revision",
|
||||||
|
"total_workload_count",
|
||||||
|
)
|
||||||
|
expected = {key: first["snapshot"][key] for key in keys}
|
||||||
|
source = first["source"]
|
||||||
|
previous_cursor: str | None = None
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
identities: set[tuple[str, str]] = set()
|
||||||
|
deployables: set[str] = set()
|
||||||
|
for index, page in enumerate(pages):
|
||||||
|
snapshot = page["snapshot"]
|
||||||
|
if any(snapshot[key] != value for key, value in expected.items()):
|
||||||
|
raise WorkloadProjectionRejected("workload snapshot metadata changed between pages")
|
||||||
|
if page["source"] != source:
|
||||||
|
raise WorkloadProjectionRejected("workload source metadata changed between pages")
|
||||||
|
if snapshot["page_cursor"] != previous_cursor:
|
||||||
|
raise WorkloadProjectionRejected(f"page {index} does not match requested cursor")
|
||||||
|
if any(item["severity"] == "error" for item in page["diagnostics"]):
|
||||||
|
raise WorkloadProjectionRejected("workload transfer contains an error diagnostic")
|
||||||
|
previous_cursor = snapshot["next_cursor"]
|
||||||
|
for raw in page["workloads"]:
|
||||||
|
record = deepcopy(raw)
|
||||||
|
identity = (record["rapp_id"], record["name"])
|
||||||
|
if identity in identities:
|
||||||
|
raise WorkloadProjectionRejected(f"duplicate workload reference {identity!r}")
|
||||||
|
if record["declaration_repo"] != record["rapp_id"]:
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
f"declaration repository does not match {record['rapp_id']}"
|
||||||
|
)
|
||||||
|
expected_path = f"{record['declaration_repo']}/declarations/rapp.yaml"
|
||||||
|
if record["declaration_path"] != expected_path:
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
f"declaration path is not canonical for {record['rapp_id']}"
|
||||||
|
)
|
||||||
|
overlap = deployables.intersection(record["deployables"])
|
||||||
|
if overlap:
|
||||||
|
raise WorkloadProjectionRejected(
|
||||||
|
f"deployable belongs to more than one workload: {sorted(overlap)[0]}"
|
||||||
|
)
|
||||||
|
identities.add(identity)
|
||||||
|
deployables.update(record["deployables"])
|
||||||
|
record["deployables"] = sorted(record["deployables"])
|
||||||
|
records.append(record)
|
||||||
|
normalized = tuple(sorted(records, key=lambda item: (item["rapp_id"], item["name"])))
|
||||||
|
if len(normalized) != expected["total_workload_count"]:
|
||||||
|
raise WorkloadProjectionRejected("total_workload_count does not match records")
|
||||||
|
if list(normalized) != records:
|
||||||
|
raise WorkloadProjectionRejected("workloads are not ordered by exact reference")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _filters(
|
||||||
|
*, rapp_id: str | None, name: str | None, deployable: str | None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
values = {"rapp_id": rapp_id, "name": name, "deployable": deployable}
|
||||||
|
for key, value in values.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
pattern = r"rapp-[a-z0-9]+(?:-[a-z0-9]+)*" if key == "rapp_id" else r"[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||||
|
if len(value) > 120 or re.fullmatch(pattern, value) is None:
|
||||||
|
raise ValueError(f"invalid {key}")
|
||||||
|
return {key: value for key, value in values.items() if value is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _matches(record: Mapping[str, Any], filters: Mapping[str, str]) -> bool:
|
||||||
|
return all(
|
||||||
|
(value in record["deployables"] if key == "deployable" else record[key] == value)
|
||||||
|
for key, value in filters.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(offset: int, generation_id: str, filter_hash: str) -> str:
|
||||||
|
payload = json.dumps(
|
||||||
|
{"generation_id": generation_id, "filter_hash": filter_hash, "offset": offset},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
||||||
|
checksum = hashlib.sha256(b"workload-projection/1.0.0:" + payload).hexdigest()
|
||||||
|
return f"{encoded}.{checksum}"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(cursor: str, generation_id: str, filter_hash: str) -> int:
|
||||||
|
try:
|
||||||
|
encoded, checksum = cursor.split(".", 1)
|
||||||
|
payload = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||||
|
expected = hashlib.sha256(b"workload-projection/1.0.0:" + payload).hexdigest()
|
||||||
|
value = json.loads(payload)
|
||||||
|
if checksum != expected:
|
||||||
|
raise ValueError("checksum")
|
||||||
|
if value["generation_id"] != generation_id or value["filter_hash"] != filter_hash:
|
||||||
|
raise ValueError("generation or filters")
|
||||||
|
offset = int(value["offset"])
|
||||||
|
if offset < 0:
|
||||||
|
raise ValueError("offset")
|
||||||
|
return offset
|
||||||
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise WorkloadCursorMismatch(
|
||||||
|
"cursor_snapshot_mismatch: restart from the first page"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: Any) -> str:
|
||||||
|
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime) -> str:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
normalized = value.astimezone(timezone.utc)
|
||||||
|
return normalized.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
77
hub_core/runtime/workload_projection_routes.py
Normal file
77
hub_core/runtime/workload_projection_routes.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from hub_core.runtime.workload_projection import (
|
||||||
|
WorkloadCursorMismatch,
|
||||||
|
WorkloadProjectionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_workload_projection_service(request: Request) -> WorkloadProjectionService:
|
||||||
|
return request.app.state.workload_projection
|
||||||
|
|
||||||
|
|
||||||
|
def create_workload_projection_router() -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/ports/projections/workloads")
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
tags=["workload-projection"],
|
||||||
|
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
|
||||||
|
)
|
||||||
|
async def query_workloads(
|
||||||
|
rapp_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
deployable: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
service: WorkloadProjectionService = Depends(get_workload_projection_service),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
result = await service.query(
|
||||||
|
rapp_id=rapp_id,
|
||||||
|
name=name,
|
||||||
|
deployable=deployable,
|
||||||
|
cursor=cursor,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except WorkloadCursorMismatch as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return _available(result)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/resolve",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
tags=["workload-projection"],
|
||||||
|
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
|
||||||
|
)
|
||||||
|
async def resolve_workload(
|
||||||
|
rapp_id: str,
|
||||||
|
name: str,
|
||||||
|
deployable: str | None = None,
|
||||||
|
service: WorkloadProjectionService = Depends(get_workload_projection_service),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
result = await service.resolve(
|
||||||
|
rapp_id=rapp_id, name=name, deployable=deployable
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return _available(result)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _available(result: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="no accepted workload projection is available",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
@ -32,6 +32,8 @@ def test_mcp_base_server_registers_orientation_doi_and_fos10_tools() -> None:
|
||||||
"get_alerts",
|
"get_alerts",
|
||||||
"query_repository_navigation",
|
"query_repository_navigation",
|
||||||
"get_repository_navigation_facet",
|
"get_repository_navigation_facet",
|
||||||
|
"query_workloads",
|
||||||
|
"resolve_workload_reference",
|
||||||
} <= names
|
} <= names
|
||||||
assert names == CORE_TOOL_NAMES
|
assert names == CORE_TOOL_NAMES
|
||||||
|
|
||||||
|
|
|
||||||
271
tests/test_workload_projection.py
Normal file
271
tests/test_workload_projection.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from hub_core.contracts import workload_projection_contract_root
|
||||||
|
from hub_core.runtime.app import create_app
|
||||||
|
from hub_core.runtime.config import RuntimeSettings
|
||||||
|
from hub_core.runtime.postgres_store import PostgresPortStore
|
||||||
|
from hub_core.runtime.store import InMemoryPortStore
|
||||||
|
from hub_core.runtime.tables import (
|
||||||
|
runtime_metadata,
|
||||||
|
runtime_workload_projection_records,
|
||||||
|
runtime_workload_projection_state,
|
||||||
|
)
|
||||||
|
from hub_core.runtime.workload_projection import (
|
||||||
|
WorkloadCursorMismatch,
|
||||||
|
WorkloadProjectionRejected,
|
||||||
|
WorkloadProjectionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fixture() -> dict[str, Any]:
|
||||||
|
resource = workload_projection_contract_root().joinpath(
|
||||||
|
"fixtures", "repo-manager-nine-workloads.json"
|
||||||
|
)
|
||||||
|
return json.loads(resource.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class PageClient:
|
||||||
|
def __init__(self, pages: list[dict[str, Any]]) -> None:
|
||||||
|
self.pages = pages
|
||||||
|
self.calls: list[str | None] = []
|
||||||
|
|
||||||
|
async def fetch_workload_page(self, cursor: str | None) -> dict[str, Any]:
|
||||||
|
self.calls.append(cursor)
|
||||||
|
for page in self.pages:
|
||||||
|
if page["snapshot"]["page_cursor"] == cursor:
|
||||||
|
return deepcopy(page)
|
||||||
|
raise RuntimeError(f"unexpected cursor {cursor!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class FailingClient:
|
||||||
|
async def fetch_workload_page(self, cursor: str | None) -> dict[str, Any]:
|
||||||
|
raise RuntimeError("repo-manager workload index unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def next_snapshot(
|
||||||
|
page: dict[str, Any], *, marker: str, generated_at: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
changed = deepcopy(page)
|
||||||
|
changed["snapshot"].update(
|
||||||
|
{"snapshot_id": marker * 64, "generated_at": generated_at}
|
||||||
|
)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingests_nine_records_and_resolves_only_exact_reference() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
store = InMemoryPortStore()
|
||||||
|
service = WorkloadProjectionService(client=PageClient([fixture()]), store=store)
|
||||||
|
|
||||||
|
result = await service.refresh()
|
||||||
|
resolved = await service.resolve(
|
||||||
|
rapp_id="rapp-qonto", name="qonto", deployable="rapp-qonto"
|
||||||
|
)
|
||||||
|
wrong_name = await service.resolve(
|
||||||
|
rapp_id="rapp-qonto", name="qonto-assistant"
|
||||||
|
)
|
||||||
|
wrong_deployable = await service.resolve(
|
||||||
|
rapp_id="rapp-openbao", name="openbao", deployable="rapp-openbao"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.workload_count == 9
|
||||||
|
assert resolved is not None and resolved["status"] == "resolved"
|
||||||
|
assert wrong_name is not None and wrong_name["reason"] == "not_found"
|
||||||
|
assert wrong_deployable is not None
|
||||||
|
assert wrong_deployable["reason"] == "deployable_not_declared"
|
||||||
|
assert await service.readiness_checks() == {"workload_projection": "ok"}
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_deletion_and_full_rebuild_restore_equivalent_content() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
source = fixture()
|
||||||
|
store = InMemoryPortStore()
|
||||||
|
initial = WorkloadProjectionService(client=PageClient([source]), store=store)
|
||||||
|
original = await initial.refresh()
|
||||||
|
|
||||||
|
reduced = next_snapshot(
|
||||||
|
source, marker="a", generated_at="2026-08-22T12:05:00Z"
|
||||||
|
)
|
||||||
|
reduced["workloads"] = reduced["workloads"][:-1]
|
||||||
|
reduced["snapshot"]["total_workload_count"] = 8
|
||||||
|
await WorkloadProjectionService(
|
||||||
|
client=PageClient([reduced]), store=store
|
||||||
|
).refresh()
|
||||||
|
after_delete = await store.get_workload_projection()
|
||||||
|
|
||||||
|
restored = next_snapshot(
|
||||||
|
source, marker="b", generated_at="2026-08-22T12:10:00Z"
|
||||||
|
)
|
||||||
|
rebuilt = await WorkloadProjectionService(
|
||||||
|
client=PageClient([restored]), store=store
|
||||||
|
).refresh()
|
||||||
|
|
||||||
|
assert after_delete is not None and len(after_delete.workloads) == 8
|
||||||
|
assert rebuilt.workload_count == 9
|
||||||
|
assert rebuilt.content_hash == original.content_hash
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_non_utc_input_preserves_last_generation_as_stale() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
store = InMemoryPortStore()
|
||||||
|
await WorkloadProjectionService(
|
||||||
|
client=PageClient([fixture()]), store=store
|
||||||
|
).refresh()
|
||||||
|
before = await store.get_workload_projection()
|
||||||
|
invalid = next_snapshot(
|
||||||
|
fixture(), marker="c", generated_at="2026-08-22T14:00:00+02:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(WorkloadProjectionRejected, match="invalid workload page"):
|
||||||
|
await WorkloadProjectionService(
|
||||||
|
client=PageClient([invalid]), store=store
|
||||||
|
).refresh()
|
||||||
|
after = await store.get_workload_projection()
|
||||||
|
|
||||||
|
assert before is not None and after is not None
|
||||||
|
assert after.content_hash == before.content_hash
|
||||||
|
assert after.workloads == before.workloads
|
||||||
|
assert after.projection_status == "stale"
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_order_cursor_and_generation_binding() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
store = InMemoryPortStore()
|
||||||
|
service = WorkloadProjectionService(client=PageClient([fixture()]), store=store)
|
||||||
|
await service.refresh()
|
||||||
|
first = await service.query(limit=4)
|
||||||
|
|
||||||
|
assert first is not None and first["next_cursor"]
|
||||||
|
assert [item["rapp_id"] for item in first["workloads"]] == sorted(
|
||||||
|
item["rapp_id"] for item in first["workloads"]
|
||||||
|
)
|
||||||
|
second = await service.query(cursor=first["next_cursor"], limit=4)
|
||||||
|
assert second is not None and len(second["workloads"]) == 4
|
||||||
|
|
||||||
|
changed = next_snapshot(
|
||||||
|
fixture(), marker="d", generated_at="2026-08-22T12:15:00Z"
|
||||||
|
)
|
||||||
|
await WorkloadProjectionService(
|
||||||
|
client=PageClient([changed]), store=store
|
||||||
|
).refresh()
|
||||||
|
with pytest.raises(WorkloadCursorMismatch, match="cursor_snapshot_mismatch"):
|
||||||
|
await service.query(cursor=first["next_cursor"], limit=4)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_outage_without_generation_fails_readiness_closed() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
service = WorkloadProjectionService(
|
||||||
|
client=FailingClient(), store=InMemoryPortStore()
|
||||||
|
)
|
||||||
|
with pytest.raises(WorkloadProjectionRejected, match="unavailable"):
|
||||||
|
await service.refresh()
|
||||||
|
assert await service.readiness_checks() == {"workload_projection": "unavailable"}
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_and_mcp_backing_routes_are_read_only_and_schema_valid() -> None:
|
||||||
|
settings = RuntimeSettings(environment="test", backend="memory", allow_ephemeral=True)
|
||||||
|
app = create_app(
|
||||||
|
settings=settings,
|
||||||
|
port_store=InMemoryPortStore(),
|
||||||
|
workload_projection_client=PageClient([fixture()]),
|
||||||
|
)
|
||||||
|
with TestClient(app) as runtime:
|
||||||
|
response = runtime.get(
|
||||||
|
"/ports/projections/workloads", params={"deployable": "openbao"}
|
||||||
|
)
|
||||||
|
resolved = runtime.get(
|
||||||
|
"/ports/projections/workloads/resolve",
|
||||||
|
params={"rapp_id": "rapp-openbao", "name": "openbao"},
|
||||||
|
)
|
||||||
|
unknown = runtime.get(
|
||||||
|
"/ports/projections/workloads/resolve",
|
||||||
|
params={"rapp_id": "rapp-openbao", "name": "railiance-platform"},
|
||||||
|
)
|
||||||
|
ready = runtime.get("/readyz")
|
||||||
|
openapi = runtime.get("/openapi.json").json()
|
||||||
|
|
||||||
|
schema_root = workload_projection_contract_root().joinpath("schemas")
|
||||||
|
projection_schema = json.loads(
|
||||||
|
schema_root.joinpath("workload-projection.schema.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
resolution_schema = json.loads(
|
||||||
|
schema_root.joinpath("workload-resolution.schema.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
Draft202012Validator(projection_schema, format_checker=FormatChecker()).validate(
|
||||||
|
response.json()
|
||||||
|
)
|
||||||
|
Draft202012Validator(resolution_schema, format_checker=FormatChecker()).validate(
|
||||||
|
resolved.json()
|
||||||
|
)
|
||||||
|
Draft202012Validator(resolution_schema, format_checker=FormatChecker()).validate(
|
||||||
|
unknown.json()
|
||||||
|
)
|
||||||
|
assert response.status_code == resolved.status_code == unknown.status_code == 200
|
||||||
|
assert [record["rapp_id"] for record in response.json()["workloads"]] == [
|
||||||
|
"rapp-openbao"
|
||||||
|
]
|
||||||
|
assert resolved.json()["status"] == "resolved"
|
||||||
|
assert unknown.json()["status"] == "unknown"
|
||||||
|
assert ready.json()["checks"]["workload_projection"] == "ok"
|
||||||
|
paths = {
|
||||||
|
path: item
|
||||||
|
for path, item in openapi["paths"].items()
|
||||||
|
if path.startswith("/ports/projections/workloads")
|
||||||
|
}
|
||||||
|
assert paths and all(set(item) == {"get"} for item in paths.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_postgres_workload_projection_survives_reopen(tmp_path) -> None:
|
||||||
|
database_url = f"sqlite+aiosqlite:///{tmp_path / 'workloads.db'}"
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
engine = create_async_engine(database_url)
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(runtime_metadata.create_all)
|
||||||
|
await engine.dispose()
|
||||||
|
first_store = PostgresPortStore.from_url(database_url)
|
||||||
|
result = await WorkloadProjectionService(
|
||||||
|
client=PageClient([fixture()]), store=first_store
|
||||||
|
).refresh()
|
||||||
|
await first_store.aclose()
|
||||||
|
second_store = PostgresPortStore.from_url(database_url)
|
||||||
|
projection = await second_store.get_workload_projection()
|
||||||
|
await second_store.aclose()
|
||||||
|
|
||||||
|
assert projection is not None
|
||||||
|
assert projection.content_hash == result.content_hash
|
||||||
|
assert len(projection.workloads) == 9
|
||||||
|
assert all(item["observed_at"].endswith("Z") for item in projection.workloads)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_tables_and_module_have_no_private_repo_manager_coupling() -> None:
|
||||||
|
source = (
|
||||||
|
__import__("inspect")
|
||||||
|
.getsource(__import__("hub_core.runtime.workload_projection", fromlist=["*"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "repo_manager." not in source
|
||||||
|
assert not runtime_workload_projection_state.foreign_keys
|
||||||
|
assert not runtime_workload_projection_records.foreign_keys
|
||||||
95
tests/test_workload_projection_contract.py
Normal file
95
tests/test_workload_projection_contract.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
|
||||||
|
from hub_core.contracts import (
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_ID,
|
||||||
|
WORKLOAD_PROJECTION_CONTRACT_VERSION,
|
||||||
|
workload_projection_contract_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = workload_projection_contract_root()
|
||||||
|
SCHEMAS = ROOT.joinpath("schemas")
|
||||||
|
FIXTURE = ROOT.joinpath("fixtures", "repo-manager-nine-workloads.json")
|
||||||
|
OPENAPI = ROOT.joinpath("openapi", "workload-projection.openapi.json")
|
||||||
|
COMPATIBILITY = ROOT.joinpath("compatibility-matrix.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load(resource: Any) -> Any:
|
||||||
|
return json.loads(resource.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate(instance: Any, name: str) -> None:
|
||||||
|
schema = load(SCHEMAS.joinpath(name))
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
Draft202012Validator(schema, format_checker=FormatChecker()).validate(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaged_workload_contract_identity_and_artifacts() -> None:
|
||||||
|
assert WORKLOAD_PROJECTION_CONTRACT_ID == "helixforge.workload-projection"
|
||||||
|
assert WORKLOAD_PROJECTION_CONTRACT_VERSION == "1.0.0"
|
||||||
|
for artifact in (ROOT.joinpath("README.md"), FIXTURE, OPENAPI, COMPATIBILITY):
|
||||||
|
assert artifact.is_file()
|
||||||
|
for name in (
|
||||||
|
"workload-projection-page.schema.json",
|
||||||
|
"workload-projection.schema.json",
|
||||||
|
"workload-resolution.schema.json",
|
||||||
|
):
|
||||||
|
Draft202012Validator.check_schema(load(SCHEMAS.joinpath(name)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_nine_declaration_fixture_is_valid_exact_and_canonical_utc() -> None:
|
||||||
|
fixture = load(FIXTURE)
|
||||||
|
validate(fixture, "workload-projection-page.schema.json")
|
||||||
|
workloads = fixture["workloads"]
|
||||||
|
identities = [(record["rapp_id"], record["name"]) for record in workloads]
|
||||||
|
|
||||||
|
assert len(workloads) == fixture["snapshot"]["total_workload_count"] == 9
|
||||||
|
assert identities == sorted(identities)
|
||||||
|
assert len(identities) == len(set(identities))
|
||||||
|
assert all(record["declaration_repo"] == record["rapp_id"] for record in workloads)
|
||||||
|
assert all(
|
||||||
|
record["declaration_path"]
|
||||||
|
== f"{record['declaration_repo']}/declarations/rapp.yaml"
|
||||||
|
for record in workloads
|
||||||
|
)
|
||||||
|
assert fixture["snapshot"]["generated_at"].endswith("Z")
|
||||||
|
assert all(record["observed_at"].endswith("Z") for record in workloads)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compatibility_matrix_preserves_repo_manager_authority_and_forbids_inference() -> None:
|
||||||
|
matrix = load(COMPATIBILITY)
|
||||||
|
|
||||||
|
assert matrix["contract_id"] == WORKLOAD_PROJECTION_CONTRACT_ID
|
||||||
|
assert matrix["accepted_inputs"] == [
|
||||||
|
{
|
||||||
|
"contract_id": "helixforge.workload-projection-source",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source_contract": "helixforge.workload-reference/v1",
|
||||||
|
"status": "current",
|
||||||
|
"coercion": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert matrix["authority"]["index_and_resolution"] == "repo-manager"
|
||||||
|
assert set(matrix["forbidden_fallbacks"]) == {
|
||||||
|
"repository_name",
|
||||||
|
"owner_repo",
|
||||||
|
"path_segment",
|
||||||
|
"alias",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workload_openapi_is_projection_query_read_only() -> None:
|
||||||
|
document = load(OPENAPI)
|
||||||
|
operations = [
|
||||||
|
(method, operation)
|
||||||
|
for path_item in document["paths"].values()
|
||||||
|
for method, operation in path_item.items()
|
||||||
|
if method in {"get", "post", "put", "patch", "delete"}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert document["openapi"] == "3.1.0"
|
||||||
|
assert operations and all(method == "get" for method, _ in operations)
|
||||||
|
assert all(operation["x-port-id"] == "port.projection.query" for _, operation in operations)
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Authoritative workload projection transport"
|
title: "Authoritative workload projection transport"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: hub-core
|
repo: hub-core
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-08-23"
|
created: "2026-08-23"
|
||||||
|
|
@ -14,6 +14,7 @@ related:
|
||||||
- HUB-WP-0006
|
- HUB-WP-0006
|
||||||
- RMGR-WP-0010-T04
|
- RMGR-WP-0010-T04
|
||||||
- RMGR-CONTRACT-WORKLOAD-REFERENCE-0001
|
- RMGR-CONTRACT-WORKLOAD-REFERENCE-0001
|
||||||
|
state_hub_workstream_id: "1f767e78-ab36-5e62-a23a-31af0f21a723"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authoritative workload projection transport
|
# Authoritative workload projection transport
|
||||||
|
|
@ -30,6 +31,7 @@ semantics, exposing Repo Manager persistence, or inferring identities.
|
||||||
id: HUB-WP-0007-T01
|
id: HUB-WP-0007-T01
|
||||||
status: done
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "99787c58-f0b2-5e67-9816-ff4ea0ad6057"
|
||||||
```
|
```
|
||||||
|
|
||||||
Disposition: **approved** on 2026-08-23 by operator instruction to implement.
|
Disposition: **approved** on 2026-08-23 by operator instruction to implement.
|
||||||
|
|
@ -46,8 +48,9 @@ reconstructing Repo Manager work.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0007-T02
|
id: HUB-WP-0007-T02
|
||||||
status: progress
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "24279a94-a23c-5468-8623-ecddcafaef3a"
|
||||||
```
|
```
|
||||||
|
|
||||||
Package a strict versioned input/output contract for records keyed by exact
|
Package a strict versioned input/output contract for records keyed by exact
|
||||||
|
|
@ -56,12 +59,19 @@ source repository, declaration path, source Git revision, and canonical UTC
|
||||||
observation time. Preserve Repo Manager diagnostics and reject unsupported
|
observation time. Preserve Repo Manager diagnostics and reject unsupported
|
||||||
versions or inferred identity.
|
versions or inferred identity.
|
||||||
|
|
||||||
|
Completed 2026-08-23. Packaged `helixforge.workload-projection` 1.0.0 defines
|
||||||
|
strict Repo Manager input pages, normalized output and exact-resolution
|
||||||
|
schemas, a read-only OpenAPI fragment, authority/compatibility policy, and the
|
||||||
|
nine-declaration fixture. Canonical UTC is required at the schema boundary;
|
||||||
|
identity and path consistency are also checked semantically.
|
||||||
|
|
||||||
## Implement durable rebuildable ingestion
|
## Implement durable rebuildable ingestion
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0007-T03
|
id: HUB-WP-0007-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "cb0eb118-a968-51d4-84fc-0a9bf959a501"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extend the injected `port.repo` boundary with workload pages. Deterministically
|
Extend the injected `port.repo` boundary with workload pages. Deterministically
|
||||||
|
|
@ -69,12 +79,21 @@ replace the active generation in PostgreSQL, retain source/rebuild provenance
|
||||||
and a canonical content hash, and keep the previous generation on invalid or
|
and a canonical content hash, and keep the previous generation on invalid or
|
||||||
unavailable input.
|
unavailable input.
|
||||||
|
|
||||||
|
Completed 2026-08-23. `WorkloadProjectionService` consumes a separate injected
|
||||||
|
`port.repo` workload client, validates complete paged transfers, rejects error
|
||||||
|
diagnostics, duplicate identity/deployables, unsupported versions, noncanonical
|
||||||
|
paths, and stale/reused snapshots, then atomically replaces the generation.
|
||||||
|
Migration `0004_workload_projection` persists projection state and normalized
|
||||||
|
records without foreign keys or Repo Manager imports. Invalid input preserves
|
||||||
|
the prior generation as stale and readiness reports current/stale/unavailable.
|
||||||
|
|
||||||
## Expose exact read-only HTTP and MCP resolution
|
## Expose exact read-only HTTP and MCP resolution
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0007-T04
|
id: HUB-WP-0007-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "722dfd4a-62d3-59b0-9494-08ec8f184571"
|
||||||
```
|
```
|
||||||
|
|
||||||
Expose list and exact-reference queries through `port.projection.query` and
|
Expose list and exact-reference queries through `port.projection.query` and
|
||||||
|
|
@ -82,12 +101,20 @@ matching MCP tools. Exact resolution returns `resolved` or `unknown` without
|
||||||
repository-name, owner, alias, or path inference. No workload mutation route is
|
repository-name, owner, alias, or path inference. No workload mutation route is
|
||||||
permitted.
|
permitted.
|
||||||
|
|
||||||
|
Completed 2026-08-23. GET-only list and exact-resolution routes expose
|
||||||
|
normalized records with content/source provenance. Filters and cursors use
|
||||||
|
only explicit `rapp_id`, name, and deployable values. Resolution returns
|
||||||
|
`unknown/not_found` or `unknown/deployable_not_declared`; it never consults
|
||||||
|
owner, repository, path, or alias fallbacks. MCP tools `query_workloads` and
|
||||||
|
`resolve_workload_reference` call the same HTTP surface.
|
||||||
|
|
||||||
## Prove conformance and hand off
|
## Prove conformance and hand off
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0007-T05
|
id: HUB-WP-0007-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "04569701-6cb4-5762-ac9b-a9dec4e2bb52"
|
||||||
```
|
```
|
||||||
|
|
||||||
Ingest a nine-declaration Repo Manager fixture; prove deletion plus full rebuild
|
Ingest a nine-declaration Repo Manager fixture; prove deletion plus full rebuild
|
||||||
|
|
@ -96,10 +123,18 @@ reopen, provenance, deterministic hashes/order, read-only OpenAPI, and absence
|
||||||
of Repo Manager private-model or foreign-database coupling. Run `make test` and
|
of Repo Manager private-model or foreign-database coupling. Run `make test` and
|
||||||
`make conformance`, then reply with owner identifiers and evidence.
|
`make conformance`, then reply with owner identifiers and evidence.
|
||||||
|
|
||||||
|
Completed 2026-08-23. Tests ingest and query all nine fixture declarations,
|
||||||
|
prove exact/unknown resolution, deletion plus equivalent full rebuild,
|
||||||
|
generation-bound cursors, UTC rejection, outage/stale behavior, durable reopen,
|
||||||
|
schema-valid HTTP responses, GET-only OpenAPI, and absence of private-model or
|
||||||
|
foreign-database coupling. `make test` passes 100 tests, live
|
||||||
|
`make conformance` passes 8/8, the wheel builds, and PostgreSQL migrations
|
||||||
|
render through `0004_workload_projection` (501 lines).
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- [x] Owner interface disposition and source revisions recorded
|
- [x] Owner interface disposition and source revisions recorded
|
||||||
- [ ] Normalized transport contract is versioned and packaged
|
- [x] Normalized transport contract is versioned and packaged
|
||||||
- [ ] Durable projection rebuild preserves authority and provenance
|
- [x] Durable projection rebuild preserves authority and provenance
|
||||||
- [ ] HTTP/MCP exact reference queries never infer identity
|
- [x] HTTP/MCP exact reference queries never infer identity
|
||||||
- [ ] Nine-declaration and rebuild conformance evidence passes
|
- [x] Nine-declaration and rebuild conformance evidence passes
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue