test: prove repository navigation conformance
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
tegwick 2026-08-22 00:44:09 +02:00
parent fe0e845cb1
commit 93a9151558
6 changed files with 210 additions and 26 deletions

View file

@ -42,5 +42,5 @@
| task | HUB-WP-0006-T02 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md |
| task | HUB-WP-0006-T03 | 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 | todo | — | 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 |

View file

@ -9,7 +9,8 @@ classification projections:
repository, facet, provenance, diagnostic, cursor, and legacy-alias output;
- `openapi/repository-navigation.openapi.json` defines the read-only
`port.projection.query` routes;
- `fixtures/` supplies a valid full input page and its representative output;
- `fixtures/` supplies valid full and incremental/delete input pages plus a
representative derived output;
- `compatibility-matrix.json` records the only accepted input version and the
temporary State Hub topic adapter.

View file

@ -0,0 +1,52 @@
{
"contract_id": "helixforge.repository-classification-projection",
"contract_version": "1.0.0",
"source": {
"system": "repo-manager",
"classification_contract_id": "RMGR-CONTRACT-CLASSIFICATION-0001",
"classification_contract_version": "1.0",
"producer_version": "0.4.0"
},
"snapshot": {
"snapshot_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"mode": "incremental",
"generated_at": "2026-08-22T12:05:00Z",
"source_revision": "123456789abcdef0123456789abcdef012345678",
"page_cursor": null,
"next_cursor": null,
"final_page": true,
"total_repository_count": null
},
"repositories": [
{
"operation": "delete",
"repository_id": "11111111-1111-4111-8111-111111111111",
"slug": "hub-core",
"revision": {
"head_sha": "1111111111111111111111111111111111111111",
"source_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"observed_at": "2026-08-22T12:04:00Z"
}
},
{
"operation": "upsert",
"repository_id": "33333333-3333-4333-8333-333333333333",
"slug": "classification-catalog",
"lifecycle": "active",
"classification": {
"category": "product",
"domain": "infotech",
"secondary_domains": [],
"capability_tags": ["repository-governance"],
"business_stake": ["operations", "technology"],
"business_mechanics": ["control", "coordination"]
},
"revision": {
"head_sha": "3333333333333333333333333333333333333333",
"source_fingerprint": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"observed_at": "2026-08-22T12:04:30Z"
}
}
],
"diagnostics": []
}

View file

@ -14,6 +14,9 @@ from hub_core.contracts import (
ROOT = repository_navigation_contract_root()
SCHEMAS = ROOT.joinpath("schemas")
INPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-classification-page.json")
INCREMENTAL_FIXTURE = ROOT.joinpath(
"fixtures", "repository-classification-incremental.json"
)
OUTPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-navigation-projection.json")
OPENAPI = ROOT.joinpath("openapi", "repository-navigation.openapi.json")
COMPATIBILITY = ROOT.joinpath("compatibility-matrix.json")
@ -70,6 +73,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non
for resource in (
ROOT.joinpath("README.md"),
INPUT_FIXTURE,
INCREMENTAL_FIXTURE,
OUTPUT_FIXTURE,
OPENAPI,
COMPATIBILITY,
@ -79,6 +83,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non
def test_input_and_output_fixtures_validate() -> None:
validate(load_json(INPUT_FIXTURE), "repository-classification-page.schema.json")
validate(load_json(INCREMENTAL_FIXTURE), "repository-classification-page.schema.json")
validate(load_json(OUTPUT_FIXTURE), "repository-navigation-projection.schema.json")

View file

@ -21,6 +21,11 @@ from hub_core.runtime.repository_navigation import (
)
from hub_core.runtime.store import InMemoryPortStore
from hub_core.runtime.tables import runtime_metadata
from hub_core.runtime.tables import (
runtime_repository_navigation_facets,
runtime_repository_navigation_repositories,
runtime_repository_navigation_state,
)
def fixture_page() -> dict[str, Any]:
@ -30,6 +35,13 @@ def fixture_page() -> dict[str, Any]:
return json.loads(resource.read_text(encoding="utf-8"))
def incremental_fixture() -> dict[str, Any]:
resource = repository_navigation_contract_root().joinpath(
"fixtures", "repository-classification-incremental.json"
)
return json.loads(resource.read_text(encoding="utf-8"))
class PageClient:
def __init__(self, pages: list[dict[str, Any]]) -> None:
self.pages = pages
@ -87,27 +99,7 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None:
)
await first_service.refresh()
incremental = fixture_page()
incremental["snapshot"].update(
{
"snapshot_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"mode": "incremental",
"generated_at": "2026-08-22T12:05:00Z",
"source_revision": "123456789abcdef0123456789abcdef012345678",
"total_repository_count": None,
}
)
delete = {
"operation": "delete",
"repository_id": "11111111-1111-4111-8111-111111111111",
"slug": "hub-core",
"revision": deepcopy(initial["repositories"][0]["revision"]),
}
upsert = deepcopy(initial["repositories"][1])
upsert["repository_id"] = "33333333-3333-4333-8333-333333333333"
upsert["slug"] = "classification-catalog"
upsert["classification"]["category"] = "product"
incremental["repositories"] = [delete, upsert]
incremental = incremental_fixture()
result = await RepositoryNavigationService(
client=PageClient([incremental]), store=store
@ -130,6 +122,100 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None:
asyncio.run(run())
def test_multi_page_full_rebuild_is_complete_and_cursor_driven() -> None:
async def run() -> None:
first = fixture_page()
second = deepcopy(first)
first["repositories"] = [first["repositories"][0]]
first["snapshot"].update(
{"next_cursor": "page-2", "final_page": False}
)
second["repositories"] = [second["repositories"][1]]
second["snapshot"].update(
{"page_cursor": "page-2", "next_cursor": None, "final_page": True}
)
client = PageClient([first, second])
store = InMemoryPortStore()
result = await RepositoryNavigationService(
client=client, store=store
).refresh()
assert result.repository_count == 2
assert client.calls == [None, "page-2"]
asyncio.run(run())
def test_inconsistent_multi_page_rebuild_never_partly_replaces_active_rows() -> None:
async def run() -> None:
store = InMemoryPortStore()
await RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
).refresh()
before = await store.get_repository_navigation()
first = fixture_page()
second = deepcopy(first)
for page in (first, second):
page["snapshot"].update(
{
"snapshot_id": "9999999999999999999999999999999999999999999999999999999999999999",
"generated_at": "2026-08-22T12:10:00Z",
}
)
first["repositories"] = [first["repositories"][0]]
first["snapshot"].update({"next_cursor": "page-2", "final_page": False})
second["repositories"] = [second["repositories"][1]]
second["snapshot"].update(
{
"source_revision": "abcdef0123456789abcdef0123456789abcdef01",
"page_cursor": "page-2",
"next_cursor": None,
"final_page": True,
}
)
with pytest.raises(ProjectionRejected, match="metadata changed"):
await RepositoryNavigationService(
client=PageClient([first, second]), store=store
).refresh()
after = await store.get_repository_navigation()
assert before is not None and after is not None
assert after.content_hash == before.content_hash
assert after.repositories == before.repositories
assert after.projection_status == "stale"
asyncio.run(run())
def test_reused_snapshot_identity_with_different_content_is_rejected() -> None:
async def run() -> None:
store = InMemoryPortStore()
original = fixture_page()
await RepositoryNavigationService(
client=PageClient([original]), store=store
).refresh()
changed = fixture_page()
changed["repositories"][0]["slug"] = "hub-core-renamed"
with pytest.raises(ProjectionRejected, match="snapshot_id was reused"):
await RepositoryNavigationService(
client=PageClient([changed]), store=store
).refresh()
projection = await store.get_repository_navigation()
assert projection is not None
assert projection.projection_status == "stale"
assert [row["slug"] for row in projection.repositories] == [
"hub-core",
"repo-manager",
]
asyncio.run(run())
def test_rejected_version_preserves_last_projection_and_marks_it_stale() -> None:
async def run() -> None:
store = InMemoryPortStore()
@ -241,6 +327,26 @@ def test_query_cursor_is_stable_and_rejects_different_filters() -> None:
asyncio.run(run())
def test_query_cursor_is_invalidated_by_an_incremental_generation() -> None:
async def run() -> None:
store = InMemoryPortStore()
initial = RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
)
await initial.refresh()
first = await initial.query(filters={}, limit=1)
assert first is not None and first["next_cursor"]
updated = RepositoryNavigationService(
client=PageClient([incremental_fixture()]), store=store
)
await updated.refresh()
with pytest.raises(ProjectionCursorMismatch, match="cursor_snapshot_mismatch"):
await updated.query(filters={}, cursor=first["next_cursor"], limit=1)
asyncio.run(run())
def test_http_navigation_routes_are_read_only_and_return_contract_provenance() -> None:
settings = RuntimeSettings(
environment="test", backend="memory", allow_ephemeral=True
@ -326,3 +432,14 @@ def test_postgres_projection_survives_store_reopen(tmp_path) -> None:
assert projection.source_snapshot["source_system"] == "repo-manager"
asyncio.run(run())
def test_navigation_projection_tables_have_no_foreign_database_coupling() -> None:
owned_tables = (
runtime_repository_navigation_state,
runtime_repository_navigation_repositories,
runtime_repository_navigation_facets,
)
assert all(not table.foreign_keys for table in owned_tables)
assert {table.name for table in owned_tables} <= set(runtime_metadata.tables)

View file

@ -67,7 +67,7 @@ defines the strict Repo Manager page envelope, stable registrar UUID identity,
atomic snapshot/rebuild and cursor rules, per-repository provenance, failure
diagnostics, all six derived facet kinds, read-only query routes, and expiring
State Hub topic aliases with residual ownership. Both representative fixtures
validate, the wheel contains all eight artifacts, and the full suite passes
validate, the wheel contains the versioned artifacts, and the full suite passes
72 tests.
## Implement Repo Manager ingestion and rebuild
@ -123,7 +123,7 @@ OpenAPI inspection confirms that every navigation operation is GET-only.
```task
id: HUB-WP-0006-T05
status: todo
status: done
priority: medium
state_hub_task_id: "a3c0efba-9913-4617-b167-8853b34de36e"
```
@ -133,6 +133,15 @@ deletion, duplicate delivery, unsupported versions, Repo Manager outage,
stable ordering/cursors, provenance, and absence of classification writes or
foreign database access.
Completed 2026-08-22. A packaged incremental fixture and 15 focused ingestion
checks now cover single- and multi-page full rebuild, incremental upsert plus
delete, duplicate delivery, snapshot-ID reuse with changed content,
unsupported versions, upstream outage, inconsistent-page atomicity, durable
reopen, stable ordering, filter- and generation-bound cursors, response
provenance, GET-only HTTP navigation, and projection tables without foreign
database keys. The wheel rebuild succeeds and the complete suite passes 88
tests.
## Hand A5/A4 navigation to State Hub retirement
```task
@ -151,6 +160,6 @@ expiry. Wait on T02-T05 and the State Hub cutover window.
- [x] Repo Manager v1.0 authority and projection contract accepted
- [x] Hub-side projection and compatibility alias contract versioned
- [ ] Rebuildable durable ingestion passes provenance and failure checks
- [x] Rebuildable durable ingestion passes provenance and failure checks
- [x] HTTP/MCP navigation reads only the derived projection
- [ ] A5/A4 consumer comparison and rollback evidence recorded