From ddb10c07995eb86a60aeb103d3b3f195bd7b1102 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:20:36 -0500 Subject: [PATCH 01/25] fleet: route the protocol-family probe through the catalog-or-harvest resolver _probe_protocol_families called _harvest_resource_bodies directly twice; both now go through _resolve_via_local_catalog_or_harvest so admitted children resolve from the resident catalog with no wire read. Spec: GRAPHOS-FLEET-R006.3.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/fleet/multiplexer.py | 4 +- specs/fleet-catalog-and-tools/status.json | 9 ++-- tests/fleet/test_harvest_inventory.py | 54 ++++++++++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/graph_os/fleet/multiplexer.py b/graph_os/fleet/multiplexer.py index a2e2cffc..f439f06b 100755 --- a/graph_os/fleet/multiplexer.py +++ b/graph_os/fleet/multiplexer.py @@ -4917,7 +4917,7 @@ async def _probe_protocol_families( errors["prompts"] = type(exc).__name__ native_prompts = [] - await self._harvest_resource_bodies( + await self._resolve_via_local_catalog_or_harvest( server_name, session, skills, @@ -4925,7 +4925,7 @@ async def _probe_protocol_families( self._read_skill_body, probe_deadline=probe_deadline, ) - await self._harvest_resource_bodies( + await self._resolve_via_local_catalog_or_harvest( server_name, session, prompt_resources, diff --git a/specs/fleet-catalog-and-tools/status.json b/specs/fleet-catalog-and-tools/status.json index 6f34649b..3dfe48f5 100644 --- a/specs/fleet-catalog-and-tools/status.json +++ b/specs/fleet-catalog-and-tools/status.json @@ -324,8 +324,8 @@ "a94c87aa090e" ], "verified_by": [ - "tests/fleet/test_harvest_inventory.py:106", - "tests/fleet/test_harvest_inventory.py:117" + "tests/fleet/test_harvest_inventory.py:105", + "tests/fleet/test_harvest_inventory.py:116" ] }, { @@ -362,7 +362,10 @@ "title": "`_probe_protocol_families` harvest calls routed through the cutover helper", "delivery_state": "SPECIFIED", "landed_in": [], - "verified_by": [] + "verified_by": [ + "tests/fleet/test_harvest_inventory.py:128", + "tests/fleet/test_harvest_inventory.py:135" + ] }, { "id": "GRAPHOS-FLEET-R006.3.2", diff --git a/tests/fleet/test_harvest_inventory.py b/tests/fleet/test_harvest_inventory.py index 39f40891..f1a14ff6 100644 --- a/tests/fleet/test_harvest_inventory.py +++ b/tests/fleet/test_harvest_inventory.py @@ -49,7 +49,6 @@ # _resolve_via_local_catalog_or_harvest; GRAPHOS-FLEET-R006.3 removes that # fallback and the remaining _probe_protocol_families sites. _BASELINE_CALL_SITES_BY_METHOD = { - "_probe_protocol_families": 2, "_resolve_via_local_catalog_or_harvest": 1, } _BASELINE_TOTAL_CALL_SITES = sum(_BASELINE_CALL_SITES_BY_METHOD.values()) @@ -124,3 +123,56 @@ def test_harvest_resource_bodies_call_sites_match_pinned_baseline() -> None: f"baseline={_BASELINE_CALL_SITES_BY_METHOD}" ) assert sum(found.values()) == _BASELINE_TOTAL_CALL_SITES + + +@pytest.mark.spec("GRAPHOS-FLEET-R006.3.1") +def test_probe_protocol_families_has_no_direct_harvest_call() -> None: + found = _harvest_resource_bodies_call_sites_by_method(_multiplexer_tree()) + assert "_probe_protocol_families" not in found + assert found == {"_resolve_via_local_catalog_or_harvest": 1} + + +@pytest.mark.spec("GRAPHOS-FLEET-R006.3.1") +@pytest.mark.asyncio +async def test_probe_protocol_families_resolves_admitted_skill_without_wire_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from types import SimpleNamespace + + from graph_os.fleet import multiplexer as mux + + resource = SimpleNamespace( + uri="skill://admitted/SKILL.md", name="admitted", description="d" + ) + + class _Session: + async def list_resources(self): + return SimpleNamespace(resources=[resource]) + + async def list_resource_templates(self): + return SimpleNamespace(resource_templates=[]) + + async def list_prompts(self): + return SimpleNamespace(prompts=[]) + + async def read_resource(self, *_a, **_k): + raise AssertionError("wire read for an admitted child") + + monkeypatch.setattr( + mux, + "build_local_skill_catalog", + lambda: ([{"name": "admitted", "instructions": "LOCAL BODY"}], []), + ) + monkeypatch.setattr( + mux, + "_bounded_skill_catalog", + lambda _r: [{"name": "admitted", "uri": resource.uri}], + ) + monkeypatch.setattr(mux, "_bounded_prompt_catalog", lambda _r: []) + monkeypatch.setattr(mux, "_bounded_descriptor_catalog", lambda *_a, **_k: []) + m = mux.MCPMultiplexer.__new__(mux.MCPMultiplexer) + *_rest, skills, _prompts, errors = await m._probe_protocol_families( + "srv", _Session() + ) + assert errors == {} + assert skills[0][mux._SKILL_HARVEST_SPEC.body_field] == "LOCAL BODY" From 837364943edd4c6b2365f8844384663bb8bb1e85 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:21:57 -0500 Subject: [PATCH 02/25] Split GRAPHOS-HOST-R005.2.1 and .2.3 into per-operation children Only ingest sync has a real registry operation; every other analyze/ingest legacy action is a gap, so each row is split into add-operation children plus an approve-mappings child. Spec: none (refactor) Co-Authored-By: Claude Sonnet 5.5 --- .../host-composition-boundary/requirements.md | 13 ++ specs/host-composition-boundary/status.json | 121 ++++++++++++++++++ specs/host-composition-boundary/tasks.md | 13 ++ 3 files changed, 147 insertions(+) diff --git a/specs/host-composition-boundary/requirements.md b/specs/host-composition-boundary/requirements.md index b19f15dd..f5e3c999 100644 --- a/specs/host-composition-boundary/requirements.md +++ b/specs/host-composition-boundary/requirements.md @@ -10,8 +10,21 @@ | `GRAPHOS-HOST-R005.1` | **Landed: the typed approved-mapping model.** `graph_os/mcp_server/legacy_action_mapping.py` defines `APPROVED_ACTION_MAPPING`, `build_approved_action_mapping` and `validate_served_actions` (construction-time duplicate refusal, lookup-time unmapped-action refusal); `runtime.py` defines `served_legacy_actions_within_approved_mapping` over this table, but nothing calls it against the actual served MCP operation list yet. | Proven today by `tests/mcp_server/test_legacy_action_mapping.py`. | | `GRAPHOS-HOST-R005.2` | **Parity test proves no served action is dropped.** Rollup: the approved table covers six of the legacy host's actions; a parity test over the legacy host's full action surface fails today, so the table is extended per action family (`.2.1`-`.2.7`) and the parity test lands last (`.2.8`). Child slice of `GRAPHOS-HOST-R005`. | Satisfied when all `GRAPHOS-HOST-R005.2.n` children are verified. | | `GRAPHOS-HOST-R005.2.1` | **Approved mapping for the analyze action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_analyze` and its REST twins (code_context, explain, process_writeback, blast_radius, call_graph and the rest), each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | +| `GRAPHOS-HOST-R005.2.1.1` | **Add graph-os operation for code navigation actions.** Gap: no registry operation serves legacy `code_context`, `explain`, `call_graph`, `routes`; add the operation(s) under `graph_os/api/ops/` and never invent an id before it exists. Child slice of `GRAPHOS-HOST-R005.2.1`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.1.2` | **Add graph-os operation for impact actions.** Gap: no registry operation serves legacy `blast_radius`, `change_coupling`; add it under `graph_os/api/ops/`. Child slice of `GRAPHOS-HOST-R005.2.1`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.1.3` | **Add graph-os operation for `similar_code` and the remaining `graph_analyze` actions.** Gap: no registry operation serves `similar_code` or any other `graph_analyze` REST-twin action not listed in sibling children; enumerate them from the legacy tool, then add the operation(s). Child slice of `GRAPHOS-HOST-R005.2.1`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.1.4` | **Add graph-os operation for `process_writeback`.** Gap: no registry operation serves legacy `process_writeback` (the current table maps it to a non-registry id); add the operation and remap. Child slice of `GRAPHOS-HOST-R005.2.1`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.1.5` | **Approve the analyze mappings.** Replace the analyze entries in `APPROVED_ACTION_MAPPING` with the real operation ids from the children above, then the whole family is covered. Child slice of `GRAPHOS-HOST-R005.2.1`. | A test bound to this ID asserts the listed actions appear in `APPROVED_ACTION_MAPPING` and that each mapped operation id is in `{op.id for op in operations()}` from `graph_os/api/ops/*.py`. | | `GRAPHOS-HOST-R005.2.2` | **Approved mapping for the configure action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_configure`, `graph_config` and secret/hook/vault-sync actions, each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | | `GRAPHOS-HOST-R005.2.3` | **Approved mapping for the ingest action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_ingest`, `graph_jobs` and knowledge-pack/corpus/materialize actions, each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | +| `GRAPHOS-HOST-R005.2.3.1` | **Map `sync` to `ingest.sources.sync`.** The only legacy ingest action with an existing registry operation: add `ActionOperationMapping("sync", "ingest.sources.sync")` with a bound resolution test. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the mapping is in the table and a bound test proves the id resolves. | +| `GRAPHOS-HOST-R005.2.3.2` | **Add graph-os operation for ingest submission actions.** Gap: no registry operation serves legacy `ingest`, `ingest_url`, `ingest_knowledge_pack`, `fact_extract`, `distill`; add the operation(s) and remap the existing non-registry `ingest_knowledge_pack` entry. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.3` | **Add graph-os operation for `backfill_platform_history` and `corpus`.** Gap: no registry operation serves these legacy actions. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.4` | **Add graph-os operation for job lifecycle actions.** Gap: no registry operation serves legacy `jobs`, `job_status`, `cancel`, `clear`, `prioritize`. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.5` | **Add graph-os operation for `rebuild_indexes` and `observe`.** Gap: no registry operation serves these legacy actions. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.6` | **Add graph-os operation for `materialize` and `materialize_source`.** Gap: no registry operation serves these legacy actions. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.7` | **Add graph-os operation for `reflect` and `agent_toolkit`.** Gap: no registry operation serves these legacy actions. Child slice of `GRAPHOS-HOST-R005.2.3`. | Satisfied when the operation exists in the registry and a test bound to this ID proves it resolves. | +| `GRAPHOS-HOST-R005.2.3.8` | **Approve the ingest mappings.** Extend `APPROVED_ACTION_MAPPING` with every ingest action using the real ids from the children above, then the whole family is covered. Child slice of `GRAPHOS-HOST-R005.2.3`. | A test bound to this ID asserts the listed actions appear in `APPROVED_ACTION_MAPPING` and that each mapped operation id is in `{op.id for op in operations()}` from `graph_os/api/ops/*.py`. | | `GRAPHOS-HOST-R005.2.4` | **Approved mapping for the query-memory action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_query`, `graph_memory` and `graph_write` memory/recall/SDD actions, each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | | `GRAPHOS-HOST-R005.2.5` | **Approved mapping for the orchestrate action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_orchestrate`, `graph_loops`, `graph_agents`, `graph_goals` and `graph_evolution` actions, each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | | `GRAPHOS-HOST-R005.2.6` | **Approved mapping for the mine-learn action family.** Add an approved `ActionOperationMapping` entry for every legacy MCP action of `graph_mine`, `graph_mine_deep`, `graph_learn` and `graph_evaluate` actions, each naming the GraphOS registry operation that serves it, or record that none exists as a gap to build. Child slice of `GRAPHOS-HOST-R005.2`. | A test asserts every legacy action of the family appears in `APPROVED_ACTION_MAPPING` and that each mapped operation resolves in the GraphOS registry. | diff --git a/specs/host-composition-boundary/status.json b/specs/host-composition-boundary/status.json index 3d53185f..efb65b89 100644 --- a/specs/host-composition-boundary/status.json +++ b/specs/host-composition-boundary/status.json @@ -12,8 +12,21 @@ "GRAPHOS-HOST-R005.1", "GRAPHOS-HOST-R005.2", "GRAPHOS-HOST-R005.2.1", + "GRAPHOS-HOST-R005.2.1.1", + "GRAPHOS-HOST-R005.2.1.2", + "GRAPHOS-HOST-R005.2.1.3", + "GRAPHOS-HOST-R005.2.1.4", + "GRAPHOS-HOST-R005.2.1.5", "GRAPHOS-HOST-R005.2.2", "GRAPHOS-HOST-R005.2.3", + "GRAPHOS-HOST-R005.2.3.1", + "GRAPHOS-HOST-R005.2.3.2", + "GRAPHOS-HOST-R005.2.3.3", + "GRAPHOS-HOST-R005.2.3.4", + "GRAPHOS-HOST-R005.2.3.5", + "GRAPHOS-HOST-R005.2.3.6", + "GRAPHOS-HOST-R005.2.3.7", + "GRAPHOS-HOST-R005.2.3.8", "GRAPHOS-HOST-R005.2.4", "GRAPHOS-HOST-R005.2.5", "GRAPHOS-HOST-R005.2.6", @@ -197,6 +210,48 @@ "title": "Approved mapping for the analyze action family", "delivery_state": "SPECIFIED", "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-HOST-R005.2.1.1", + "GRAPHOS-HOST-R005.2.1.2", + "GRAPHOS-HOST-R005.2.1.3", + "GRAPHOS-HOST-R005.2.1.4", + "GRAPHOS-HOST-R005.2.1.5" + ] + }, + { + "id": "GRAPHOS-HOST-R005.2.1.1", + "title": "Add graph-os operation for code navigation actions", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.1.2", + "title": "Add graph-os operation for impact actions", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.1.3", + "title": "Add graph-os operation for `similar_code` and the remaining `graph_analyze` actions", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.1.4", + "title": "Add graph-os operation for `process_writeback`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.1.5", + "title": "Approve the analyze mappings", + "delivery_state": "SPECIFIED", + "landed_in": [], "verified_by": [] }, { @@ -211,6 +266,72 @@ "title": "Approved mapping for the ingest action family", "delivery_state": "SPECIFIED", "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-HOST-R005.2.3.1", + "GRAPHOS-HOST-R005.2.3.2", + "GRAPHOS-HOST-R005.2.3.3", + "GRAPHOS-HOST-R005.2.3.4", + "GRAPHOS-HOST-R005.2.3.5", + "GRAPHOS-HOST-R005.2.3.6", + "GRAPHOS-HOST-R005.2.3.7", + "GRAPHOS-HOST-R005.2.3.8" + ] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.1", + "title": "Map `sync` to `ingest.sources.sync`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.2", + "title": "Add graph-os operation for ingest submission actions", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.3", + "title": "Add graph-os operation for `backfill_platform_history` and `corpus`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.4", + "title": "Add graph-os operation for job lifecycle actions", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.5", + "title": "Add graph-os operation for `rebuild_indexes` and `observe`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.6", + "title": "Add graph-os operation for `materialize` and `materialize_source`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.7", + "title": "Add graph-os operation for `reflect` and `agent_toolkit`", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-HOST-R005.2.3.8", + "title": "Approve the ingest mappings", + "delivery_state": "SPECIFIED", + "landed_in": [], "verified_by": [] }, { diff --git a/specs/host-composition-boundary/tasks.md b/specs/host-composition-boundary/tasks.md index 448a8d5e..c389a8b6 100644 --- a/specs/host-composition-boundary/tasks.md +++ b/specs/host-composition-boundary/tasks.md @@ -37,8 +37,21 @@ Status vocabulary is defined in [spec.md](spec.md). Check a task only after its - [x] **GRAPHOS-HOST-R005.1:** Landed typed approved action-to-operation mapping model. Evidence: `tests/mcp_server/test_legacy_action_mapping.py`. - [ ] **GRAPHOS-HOST-R005.2:** Parity test proves no served action is dropped (rollup of .2.1-.2.8) - [ ] **GRAPHOS-HOST-R005.2.1:** Approved mapping for the analyze action family +- [ ] **GRAPHOS-HOST-R005.2.1.1:** Add graph-os operation for code navigation actions +- [ ] **GRAPHOS-HOST-R005.2.1.2:** Add graph-os operation for impact actions +- [ ] **GRAPHOS-HOST-R005.2.1.3:** Add graph-os operation for `similar_code` and the remaining `graph_analyze` actions +- [ ] **GRAPHOS-HOST-R005.2.1.4:** Add graph-os operation for `process_writeback` +- [ ] **GRAPHOS-HOST-R005.2.1.5:** Approve the analyze mappings - [ ] **GRAPHOS-HOST-R005.2.2:** Approved mapping for the configure action family - [ ] **GRAPHOS-HOST-R005.2.3:** Approved mapping for the ingest action family +- [ ] **GRAPHOS-HOST-R005.2.3.1:** Map `sync` to `ingest.sources.sync` +- [ ] **GRAPHOS-HOST-R005.2.3.2:** Add graph-os operation for ingest submission actions +- [ ] **GRAPHOS-HOST-R005.2.3.3:** Add graph-os operation for `backfill_platform_history` and `corpus` +- [ ] **GRAPHOS-HOST-R005.2.3.4:** Add graph-os operation for job lifecycle actions +- [ ] **GRAPHOS-HOST-R005.2.3.5:** Add graph-os operation for `rebuild_indexes` and `observe` +- [ ] **GRAPHOS-HOST-R005.2.3.6:** Add graph-os operation for `materialize` and `materialize_source` +- [ ] **GRAPHOS-HOST-R005.2.3.7:** Add graph-os operation for `reflect` and `agent_toolkit` +- [ ] **GRAPHOS-HOST-R005.2.3.8:** Approve the ingest mappings - [ ] **GRAPHOS-HOST-R005.2.4:** Approved mapping for the query-memory action family - [ ] **GRAPHOS-HOST-R005.2.5:** Approved mapping for the orchestrate action family - [ ] **GRAPHOS-HOST-R005.2.6:** Approved mapping for the mine-learn action family From 3dc5a36078687804c2c2041e7936cc3871e75c44 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:22:00 -0500 Subject: [PATCH 03/25] GRAPHOS-OPS-R032.2.1: identity composition-root constructor returning ports build_identity_ports builds the concrete runtime once and returns it typed as CredentialAuthority/SessionAuthority; fails closed with IdentityUnavailable. Spec: GRAPHOS-OPS-R032.2.1 Co-Authored-By: Claude Sonnet 5.5 --- graph_os/identity/composition.py | 58 +++++++++++++++++ tests/identity/test_identity_composition.py | 70 +++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 graph_os/identity/composition.py create mode 100644 tests/identity/test_identity_composition.py diff --git a/graph_os/identity/composition.py b/graph_os/identity/composition.py new file mode 100644 index 00000000..b13f56e0 --- /dev/null +++ b/graph_os/identity/composition.py @@ -0,0 +1,58 @@ +"""Identity composition root: build the concrete runtime once, expose only ports. + +Dependents receive ``CredentialAuthority`` and ``SessionAuthority`` objects and +never import a concrete implementation. Absent authoritative facts or a +malformed authority answer fail closed with ``IdentityUnavailable``. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from .engine import IdentityUnavailable +from .ports import ( + CredentialAuthority, + CredentialState, + SessionAuthority, + SessionState, +) + + +@dataclass(frozen=True, slots=True) +class IdentityPorts: + """The only identity surface handed to dependents.""" + + credentials: CredentialAuthority + sessions: SessionAuthority + + +class _CheckedRuntime: + """Port-typed view over the single concrete runtime; validates answers.""" + + def __init__(self, runtime: Any) -> None: + self._runtime = runtime + + async def resolve_credential(self, credential: str) -> CredentialState: + state = await self._runtime.resolve_credential(credential) + if not isinstance(state, CredentialState): + raise IdentityUnavailable("credential authority returned no state") + return state + + async def resolve_session(self, credential: str) -> SessionState: + state = await self._runtime.resolve_session(credential) + if not isinstance(state, SessionState): + raise IdentityUnavailable("session authority returned no state") + return state + + +def build_identity_ports( + credentials: Any, + sessions: Any, + *, + runtime_factory: Callable[[Any, Any], Any], +) -> IdentityPorts: + """Construct the concrete runtime exactly once and return port-typed views.""" + if credentials is None or sessions is None: + raise IdentityUnavailable("authoritative identity sources required") + runtime = _CheckedRuntime(runtime_factory(credentials, sessions)) + return IdentityPorts(credentials=runtime, sessions=runtime) diff --git a/tests/identity/test_identity_composition.py b/tests/identity/test_identity_composition.py new file mode 100644 index 00000000..c3f49e12 --- /dev/null +++ b/tests/identity/test_identity_composition.py @@ -0,0 +1,70 @@ +"""GRAPHOS-OPS-R032.2.1: identity composition root returns port-typed objects.""" + +import pytest + +from graph_os.identity.composition import IdentityPorts, build_identity_ports +from graph_os.identity.engine import IdentityUnavailable, Resolution +from graph_os.identity.ports import CredentialState, SessionState + +from .test_engine_resolution import resolution_value + + +class _Runtime: + def __init__(self, credential_result=None, session_result=None): + self.credential_result = credential_result + self.session_result = session_result + + async def resolve_credential(self, credential): + return self.credential_result + + async def resolve_session(self, credential): + return self.session_result + + +def _states(): + resolution = Resolution.parse(resolution_value()) + return ( + CredentialState(resolution, 1000), + SessionState(resolution, 1000, "session-binding-ref", None), + ) + + +@pytest.mark.spec("GRAPHOS-OPS-R032.2.1") +async def test_builds_runtime_once_and_returns_ports(): + credential_state, session_state = _states() + built = [] + + def factory(credentials, sessions): + runtime = _Runtime(credential_state, session_state) + built.append((credentials, sessions, runtime)) + return runtime + + ports = build_identity_ports("c", "s", runtime_factory=factory) + + assert isinstance(ports, IdentityPorts) + assert len(built) == 1 and built[0][:2] == ("c", "s") + assert await ports.credentials.resolve_credential("x") is credential_state + assert await ports.sessions.resolve_session("x") is session_state + assert len(built) == 1 + assert not isinstance(ports.credentials, _Runtime) + + +@pytest.mark.spec("GRAPHOS-OPS-R032.2.1") +@pytest.mark.parametrize(("credentials", "sessions"), [(None, "s"), ("c", None)]) +def test_absent_sources_fail_closed_without_building(credentials, sessions): + def factory(*_): + raise AssertionError("runtime must not be built") + + with pytest.raises(IdentityUnavailable): + build_identity_ports(credentials, sessions, runtime_factory=factory) + + +@pytest.mark.spec("GRAPHOS-OPS-R032.2.1") +async def test_wrong_authority_answers_fail_closed(): + ports = build_identity_ports( + "c", "s", runtime_factory=lambda *_: _Runtime({"a": 1}, object()) + ) + with pytest.raises(IdentityUnavailable): + await ports.credentials.resolve_credential("x") + with pytest.raises(IdentityUnavailable): + await ports.sessions.resolve_session("x") From d45ad779b80c1c3cc239c0196187c69323a43843 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:22:02 -0500 Subject: [PATCH 04/25] GRAPHOS-IDENTITY-R008.2: API-key scope intersection and revocation Add expires_at and effective_scopes(): intersect with owner scopes at use time, deny revoked/expired keys, never yield approver scopes. Spec: GRAPHOS-IDENTITY-R008.2 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/identity/api_keys.py | 21 ++++++++++ specs/identity-access/tasks.md | 2 +- tests/identity/test_api_key_use_time.py | 55 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/identity/test_api_key_use_time.py diff --git a/graph_os/identity/api_keys.py b/graph_os/identity/api_keys.py index 7cdb5ca3..4941e5c7 100644 --- a/graph_os/identity/api_keys.py +++ b/graph_os/identity/api_keys.py @@ -6,7 +6,9 @@ from __future__ import annotations +from collections.abc import Collection from dataclasses import dataclass +from datetime import datetime from .engine import IdentityUnavailable @@ -20,6 +22,7 @@ class ApiKeyGrant: key_id: str owner_principal_id: str scopes: frozenset[str] + expires_at: datetime | None = None def __post_init__(self) -> None: if not isinstance(self.key_id, str) or not self.key_id: @@ -31,3 +34,21 @@ def __post_init__(self) -> None: raise IdentityUnavailable( f"API keys may not carry approver-class scope(s): {sorted(rejected)}" ) + + +def effective_scopes( + grant: ApiKeyGrant, + owner_scopes: frozenset[str], + *, + now: datetime, + revoked_key_ids: Collection[str] = (), +) -> frozenset[str]: + """Scopes usable right now: key scopes intersected with the owner's current scopes. + + Fails closed: a revoked or expired key is refused immediately. + """ + if grant.key_id in revoked_key_ids: + raise IdentityUnavailable("API key has been revoked") + if grant.expires_at is not None and now >= grant.expires_at: + raise IdentityUnavailable("API key has expired") + return (grant.scopes & owner_scopes) - _APPROVER_SCOPES diff --git a/specs/identity-access/tasks.md b/specs/identity-access/tasks.md index 397669f1..737d79f7 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -51,7 +51,7 @@ State: READY FOR IMPLEMENTATION. Check a task only with an exact commit and test - [x] **GRAPHOS-IDENTITY-R007.1:** Typed admin-console tab model with refusal tests - [x] **GRAPHOS-IDENTITY-R007.2:** Mapping dry-run preview and mode-transition wizard - [ ] **GRAPHOS-IDENTITY-R008.1:** Typed API-key grant model with refusal tests -- [ ] **GRAPHOS-IDENTITY-R008.2:** Scope intersection at use time and immediate revocation +- [x] **GRAPHOS-IDENTITY-R008.2:** Scope intersection at use time and immediate revocation - [x] **GRAPHOS-IDENTITY-R009:** Multi-provider OIDC with mapping rules and JIT policy - [x] **GRAPHOS-IDENTITY-R009.1:** Typed OIDC mapping-rule model with refusal tests - [ ] **GRAPHOS-IDENTITY-R009.2:** PKCE/state/nonce flow, presets, link migration, hinted logout diff --git a/tests/identity/test_api_key_use_time.py b/tests/identity/test_api_key_use_time.py new file mode 100644 index 00000000..a13b0546 --- /dev/null +++ b/tests/identity/test_api_key_use_time.py @@ -0,0 +1,55 @@ +"""Contract tests for API-key use-time scope intersection and revocation (GRAPHOS-IDENTITY-R008.2).""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from graph_os.identity.api_keys import ApiKeyGrant, effective_scopes +from graph_os.identity.engine import IdentityUnavailable + +NOW = datetime(2026, 10, 10, tzinfo=UTC) + + +def _grant(expires_at: datetime | None = None) -> ApiKeyGrant: + return ApiKeyGrant( + key_id="k1", + owner_principal_id="p1", + scopes=frozenset({"a:read", "b:write"}), + expires_at=expires_at, + ) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R008.2") +def test_scopes_are_intersected_with_current_owner_scopes() -> None: + grant = _grant() + assert effective_scopes(grant, frozenset({"a:read", "c:read"}), now=NOW) == { + "a:read" + } + assert effective_scopes(grant, frozenset(), now=NOW) == frozenset() + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R008.2") +def test_revoked_key_is_denied_immediately() -> None: + with pytest.raises(IdentityUnavailable): + effective_scopes( + _grant(), frozenset({"a:read"}), now=NOW, revoked_key_ids={"k1"} + ) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R008.2") +def test_expired_key_is_denied_and_unexpired_allowed() -> None: + owner = frozenset({"a:read"}) + with pytest.raises(IdentityUnavailable): + effective_scopes(_grant(NOW - timedelta(seconds=1)), owner, now=NOW) + with pytest.raises(IdentityUnavailable): + effective_scopes(_grant(NOW), owner, now=NOW) + assert effective_scopes(_grant(NOW + timedelta(hours=1)), owner, now=NOW) == owner + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R008.2") +def test_approver_scope_never_effective_even_if_owner_holds_it() -> None: + grant = _grant() + field = "scopes" + object.__setattr__(grant, field, frozenset({"a:read", "approver"})) + out = effective_scopes(grant, frozenset({"a:read", "approver"}), now=NOW) + assert out == {"a:read"} From 3dcb050c90357ee0ddbfaa3196f8809e798a2a53 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:23:19 -0500 Subject: [PATCH 05/25] chore(specs): regenerate status for the third 2026-10-10 landing train Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/fleet-catalog-and-tools/status.json | 6 ++++-- specs/hosted-api-operations/status.json | 12 +++++++++--- specs/identity-access/status.json | 15 +++++++++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/specs/fleet-catalog-and-tools/status.json b/specs/fleet-catalog-and-tools/status.json index 3dfe48f5..56729b2e 100644 --- a/specs/fleet-catalog-and-tools/status.json +++ b/specs/fleet-catalog-and-tools/status.json @@ -360,8 +360,10 @@ { "id": "GRAPHOS-FLEET-R006.3.1", "title": "`_probe_protocol_families` harvest calls routed through the cutover helper", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "VERIFIED", + "landed_in": [ + "ddb10c07995e" + ], "verified_by": [ "tests/fleet/test_harvest_inventory.py:128", "tests/fleet/test_harvest_inventory.py:135" diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 9f2062be..19d73b41 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -704,9 +704,15 @@ { "id": "GRAPHOS-OPS-R032.2.1", "title": "Identity runtime constructor in the composition root", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "3dc5a3607868" + ], + "verified_by": [ + "tests/identity/test_identity_composition.py:32", + "tests/identity/test_identity_composition.py:52", + "tests/identity/test_identity_composition.py:62" + ] }, { "id": "GRAPHOS-OPS-R032.2.2", diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index 65db4532..e4813248 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -183,7 +183,7 @@ { "id": "GRAPHOS-IDENTITY-R008", "title": "API keys and service accounts replace static MCP tokens", - "delivery_state": "SPECIFIED", + "delivery_state": "VERIFIED", "landed_in": [ "e74110bb69b5" ], @@ -236,9 +236,16 @@ { "id": "GRAPHOS-IDENTITY-R008.2", "title": "Scope intersection and revocation", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "d45ad779b80c" + ], + "verified_by": [ + "tests/identity/test_api_key_use_time.py:22", + "tests/identity/test_api_key_use_time.py:31", + "tests/identity/test_api_key_use_time.py:39", + "tests/identity/test_api_key_use_time.py:49" + ] }, { "id": "GRAPHOS-IDENTITY-R009.1", From 316ed1e222b00db3c9f3265141fd1675e616577d Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:28:19 -0500 Subject: [PATCH 06/25] Split GRAPHOS-OPS-R032.2.3 into per-module children Injection needs run_web_ui and mcp_server.composition startup wiring that cannot be tested in isolation; split into compose_web_application (.1) and run_web_ui/supervisor threading (.2). Spec: none (refactor) Co-Authored-By: Claude Sonnet 5.5 --- specs/hosted-api-operations/requirements.md | 4 +++- specs/hosted-api-operations/status.json | 22 ++++++++++++++++++++- specs/hosted-api-operations/tasks.md | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/specs/hosted-api-operations/requirements.md b/specs/hosted-api-operations/requirements.md index 0027e341..98e0ed04 100644 --- a/specs/hosted-api-operations/requirements.md +++ b/specs/hosted-api-operations/requirements.md @@ -63,7 +63,9 @@ | `GRAPHOS-OPS-R032.2` | **Injected composition root for identity ports.** GraphOS constructs the concrete identity runtime once in the serving composition root and injects it into webui_host, webui_co_service, and mcp_server exclusively through the graph_os.identity.ports Protocol/DTO types. | An integration test confirms each module receives its identity capability only via an injected graph_os.identity.ports object, never by importing the concrete runtime directly. | | `GRAPHOS-OPS-R032.2.1` | **Identity runtime constructor in the composition root.** GraphOS has one composition-root function that constructs the concrete identity runtime once and returns it typed only as graph_os.identity.ports Protocol objects (CredentialAuthority, SessionAuthority). Edits graph_os/identity and the composition root only, not graph_os/api/ops/registry_factory.py. | A unit test confirms the function returns port-typed objects and builds the runtime exactly once per call of the root, and fails closed with IdentityUnavailable when authoritative facts are absent. | | `GRAPHOS-OPS-R032.2.2` | **Inject identity ports into mcp_server.** graph_os.mcp_server receives its identity capability as an injected graph_os.identity.ports object supplied by the composition root. Depends on GRAPHOS-OPS-R032.2.1 and on the graph_os/api/ops/identity.py and registry_factory.py registration from PR #176 (deliver-ops-r014). | A test confirms mcp_server accepts only a ports-typed object and refuses to start without one. | -| `GRAPHOS-OPS-R032.2.3` | **Inject identity ports into webui_host and webui_co_service.** graph_os.webui_host and graph_os.webui_host.webui_co_service (compose_web_application) receive their identity capability as an injected graph_os.identity.ports object supplied by the composition root. Depends on GRAPHOS-OPS-R032.2.1. | A test confirms both modules accept only a ports-typed object and refuse to compose without one. | +| `GRAPHOS-OPS-R032.2.3` | **Inject identity ports into webui_host and webui_co_service (rollup).** graph_os.webui_host and graph_os.webui_host.webui_co_service (compose_web_application) receive their identity capability as an injected graph_os.identity.ports object supplied by the composition root. Rollup of GRAPHOS-OPS-R032.2.3.1 and GRAPHOS-OPS-R032.2.3.2. Depends on GRAPHOS-OPS-R032.2.1. | Both children are delivered and their bound tests pass. | +| `GRAPHOS-OPS-R032.2.3.1` | **compose_web_application requires injected identity ports.** graph_os.webui_host.webui_co_service.compose_web_application takes an `IdentityPorts` argument from graph_os.identity.composition and raises a typed refusal when it is absent or not ports-typed; it imports no graph_os.identity submodule other than ports/composition types. Edits webui_co_service.py only. Depends on GRAPHOS-OPS-R032.2.1. | A test confirms compose_web_application accepts only an IdentityPorts object and refuses to compose without one. | +| `GRAPHOS-OPS-R032.2.3.2` | **Thread identity ports through run_web_ui and the webui_host package.** run_web_ui and graph_os.webui_host receive the composition root's IdentityPorts and bind them into the application composer passed to the WebUI factory; graph_os.mcp_server.composition passes the ports when starting the agent-webui co-service. Depends on GRAPHOS-OPS-R032.2.3.1 and GRAPHOS-OPS-R032.2.2. | A test confirms run_web_ui refuses to start without ports and the supervisor start call supplies them. | | `GRAPHOS-OPS-R032.2.4` | **Integration test of single injected identity runtime.** An integration test builds the composition root once and confirms mcp_server, webui_host, and webui_co_service all receive the same ports-typed identity object and none imports the concrete runtime. Depends on GRAPHOS-OPS-R032.2.2 and GRAPHOS-OPS-R032.2.3. | The integration test passes against the real composition root. | | `GRAPHOS-OPS-R033` | **Dynamic multiplexer discovery, load, and call.** GraphOS implements find_tools (with browse=true replacing the former catalog listing), load_tools, unload_tools, and multiplexer_status over one per-caller-filtered catalog spanning fleet tools, prompts, resources, skills, and connector items, enforcing a default load cap of 64 (hard maximum 256), one-hour idle expiry, opt-in LRU eviction, and routing every loaded tool's body through a fleet call with the caller's delegated credential. | Integration tests confirm the load cap, idle expiry, and that each loaded tool call is dispatched through the fleet-call path rather than directly. | | `GRAPHOS-OPS-R034` | **Eunomia narrowing-only policy enforcement.** GraphOS applies its policy engine as a narrowing-only step at discovery, load, and every call, always leaving exact-scope filtering in place when policy is off, failing closed when policy is enabled but unreachable, defaulting to on for local and external deployments and off only for the none deployment mode, with its doctor check warning when policy is disabled. | Integration tests cover the off, on, and unreachable policy states, and a doctor-check test confirms the warning fires when policy is off. | diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 19d73b41..9aac3b23 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -66,6 +66,8 @@ "GRAPHOS-OPS-R032.2.1", "GRAPHOS-OPS-R032.2.2", "GRAPHOS-OPS-R032.2.3", + "GRAPHOS-OPS-R032.2.3.1", + "GRAPHOS-OPS-R032.2.3.2", "GRAPHOS-OPS-R032.2.4", "GRAPHOS-OPS-R033", "GRAPHOS-OPS-R034", @@ -723,7 +725,25 @@ }, { "id": "GRAPHOS-OPS-R032.2.3", - "title": "Inject identity ports into webui_host and webui_co_service", + "title": "Inject identity ports into webui_host and webui_co_service (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-OPS-R032.2.3.1", + "GRAPHOS-OPS-R032.2.3.2" + ] + }, + { + "id": "GRAPHOS-OPS-R032.2.3.1", + "title": "compose_web_application requires injected identity ports", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R032.2.3.2", + "title": "Thread identity ports through run_web_ui and the webui_host package", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/hosted-api-operations/tasks.md b/specs/hosted-api-operations/tasks.md index 6ce0892b..b6404b4e 100644 --- a/specs/hosted-api-operations/tasks.md +++ b/specs/hosted-api-operations/tasks.md @@ -60,6 +60,8 @@ Status: **READY FOR IMPLEMENTATION**. Delivery: **NOT ACCEPTED**. See [design](p - [ ] **GRAPHOS-OPS-R032.2.1:** Remaining scope of GRAPHOS-OPS-R032.2: Identity runtime constructor in the composition root. - [ ] **GRAPHOS-OPS-R032.2.2:** Remaining scope of GRAPHOS-OPS-R032.2: Inject identity ports into mcp_server. - [ ] **GRAPHOS-OPS-R032.2.3:** Remaining scope of GRAPHOS-OPS-R032.2: Inject identity ports into webui_host and webui_co_service. +- [ ] **GRAPHOS-OPS-R032.2.3.1:** Remaining scope of GRAPHOS-OPS-R032.2.3: compose_web_application requires injected identity ports. +- [ ] **GRAPHOS-OPS-R032.2.3.2:** Remaining scope of GRAPHOS-OPS-R032.2.3: Thread identity ports through run_web_ui and the webui_host package. - [ ] **GRAPHOS-OPS-R032.2.4:** Remaining scope of GRAPHOS-OPS-R032.2: Integration test of single injected identity runtime. - [ ] **GRAPHOS-OPS-R035:** Multiplexer registration reduced to four resident tools - [ ] **GRAPHOS-OPS-R035.1:** Remaining scope of GRAPHOS-OPS-R035 (slice .1): Multiplexer registration reduced to four resident tools From 567a2b417ed7cd0cd761dd1d1b60f9d9a4e74a97 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:28:25 -0500 Subject: [PATCH 07/25] Map legacy sync action to ingest.sources.sync Adds the approved mapping and a bound test that the operation id exists in the ingest registry. Spec: GRAPHOS-HOST-R005.2.3.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/mcp_server/legacy_action_mapping.py | 1 + .../test_legacy_action_mapping_ingest_sync.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/mcp_server/test_legacy_action_mapping_ingest_sync.py diff --git a/graph_os/mcp_server/legacy_action_mapping.py b/graph_os/mcp_server/legacy_action_mapping.py index b90c394e..14601b33 100644 --- a/graph_os/mcp_server/legacy_action_mapping.py +++ b/graph_os/mcp_server/legacy_action_mapping.py @@ -82,5 +82,6 @@ def validate_served_actions( ActionOperationMapping("vault_sync", "secret_vault.vault_sync"), ActionOperationMapping("install_hooks", "graph_loops.install_hooks"), ActionOperationMapping("uninstall_hooks", "graph_loops.uninstall_hooks"), + ActionOperationMapping("sync", "ingest.sources.sync"), ) ) diff --git a/tests/mcp_server/test_legacy_action_mapping_ingest_sync.py b/tests/mcp_server/test_legacy_action_mapping_ingest_sync.py new file mode 100644 index 00000000..a34ffdf8 --- /dev/null +++ b/tests/mcp_server/test_legacy_action_mapping_ingest_sync.py @@ -0,0 +1,14 @@ +"""R005.2.3.1: legacy ``sync`` resolves to a real ingest registry operation.""" + +from __future__ import annotations + +import pytest + +from graph_os.api.ops import ingest +from graph_os.mcp_server.legacy_action_mapping import APPROVED_ACTION_MAPPING + + +@pytest.mark.spec("GRAPHOS-HOST-R005.2.3.1") +def test_sync_maps_to_registered_ingest_operation() -> None: + assert APPROVED_ACTION_MAPPING["sync"] == "ingest.sources.sync" + assert APPROVED_ACTION_MAPPING["sync"] in {op.id for op in ingest.operations()} From 41451e85b62e93328359cca6d06ecf6c2a8d913e Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:28:27 -0500 Subject: [PATCH 08/25] GRAPHOS-MCP-RESOURCES-R003: split into guard (.1) and publish-step wiring (.2) The R022/PA-12 publish step is not built in graph-os, so there is no entry point to wire. R003 becomes a rollup with two bite-sized children. Spec: none (refactor) Co-Authored-By: Claude Sonnet 5.5 --- .../requirements.md | 4 +++- .../spec.md | 6 +++-- .../status.json | 22 ++++++++++++++++++- .../tasks.md | 6 +++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/specs/mcp-resource-publication-reconciliation/requirements.md b/specs/mcp-resource-publication-reconciliation/requirements.md index 9b9a7bb4..91227863 100644 --- a/specs/mcp-resource-publication-reconciliation/requirements.md +++ b/specs/mcp-resource-publication-reconciliation/requirements.md @@ -5,5 +5,7 @@ | `GRAPHOS-MCP-RESOURCES-R001` | **Publication gate refuses without a valid receipt (rollup).** graph-os's MCP resource/template publication path never claims a candidate generation is published unless a `ReconciliationReceipt` exists, matches that exact candidate, and is fresh; otherwise it returns the typed `reingestion-unreconciled` result. Delivered through its child requirement, producer first. | Covered by `GRAPHOS-MCP-RESOURCES-R001.1` plus `GRAPHOS-MCP-RESOURCES-R003` (not yet specified). | | `GRAPHOS-MCP-RESOURCES-R001.1` | **Typed `ReconciliationReceipt` model and publication gate.** `graph_os/fleet/mcp_resource_reconciliation.py` defines a frozen `ReconciliationReceipt` and `gate_mcp_resource_publication()` returning a typed `PublicationGateResult`: `"reconciled"` only for a receipt matching the exact tenant, `catalog_generation`, `snapshot_digest`, and all four families (`tools`, `prompts`, `resources`, `resource_templates`) within a freshness bound; `"reingestion-unreconciled"` with a stable `reason` (`missing`, `stale`, `invalid`) otherwise. | `tests/fleet/test_mcp_resource_reconciliation.py` covers missing, stale, digest-mismatched, generation-mismatched, partial-family, and wrong-tenant receipts all returning `reingestion-unreconciled`, and a fully matching fresh receipt returning `reconciled`. | | `GRAPHOS-MCP-RESOURCES-R002` | **epistemic-graph is the sole admissible receipt source.** The gate may only treat as reconciling a receipt epistemic-graph issued after durably committing the candidate's four-family projection; graph-os never synthesizes or locally fabricates a passing receipt. Producing contract: [`EG-REPO-INGEST-001`](https://github.com/Knuckles-Team/epistemic-graph/blob/main/specs/repository-index-and-ingestion/spec.md) (owned by epistemic-graph). | Cross-repository link verification: this spec's `spec.md` cites the exact EG spec ID and URL; no graph-os code path constructs a `ReconciliationReceipt` locally outside of test fixtures. | -| `GRAPHOS-MCP-RESOURCES-R003` | **Gate wired at the one catalog-publish entry point.** The fleet catalog's atomic generation-publish step (GRAPHOS-FLEET-001 R022/PA-12) calls `gate_mcp_resource_publication()` before swapping the active generation pointer for the four MCP families; only an all-families `"reconciled"` result allows the swap. | Integration test drives the publish step with a missing/stale/invalid receipt and confirms the swap is refused and the typed result is surfaced; a matching receipt allows the swap. Not built in this slice. | +| `GRAPHOS-MCP-RESOURCES-R003` | **Gate wired at the one catalog-publish entry point (rollup).** The fleet catalog's atomic generation-publish step (`fleet-catalog-and-tools` R022/PA-12) calls `gate_mcp_resource_publication()` before swapping the active generation pointer for the four MCP families; only an all-families `"reconciled"` result allows the swap. Split because the publish step it must call is not yet built in graph-os (`fleet-catalog-and-tools` R022/PA-12 is still BUILDING, so there is no entry point to wire). Delivered through its children. | Covered by `GRAPHOS-MCP-RESOURCES-R003.1` and `GRAPHOS-MCP-RESOURCES-R003.2`. | +| `GRAPHOS-MCP-RESOURCES-R003.1` | **Swap guard function.** `graph_os/fleet/mcp_resource_reconciliation.py` adds `require_reconciled_for_swap()`, which wraps `gate_mcp_resource_publication()` and raises a typed `PublicationRefusedError` carrying the `PublicationGateResult` for any `reingestion-unreconciled` result, and returns the receipt only for `reconciled`. The publish step can then call one function that cannot be bypassed by ignoring a return value. | Unit tests: missing, stale, and invalid receipts raise `PublicationRefusedError` with the typed result attached and the stable `reason`; a matching fresh receipt returns the receipt. | +| `GRAPHOS-MCP-RESOURCES-R003.2` | **Call the guard from the publish step (depends on `fleet-catalog-and-tools` R022/PA-12 landing in graph-os).** The atomic generation-publish step calls `require_reconciled_for_swap()` before swapping the active generation pointer for the four families, leaves the last-known-good generation active on refusal, and surfaces the typed result in the reload outcome. | Integration test drives the publish step with missing/stale/invalid receipts and confirms the swap is refused with the typed result surfaced; a matching receipt allows the swap. | | `GRAPHOS-MCP-RESOURCES-R004` | **Status docs are the gate's result, not hand-maintained prose.** `docs/status.md`'s "Explicitly unavailable surfaces" row and `docs/fleet.md`'s reconciliation paragraph describe exactly the `reingestion-unreconciled` result the gate returns. | A docs-consistency check (or review note) confirms the status/reason vocabulary in both docs matches `graph_os/fleet/mcp_resource_reconciliation.py` exactly. Not built in this slice. | diff --git a/specs/mcp-resource-publication-reconciliation/spec.md b/specs/mcp-resource-publication-reconciliation/spec.md index a8e769c3..4e83d7b8 100644 --- a/specs/mcp-resource-publication-reconciliation/spec.md +++ b/specs/mcp-resource-publication-reconciliation/spec.md @@ -5,7 +5,7 @@ **Owner:** graph-os **State:** READY FOR IMPLEMENTATION — architecture and acceptance specified; no claim that the full surface is deployed or accepted. -**Scope IDs:** GRAPHOS-MCP-RESOURCES-R001, GRAPHOS-MCP-RESOURCES-R001.1, GRAPHOS-MCP-RESOURCES-R002, GRAPHOS-MCP-RESOURCES-R003, GRAPHOS-MCP-RESOURCES-R004. +**Scope IDs:** GRAPHOS-MCP-RESOURCES-R001, GRAPHOS-MCP-RESOURCES-R001.1, GRAPHOS-MCP-RESOURCES-R002, GRAPHOS-MCP-RESOURCES-R003, GRAPHOS-MCP-RESOURCES-R003.1, GRAPHOS-MCP-RESOURCES-R003.2, GRAPHOS-MCP-RESOURCES-R004. ## Outcome and state legend @@ -51,7 +51,9 @@ step specifically. | `GRAPHOS-MCP-RESOURCES-R001` | **Publication gate refuses without a valid receipt (rollup).** graph-os's MCP resource/template publication path never claims a candidate generation is published unless a `ReconciliationReceipt` exists, matches that exact candidate, and is fresh; otherwise it returns the typed `reingestion-unreconciled` result. Delivered through its child requirement, producer first. | | `GRAPHOS-MCP-RESOURCES-R001.1` | **Typed `ReconciliationReceipt` model and publication gate (this PR).** `graph_os/fleet/mcp_resource_reconciliation.py` defines a frozen `ReconciliationReceipt` (tenant, candidate `catalog_generation`, `snapshot_digest`, acknowledged family set, issue time) and `gate_mcp_resource_publication()`, a pure function returning a typed `PublicationGateResult` whose `status` is `"reconciled"` only for a receipt matching the exact tenant, generation, digest, and all four families within a freshness bound, and `"reingestion-unreconciled"` (with a stable `reason` of `missing`, `stale`, or `invalid`) for every other case. No caller is wired to it yet (`GRAPHOS-MCP-RESOURCES-R003`, not yet specified in this slice). | | `GRAPHOS-MCP-RESOURCES-R002` | **epistemic-graph is the sole admissible receipt source.** The only receipt the gate may treat as reconciling is one epistemic-graph issues once the durable graph has actually committed the candidate's four-family projection; graph-os does not synthesize, cache past expiry, or otherwise locally fabricate a passing receipt. The producing contract is owned by epistemic-graph as [`EG-REPO-INGEST-001`](https://github.com/Knuckles-Team/epistemic-graph/blob/main/specs/repository-index-and-ingestion/spec.md) (`specs/repository-index-and-ingestion` in that repository); this spec consumes that ID and does not redefine it. | -| `GRAPHOS-MCP-RESOURCES-R003` | **Gate wired at the one catalog-publish entry point.** The fleet catalog's atomic generation-publish step (`fleet-catalog-and-tools` R022/PA-12) calls `gate_mcp_resource_publication()` before it swaps the active generation pointer for the four MCP families, and only an all-families `"reconciled"` result allows the swap. Not built in this slice. | +| `GRAPHOS-MCP-RESOURCES-R003` | **Gate wired at the one catalog-publish entry point (rollup).** The fleet catalog's atomic generation-publish step (`fleet-catalog-and-tools` R022/PA-12) calls `gate_mcp_resource_publication()` before swapping the active generation pointer for the four MCP families; only an all-families `"reconciled"` result allows the swap. Split because the publish step it must call is not yet built in graph-os (`fleet-catalog-and-tools` R022/PA-12 is still BUILDING, so there is no entry point to wire). Delivered through its children. | +| `GRAPHOS-MCP-RESOURCES-R003.1` | **Swap guard function.** `graph_os/fleet/mcp_resource_reconciliation.py` adds `require_reconciled_for_swap()`, which wraps `gate_mcp_resource_publication()` and raises a typed `PublicationRefusedError` carrying the `PublicationGateResult` for any `reingestion-unreconciled` result, and returns the receipt only for `reconciled`. The publish step can then call one function that cannot be bypassed by ignoring a return value. Not built in this slice. | +| `GRAPHOS-MCP-RESOURCES-R003.2` | **Call the guard from the publish step (depends on `fleet-catalog-and-tools` R022/PA-12 landing in graph-os).** The atomic generation-publish step calls `require_reconciled_for_swap()` before swapping the active generation pointer for the four families, leaves the last-known-good generation active on refusal, and surfaces the typed result in the reload outcome. Not built in this slice. | | `GRAPHOS-MCP-RESOURCES-R004` | **Status docs are the gate's result, not hand-maintained prose.** `docs/status.md`'s "Explicitly unavailable surfaces" row and `docs/fleet.md`'s reconciliation paragraph describe exactly the `reingestion-unreconciled` result this gate returns; a change to the gate's status/reason vocabulary requires updating both in the same change. Not built in this slice. | ## Reuse and non-goals diff --git a/specs/mcp-resource-publication-reconciliation/status.json b/specs/mcp-resource-publication-reconciliation/status.json index b8e80585..5d5c921d 100644 --- a/specs/mcp-resource-publication-reconciliation/status.json +++ b/specs/mcp-resource-publication-reconciliation/status.json @@ -8,6 +8,8 @@ "GRAPHOS-MCP-RESOURCES-R001.1", "GRAPHOS-MCP-RESOURCES-R002", "GRAPHOS-MCP-RESOURCES-R003", + "GRAPHOS-MCP-RESOURCES-R003.1", + "GRAPHOS-MCP-RESOURCES-R003.2", "GRAPHOS-MCP-RESOURCES-R004" ], "requirements": [ @@ -41,7 +43,25 @@ }, { "id": "GRAPHOS-MCP-RESOURCES-R003", - "title": "Gate wired at the one catalog-publish entry point", + "title": "Gate wired at the one catalog-publish entry point (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-MCP-RESOURCES-R003.1", + "GRAPHOS-MCP-RESOURCES-R003.2" + ] + }, + { + "id": "GRAPHOS-MCP-RESOURCES-R003.1", + "title": "Swap guard function", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-MCP-RESOURCES-R003.2", + "title": "Call the guard from the publish step (depends on `fleet-catalog-and-tools` R022/PA-12 landing in graph-os)", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/mcp-resource-publication-reconciliation/tasks.md b/specs/mcp-resource-publication-reconciliation/tasks.md index 4651a27d..28f70cec 100644 --- a/specs/mcp-resource-publication-reconciliation/tasks.md +++ b/specs/mcp-resource-publication-reconciliation/tasks.md @@ -13,8 +13,10 @@ the final release task records served evidence. - [ ] T03 — Confirm and link the epistemic-graph producing contract (`EG-REPO-INGEST-001`) in `spec.md`/`requirements.md`. Cover `GRAPHOS-MCP-RESOURCES-R002`. (Link added this PR; EG-side delivery is tracked in epistemic-graph's own spec, not here.) -- [ ] T04 — Wire `gate_mcp_resource_publication()` into the GRAPHOS-FLEET-001 atomic generation- - publish step for the four MCP families. Cover `GRAPHOS-MCP-RESOURCES-R003`. +- [ ] T04 — Add `require_reconciled_for_swap()` and `PublicationRefusedError` with refusal tests. + Cover `GRAPHOS-MCP-RESOURCES-R003.1`. +- [ ] T04.1 — Call the guard from the GRAPHOS-FLEET-001 atomic generation-publish step once + `fleet-catalog-and-tools` R022/PA-12 lands. Cover `GRAPHOS-MCP-RESOURCES-R003.2`. - [ ] T05 — Reconcile `docs/status.md` / `docs/fleet.md` wording against the gate's actual status/reason vocabulary. Cover `GRAPHOS-MCP-RESOURCES-R004`. - [ ] T06 — Land reviewed code and record merge commits per slice in `spec.md`; do not mark From 63762147f0bc329a26a474f9cca1e61c959670fd Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:28:34 -0500 Subject: [PATCH 09/25] GRAPHOS-A2A-002-R005: bind no-advisory/cached/partial admission test admit_tool_subset already refuses closed; add a negative test across varied graph refs and budgets proving no trimmed admission is returned. Spec: GRAPHOS-A2A-002-R005 Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/graphos-a2a-002/status.json | 4 +++- tests/a2a/test_admission.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/specs/graphos-a2a-002/status.json b/specs/graphos-a2a-002/status.json index faf243d9..12c9a3dd 100644 --- a/specs/graphos-a2a-002/status.json +++ b/specs/graphos-a2a-002/status.json @@ -66,7 +66,9 @@ "title": "No advisory, cached, or partial substitute", "delivery_state": "SPECIFIED", "landed_in": [], - "verified_by": [] + "verified_by": [ + "tests/a2a/test_admission.py:52" + ] }, { "id": "GRAPHOS-A2A-002-R006", diff --git a/tests/a2a/test_admission.py b/tests/a2a/test_admission.py index b6197826..f443fdb5 100644 --- a/tests/a2a/test_admission.py +++ b/tests/a2a/test_admission.py @@ -47,3 +47,20 @@ def test_admission_decision_always_carries_provenance_and_typed_reason() -> None "EG_ASSEMBLE_UNAVAILABLE", "ENVELOPE_LACKS_ALLOWED_TOOL_SUBSET", ) + + +@pytest.mark.spec("GRAPHOS-A2A-002-R005") +def test_admission_never_returns_advisory_cached_or_partial_subset() -> None: + """GRAPHOS-A2A-002-R005: an incomplete upstream answer is a refusal.""" + + for ref, tokens in (("graph-a", 4_096), ("graph-a", 65_536), ("graph-b", 4_096)): + decision = admit_tool_subset( + ToolSubsetAdmissionRequest( + agent_graph_ref=ref, + context_budget=A2AContextBudget(tokens=tokens), + ) + ) + + assert decision.admitted is False + assert decision.admitted_tools == () + assert decision.refusal_reason is not None From 16c9b2bddda3b49a5a34c211dd48738b2c5bab44 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:30:06 -0500 Subject: [PATCH 10/25] GRAPHOS-OPS-R020.3.1.1: add ingest.packs.list op, split R020.3.1 Split R020.3.1 into .1 (ingest.packs.list) and .2 (ingest.jobs.status) and deliver .1 through the typed ingest runner port with the ingest:read scope. Spec: GRAPHOS-OPS-R020.3.1.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/api/ops/ingest.py | 21 ++++++++++++ graph_os/ingest/service.py | 27 +++++++++++++++ specs/hosted-api-operations/requirements.md | 4 ++- specs/hosted-api-operations/status.json | 38 +++++++++++++++++---- specs/hosted-api-operations/tasks.md | 2 ++ tests/api/test_ingest_ops.py | 38 +++++++++++++++++++++ 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/graph_os/api/ops/ingest.py b/graph_os/api/ops/ingest.py index 29ffa144..3b1feb28 100644 --- a/graph_os/api/ops/ingest.py +++ b/graph_os/api/ops/ingest.py @@ -27,6 +27,7 @@ from graph_os.ingest.service import ( get_source_status, index_repository, + list_packs, list_sources, sync_source, ) @@ -40,6 +41,7 @@ "operations", "get_source_status", "index_repository", + "list_packs", "list_sources", "sync_source", ] @@ -82,6 +84,14 @@ class IngestSourceStatusResult(_Params): value: dict[str, str] +class IngestPacksListParams(_Params): + pass + + +class IngestPackListResult(_Params): + value: dict[str, object] + + def operations() -> tuple[OpSpec, ...]: return ( OpSpec( @@ -132,4 +142,15 @@ def operations() -> tuple[OpSpec, ...]: scopes=frozenset({"ingest:read"}), effect=Effect.READ, ), + OpSpec( + id="ingest.packs.list", + verb=Verb.FIND, + summary="List this tenant's ingestion packs", + examples=("list my ingestion packs",), + params=IngestPacksListParams, + result=IngestPackListResult, + binding=Composite(handler="graph_os.api.ops.ingest.list_packs"), + scopes=frozenset({"ingest:read"}), + effect=Effect.READ, + ), ) diff --git a/graph_os/ingest/service.py b/graph_os/ingest/service.py index 007c134c..06599ed0 100644 --- a/graph_os/ingest/service.py +++ b/graph_os/ingest/service.py @@ -66,6 +66,24 @@ class IngestSourceInventory(BaseModel): sources: tuple[IngestSourceRecord, ...] +class IngestPackRecord(BaseModel): + """One ingestion pack's identity and current state.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + pack_id: str + tenant: str + state: IngestSourceState + + +class IngestPackInventory(BaseModel): + """A tenant's full ingestion-pack inventory.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + packs: tuple[IngestPackRecord, ...] + + @runtime_checkable class IngestRunner(Protocol): """The one typed port GraphOS calls the ingestion SDK through. @@ -90,6 +108,8 @@ async def get_source_status( self, *, tenant: str, source_id: str ) -> IngestSourceRecord: ... + async def list_packs(self, *, tenant: str) -> IngestPackInventory: ... + def _bound_runner(context: Any) -> IngestRunner: runner = context.services.get("ingest_runner") @@ -142,3 +162,10 @@ async def get_source_status(context: Any, params: Mapping[str, Any], op: Any) -> tenant=context.caller.tenant, source_id=params["source_id"] ) return {"value": record.model_dump(mode="json")} + + +async def list_packs(context: Any, params: Mapping[str, Any], op: Any) -> Any: + """List this tenant's ingestion packs through the composed runner.""" + runner = _bound_runner(context) + inventory = await runner.list_packs(tenant=context.caller.tenant) + return {"value": inventory.model_dump(mode="json")} diff --git a/specs/hosted-api-operations/requirements.md b/specs/hosted-api-operations/requirements.md index 0027e341..dcd689be 100644 --- a/specs/hosted-api-operations/requirements.md +++ b/specs/hosted-api-operations/requirements.md @@ -31,7 +31,9 @@ | `GRAPHOS-OPS-R020.1` | **Typed ingest runner port and the source-sync entry point.** GraphOS defines the one typed port it calls the ingestion SDK through and exposes `ingest.sources.sync`, failing closed with `UNAVAILABLE` when no runner is composed. | A test proves a durable job receipt on success and a typed `UNAVAILABLE` refusal when the runner is absent. | | `GRAPHOS-OPS-R020.2` | **Repository indexing and source inventory operations.** GraphOS exposes `ingest.repositories.index` and `ingest.sources.{list,status}` through the same typed runner port. | Integration tests confirm each operation reaches the runner and fails closed with `UNAVAILABLE` when the runner is absent. | | `GRAPHOS-OPS-R020.3` | **Pack and job management operations.** GraphOS exposes `ingest.packs.*` and `ingest.jobs.*` through the same typed runner port. | Integration tests confirm pack and job operations reach the runner and report durable job state. | -| `GRAPHOS-OPS-R020.3.1` | **Missing `ingest.packs.*` and `ingest.jobs.*` ops.** `GRAPHOS-OPS-R020.3` was marked LANDED by a commit whose scope note said R020.3 through R020.5 remain SPECIFIED; no `ingest.packs.*` or `ingest.jobs.*` operation actually exists through the typed runner port today. | Integration tests confirm `ingest.packs.*` and `ingest.jobs.*` reach the runner and report durable job state, and fail closed with `UNAVAILABLE` when the runner is absent. | +| `GRAPHOS-OPS-R020.3.1` | **Missing `ingest.packs.*` and `ingest.jobs.*` ops.** Delivered as the rollup of `GRAPHOS-OPS-R020.3.1.1` through `GRAPHOS-OPS-R020.3.1.2`. `GRAPHOS-OPS-R020.3` was marked LANDED by a commit whose scope note said R020.3 through R020.5 remain SPECIFIED; no `ingest.packs.*` or `ingest.jobs.*` operation actually exists through the typed runner port today. | Integration tests confirm `ingest.packs.*` and `ingest.jobs.*` reach the runner and report durable job state, and fail closed with `UNAVAILABLE` when the runner is absent. | +| `GRAPHOS-OPS-R020.3.1.1` | **`ingest.packs.list` op.** GraphOS exposes `ingest.packs.list` as a read operation through the typed ingest runner port, listing this tenant's ingestion packs. | Tests confirm the op reaches the runner with the caller's tenant, declares the existing `ingest:read` scope, and fails closed with `UNAVAILABLE` when the runner is absent. | +| `GRAPHOS-OPS-R020.3.1.2` | **`ingest.jobs.status` op.** GraphOS exposes `ingest.jobs.status` as a read operation through the typed ingest runner port, reporting one durable job's state. Depends on `GRAPHOS-OPS-R020.3.1.1` for the shared pack/job port conventions. | Tests confirm the op reaches the runner, reports durable job state, declares `ingest:read`, and fails closed with `UNAVAILABLE` when the runner is absent. | | `GRAPHOS-OPS-R020.4` | **Drift listing and repair-with-approval operations.** GraphOS exposes `ingest.drift.{list,get,repair_propose,repair_approve}`, gating repair behind an approval reference. | Integration tests confirm drift repair previews and audits its effect before applying it. | | `GRAPHOS-OPS-R020.5` | **Embedding admission and re-embedding operations.** GraphOS exposes `ingest.embedding.{admit,reembed}` through the same typed runner port. | Integration tests confirm embedding operations reach the runner and report durable job state. | | `GRAPHOS-OPS-R021` | **Decision, retrieval, and policy operations.** GraphOS exposes decide, retrieval, context, freshness, policy, and swarm read operations while restricting decision-commit actions to the service executor, so no caller can materialize an agent decision using only a human-scoped token. | Integration tests confirm decision commits are service-only and that provenance is attached to each result. | diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 9f2062be..2208cea8 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -34,6 +34,8 @@ "GRAPHOS-OPS-R020.2", "GRAPHOS-OPS-R020.3", "GRAPHOS-OPS-R020.3.1", + "GRAPHOS-OPS-R020.3.1.1", + "GRAPHOS-OPS-R020.3.1.2", "GRAPHOS-OPS-R020.4", "GRAPHOS-OPS-R020.5", "GRAPHOS-OPS-R021", @@ -350,8 +352,8 @@ "cc90ee37583f" ], "verified_by": [ - "tests/api/test_ingest_ops.py:80", - "tests/api/test_ingest_ops.py:99" + "tests/api/test_ingest_ops.py:109", + "tests/api/test_ingest_ops.py:90" ] }, { @@ -366,11 +368,11 @@ "cc90ee37583f" ], "verified_by": [ - "tests/api/test_ingest_ops.py:121", - "tests/api/test_ingest_ops.py:137", - "tests/api/test_ingest_ops.py:151", - "tests/api/test_ingest_ops.py:164", - "tests/api/test_ingest_ops.py:174" + "tests/api/test_ingest_ops.py:131", + "tests/api/test_ingest_ops.py:147", + "tests/api/test_ingest_ops.py:161", + "tests/api/test_ingest_ops.py:174", + "tests/api/test_ingest_ops.py:184" ] }, { @@ -390,6 +392,28 @@ "title": "Missing `ingest.packs.*` and `ingest.jobs.*` ops", "delivery_state": "SPECIFIED", "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-OPS-R020.3.1.1", + "GRAPHOS-OPS-R020.3.1.2" + ] + }, + { + "id": "GRAPHOS-OPS-R020.3.1.1", + "title": "`ingest.packs.list` op", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [ + "tests/api/test_ingest_ops.py:221", + "tests/api/test_ingest_ops.py:233", + "tests/api/test_ingest_ops.py:242" + ] + }, + { + "id": "GRAPHOS-OPS-R020.3.1.2", + "title": "`ingest.jobs.status` op", + "delivery_state": "SPECIFIED", + "landed_in": [], "verified_by": [] }, { diff --git a/specs/hosted-api-operations/tasks.md b/specs/hosted-api-operations/tasks.md index 6ce0892b..5e8a7335 100644 --- a/specs/hosted-api-operations/tasks.md +++ b/specs/hosted-api-operations/tasks.md @@ -49,6 +49,8 @@ Status: **READY FOR IMPLEMENTATION**. Delivery: **NOT ACCEPTED**. See [design](p - [x] **GRAPHOS-OPS-R020.2:** Repository indexing and source inventory operations - [x] **GRAPHOS-OPS-R020.3:** Pack and job management operations - [ ] **GRAPHOS-OPS-R020.3.1:** Remaining scope of GRAPHOS-OPS-R020.3 (slice .1): Pack and job management operations +- [ ] **GRAPHOS-OPS-R020.3.1.1:** `ingest.packs.list` op +- [ ] **GRAPHOS-OPS-R020.3.1.2:** `ingest.jobs.status` op - [ ] **GRAPHOS-OPS-R020.4:** Drift listing and repair-with-approval operations - [ ] **GRAPHOS-OPS-R020.5:** Embedding admission and re-embedding operations - [ ] **GRAPHOS-OPS-R020.3.1:** Remaining scope of GRAPHOS-OPS-R020.3 (slice .1): Pack and job management operations diff --git a/tests/api/test_ingest_ops.py b/tests/api/test_ingest_ops.py index d63ee292..dd526859 100644 --- a/tests/api/test_ingest_ops.py +++ b/tests/api/test_ingest_ops.py @@ -14,12 +14,15 @@ from graph_os.api.ops.ingest import ( get_source_status, index_repository, + list_packs, list_sources, operations, sync_source, ) from graph_os.ingest.service import ( IngestIndexReceipt, + IngestPackInventory, + IngestPackRecord, IngestSourceInventory, IngestSourceRecord, IngestSyncMode, @@ -33,6 +36,7 @@ def __init__(self) -> None: self.index_calls: list[tuple[str, str, str]] = [] self.list_calls: list[str] = [] self.status_calls: list[tuple[str, str]] = [] + self.pack_calls: list[str] = [] async def sync_source( self, *, tenant: str, source_id: str, mode: IngestSyncMode, idempotency_key: str @@ -68,6 +72,12 @@ async def get_source_status( self.status_calls.append((tenant, source_id)) return IngestSourceRecord(source_id=source_id, tenant=tenant, state="active") + async def list_packs(self, *, tenant: str) -> IngestPackInventory: + self.pack_calls.append(tenant) + return IngestPackInventory( + packs=(IngestPackRecord(pack_id="pack-1", tenant=tenant, state="active"),) + ) + def _context(*, runner: object | None, idempotency_key: str | None = "idem-1"): caller = SimpleNamespace(tenant="tenant-a", principal="caller-a") @@ -206,3 +216,31 @@ def test_source_inventory_operations_declare_the_read_scope() -> None: op = ops[op_id] assert op.scopes == frozenset({"ingest:read"}) assert op.effect.value == "read" + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.1") +async def test_list_packs_returns_the_tenant_inventory() -> None: + runner = _FakeRunner() + result = await list_packs(_context(runner=runner), {}, None) + assert result == { + "value": { + "packs": [{"pack_id": "pack-1", "tenant": "tenant-a", "state": "active"}] + } + } + assert runner.pack_calls == ["tenant-a"] + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.1") +async def test_list_packs_fails_closed_with_unavailable_when_runner_not_composed() -> ( + None +): + with pytest.raises(OperationRefused) as excinfo: + await list_packs(_context(runner=None), {}, None) + assert excinfo.value.code == "UNAVAILABLE" + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.1") +def test_packs_list_operation_declares_the_read_scope() -> None: + op = {op.id: op for op in operations()}["ingest.packs.list"] + assert op.scopes == frozenset({"ingest:read"}) + assert op.effect.value == "read" From bdf40f7637d7d5c9e3e66da7b03b957d1de32be4 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:43:56 -0500 Subject: [PATCH 11/25] chore(specs): regenerate status for the fourth 2026-10-10 landing train Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/graphos-a2a-002/status.json | 6 ++++-- specs/host-composition-boundary/status.json | 10 +++++++--- specs/hosted-api-operations/status.json | 10 +++++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/specs/graphos-a2a-002/status.json b/specs/graphos-a2a-002/status.json index 12c9a3dd..c9a7e754 100644 --- a/specs/graphos-a2a-002/status.json +++ b/specs/graphos-a2a-002/status.json @@ -64,8 +64,10 @@ { "id": "GRAPHOS-A2A-002-R005", "title": "No advisory, cached, or partial substitute", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "VERIFIED", + "landed_in": [ + "63762147f0bc" + ], "verified_by": [ "tests/a2a/test_admission.py:52" ] diff --git a/specs/host-composition-boundary/status.json b/specs/host-composition-boundary/status.json index efb65b89..9fb42234 100644 --- a/specs/host-composition-boundary/status.json +++ b/specs/host-composition-boundary/status.json @@ -281,9 +281,13 @@ { "id": "GRAPHOS-HOST-R005.2.3.1", "title": "Map `sync` to `ingest.sources.sync`", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "567a2b417ed7" + ], + "verified_by": [ + "tests/mcp_server/test_legacy_action_mapping_ingest_sync.py:11" + ] }, { "id": "GRAPHOS-HOST-R005.2.3.2", diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index b178af7f..db4924c3 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -393,7 +393,9 @@ "id": "GRAPHOS-OPS-R020.3.1", "title": "Missing `ingest.packs.*` and `ingest.jobs.*` ops", "delivery_state": "SPECIFIED", - "landed_in": [], + "landed_in": [ + "16c9b2bddda3" + ], "verified_by": [], "rollup_of": [ "GRAPHOS-OPS-R020.3.1.1", @@ -403,8 +405,10 @@ { "id": "GRAPHOS-OPS-R020.3.1.1", "title": "`ingest.packs.list` op", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "VERIFIED", + "landed_in": [ + "16c9b2bddda3" + ], "verified_by": [ "tests/api/test_ingest_ops.py:221", "tests/api/test_ingest_ops.py:233", From 634a344fade751af33e4594a7cb96fb4c338a4e7 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:44:54 -0500 Subject: [PATCH 12/25] GRAPHOS-OPS-R020.3.1.2: add ingest.jobs.status read op Typed IngestJobRecord and runner port method get_job_status; read op under ingest:read, fails closed with UNAVAILABLE when no runner is composed. Spec: GRAPHOS-OPS-R020.3.1.2 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/api/ops/ingest.py | 21 ++++++++++++++++ graph_os/ingest/service.py | 21 ++++++++++++++++ specs/hosted-api-operations/status.json | 26 ++++++++++--------- tests/api/test_ingest_ops.py | 33 +++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 11 deletions(-) diff --git a/graph_os/api/ops/ingest.py b/graph_os/api/ops/ingest.py index 3b1feb28..5860a963 100644 --- a/graph_os/api/ops/ingest.py +++ b/graph_os/api/ops/ingest.py @@ -25,6 +25,7 @@ Verb, ) from graph_os.ingest.service import ( + get_job_status, get_source_status, index_repository, list_packs, @@ -39,6 +40,7 @@ #: (``graph_os.ingest.service``). __all__ = [ "operations", + "get_job_status", "get_source_status", "index_repository", "list_packs", @@ -92,6 +94,14 @@ class IngestPackListResult(_Params): value: dict[str, object] +class IngestJobStatusParams(_Params): + job_id: str = Field(min_length=1, max_length=256) + + +class IngestJobStatusResult(_Params): + value: dict[str, str] + + def operations() -> tuple[OpSpec, ...]: return ( OpSpec( @@ -153,4 +163,15 @@ def operations() -> tuple[OpSpec, ...]: scopes=frozenset({"ingest:read"}), effect=Effect.READ, ), + OpSpec( + id="ingest.jobs.status", + verb=Verb.ASK, + summary="Show one durable ingestion job's current status", + examples=("show the status of this ingestion job",), + params=IngestJobStatusParams, + result=IngestJobStatusResult, + binding=Composite(handler="graph_os.api.ops.ingest.get_job_status"), + scopes=frozenset({"ingest:read"}), + effect=Effect.READ, + ), ) diff --git a/graph_os/ingest/service.py b/graph_os/ingest/service.py index 06599ed0..4e47e904 100644 --- a/graph_os/ingest/service.py +++ b/graph_os/ingest/service.py @@ -84,6 +84,16 @@ class IngestPackInventory(BaseModel): packs: tuple[IngestPackRecord, ...] +class IngestJobRecord(BaseModel): + """One durable ingestion job's identity and current state.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + job_id: str + tenant: str + status: IngestJobStatus + + @runtime_checkable class IngestRunner(Protocol): """The one typed port GraphOS calls the ingestion SDK through. @@ -110,6 +120,8 @@ async def get_source_status( async def list_packs(self, *, tenant: str) -> IngestPackInventory: ... + async def get_job_status(self, *, tenant: str, job_id: str) -> IngestJobRecord: ... + def _bound_runner(context: Any) -> IngestRunner: runner = context.services.get("ingest_runner") @@ -169,3 +181,12 @@ async def list_packs(context: Any, params: Mapping[str, Any], op: Any) -> Any: runner = _bound_runner(context) inventory = await runner.list_packs(tenant=context.caller.tenant) return {"value": inventory.model_dump(mode="json")} + + +async def get_job_status(context: Any, params: Mapping[str, Any], op: Any) -> Any: + """Report one durable ingestion job's state through the composed runner.""" + runner = _bound_runner(context) + record = await runner.get_job_status( + tenant=context.caller.tenant, job_id=params["job_id"] + ) + return {"value": record.model_dump(mode="json")} diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index db4924c3..93df6486 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -354,8 +354,8 @@ "cc90ee37583f" ], "verified_by": [ - "tests/api/test_ingest_ops.py:109", - "tests/api/test_ingest_ops.py:90" + "tests/api/test_ingest_ops.py:116", + "tests/api/test_ingest_ops.py:97" ] }, { @@ -370,11 +370,11 @@ "cc90ee37583f" ], "verified_by": [ - "tests/api/test_ingest_ops.py:131", - "tests/api/test_ingest_ops.py:147", - "tests/api/test_ingest_ops.py:161", - "tests/api/test_ingest_ops.py:174", - "tests/api/test_ingest_ops.py:184" + "tests/api/test_ingest_ops.py:138", + "tests/api/test_ingest_ops.py:154", + "tests/api/test_ingest_ops.py:168", + "tests/api/test_ingest_ops.py:181", + "tests/api/test_ingest_ops.py:191" ] }, { @@ -410,9 +410,9 @@ "16c9b2bddda3" ], "verified_by": [ - "tests/api/test_ingest_ops.py:221", - "tests/api/test_ingest_ops.py:233", - "tests/api/test_ingest_ops.py:242" + "tests/api/test_ingest_ops.py:228", + "tests/api/test_ingest_ops.py:240", + "tests/api/test_ingest_ops.py:249" ] }, { @@ -420,7 +420,11 @@ "title": "`ingest.jobs.status` op", "delivery_state": "SPECIFIED", "landed_in": [], - "verified_by": [] + "verified_by": [ + "tests/api/test_ingest_ops.py:256", + "tests/api/test_ingest_ops.py:266", + "tests/api/test_ingest_ops.py:275" + ] }, { "id": "GRAPHOS-OPS-R020.4", diff --git a/tests/api/test_ingest_ops.py b/tests/api/test_ingest_ops.py index dd526859..b24701cb 100644 --- a/tests/api/test_ingest_ops.py +++ b/tests/api/test_ingest_ops.py @@ -12,6 +12,7 @@ from graph_os.api.invoke.pipeline import OperationRefused from graph_os.api.ops.ingest import ( + get_job_status, get_source_status, index_repository, list_packs, @@ -21,6 +22,7 @@ ) from graph_os.ingest.service import ( IngestIndexReceipt, + IngestJobRecord, IngestPackInventory, IngestPackRecord, IngestSourceInventory, @@ -37,6 +39,7 @@ def __init__(self) -> None: self.list_calls: list[str] = [] self.status_calls: list[tuple[str, str]] = [] self.pack_calls: list[str] = [] + self.job_calls: list[tuple[str, str]] = [] async def sync_source( self, *, tenant: str, source_id: str, mode: IngestSyncMode, idempotency_key: str @@ -78,6 +81,10 @@ async def list_packs(self, *, tenant: str) -> IngestPackInventory: packs=(IngestPackRecord(pack_id="pack-1", tenant=tenant, state="active"),) ) + async def get_job_status(self, *, tenant: str, job_id: str) -> IngestJobRecord: + self.job_calls.append((tenant, job_id)) + return IngestJobRecord(job_id=job_id, tenant=tenant, status="running") + def _context(*, runner: object | None, idempotency_key: str | None = "idem-1"): caller = SimpleNamespace(tenant="tenant-a", principal="caller-a") @@ -244,3 +251,29 @@ def test_packs_list_operation_declares_the_read_scope() -> None: op = {op.id: op for op in operations()}["ingest.packs.list"] assert op.scopes == frozenset({"ingest:read"}) assert op.effect.value == "read" + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.2") +async def test_job_status_reports_durable_job_state() -> None: + runner = _FakeRunner() + result = await get_job_status(_context(runner=runner), {"job_id": "job-9"}, None) + assert result == { + "value": {"job_id": "job-9", "tenant": "tenant-a", "status": "running"} + } + assert runner.job_calls == [("tenant-a", "job-9")] + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.2") +async def test_job_status_fails_closed_with_unavailable_when_runner_not_composed() -> ( + None +): + with pytest.raises(OperationRefused) as excinfo: + await get_job_status(_context(runner=None), {"job_id": "job-9"}, None) + assert excinfo.value.code == "UNAVAILABLE" + + +@pytest.mark.spec("GRAPHOS-OPS-R020.3.1.2") +def test_jobs_status_operation_declares_the_read_scope() -> None: + op = {op.id: op for op in operations()}["ingest.jobs.status"] + assert op.scopes == frozenset({"ingest:read"}) + assert op.effect.value == "read" From 965d34390876df492b764a6dbb207742b06af327 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 15:45:01 -0500 Subject: [PATCH 13/25] GRAPHOS-MCP-RESOURCES-R003.1: add require_reconciled_for_swap guard Adds PublicationRefusedError and require_reconciled_for_swap(), which raises the typed refusal for any reingestion-unreconciled gate result. Spec: GRAPHOS-MCP-RESOURCES-R003.1 Co-Authored-By: Claude Sonnet 5.5 --- graph_os/fleet/mcp_resource_reconciliation.py | 39 +++++++++++++++++++ .../status.json | 7 +++- .../fleet/test_mcp_resource_reconciliation.py | 38 ++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/graph_os/fleet/mcp_resource_reconciliation.py b/graph_os/fleet/mcp_resource_reconciliation.py index 432e972a..be85d6d5 100644 --- a/graph_os/fleet/mcp_resource_reconciliation.py +++ b/graph_os/fleet/mcp_resource_reconciliation.py @@ -106,3 +106,42 @@ def gate_mcp_resource_publication( return _unreconciled("stale", receipt) return _reconciled(receipt) + + +class PublicationRefusedError(Exception): + """Raised when a candidate generation may not be swapped in as published. + + Carries the typed ``PublicationGateResult`` so the caller can surface the + stable ``reason`` instead of swallowing the refusal. + """ + + def __init__(self, result: PublicationGateResult) -> None: + self.result = result + super().__init__(f"{result.code}: publication refused (reason={result.reason})") + + +def require_reconciled_for_swap( + receipt: ReconciliationReceipt | None, + *, + tenant_id: str, + expected_generation: int, + expected_digest: str, + now_ms: int, + max_age_ms: int, +) -> ReconciliationReceipt: + """Return the matching receipt, or raise ``PublicationRefusedError``. + + Wraps ``gate_mcp_resource_publication`` so the publish step cannot bypass + the gate by ignoring a returned status. + """ + result = gate_mcp_resource_publication( + receipt, + tenant_id=tenant_id, + expected_generation=expected_generation, + expected_digest=expected_digest, + now_ms=now_ms, + max_age_ms=max_age_ms, + ) + if result.status != "reconciled" or result.receipt is None: + raise PublicationRefusedError(result) + return result.receipt diff --git a/specs/mcp-resource-publication-reconciliation/status.json b/specs/mcp-resource-publication-reconciliation/status.json index 5d5c921d..c9ae0b46 100644 --- a/specs/mcp-resource-publication-reconciliation/status.json +++ b/specs/mcp-resource-publication-reconciliation/status.json @@ -31,7 +31,7 @@ "6a19f564d301" ], "verified_by": [ - "tests/fleet/test_mcp_resource_reconciliation.py:20" + "tests/fleet/test_mcp_resource_reconciliation.py:22" ] }, { @@ -57,7 +57,10 @@ "title": "Swap guard function", "delivery_state": "SPECIFIED", "landed_in": [], - "verified_by": [] + "verified_by": [ + "tests/fleet/test_mcp_resource_reconciliation.py:119", + "tests/fleet/test_mcp_resource_reconciliation.py:138" + ] }, { "id": "GRAPHOS-MCP-RESOURCES-R003.2", diff --git a/tests/fleet/test_mcp_resource_reconciliation.py b/tests/fleet/test_mcp_resource_reconciliation.py index b482115e..84912752 100644 --- a/tests/fleet/test_mcp_resource_reconciliation.py +++ b/tests/fleet/test_mcp_resource_reconciliation.py @@ -13,8 +13,10 @@ from graph_os.fleet.mcp_resource_reconciliation import ( MCP_RESOURCE_FAMILIES, PublicationGateResult, + PublicationRefusedError, ReconciliationReceipt, gate_mcp_resource_publication, + require_reconciled_for_swap, ) pytestmark = pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R001.1") @@ -101,3 +103,39 @@ def test_valid_receipt_is_reconciled() -> None: assert result.status == "reconciled" assert result.reason is None assert result.receipt is receipt + + +def _require(receipt: ReconciliationReceipt | None) -> ReconciliationReceipt: + return require_reconciled_for_swap( + receipt, + tenant_id=TENANT, + expected_generation=GENERATION, + expected_digest=DIGEST, + now_ms=NOW_MS, + max_age_ms=MAX_AGE_MS, + ) + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R003.1") +@pytest.mark.parametrize( + ("receipt", "reason"), + [ + (None, "missing"), + (_valid_receipt(issued_at_ms=NOW_MS - MAX_AGE_MS - 1), "stale"), + (_valid_receipt(snapshot_digest="sha256:other"), "invalid"), + ], +) +def test_swap_guard_raises_typed_refusal( + receipt: ReconciliationReceipt | None, reason: str +) -> None: + with pytest.raises(PublicationRefusedError) as excinfo: + _require(receipt) + assert excinfo.value.result.status == "reingestion-unreconciled" + assert excinfo.value.result.reason == reason + assert excinfo.value.result.receipt is receipt + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R003.1") +def test_swap_guard_returns_matching_receipt() -> None: + receipt = _valid_receipt() + assert _require(receipt) is receipt From 2d90ac5f912a797fb01ae46d2e1ebc0456eab29d Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:14:18 -0500 Subject: [PATCH 14/25] chore(clones): re-review the ops/ingest operations() register entry ingest.operations() gained ingest.packs.list; both functions are still the per-domain declarative OpSpec registries the entry describes. Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- .config/dupehound-distinct.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.config/dupehound-distinct.toml b/.config/dupehound-distinct.toml index aa1e4fe5..a00252ea 100644 --- a/.config/dupehound-distinct.toml +++ b/.config/dupehound-distinct.toml @@ -36,9 +36,9 @@ left_name = "operations" left_digest = "sha256:1dd92a3cf607864448698507dd859bedcd0b2837cddb97a0aeef56d1adc0a706" right_file = "graph_os/api/ops/ingest.py" right_name = "operations" -right_digest = "sha256:ee097ee89d61f7ddcec595ea9f1a06767ad045baf374b12e808b69399a111d71" +right_digest = "sha256:b34d30a8a6914622efd841d8584e31cac462f51acf90ab8f9a1e51992dbe2193" reason = "Both declare an unrelated domain's own OpSpec registry (operational-admin vs ingestion) using the one mandated declarative registration shape every ops module shares by design; the registry factory, not this duplication, is the single point of consolidation." -reviewed_on = "2026-10-09" +reviewed_on = "2026-10-10" [[pair]] left_file = "graph_os/api/ops/memory.py" From 1f5c37db90541f731484cf7a2ebef4051ca42e8b Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:14:29 -0500 Subject: [PATCH 15/25] chore(specs): regenerate status after adding #221 and #222 Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/hosted-api-operations/status.json | 12 +++++++----- .../status.json | 6 ++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 93df6486..7224dac0 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -329,7 +329,7 @@ { "id": "GRAPHOS-OPS-R020", "title": "Ingest operations through a typed SDK runner facade", - "delivery_state": "SPECIFIED", + "delivery_state": "LANDED", "landed_in": [ "e990e195ab42" ], @@ -380,7 +380,7 @@ { "id": "GRAPHOS-OPS-R020.3", "title": "Pack and job management operations", - "delivery_state": "SPECIFIED", + "delivery_state": "VERIFIED", "landed_in": [ "822f55d5835d" ], @@ -392,7 +392,7 @@ { "id": "GRAPHOS-OPS-R020.3.1", "title": "Missing `ingest.packs.*` and `ingest.jobs.*` ops", - "delivery_state": "SPECIFIED", + "delivery_state": "VERIFIED", "landed_in": [ "16c9b2bddda3" ], @@ -418,8 +418,10 @@ { "id": "GRAPHOS-OPS-R020.3.1.2", "title": "`ingest.jobs.status` op", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "VERIFIED", + "landed_in": [ + "634a344fade7" + ], "verified_by": [ "tests/api/test_ingest_ops.py:256", "tests/api/test_ingest_ops.py:266", diff --git a/specs/mcp-resource-publication-reconciliation/status.json b/specs/mcp-resource-publication-reconciliation/status.json index c9ae0b46..0a174c93 100644 --- a/specs/mcp-resource-publication-reconciliation/status.json +++ b/specs/mcp-resource-publication-reconciliation/status.json @@ -55,8 +55,10 @@ { "id": "GRAPHOS-MCP-RESOURCES-R003.1", "title": "Swap guard function", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "VERIFIED", + "landed_in": [ + "965d34390876" + ], "verified_by": [ "tests/fleet/test_mcp_resource_reconciliation.py:119", "tests/fleet/test_mcp_resource_reconciliation.py:138" From e101e081d915a48de55bdcac435e36f49d542c94 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:15:22 -0500 Subject: [PATCH 16/25] Split GRAPHOS-OPS-R032.2.3.2: ports in run_web_ui vs supply from mcp_server No production identity runtime exists (build_identity_ports has only test callers), so the supervisor start call cannot supply real ports yet. Split .2.3.2 into .2.3.2.1 (run_web_ui accepts/binds ports, lands with .2.3.1) and .2.3.2.2 (supply from mcp_server composition, depends on R032.2.2). Spec: none (refactor) Co-Authored-By: Claude Sonnet 5.5 --- specs/hosted-api-operations/requirements.md | 4 +++- specs/hosted-api-operations/status.json | 22 ++++++++++++++++++++- specs/hosted-api-operations/tasks.md | 2 ++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/specs/hosted-api-operations/requirements.md b/specs/hosted-api-operations/requirements.md index 50cc281e..7bce0a97 100644 --- a/specs/hosted-api-operations/requirements.md +++ b/specs/hosted-api-operations/requirements.md @@ -67,7 +67,9 @@ | `GRAPHOS-OPS-R032.2.2` | **Inject identity ports into mcp_server.** graph_os.mcp_server receives its identity capability as an injected graph_os.identity.ports object supplied by the composition root. Depends on GRAPHOS-OPS-R032.2.1 and on the graph_os/api/ops/identity.py and registry_factory.py registration from PR #176 (deliver-ops-r014). | A test confirms mcp_server accepts only a ports-typed object and refuses to start without one. | | `GRAPHOS-OPS-R032.2.3` | **Inject identity ports into webui_host and webui_co_service (rollup).** graph_os.webui_host and graph_os.webui_host.webui_co_service (compose_web_application) receive their identity capability as an injected graph_os.identity.ports object supplied by the composition root. Rollup of GRAPHOS-OPS-R032.2.3.1 and GRAPHOS-OPS-R032.2.3.2. Depends on GRAPHOS-OPS-R032.2.1. | Both children are delivered and their bound tests pass. | | `GRAPHOS-OPS-R032.2.3.1` | **compose_web_application requires injected identity ports.** graph_os.webui_host.webui_co_service.compose_web_application takes an `IdentityPorts` argument from graph_os.identity.composition and raises a typed refusal when it is absent or not ports-typed; it imports no graph_os.identity submodule other than ports/composition types. Edits webui_co_service.py only. Depends on GRAPHOS-OPS-R032.2.1. | A test confirms compose_web_application accepts only an IdentityPorts object and refuses to compose without one. | -| `GRAPHOS-OPS-R032.2.3.2` | **Thread identity ports through run_web_ui and the webui_host package.** run_web_ui and graph_os.webui_host receive the composition root's IdentityPorts and bind them into the application composer passed to the WebUI factory; graph_os.mcp_server.composition passes the ports when starting the agent-webui co-service. Depends on GRAPHOS-OPS-R032.2.3.1 and GRAPHOS-OPS-R032.2.2. | A test confirms run_web_ui refuses to start without ports and the supervisor start call supplies them. | +| `GRAPHOS-OPS-R032.2.3.2` | **Thread identity ports through run_web_ui and the webui_host package (rollup).** run_web_ui and graph_os.webui_host receive the composition root's IdentityPorts and bind them into the application composer passed to the WebUI factory; graph_os.mcp_server.composition passes the ports when starting the agent-webui co-service. Rollup of GRAPHOS-OPS-R032.2.3.2.1 and GRAPHOS-OPS-R032.2.3.2.2. Depends on GRAPHOS-OPS-R032.2.3.1 and GRAPHOS-OPS-R032.2.2. | Both children are delivered and their bound tests pass. | +| `GRAPHOS-OPS-R032.2.3.2.1` | **run_web_ui accepts and binds identity ports.** run_web_ui takes a required `IdentityPorts` argument, refuses to start (typed refusal) when it is absent or not ports-typed, and binds the ports into the composer passed to the WebUI factory with functools.partial over compose_web_application. Delivered together with GRAPHOS-OPS-R032.2.3.1 in one PR because compose_web_application cannot land requiring ports while run_web_ui still passes it bare. Edits webui_co_service.py and the existing webui_host tests only. Depends on GRAPHOS-OPS-R032.2.3.1. | A test confirms run_web_ui refuses to start without ports and passes a composer bound to the supplied ports to the WebUI factory. | +| `GRAPHOS-OPS-R032.2.3.2.2` | **Supply identity ports from mcp_server composition to the agent-webui start call.** graph_os.mcp_server.composition.start_composed_services receives the composition root's IdentityPorts and passes them to the agent-webui co-service start call. No production identity runtime is constructed anywhere yet (build_identity_ports has only test callers), so this needs the injected runtime from GRAPHOS-OPS-R032.2.2. Depends on GRAPHOS-OPS-R032.2.3.2.1 and GRAPHOS-OPS-R032.2.2. | A test confirms the supervisor start call for agent-webui supplies the ports. | | `GRAPHOS-OPS-R032.2.4` | **Integration test of single injected identity runtime.** An integration test builds the composition root once and confirms mcp_server, webui_host, and webui_co_service all receive the same ports-typed identity object and none imports the concrete runtime. Depends on GRAPHOS-OPS-R032.2.2 and GRAPHOS-OPS-R032.2.3. | The integration test passes against the real composition root. | | `GRAPHOS-OPS-R033` | **Dynamic multiplexer discovery, load, and call.** GraphOS implements find_tools (with browse=true replacing the former catalog listing), load_tools, unload_tools, and multiplexer_status over one per-caller-filtered catalog spanning fleet tools, prompts, resources, skills, and connector items, enforcing a default load cap of 64 (hard maximum 256), one-hour idle expiry, opt-in LRU eviction, and routing every loaded tool's body through a fleet call with the caller's delegated credential. | Integration tests confirm the load cap, idle expiry, and that each loaded tool call is dispatched through the fleet-call path rather than directly. | | `GRAPHOS-OPS-R034` | **Eunomia narrowing-only policy enforcement.** GraphOS applies its policy engine as a narrowing-only step at discovery, load, and every call, always leaving exact-scope filtering in place when policy is off, failing closed when policy is enabled but unreachable, defaulting to on for local and external deployments and off only for the none deployment mode, with its doctor check warning when policy is disabled. | Integration tests cover the off, on, and unreachable policy states, and a doctor-check test confirms the warning fires when policy is off. | diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 7224dac0..744fd177 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -70,6 +70,8 @@ "GRAPHOS-OPS-R032.2.3", "GRAPHOS-OPS-R032.2.3.1", "GRAPHOS-OPS-R032.2.3.2", + "GRAPHOS-OPS-R032.2.3.2.1", + "GRAPHOS-OPS-R032.2.3.2.2", "GRAPHOS-OPS-R032.2.4", "GRAPHOS-OPS-R033", "GRAPHOS-OPS-R034", @@ -777,7 +779,25 @@ }, { "id": "GRAPHOS-OPS-R032.2.3.2", - "title": "Thread identity ports through run_web_ui and the webui_host package", + "title": "Thread identity ports through run_web_ui and the webui_host package (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-OPS-R032.2.3.2.1", + "GRAPHOS-OPS-R032.2.3.2.2" + ] + }, + { + "id": "GRAPHOS-OPS-R032.2.3.2.1", + "title": "run_web_ui accepts and binds identity ports", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R032.2.3.2.2", + "title": "Supply identity ports from mcp_server composition to the agent-webui start call", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/hosted-api-operations/tasks.md b/specs/hosted-api-operations/tasks.md index 263ef023..daf97b84 100644 --- a/specs/hosted-api-operations/tasks.md +++ b/specs/hosted-api-operations/tasks.md @@ -64,6 +64,8 @@ Status: **READY FOR IMPLEMENTATION**. Delivery: **NOT ACCEPTED**. See [design](p - [ ] **GRAPHOS-OPS-R032.2.3:** Remaining scope of GRAPHOS-OPS-R032.2: Inject identity ports into webui_host and webui_co_service. - [ ] **GRAPHOS-OPS-R032.2.3.1:** Remaining scope of GRAPHOS-OPS-R032.2.3: compose_web_application requires injected identity ports. - [ ] **GRAPHOS-OPS-R032.2.3.2:** Remaining scope of GRAPHOS-OPS-R032.2.3: Thread identity ports through run_web_ui and the webui_host package. +- [ ] **GRAPHOS-OPS-R032.2.3.2.1:** Remaining scope of GRAPHOS-OPS-R032.2.3.2: run_web_ui accepts and binds identity ports (with GRAPHOS-OPS-R032.2.3.1). +- [ ] **GRAPHOS-OPS-R032.2.3.2.2:** Remaining scope of GRAPHOS-OPS-R032.2.3.2: Supply identity ports from mcp_server composition (depends on GRAPHOS-OPS-R032.2.2). - [ ] **GRAPHOS-OPS-R032.2.4:** Remaining scope of GRAPHOS-OPS-R032.2: Integration test of single injected identity runtime. - [ ] **GRAPHOS-OPS-R035:** Multiplexer registration reduced to four resident tools - [ ] **GRAPHOS-OPS-R035.1:** Remaining scope of GRAPHOS-OPS-R035 (slice .1): Multiplexer registration reduced to four resident tools From 9fc4eae0f299d2604258b3b530a32dad063cf3d1 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:15:38 -0500 Subject: [PATCH 17/25] GRAPHOS-IDENTITY-R010.2.1: group-DN-to-role mapping; split R010.2 into children Split R010.2 into .2.1-.2.4 and deliver .2.1: map_groups_to_roles with DN normalization, refusing when no group maps to a role. Spec: GRAPHOS-IDENTITY-R010.2.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/identity/ldap.py | 28 +++++++++++++++++ specs/identity-access/requirements.md | 6 +++- specs/identity-access/status.json | 40 ++++++++++++++++++++++++- specs/identity-access/tasks.md | 6 +++- tests/identity/test_ldap_group_roles.py | 35 ++++++++++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 tests/identity/test_ldap_group_roles.py diff --git a/graph_os/identity/ldap.py b/graph_os/identity/ldap.py index 58d535c3..f5c2fb45 100644 --- a/graph_os/identity/ldap.py +++ b/graph_os/identity/ldap.py @@ -6,6 +6,7 @@ from __future__ import annotations +from collections.abc import Collection, Mapping from dataclasses import dataclass from .engine import IdentityUnavailable @@ -35,3 +36,30 @@ def __post_init__(self) -> None: raise IdentityUnavailable( "LDAP bind must use ldaps, plaintext ldap is refused" ) + + +def _normalize_dn(dn: str) -> str: + """Case-fold a DN and strip whitespace around RDN separators and ``=``.""" + parts = [] + for rdn in dn.split(","): + attr, sep, value = rdn.partition("=") + parts.append(f"{attr.strip()}{sep}{value.strip()}".casefold()) + return ",".join(parts) + + +def map_groups_to_roles( + group_dns: Collection[str], + mapping: Mapping[str, Collection[str]], +) -> frozenset[str]: + """Roles granted by a directory user's groups (GRAPHOS-IDENTITY-R010.2.1). + + ``mapping`` is group DN to roles; DNs compare case-insensitively. Fails + closed: no matching group, or a matching group yielding no role, is refused. + """ + wanted = {_normalize_dn(dn): roles for dn, roles in mapping.items()} + roles: set[str] = set() + for dn in group_dns: + roles.update(wanted.get(_normalize_dn(dn), ())) + if not roles: + raise IdentityUnavailable("no directory group maps to a role") + return frozenset(roles) diff --git a/specs/identity-access/requirements.md b/specs/identity-access/requirements.md index ed4247c0..b832b277 100644 --- a/specs/identity-access/requirements.md +++ b/specs/identity-access/requirements.md @@ -23,7 +23,11 @@ | `GRAPHOS-IDENTITY-R009.1` | **Typed OIDC mapping-rule model.** `OidcMappingRule` refuses an unknown JIT policy and refuses a negative rule order. | Verified by refusal unit tests in `tests/identity/test_api_keys_oidc_ldap_models.py`. | | `GRAPHOS-IDENTITY-R009.2` | **OIDC authentication flow.** Implement PKCE/state/nonce verification, ordered rule evaluation, provider presets, idempotent link migration, and hinted logout. | Verified by an OIDC integration test against mock issuers asserting PKCE/nonce/state validation, mapping-rule outcomes, idempotent link migration, and hinted logout. | | `GRAPHOS-IDENTITY-R010.1` | **Typed LDAPS bind-config model.** `LdapBindConfig` refuses a non-`ldaps` scheme and refuses an invalid port. | Verified by refusal unit tests in `tests/identity/test_api_keys_oidc_ldap_models.py`. | -| `GRAPHOS-IDENTITY-R010.2` | **Directory bind and group sync.** Implement the LDAPS bind, filter escaping, nested-group resolution, and deprovisioning of disabled accounts. | Verified by an integration test against a mock TLS LDAP directory covering escaped filters, bounded nested-group resolution, and deprovisioning of a disabled account. | +| `GRAPHOS-IDENTITY-R010.2` | **Directory bind and group sync (rollup).** Split into `.2.1`-`.2.4`; implement the LDAPS bind, filter escaping, nested-group resolution, and deprovisioning of disabled accounts. | Verified by an integration test against a mock TLS LDAP directory covering escaped filters, bounded nested-group resolution, and deprovisioning of a disabled account. | +| `GRAPHOS-IDENTITY-R010.2.1` | **Group-DN-to-role mapping evaluation.** `map_groups_to_roles()` maps a directory user's group DNs (DN-normalized, case-insensitive) to roles through an explicit mapping and refuses with `IdentityUnavailable` when no group matches. | Verified by unit tests in `tests/identity/test_ldap_group_roles.py`. | +| `GRAPHOS-IDENTITY-R010.2.2` | **Bind through an injected directory port.** Bind and search via an injected directory port with typed `IdentityUnavailable` refusal when the directory is unavailable, plus search-filter escaping. | Verified by unit tests using a fake directory port. | +| `GRAPHOS-IDENTITY-R010.2.3` | **Scheduled group sync.** Bounded nested-group resolution and scheduled sync, deprovisioning disabled accounts. | Verified by a test against a fake directory covering bounded nesting and deprovisioning of a disabled account. | +| `GRAPHOS-IDENTITY-R010.2.4` | **Live directory probe.** Probe a live LDAPS directory (requires a live service). | Verified by a live probe against the deployed directory. | | `GRAPHOS-IDENTITY-R011` | **SCIM 2.0 provisioning server.** GraphOS exposes a SCIM 2.0 server bound to a provider-specific service credential, supporting user and group provisioning and deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | | `GRAPHOS-IDENTITY-R012` | **Native SAML service provider.** GraphOS acts as a SAML 2.0 service provider itself, so a deployment can sign in through a SAML identity provider without an OIDC broker in between; brokering through an OIDC provider remains a supported alternative. Assertions are accepted only with a valid signature from the configured provider certificate, the expected audience and recipient, an unexpired validity window and an unreplayed assertion ID, and a SAML sign-in resolves to the same stable principal and narrowed scopes as every other login method. | Integration tests assert a signed assertion yields the stable principal, and unsigned, wrongly signed, wrong-audience, expired and replayed assertions and XML signature-wrapping attempts are each refused. | | `GRAPHOS-IDENTITY-R011.1` | **Typed SCIM service-credential model.** `ScimServiceCredential` refuses an empty provider id and exposes `authorizes()` refusing a mismatched provider. | Verified by refusal unit tests in `tests/identity/test_scim_saml_models.py`. | diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index e4813248..f9ac2b87 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -26,6 +26,10 @@ "GRAPHOS-IDENTITY-R009.2", "GRAPHOS-IDENTITY-R010.1", "GRAPHOS-IDENTITY-R010.2", + "GRAPHOS-IDENTITY-R010.2.1", + "GRAPHOS-IDENTITY-R010.2.2", + "GRAPHOS-IDENTITY-R010.2.3", + "GRAPHOS-IDENTITY-R010.2.4", "GRAPHOS-IDENTITY-R011", "GRAPHOS-IDENTITY-R012", "GRAPHOS-IDENTITY-R011.1", @@ -283,7 +287,41 @@ }, { "id": "GRAPHOS-IDENTITY-R010.2", - "title": "Directory bind and group sync", + "title": "Directory bind and group sync (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-IDENTITY-R010.2.1", + "GRAPHOS-IDENTITY-R010.2.2", + "GRAPHOS-IDENTITY-R010.2.3", + "GRAPHOS-IDENTITY-R010.2.4" + ] + }, + { + "id": "GRAPHOS-IDENTITY-R010.2.1", + "title": "Group-DN-to-role mapping evaluation", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R010.2.2", + "title": "Bind through an injected directory port", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R010.2.3", + "title": "Scheduled group sync", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R010.2.4", + "title": "Live directory probe", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/identity-access/tasks.md b/specs/identity-access/tasks.md index 737d79f7..ab3704d4 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -57,7 +57,11 @@ State: READY FOR IMPLEMENTATION. Check a task only with an exact commit and test - [ ] **GRAPHOS-IDENTITY-R009.2:** PKCE/state/nonce flow, presets, link migration, hinted logout - [ ] **GRAPHOS-IDENTITY-R010:** LDAPS bind with nested group sync - [ ] **GRAPHOS-IDENTITY-R010.1:** Typed LDAPS bind-config model with refusal tests -- [ ] **GRAPHOS-IDENTITY-R010.2:** Directory bind, filter escaping, nested-group sync, deprovisioning +- [ ] **GRAPHOS-IDENTITY-R010.2:** Directory bind, filter escaping, nested-group sync, deprovisioning (rollup) +- [x] **GRAPHOS-IDENTITY-R010.2.1:** Group-DN-to-role mapping evaluation with refusal on no match +- [ ] **GRAPHOS-IDENTITY-R010.2.2:** Bind through injected directory port, filter escaping +- [ ] **GRAPHOS-IDENTITY-R010.2.3:** Scheduled nested-group sync and deprovisioning +- [ ] **GRAPHOS-IDENTITY-R010.2.4:** Live directory probe - [ ] **GRAPHOS-IDENTITY-R011.1:** Typed SCIM service-credential model with refusal tests - [ ] **GRAPHOS-IDENTITY-R011.2:** SCIM create/update/patch/deactivate server surface - [ ] **GRAPHOS-IDENTITY-R012:** Native SAML service provider (rollup) diff --git a/tests/identity/test_ldap_group_roles.py b/tests/identity/test_ldap_group_roles.py new file mode 100644 index 00000000..2ac8f75b --- /dev/null +++ b/tests/identity/test_ldap_group_roles.py @@ -0,0 +1,35 @@ +"""Spec-bound tests for directory group-DN-to-role mapping (R010.2.1).""" + +import pytest + +from graph_os.identity.engine import IdentityUnavailable +from graph_os.identity.ldap import map_groups_to_roles + +_ADMINS = "cn=admins,ou=groups,dc=example,dc=org" +_OPS = "cn=ops,ou=groups,dc=example,dc=org" +_MAPPING = {_ADMINS: {"admin"}, _OPS: ["operator", "viewer"]} + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R010.2.1") +def test_matching_groups_union_their_roles() -> None: + roles = map_groups_to_roles([_ADMINS, _OPS], _MAPPING) + assert roles == frozenset({"admin", "operator", "viewer"}) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R010.2.1") +def test_dn_match_ignores_case_and_rdn_spacing() -> None: + roles = map_groups_to_roles(["CN=Admins, OU=Groups , DC=Example,DC=org"], _MAPPING) + assert roles == frozenset({"admin"}) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R010.2.1") +@pytest.mark.parametrize("groups", [[], ["cn=other,dc=example,dc=org"]]) +def test_no_matching_group_is_refused(groups: list[str]) -> None: + with pytest.raises(IdentityUnavailable): + map_groups_to_roles(groups, _MAPPING) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R010.2.1") +def test_matching_group_with_no_roles_is_refused() -> None: + with pytest.raises(IdentityUnavailable): + map_groups_to_roles([_ADMINS], {_ADMINS: set()}) From 0714a7bf6f8e36124d22e0ab7862c0d4187bed3c Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:15:38 -0500 Subject: [PATCH 18/25] GRAPHOS-IDENTITY-R009.2.1: ordered OIDC claim-to-rule evaluation Split R009.2 into children .2.1-.2.4; deliver pure select_mapping_rule. Spec: GRAPHOS-IDENTITY-R009.2.1 Co-Authored-By: Claude Sonnet 5.5 --- graph_os/identity/oidc.py | 43 ++++++++++++++++- specs/identity-access/requirements.md | 6 ++- specs/identity-access/status.json | 40 +++++++++++++++- specs/identity-access/tasks.md | 6 ++- tests/identity/test_oidc_rule_selection.py | 54 ++++++++++++++++++++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 tests/identity/test_oidc_rule_selection.py diff --git a/graph_os/identity/oidc.py b/graph_os/identity/oidc.py index babbc137..f2936e55 100644 --- a/graph_os/identity/oidc.py +++ b/graph_os/identity/oidc.py @@ -1,12 +1,15 @@ """Typed model for ordered OIDC mapping rules (GRAPHOS-IDENTITY-R009). -Slice .1: the typed model, construction validation, and refusal tests only. -PKCE/state/nonce verification and JIT policy enforcement are later slices. +Slice .1: the typed model and construction validation. +Slice .2.1: pure ordered claim-to-rule evaluation (``select_mapping_rule``). +PKCE/state/nonce verification and the callback wiring are later slices. """ from __future__ import annotations +from collections.abc import Iterable, Mapping from dataclasses import dataclass +from typing import Any from .engine import IdentityUnavailable @@ -37,3 +40,39 @@ def __post_init__(self) -> None: raise IdentityUnavailable( f"unknown JIT policy {self.jit_policy!r}; expected one of {sorted(_JIT_POLICIES)}" ) + + +def _claim_matches(claim_match: str, claims: Mapping[str, Any]) -> bool: + """True when ``name=value`` matches a scalar claim or a member of a list claim.""" + name, sep, expected = claim_match.partition("=") + if not sep or not name or not expected: + raise IdentityUnavailable( + f"malformed OIDC claim match {claim_match!r}; expected 'name=value'" + ) + actual = claims.get(name) + if isinstance(actual, str): + return actual == expected + if isinstance(actual, (list, tuple, set, frozenset)): + return expected in actual + return False + + +def select_mapping_rule( + rules: Iterable[OidcMappingRule], + provider_id: str, + claims: Mapping[str, Any], +) -> OidcMappingRule: + """Return the first rule (lowest ``order``) of the provider matching the claims. + + Fails closed: no matching rule, or two rules sharing an order, is refused. + """ + candidates = sorted( + (r for r in rules if r.provider_id == provider_id), key=lambda r: r.order + ) + orders = [r.order for r in candidates] + if len(set(orders)) != len(orders): + raise IdentityUnavailable("OIDC mapping rules have ambiguous duplicate order") + for rule in candidates: + if _claim_matches(rule.claim_match, claims): + return rule + raise IdentityUnavailable("no OIDC mapping rule matches the presented claims") diff --git a/specs/identity-access/requirements.md b/specs/identity-access/requirements.md index ed4247c0..4334030d 100644 --- a/specs/identity-access/requirements.md +++ b/specs/identity-access/requirements.md @@ -21,7 +21,11 @@ | `GRAPHOS-IDENTITY-R008.1` | **Typed API-key grant model.** `ApiKeyGrant` refuses an approver-class scope and refuses an empty owner principal id. | Verified by refusal unit tests in `tests/identity/test_api_key_grant.py`. | | `GRAPHOS-IDENTITY-R008.2` | **Scope intersection and revocation.** Intersect effective scopes with the owner's current scopes at use time; deny a revoked or expired key immediately. | Verified by an API-key contract test asserting scope intersection at use time and immediate denial of a revoked, expired, or approver-scoped key. | | `GRAPHOS-IDENTITY-R009.1` | **Typed OIDC mapping-rule model.** `OidcMappingRule` refuses an unknown JIT policy and refuses a negative rule order. | Verified by refusal unit tests in `tests/identity/test_api_keys_oidc_ldap_models.py`. | -| `GRAPHOS-IDENTITY-R009.2` | **OIDC authentication flow.** Implement PKCE/state/nonce verification, ordered rule evaluation, provider presets, idempotent link migration, and hinted logout. | Verified by an OIDC integration test against mock issuers asserting PKCE/nonce/state validation, mapping-rule outcomes, idempotent link migration, and hinted logout. | +| `GRAPHOS-IDENTITY-R009.2` | **OIDC authentication flow (rollup).** Delivered through children `.2.1`-`.2.4`: ordered rule evaluation, ID-token validation, callback wiring, and live IdP probe. | Verified when every child is verified. | +| `GRAPHOS-IDENTITY-R009.2.1` | **Ordered claim-to-rule evaluation.** `select_mapping_rule` returns the lowest-order matching `OidcMappingRule` for a provider and refuses no match, duplicate order, or malformed match. | Verified by unit tests in `tests/identity/test_oidc_rule_selection.py`. | +| `GRAPHOS-IDENTITY-R009.2.2` | **ID-token validation inputs and typed refusal.** Pure PKCE/state/nonce/audience/expiry checks over decoded token claims, refusing any mismatch. | Verified by refusal unit tests. | +| `GRAPHOS-IDENTITY-R009.2.3` | **Callback handler wiring.** Wire rule evaluation and token validation into the OIDC callback, with idempotent link migration and hinted logout. | Verified by a callback test against a mock issuer. | +| `GRAPHOS-IDENTITY-R009.2.4` | **Live IdP probe.** Exercise the flow against a live identity provider (requires a deployed IdP service). | Verified by a live probe against the deployed IdP. | | `GRAPHOS-IDENTITY-R010.1` | **Typed LDAPS bind-config model.** `LdapBindConfig` refuses a non-`ldaps` scheme and refuses an invalid port. | Verified by refusal unit tests in `tests/identity/test_api_keys_oidc_ldap_models.py`. | | `GRAPHOS-IDENTITY-R010.2` | **Directory bind and group sync.** Implement the LDAPS bind, filter escaping, nested-group resolution, and deprovisioning of disabled accounts. | Verified by an integration test against a mock TLS LDAP directory covering escaped filters, bounded nested-group resolution, and deprovisioning of a disabled account. | | `GRAPHOS-IDENTITY-R011` | **SCIM 2.0 provisioning server.** GraphOS exposes a SCIM 2.0 server bound to a provider-specific service credential, supporting user and group provisioning and deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index e4813248..76610c1c 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -24,6 +24,10 @@ "GRAPHOS-IDENTITY-R008.2", "GRAPHOS-IDENTITY-R009.1", "GRAPHOS-IDENTITY-R009.2", + "GRAPHOS-IDENTITY-R009.2.1", + "GRAPHOS-IDENTITY-R009.2.2", + "GRAPHOS-IDENTITY-R009.2.3", + "GRAPHOS-IDENTITY-R009.2.4", "GRAPHOS-IDENTITY-R010.1", "GRAPHOS-IDENTITY-R010.2", "GRAPHOS-IDENTITY-R011", @@ -263,7 +267,41 @@ }, { "id": "GRAPHOS-IDENTITY-R009.2", - "title": "OIDC authentication flow", + "title": "OIDC authentication flow (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-IDENTITY-R009.2.1", + "GRAPHOS-IDENTITY-R009.2.2", + "GRAPHOS-IDENTITY-R009.2.3", + "GRAPHOS-IDENTITY-R009.2.4" + ] + }, + { + "id": "GRAPHOS-IDENTITY-R009.2.1", + "title": "Ordered claim-to-rule evaluation", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R009.2.2", + "title": "ID-token validation inputs and typed refusal", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R009.2.3", + "title": "Callback handler wiring", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R009.2.4", + "title": "Live IdP probe", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/identity-access/tasks.md b/specs/identity-access/tasks.md index 737d79f7..b048e86b 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -54,7 +54,11 @@ State: READY FOR IMPLEMENTATION. Check a task only with an exact commit and test - [x] **GRAPHOS-IDENTITY-R008.2:** Scope intersection at use time and immediate revocation - [x] **GRAPHOS-IDENTITY-R009:** Multi-provider OIDC with mapping rules and JIT policy - [x] **GRAPHOS-IDENTITY-R009.1:** Typed OIDC mapping-rule model with refusal tests -- [ ] **GRAPHOS-IDENTITY-R009.2:** PKCE/state/nonce flow, presets, link migration, hinted logout +- [ ] **GRAPHOS-IDENTITY-R009.2:** OIDC authentication flow (rollup) +- [ ] **GRAPHOS-IDENTITY-R009.2.1:** Ordered claim-to-rule evaluation +- [ ] **GRAPHOS-IDENTITY-R009.2.2:** ID-token validation inputs and typed refusal +- [ ] **GRAPHOS-IDENTITY-R009.2.3:** Callback handler wiring, link migration, hinted logout +- [ ] **GRAPHOS-IDENTITY-R009.2.4:** Live IdP probe - [ ] **GRAPHOS-IDENTITY-R010:** LDAPS bind with nested group sync - [ ] **GRAPHOS-IDENTITY-R010.1:** Typed LDAPS bind-config model with refusal tests - [ ] **GRAPHOS-IDENTITY-R010.2:** Directory bind, filter escaping, nested-group sync, deprovisioning diff --git a/tests/identity/test_oidc_rule_selection.py b/tests/identity/test_oidc_rule_selection.py new file mode 100644 index 00000000..df47d9a1 --- /dev/null +++ b/tests/identity/test_oidc_rule_selection.py @@ -0,0 +1,54 @@ +"""Spec-bound tests for ordered OIDC claim-to-rule evaluation.""" + +from typing import Any + +import pytest + +from graph_os.identity.engine import IdentityUnavailable +from graph_os.identity.oidc import OidcMappingRule, select_mapping_rule + + +def _rule(order: int, match: str, provider: str = "kc") -> OidcMappingRule: + return OidcMappingRule( + provider_id=provider, order=order, claim_match=match, jit_policy="create" + ) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +def test_lowest_order_matching_rule_wins() -> None: + rules = [_rule(2, "role=admin"), _rule(1, "role=admin"), _rule(0, "role=ops")] + got = select_mapping_rule(rules, "kc", {"role": "admin"}) + assert got.order == 1 + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +def test_list_claim_matches_member() -> None: + got = select_mapping_rule([_rule(0, "groups=ops")], "kc", {"groups": ["a", "ops"]}) + assert got.claim_match == "groups=ops" + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +@pytest.mark.parametrize("claims", [{}, {"role": "viewer"}, {"role": 7}]) +def test_no_match_is_refused(claims: dict[str, Any]) -> None: + with pytest.raises(IdentityUnavailable): + select_mapping_rule([_rule(0, "role=admin")], "kc", claims) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +def test_other_providers_rules_are_ignored() -> None: + with pytest.raises(IdentityUnavailable): + select_mapping_rule([_rule(0, "role=admin", "other")], "kc", {"role": "admin"}) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +def test_duplicate_order_is_refused() -> None: + rules = [_rule(1, "role=a"), _rule(1, "role=b")] + with pytest.raises(IdentityUnavailable): + select_mapping_rule(rules, "kc", {"role": "a"}) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.1") +@pytest.mark.parametrize("match", ["role", "=x", "role="]) +def test_malformed_claim_match_is_refused(match: str) -> None: + with pytest.raises(IdentityUnavailable): + select_mapping_rule([_rule(0, match)], "kc", {"role": "x"}) From e92d812eb39ddd804da7493613e76ec8076c815c Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:15:50 -0500 Subject: [PATCH 19/25] GRAPHOS-IDENTITY-R011.2.1: split SCIM server surface, add typed ScimUser model R011.2 becomes a rollup of .2.1-.2.4; .2.1 delivers ScimUser with validation/refusal tests. Spec: GRAPHOS-IDENTITY-R011.2.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/identity/scim.py | 42 ++++++++++++++++++++++- specs/identity-access/requirements.md | 6 +++- specs/identity-access/status.json | 40 +++++++++++++++++++++- specs/identity-access/tasks.md | 6 +++- tests/identity/test_scim_user.py | 49 +++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/identity/test_scim_user.py diff --git a/graph_os/identity/scim.py b/graph_os/identity/scim.py index 227dc536..2f05bca5 100644 --- a/graph_os/identity/scim.py +++ b/graph_os/identity/scim.py @@ -1,11 +1,12 @@ """Typed model for the SCIM 2.0 service credential (GRAPHOS-IDENTITY-R011). -Slice .1: the typed model, construction validation, and refusal tests only. +Slices .1 (credential) and .2.1 (User resource model); handlers and routes follow. The create/update/patch/deactivate server surface is a later slice. """ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from .engine import IdentityUnavailable @@ -27,3 +28,42 @@ def __post_init__(self) -> None: def authorizes(self, requested_provider_id: str) -> bool: """A token scoped to a different provider is refused by its caller.""" return requested_provider_id == self.provider_id + + +SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" + + +@dataclass(frozen=True, slots=True) +class ScimUser: + """A validated SCIM 2.0 User resource (GRAPHOS-IDENTITY-R011.2.1).""" + + user_name: str + active: bool = True + external_id: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.user_name, str) or not self.user_name.strip(): + raise IdentityUnavailable("SCIM user requires a userName") + if not isinstance(self.active, bool): + raise IdentityUnavailable("SCIM user active must be a boolean") + if self.external_id is not None and ( + not isinstance(self.external_id, str) or not self.external_id + ): + raise IdentityUnavailable("SCIM user externalId must be non-empty") + + @classmethod + def from_payload(cls, payload: Mapping[str, object]) -> ScimUser: + """Parse a SCIM User payload, refusing a malformed one.""" + schemas = payload.get("schemas") + if not isinstance(schemas, list) or SCIM_USER_SCHEMA not in schemas: + raise IdentityUnavailable("SCIM payload lacks the User schema") + user_name = payload.get("userName") + external_id = payload.get("externalId") + active = payload.get("active", True) + if not isinstance(active, bool): + raise IdentityUnavailable("SCIM user active must be a boolean") + return cls( + user_name=user_name if isinstance(user_name, str) else "", + active=active, + external_id=external_id if isinstance(external_id, str) else None, + ) diff --git a/specs/identity-access/requirements.md b/specs/identity-access/requirements.md index ed4247c0..ed1ff561 100644 --- a/specs/identity-access/requirements.md +++ b/specs/identity-access/requirements.md @@ -27,7 +27,11 @@ | `GRAPHOS-IDENTITY-R011` | **SCIM 2.0 provisioning server.** GraphOS exposes a SCIM 2.0 server bound to a provider-specific service credential, supporting user and group provisioning and deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | | `GRAPHOS-IDENTITY-R012` | **Native SAML service provider.** GraphOS acts as a SAML 2.0 service provider itself, so a deployment can sign in through a SAML identity provider without an OIDC broker in between; brokering through an OIDC provider remains a supported alternative. Assertions are accepted only with a valid signature from the configured provider certificate, the expected audience and recipient, an unexpired validity window and an unreplayed assertion ID, and a SAML sign-in resolves to the same stable principal and narrowed scopes as every other login method. | Integration tests assert a signed assertion yields the stable principal, and unsigned, wrongly signed, wrong-audience, expired and replayed assertions and XML signature-wrapping attempts are each refused. | | `GRAPHOS-IDENTITY-R011.1` | **Typed SCIM service-credential model.** `ScimServiceCredential` refuses an empty provider id and exposes `authorizes()` refusing a mismatched provider. | Verified by refusal unit tests in `tests/identity/test_scim_saml_models.py`. | -| `GRAPHOS-IDENTITY-R011.2` | **SCIM server surface.** Implement the create/update/patch/deactivate flows and provisioning/deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | +| `GRAPHOS-IDENTITY-R011.2` | **SCIM server surface (rollup).** Implement the create/update/patch/deactivate flows and provisioning/deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | +| `GRAPHOS-IDENTITY-R011.2.1` | **Typed SCIM User resource model.** `ScimUser` validates a SCIM 2.0 User payload (schema URN, non-empty userName, boolean active) and refuses a malformed one. | Verified by refusal unit tests in `tests/identity/test_scim_user.py`. | +| `GRAPHOS-IDENTITY-R011.2.2` | **SCIM credential check.** Gate every SCIM request on `ScimServiceCredential.authorizes` and refuse a token scoped to a different provider. | Verified by a cross-provider rejection unit test. | +| `GRAPHOS-IDENTITY-R011.2.3` | **SCIM create/patch/deactivate handlers.** Handlers create, patch and deactivate users through an injected identity port; deactivation never deletes owned data. | Verified by a SCIM contract test over a fake identity port. | +| `GRAPHOS-IDENTITY-R011.2.4` | **SCIM route registration.** Register the `/scim/v2/Users` routes in the graph-os HTTP surface. | Verified by a route-registration test. | | `GRAPHOS-IDENTITY-R012.1` | **Typed SAML service-provider model.** `SamlServiceProvider` refuses a non-URL ACS endpoint and refuses a missing IdP certificate. | Verified by refusal unit tests in `tests/identity/test_saml_models.py`. | | `GRAPHOS-IDENTITY-R012.2` | **Assertion verification and sign-in.** Implement signature, audience/recipient, validity-window and replay checks, and resolve a SAML sign-in to the same stable principal as every other login method. | Integration tests assert a signed assertion yields the stable principal, and unsigned, wrongly signed, wrong-audience, expired and replayed assertions and XML signature-wrapping attempts are each refused. | | `GRAPHOS-IDENTITY-R013` | **Identity CLI commands and doctor diagnostics.** The GraphOS identity CLI supports claim, link-claim, transition, reset-admin, and rotate commands with profile-aware defaults, and its doctor diagnostic reports a failing check for an exposed unauthenticated mode or an automatically generated secret. | Verified by a CLI contract test asserting each command's typed result and a doctor test asserting failing diagnostics for an exposed mode and an auto-generated secret. | diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index e4813248..ebe29e2f 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -30,6 +30,10 @@ "GRAPHOS-IDENTITY-R012", "GRAPHOS-IDENTITY-R011.1", "GRAPHOS-IDENTITY-R011.2", + "GRAPHOS-IDENTITY-R011.2.1", + "GRAPHOS-IDENTITY-R011.2.2", + "GRAPHOS-IDENTITY-R011.2.3", + "GRAPHOS-IDENTITY-R011.2.4", "GRAPHOS-IDENTITY-R012.1", "GRAPHOS-IDENTITY-R012.2", "GRAPHOS-IDENTITY-R013", @@ -330,7 +334,41 @@ }, { "id": "GRAPHOS-IDENTITY-R011.2", - "title": "SCIM server surface", + "title": "SCIM server surface (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-IDENTITY-R011.2.1", + "GRAPHOS-IDENTITY-R011.2.2", + "GRAPHOS-IDENTITY-R011.2.3", + "GRAPHOS-IDENTITY-R011.2.4" + ] + }, + { + "id": "GRAPHOS-IDENTITY-R011.2.1", + "title": "Typed SCIM User resource model", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R011.2.2", + "title": "SCIM credential check", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R011.2.3", + "title": "SCIM create/patch/deactivate handlers", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R011.2.4", + "title": "SCIM route registration", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/identity-access/tasks.md b/specs/identity-access/tasks.md index 737d79f7..dafa1242 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -59,7 +59,11 @@ State: READY FOR IMPLEMENTATION. Check a task only with an exact commit and test - [ ] **GRAPHOS-IDENTITY-R010.1:** Typed LDAPS bind-config model with refusal tests - [ ] **GRAPHOS-IDENTITY-R010.2:** Directory bind, filter escaping, nested-group sync, deprovisioning - [ ] **GRAPHOS-IDENTITY-R011.1:** Typed SCIM service-credential model with refusal tests -- [ ] **GRAPHOS-IDENTITY-R011.2:** SCIM create/update/patch/deactivate server surface +- [ ] **GRAPHOS-IDENTITY-R011.2:** SCIM create/update/patch/deactivate server surface (rollup) +- [x] **GRAPHOS-IDENTITY-R011.2.1:** Typed SCIM User resource model with refusal tests +- [ ] **GRAPHOS-IDENTITY-R011.2.2:** SCIM credential check via `ScimServiceCredential.authorizes` +- [ ] **GRAPHOS-IDENTITY-R011.2.3:** SCIM create/patch/deactivate handlers through an injected identity port +- [ ] **GRAPHOS-IDENTITY-R011.2.4:** SCIM route registration - [ ] **GRAPHOS-IDENTITY-R012:** Native SAML service provider (rollup) - [ ] **GRAPHOS-IDENTITY-R012.1:** Typed SAML service-provider model with refusal tests - [ ] **GRAPHOS-IDENTITY-R012.2:** Assertion verification and SAML sign-in diff --git a/tests/identity/test_scim_user.py b/tests/identity/test_scim_user.py new file mode 100644 index 00000000..c6616660 --- /dev/null +++ b/tests/identity/test_scim_user.py @@ -0,0 +1,49 @@ +"""Bound tests for the typed SCIM User resource model (R011.2.1).""" + +import pytest + +from graph_os.identity.engine import IdentityUnavailable +from graph_os.identity.scim import SCIM_USER_SCHEMA, ScimUser + +_ID = "GRAPHOS-IDENTITY-R011.2.1" + + +def _payload(**extra: object) -> dict[str, object]: + return {"schemas": [SCIM_USER_SCHEMA], "userName": "alice", **extra} + + +@pytest.mark.spec(_ID) +def test_scim_user_parses_valid_payload() -> None: + user = ScimUser.from_payload(_payload(externalId="x1", active=False)) + assert (user.user_name, user.active, user.external_id) == ("alice", False, "x1") + + +@pytest.mark.spec(_ID) +def test_scim_user_defaults_active() -> None: + assert ScimUser.from_payload(_payload()).active is True + + +@pytest.mark.spec(_ID) +def test_scim_user_refuses_missing_schema() -> None: + with pytest.raises(IdentityUnavailable): + ScimUser.from_payload({"userName": "alice"}) + + +@pytest.mark.spec(_ID) +def test_scim_user_refuses_blank_user_name() -> None: + with pytest.raises(IdentityUnavailable): + ScimUser(user_name=" ") + with pytest.raises(IdentityUnavailable): + ScimUser.from_payload({"schemas": [SCIM_USER_SCHEMA]}) + + +@pytest.mark.spec(_ID) +def test_scim_user_refuses_non_boolean_active() -> None: + with pytest.raises(IdentityUnavailable): + ScimUser.from_payload(_payload(active="false")) + + +@pytest.mark.spec(_ID) +def test_scim_user_refuses_empty_external_id() -> None: + with pytest.raises(IdentityUnavailable): + ScimUser(user_name="alice", external_id="") From fdbaebdb795a10c79e7db1ebc8b54e7a767f54c0 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:15:58 -0500 Subject: [PATCH 20/25] R012.2.1: SAML parsed-assertion model and condition checks Split R012.2 into .2.1-.2.4; deliver .2.1: ParsedSamlAssertion plus pure audience/recipient/validity-window checks with typed refusal reasons. Spec: GRAPHOS-IDENTITY-R012.2.1 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/identity/saml.py | 80 +++++++++++++++- specs/identity-access/requirements.md | 6 +- specs/identity-access/status.json | 40 +++++++- specs/identity-access/tasks.md | 6 +- .../test_saml_assertion_conditions.py | 92 +++++++++++++++++++ 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 tests/identity/test_saml_assertion_conditions.py diff --git a/graph_os/identity/saml.py b/graph_os/identity/saml.py index ae3ea5ef..2d15bdd3 100644 --- a/graph_os/identity/saml.py +++ b/graph_os/identity/saml.py @@ -1,12 +1,16 @@ """Typed model for the native SAML service provider (GRAPHOS-IDENTITY-R012). Slice .1: the typed model, construction validation, and refusal tests only. -Signature/audience/replay verification of an assertion is a later slice. +Slice .2.1: the typed parsed-assertion model and the pure audience, recipient +and validity-window checks, each refused with a typed reason. Signature +verification, sign-in wiring and the live IdP probe are later slices. """ from __future__ import annotations from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import StrEnum from .engine import IdentityUnavailable @@ -35,3 +39,77 @@ def __post_init__(self) -> None: raise IdentityUnavailable( "SAML service provider requires a configured IdP certificate" ) + + +class SamlRefusalReason(StrEnum): + """Why a parsed assertion was refused.""" + + WRONG_AUDIENCE = "wrong_audience" + WRONG_RECIPIENT = "wrong_recipient" + NOT_YET_VALID = "not_yet_valid" + EXPIRED = "expired" + + +class SamlAssertionRefused(IdentityUnavailable): + """A parsed SAML assertion failed a validity check; carries the typed reason.""" + + def __init__(self, reason: SamlRefusalReason, message: str) -> None: + super().__init__(message) + self.reason = reason + + +@dataclass(frozen=True, slots=True) +class ParsedSamlAssertion: + """The already-parsed claims of one assertion (no XML handled here).""" + + assertion_id: str + issuer: str + subject: str + audiences: tuple[str, ...] + recipient: str + not_before: datetime + not_on_or_after: datetime + + def __post_init__(self) -> None: + for name in ("assertion_id", "issuer", "subject", "recipient"): + value = getattr(self, name) + if not isinstance(value, str) or not value: + raise IdentityUnavailable(f"SAML assertion requires a {name}") + if not self.audiences or not all( + isinstance(a, str) and a for a in self.audiences + ): + raise IdentityUnavailable("SAML assertion requires an audience") + for name in ("not_before", "not_on_or_after"): + value = getattr(self, name) + if not isinstance(value, datetime) or value.tzinfo is None: + raise IdentityUnavailable( + f"SAML assertion {name} must be a timezone-aware datetime" + ) + if self.not_on_or_after <= self.not_before: + raise IdentityUnavailable("SAML assertion validity window is empty") + + +def check_assertion_conditions( + provider: SamlServiceProvider, + assertion: ParsedSamlAssertion, + now: datetime, + clock_skew: timedelta = timedelta(0), +) -> None: + """Refuse unless audience, recipient and validity window all match.""" + if now.tzinfo is None: + raise IdentityUnavailable("SAML check time must be timezone-aware") + if provider.entity_id not in assertion.audiences: + raise SamlAssertionRefused( + SamlRefusalReason.WRONG_AUDIENCE, "assertion audience is not this provider" + ) + if assertion.recipient != provider.acs_url: + raise SamlAssertionRefused( + SamlRefusalReason.WRONG_RECIPIENT, "assertion recipient is not the ACS URL" + ) + now_utc = now.astimezone(UTC) + if now_utc + clock_skew < assertion.not_before: + raise SamlAssertionRefused( + SamlRefusalReason.NOT_YET_VALID, "assertion is not yet valid" + ) + if now_utc - clock_skew >= assertion.not_on_or_after: + raise SamlAssertionRefused(SamlRefusalReason.EXPIRED, "assertion has expired") diff --git a/specs/identity-access/requirements.md b/specs/identity-access/requirements.md index ed4247c0..a68dcb4d 100644 --- a/specs/identity-access/requirements.md +++ b/specs/identity-access/requirements.md @@ -29,7 +29,11 @@ | `GRAPHOS-IDENTITY-R011.1` | **Typed SCIM service-credential model.** `ScimServiceCredential` refuses an empty provider id and exposes `authorizes()` refusing a mismatched provider. | Verified by refusal unit tests in `tests/identity/test_scim_saml_models.py`. | | `GRAPHOS-IDENTITY-R011.2` | **SCIM server surface.** Implement the create/update/patch/deactivate flows and provisioning/deprovisioning without deleting owned data. | Verified by a SCIM contract test covering create/update/patch/deactivate flows and rejection of a token scoped to a different provider. | | `GRAPHOS-IDENTITY-R012.1` | **Typed SAML service-provider model.** `SamlServiceProvider` refuses a non-URL ACS endpoint and refuses a missing IdP certificate. | Verified by refusal unit tests in `tests/identity/test_saml_models.py`. | -| `GRAPHOS-IDENTITY-R012.2` | **Assertion verification and sign-in.** Implement signature, audience/recipient, validity-window and replay checks, and resolve a SAML sign-in to the same stable principal as every other login method. | Integration tests assert a signed assertion yields the stable principal, and unsigned, wrongly signed, wrong-audience, expired and replayed assertions and XML signature-wrapping attempts are each refused. | +| `GRAPHOS-IDENTITY-R012.2` | **Assertion verification and sign-in (rollup of .2.1-.2.4).** Implement signature, audience/recipient, validity-window and replay checks, and resolve a SAML sign-in to the same stable principal as every other login method. | Integration tests assert a signed assertion yields the stable principal, and unsigned, wrongly signed, wrong-audience, expired and replayed assertions and XML signature-wrapping attempts are each refused. | +| `GRAPHOS-IDENTITY-R012.2.1` | **Typed parsed-assertion model and condition checks.** `ParsedSamlAssertion` refuses malformed claims, and `check_assertion_conditions()` refuses a wrong audience, wrong recipient, not-yet-valid or expired assertion with a typed `SamlRefusalReason`. | Verified by refusal unit tests in `tests/identity/test_saml_assertion_conditions.py`. | +| `GRAPHOS-IDENTITY-R012.2.2` | **Signature verification through an injected verifier port.** Define a `SamlSignatureVerifier` port and refuse unsigned, wrongly signed and signature-wrapped assertions; no crypto or XML implementation lives in graph-os. | Verified by unit tests with a fake verifier port covering unsigned, wrongly signed and wrapped assertions. | +| `GRAPHOS-IDENTITY-R012.2.3` | **Replay check and sign-in handler wiring.** Refuse a replayed assertion ID and resolve an accepted assertion to the same stable principal and narrowed scopes as every other login method. | Integration tests assert a verified assertion yields the stable principal and a replayed assertion is refused. | +| `GRAPHOS-IDENTITY-R012.2.4` | **Live IdP sign-in probe.** A committed probe signs in through a real SAML IdP; needs a live IdP service. | Verified by the probe run against the deployed IdP. | | `GRAPHOS-IDENTITY-R013` | **Identity CLI commands and doctor diagnostics.** The GraphOS identity CLI supports claim, link-claim, transition, reset-admin, and rotate commands with profile-aware defaults, and its doctor diagnostic reports a failing check for an exposed unauthenticated mode or an automatically generated secret. | Verified by a CLI contract test asserting each command's typed result and a doctor test asserting failing diagnostics for an exposed mode and an auto-generated secret. | | `GRAPHOS-IDENTITY-R014` | **Shared RBAC decision oracle across identity modes.** GraphOS runs the same principal-by-scope-by-action-by-resource decision table against its unauthenticated, local, and external authentication modes, the last using mock OIDC and LDAP providers, and asserts identical allow/deny outcomes, reason codes, and claims digest for each principal across modes, including after a full mode-transition round trip. | Verified by a cross-mode oracle test comparing decision-table results and claims digests across all three modes. | | `GRAPHOS-IDENTITY-R015` | **Identity operations documentation and cutover runbook.** GraphOS documents its identity and access operations and configuration, and provides a runbook for cutting an existing deployment over between authentication modes, referencing a committed browser sign-in probe as the verification step for an external provider. | Verified by following the documented runbook end to end against a test deployment and confirming each described step and diagnostic matches observed behavior. | diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index e4813248..dfd40197 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -32,6 +32,10 @@ "GRAPHOS-IDENTITY-R011.2", "GRAPHOS-IDENTITY-R012.1", "GRAPHOS-IDENTITY-R012.2", + "GRAPHOS-IDENTITY-R012.2.1", + "GRAPHOS-IDENTITY-R012.2.2", + "GRAPHOS-IDENTITY-R012.2.3", + "GRAPHOS-IDENTITY-R012.2.4", "GRAPHOS-IDENTITY-R013", "GRAPHOS-IDENTITY-R014", "GRAPHOS-IDENTITY-R015", @@ -350,7 +354,41 @@ }, { "id": "GRAPHOS-IDENTITY-R012.2", - "title": "Assertion verification and sign-in", + "title": "Assertion verification and sign-in (rollup of .2.1-.2.4)", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-IDENTITY-R012.2.1", + "GRAPHOS-IDENTITY-R012.2.2", + "GRAPHOS-IDENTITY-R012.2.3", + "GRAPHOS-IDENTITY-R012.2.4" + ] + }, + { + "id": "GRAPHOS-IDENTITY-R012.2.1", + "title": "Typed parsed-assertion model and condition checks", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R012.2.2", + "title": "Signature verification through an injected verifier port", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R012.2.3", + "title": "Replay check and sign-in handler wiring", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-IDENTITY-R012.2.4", + "title": "Live IdP sign-in probe", "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] diff --git a/specs/identity-access/tasks.md b/specs/identity-access/tasks.md index 737d79f7..f69c68e9 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -62,7 +62,11 @@ State: READY FOR IMPLEMENTATION. Check a task only with an exact commit and test - [ ] **GRAPHOS-IDENTITY-R011.2:** SCIM create/update/patch/deactivate server surface - [ ] **GRAPHOS-IDENTITY-R012:** Native SAML service provider (rollup) - [ ] **GRAPHOS-IDENTITY-R012.1:** Typed SAML service-provider model with refusal tests -- [ ] **GRAPHOS-IDENTITY-R012.2:** Assertion verification and SAML sign-in +- [ ] **GRAPHOS-IDENTITY-R012.2:** Assertion verification and SAML sign-in (rollup) +- [ ] **GRAPHOS-IDENTITY-R012.2.1:** Typed parsed-assertion model and audience/recipient/time-window checks +- [ ] **GRAPHOS-IDENTITY-R012.2.2:** Signature verification through an injected verifier port +- [ ] **GRAPHOS-IDENTITY-R012.2.3:** Replay check and sign-in handler wiring +- [ ] **GRAPHOS-IDENTITY-R012.2.4:** Live IdP sign-in probe - [ ] **GRAPHOS-IDENTITY-R016:** Optional SMTP adapter for identity email - [ ] **GRAPHOS-IDENTITY-R016.1:** Remaining scope of GRAPHOS-IDENTITY-R016 (slice .1): Optional SMTP adapter for identity email - [ ] **GRAPHOS-IDENTITY-R016.2:** Remaining scope of GRAPHOS-IDENTITY-R016 (slice .2): Optional SMTP adapter for identity email diff --git a/tests/identity/test_saml_assertion_conditions.py b/tests/identity/test_saml_assertion_conditions.py new file mode 100644 index 00000000..a73aaec5 --- /dev/null +++ b/tests/identity/test_saml_assertion_conditions.py @@ -0,0 +1,92 @@ +"""Pure assertion-condition checks for the .2.1 slice: R012.2.1.""" + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from graph_os.identity.engine import IdentityUnavailable +from graph_os.identity.saml import ( + ParsedSamlAssertion, + SamlAssertionRefused, + SamlRefusalReason, + SamlServiceProvider, + check_assertion_conditions, +) + +_CERT = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----" +_ACS = "https://graph-os.example.org/saml/acs" +_T0 = datetime(2026, 10, 10, 12, 0, tzinfo=UTC) +_PROVIDER = SamlServiceProvider("urn:graph-os:sp", _ACS, _CERT) + + +def _assertion(**overrides: Any) -> ParsedSamlAssertion: + fields: dict[str, Any] = { + "assertion_id": "_a1", + "issuer": "https://idp.example.org", + "subject": "alice", + "audiences": ("urn:graph-os:sp",), + "recipient": _ACS, + "not_before": _T0 - timedelta(minutes=1), + "not_on_or_after": _T0 + timedelta(minutes=5), + } + fields.update(overrides) + return ParsedSamlAssertion(**fields) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R012.2.1") +def test_valid_assertion_passes() -> None: + check_assertion_conditions(_PROVIDER, _assertion(), _T0) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R012.2.1") +@pytest.mark.parametrize( + ("overrides", "now", "reason"), + [ + ({"audiences": ("urn:other",)}, _T0, SamlRefusalReason.WRONG_AUDIENCE), + ( + {"recipient": "https://evil.example/acs"}, + _T0, + SamlRefusalReason.WRONG_RECIPIENT, + ), + ({}, _T0 - timedelta(minutes=2), SamlRefusalReason.NOT_YET_VALID), + ({}, _T0 + timedelta(minutes=5), SamlRefusalReason.EXPIRED), + ], +) +def test_condition_failures_are_refused_with_typed_reason( + overrides: dict[str, Any], now: datetime, reason: SamlRefusalReason +) -> None: + with pytest.raises(SamlAssertionRefused) as info: + check_assertion_conditions(_PROVIDER, _assertion(**overrides), now) + assert info.value.reason is reason + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R012.2.1") +def test_clock_skew_tolerates_small_drift() -> None: + check_assertion_conditions( + _PROVIDER, + _assertion(), + _T0 + timedelta(minutes=5, seconds=10), + clock_skew=timedelta(seconds=30), + ) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R012.2.1") +@pytest.mark.parametrize( + "overrides", + [ + {"assertion_id": ""}, + {"audiences": ()}, + {"not_before": datetime(2026, 10, 10, 12, 0)}, + {"not_on_or_after": _T0 - timedelta(minutes=5)}, + ], +) +def test_malformed_assertion_is_refused(overrides: dict[str, Any]) -> None: + with pytest.raises(IdentityUnavailable): + _assertion(**overrides) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R012.2.1") +def test_naive_check_time_is_refused() -> None: + with pytest.raises(IdentityUnavailable): + check_assertion_conditions(_PROVIDER, _assertion(), datetime(2026, 10, 10, 12)) From e78a5e5b701ec135db23e5bac68edb64fb9f05f7 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:16:26 -0500 Subject: [PATCH 21/25] GRAPHOS-OPS-R021.2.1: add retrieval.search read op; split R021.2 Split R021.2 into .1 retrieval.search and .2 retrieval.freshness; deliver .1 through a typed retriever port that truncates hits to the context budget and fails closed with UNAVAILABLE. Spec: GRAPHOS-OPS-R021.2.1 Co-Authored-By: Claude Sonnet 5.5 --- graph_os/api/ops/registry_factory.py | 2 + graph_os/api/ops/retrieval.py | 106 ++++++++++++++++++++ specs/hosted-api-operations/requirements.md | 4 +- specs/hosted-api-operations/status.json | 20 ++++ specs/hosted-api-operations/tasks.md | 2 + tests/api/test_registry_factory.py | 2 + tests/api/test_retrieval_ops.py | 56 +++++++++++ 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 graph_os/api/ops/retrieval.py create mode 100644 tests/api/test_retrieval_ops.py diff --git a/graph_os/api/ops/registry_factory.py b/graph_os/api/ops/registry_factory.py index c9eb8a64..5390b8f1 100644 --- a/graph_os/api/ops/registry_factory.py +++ b/graph_os/api/ops/registry_factory.py @@ -32,6 +32,7 @@ def get_registry() -> Registry: memory, ops, policy, + retrieval, security, swarm, telemetry, @@ -51,6 +52,7 @@ def get_registry() -> Registry: memory, ops, policy, + retrieval, security, swarm, telemetry, diff --git a/graph_os/api/ops/retrieval.py b/graph_os/api/ops/retrieval.py new file mode 100644 index 00000000..755d7cbe --- /dev/null +++ b/graph_os/api/ops/retrieval.py @@ -0,0 +1,106 @@ +"""Retrieval read operations (GRAPHOS-OPS-R021.2.1, retrieval slice). + +``retrieval.search`` runs a context-budgeted retrieval through one typed port +under the caller's own tenant and authority; there is no local cache +substitute. An uncomposed retriever fails closed with a typed ``UNAVAILABLE`` +refusal, matching :mod:`graph_os.api.ops.ingest` and +:mod:`graph_os.api.ops.memory`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field + +from graph_os.api.ops._common import Params, bound_service, build_read_op +from graph_os.api.registry import OpSpec, Verb + + +class RetrievalSearchParams(Params): + query: str = Field(min_length=1, max_length=4096) + context_budget: int = Field(default=4096, ge=1, le=1_000_000) + + +class RetrievalHit(BaseModel): + """One retrieved passage with its provenance reference.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + ref: str + text: str + tokens: int = Field(ge=0) + + +class RetrievalSearchOutcome(BaseModel): + """Budget-bounded hits for one tenant-scoped query.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + tenant: str + hits: tuple[RetrievalHit, ...] + + +class RetrievalSearchResult(Params): + value: dict[str, Any] + + +@runtime_checkable +class Retriever(Protocol): + """The one typed port GraphOS calls the engine retrieval through.""" + + async def search( + self, *, tenant: str, query: str, context_budget: int + ) -> RetrievalSearchOutcome: ... + + +async def handle_retrieval_search( + context: Any, params: Mapping[str, Any], op: OpSpec +) -> dict[str, Any]: + """Return budget-bounded hits for the caller's own tenant.""" + retriever: Retriever = bound_service( + context, "retriever", reason="retriever is not composed" + ) + budget = params["context_budget"] + outcome = await retriever.search( + tenant=context.caller.tenant, query=params["query"], context_budget=budget + ) + kept: list[RetrievalHit] = [] + used = 0 + for hit in outcome.hits: + if used + hit.tokens > budget: + break + used += hit.tokens + kept.append(hit) + bounded = outcome.model_copy(update={"hits": tuple(kept)}) + return {"value": bounded.model_dump(mode="json")} + + +def operations() -> tuple[OpSpec, ...]: + return ( + build_read_op( + verb=Verb.FIND, + op_id="retrieval.search", + summary="Retrieve passages for a query within a context budget", + examples=("find passages about this topic",), + params=RetrievalSearchParams, + result=RetrievalSearchResult, + handler="graph_os.api.ops.retrieval.handle_retrieval_search", + scope="memory:read", + ), + ) + + +specs = operations + +__all__ = [ + "RetrievalHit", + "RetrievalSearchOutcome", + "RetrievalSearchParams", + "RetrievalSearchResult", + "Retriever", + "handle_retrieval_search", + "operations", + "specs", +] diff --git a/specs/hosted-api-operations/requirements.md b/specs/hosted-api-operations/requirements.md index 50cc281e..b31360a7 100644 --- a/specs/hosted-api-operations/requirements.md +++ b/specs/hosted-api-operations/requirements.md @@ -38,7 +38,9 @@ | `GRAPHOS-OPS-R020.5` | **Embedding admission and re-embedding operations.** GraphOS exposes `ingest.embedding.{admit,reembed}` through the same typed runner port. | Integration tests confirm embedding operations reach the runner and report durable job state. | | `GRAPHOS-OPS-R021` | **Decision, retrieval, and policy operations.** GraphOS exposes decide, retrieval, context, freshness, policy, and swarm read operations while restricting decision-commit actions to the service executor, so no caller can materialize an agent decision using only a human-scoped token. | Integration tests confirm decision commits are service-only and that provenance is attached to each result. | | `GRAPHOS-OPS-R021.1` | **Decide operations (service-executed) — decide slice.** Implements the decide portion of `GRAPHOS-OPS-R021`: `decide.*` operations that evaluate and commit agent decisions, routing the commit path through the service executor only (never the caller-bound path) and attaching provenance to each result. | A test confirms a human-scoped caller cannot invoke the commit path directly and that every decide result carries provenance. | -| `GRAPHOS-OPS-R021.2` | **Retrieval, context, and freshness read operations — retrieval slice.** Implements the retrieval portion of `GRAPHOS-OPS-R021`: `retrieval.*` read operations, including context-budgeted retrieval and freshness checks, executing caller-bound engine queries without a local cache substitute. | A test confirms a retrieval call reaches the engine under the caller's own authority and respects a context budget. | +| `GRAPHOS-OPS-R021.2` | **Retrieval, context, and freshness read operations — retrieval slice.** Delivered as the rollup of `GRAPHOS-OPS-R021.2.1` through `GRAPHOS-OPS-R021.2.2`. Implements the retrieval portion of `GRAPHOS-OPS-R021`: `retrieval.*` read operations, including context-budgeted retrieval and freshness checks, executing caller-bound engine queries without a local cache substitute. | A test confirms a retrieval call reaches the engine under the caller's own authority and respects a context budget. | +| `GRAPHOS-OPS-R021.2.1` | **`retrieval.search` op.** GraphOS exposes `retrieval.search` as a read operation through a typed retriever port, returning context-budgeted hits under the caller's own tenant and failing closed with `UNAVAILABLE` when the port is not composed. | Tests confirm the op reaches the retriever with the caller's tenant, truncates hits to the context budget, declares the existing `memory:read` scope, and fails closed with `UNAVAILABLE` when the retriever is absent. | +| `GRAPHOS-OPS-R021.2.2` | **`retrieval.freshness` op.** GraphOS exposes `retrieval.freshness` as a read operation through the same typed retriever port, reporting freshness of the caller's retrieval sources. Depends on `GRAPHOS-OPS-R021.2.1` for the shared retriever port. | Tests confirm the op reaches the retriever with the caller's tenant, is read-only, and fails closed with `UNAVAILABLE` when the retriever is absent. | | `GRAPHOS-OPS-R021.3` | **Policy and swarm read operations — policy slice.** Implements the policy portion of `GRAPHOS-OPS-R021`: `policy.*` and `swarm.*` read operations surfacing the active policy state and swarm topology to an authorized caller. | A test confirms policy and swarm reads are caller-scoped and read-only. | | `GRAPHOS-OPS-R022` | **Work and evolution loop operations.** GraphOS exposes work-item and offer operations plus evolution loop, schedule, and proposal operations, with its daemon acting only as a status/run/pause facade while durable loop state remains owned by the underlying service. | Integration tests confirm run/pause requires the loops:control scope, reads require loops:read, and a repeated run stays idempotent. | | `GRAPHOS-OPS-R022.1` | **Work-item and offer operations — work slice.** Implements the work portion of `GRAPHOS-OPS-R022`: `work.*` operations for work-item and offer read/write access, bound by the same control leases the fleet-facing listing already enforces. | A test confirms a work-item write requires the declared scope and that offers are visible only to their bound principal. | diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 7224dac0..e603a3cd 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -41,6 +41,8 @@ "GRAPHOS-OPS-R021", "GRAPHOS-OPS-R021.1", "GRAPHOS-OPS-R021.2", + "GRAPHOS-OPS-R021.2.1", + "GRAPHOS-OPS-R021.2.2", "GRAPHOS-OPS-R021.3", "GRAPHOS-OPS-R022", "GRAPHOS-OPS-R022.1", @@ -472,6 +474,24 @@ "title": "Retrieval, context, and freshness read operations — retrieval slice", "delivery_state": "SPECIFIED", "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-OPS-R021.2.1", + "GRAPHOS-OPS-R021.2.2" + ] + }, + { + "id": "GRAPHOS-OPS-R021.2.1", + "title": "`retrieval.search` op", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R021.2.2", + "title": "`retrieval.freshness` op", + "delivery_state": "SPECIFIED", + "landed_in": [], "verified_by": [] }, { diff --git a/specs/hosted-api-operations/tasks.md b/specs/hosted-api-operations/tasks.md index 263ef023..452e2b32 100644 --- a/specs/hosted-api-operations/tasks.md +++ b/specs/hosted-api-operations/tasks.md @@ -13,6 +13,8 @@ Status: **READY FOR IMPLEMENTATION**. Delivery: **NOT ACCEPTED**. See [design](p - [ ] Review public documentation and mark only individually proven capabilities accepted. - [x] **GRAPHOS-OPS-R021.1:** Decide operations (service-executed) — decide slice of `GRAPHOS-OPS-R021`. - [ ] **GRAPHOS-OPS-R021.2:** Retrieval, context, and freshness read operations — retrieval slice of `GRAPHOS-OPS-R021`. +- [ ] **GRAPHOS-OPS-R021.2.1:** `retrieval.search` op +- [ ] **GRAPHOS-OPS-R021.2.2:** `retrieval.freshness` op - [ ] **GRAPHOS-OPS-R021.3:** Policy and swarm read operations — policy slice of `GRAPHOS-OPS-R021`. - [ ] **GRAPHOS-OPS-R022.1:** Work-item and offer operations — work slice of `GRAPHOS-OPS-R022`. - [ ] **GRAPHOS-OPS-R022.2:** Evolution loop, schedule, and proposal operations — evolution slice of `GRAPHOS-OPS-R022`. diff --git a/tests/api/test_registry_factory.py b/tests/api/test_registry_factory.py index b965ccbc..80f6081c 100644 --- a/tests/api/test_registry_factory.py +++ b/tests/api/test_registry_factory.py @@ -20,6 +20,7 @@ memory, ops, policy, + retrieval, security, swarm, telemetry, @@ -103,6 +104,7 @@ def test_no_argument_factory_is_deterministic_and_complete( *telemetry.operations(), *usage.operations(), *policy.operations(), + *retrieval.operations(), *swarm.operations(), *memory.operations(), *work.operations(), diff --git a/tests/api/test_retrieval_ops.py b/tests/api/test_retrieval_ops.py new file mode 100644 index 00000000..0dbcce33 --- /dev/null +++ b/tests/api/test_retrieval_ops.py @@ -0,0 +1,56 @@ +"""Focused authority tests for ``retrieval.search`` (GRAPHOS-OPS-R021.2.1).""" + +from __future__ import annotations + +import pytest + +from graph_os.api.invoke.pipeline import OperationRefused +from graph_os.api.ops import retrieval +from graph_os.api.ops.retrieval import RetrievalHit, RetrievalSearchOutcome +from tests.api._ops_support import tenant_store_context + + +class _FakeRetriever: + def __init__(self) -> None: + self.calls: list[tuple[str, str, int]] = [] + + async def search( + self, *, tenant: str, query: str, context_budget: int + ) -> RetrievalSearchOutcome: + self.calls.append((tenant, query, context_budget)) + return RetrievalSearchOutcome( + tenant=tenant, + hits=( + RetrievalHit(ref="a", text="one", tokens=60), + RetrievalHit(ref="b", text="two", tokens=60), + ), + ) + + +def _context(retriever: object | None): + return tenant_store_context(store=retriever, service_name="retriever") + + +@pytest.mark.spec("GRAPHOS-OPS-R021.2.1") +async def test_search_reaches_engine_under_callers_tenant_within_budget( + op_by_id, +) -> None: + fake = _FakeRetriever() + op = op_by_id(retrieval.operations(), "retrieval.search") + result = await retrieval.handle_retrieval_search( + _context(fake), {"query": "q", "context_budget": 100}, op + ) + assert fake.calls == [("tenant-a", "q", 100)] + assert result["value"]["tenant"] == "tenant-a" + assert [h["ref"] for h in result["value"]["hits"]] == ["a"] + assert op.scopes == frozenset({"memory:read"}) + + +@pytest.mark.spec("GRAPHOS-OPS-R021.2.1") +async def test_search_fails_closed_when_retriever_not_composed(op_by_id) -> None: + op = op_by_id(retrieval.operations(), "retrieval.search") + with pytest.raises(OperationRefused) as excinfo: + await retrieval.handle_retrieval_search( + _context(None), {"query": "q", "context_budget": 100}, op + ) + assert excinfo.value.code == "UNAVAILABLE" From 94ee7217129408fea19a98ab5b4a0a6ebf2ca3e7 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:16:30 -0500 Subject: [PATCH 22/25] GRAPHOS-MCP-RESOURCES-R004: bind status docs to gate vocabulary Add docs-consistency tests asserting docs/status.md and docs/fleet.md cite exactly the reingestion-unreconciled code the gate returns. Spec: GRAPHOS-MCP-RESOURCES-R004 Co-Authored-By: Claude Sonnet 5.5 --- tests/fleet/test_mcp_resource_status_docs.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/fleet/test_mcp_resource_status_docs.py diff --git a/tests/fleet/test_mcp_resource_status_docs.py b/tests/fleet/test_mcp_resource_status_docs.py new file mode 100644 index 00000000..8fe22a70 --- /dev/null +++ b/tests/fleet/test_mcp_resource_status_docs.py @@ -0,0 +1,50 @@ +"""Status docs must describe exactly the vocabulary the reconciliation gate returns.""" + +from __future__ import annotations + +from pathlib import Path +from typing import get_args + +import pytest + +from graph_os.fleet import mcp_resource_reconciliation as gate + +DOCS = Path(__file__).resolve().parents[2] / "docs" +UNRECONCILED = "reingestion-unreconciled" + + +def _gate_unreconciled_codes() -> set[str]: + return {code for code in get_args(gate.GateStatus) if code != "reconciled"} + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R004") +def test_gate_has_single_unreconciled_code() -> None: + assert _gate_unreconciled_codes() == {UNRECONCILED} + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R004") +@pytest.mark.parametrize("doc", ["status.md", "fleet.md"]) +def test_docs_cite_gate_unreconciled_code(doc: str) -> None: + text = (DOCS / doc).read_text(encoding="utf-8") + assert f"`{UNRECONCILED}`" in text + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R004") +def test_status_row_states_no_publication_claim() -> None: + rows = [ + line + for line in (DOCS / "status.md").read_text(encoding="utf-8").splitlines() + if "four-family MCP resource/template reconciliation" in line + ] + assert len(rows) == 1 + assert f"`{UNRECONCILED}`" in rows[0] + assert "does not claim publication" in rows[0] + + +@pytest.mark.spec("GRAPHOS-MCP-RESOURCES-R004") +def test_docs_do_not_invent_unreconciled_codes() -> None: + for doc in ("status.md", "fleet.md"): + text = (DOCS / doc).read_text(encoding="utf-8") + for token in text.replace("`", " ").split(): + if token.endswith("-unreconciled"): + assert token.strip(".,;:") in _gate_unreconciled_codes() From 37e55a637dffe60ad6ae2d2b732d149c7417d5a6 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:16:36 -0500 Subject: [PATCH 23/25] chore(clones): re-review the operations() register entry after ingest.jobs.status Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- .config/dupehound-distinct.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dupehound-distinct.toml b/.config/dupehound-distinct.toml index a00252ea..9a8b557d 100644 --- a/.config/dupehound-distinct.toml +++ b/.config/dupehound-distinct.toml @@ -36,7 +36,7 @@ left_name = "operations" left_digest = "sha256:1dd92a3cf607864448698507dd859bedcd0b2837cddb97a0aeef56d1adc0a706" right_file = "graph_os/api/ops/ingest.py" right_name = "operations" -right_digest = "sha256:b34d30a8a6914622efd841d8584e31cac462f51acf90ab8f9a1e51992dbe2193" +right_digest = "sha256:7a6286e3952d0525803507b1a6ddbcd6c8023a3b65fd2305c93369b41e3ae149" reason = "Both declare an unrelated domain's own OpSpec registry (operational-admin vs ingestion) using the one mandated declarative registration shape every ops module shares by design; the registry factory, not this duplication, is the single point of consolidation." reviewed_on = "2026-10-10" From ee1f8cecbf7c020027ab81f948e0161cd168b43e Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:16:55 -0500 Subject: [PATCH 24/25] chore(specs): regenerate status and re-review registers after adding #223-#229 Spec: none (refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- specs/hosted-api-operations/status.json | 15 ++++-- specs/identity-access/status.json | 51 ++++++++++++++----- .../status.json | 13 +++-- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/specs/hosted-api-operations/status.json b/specs/hosted-api-operations/status.json index 78c43fab..fb334e64 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -475,7 +475,9 @@ "id": "GRAPHOS-OPS-R021.2", "title": "Retrieval, context, and freshness read operations — retrieval slice", "delivery_state": "SPECIFIED", - "landed_in": [], + "landed_in": [ + "e78a5e5b701e" + ], "verified_by": [], "rollup_of": [ "GRAPHOS-OPS-R021.2.1", @@ -485,9 +487,14 @@ { "id": "GRAPHOS-OPS-R021.2.1", "title": "`retrieval.search` op", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "e78a5e5b701e" + ], + "verified_by": [ + "tests/api/test_retrieval_ops.py:34", + "tests/api/test_retrieval_ops.py:49" + ] }, { "id": "GRAPHOS-OPS-R021.2.2", diff --git a/specs/identity-access/status.json b/specs/identity-access/status.json index cee143f3..251fdd91 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -293,9 +293,17 @@ { "id": "GRAPHOS-IDENTITY-R009.2.1", "title": "Ordered claim-to-rule evaluation", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "0714a7bf6f8e" + ], + "verified_by": [ + "tests/identity/test_oidc_rule_selection.py:17", + "tests/identity/test_oidc_rule_selection.py:24", + "tests/identity/test_oidc_rule_selection.py:30", + "tests/identity/test_oidc_rule_selection.py:37", + "tests/identity/test_oidc_rule_selection.py:43" + ] }, { "id": "GRAPHOS-IDENTITY-R009.2.2", @@ -335,7 +343,9 @@ "id": "GRAPHOS-IDENTITY-R010.2", "title": "Directory bind and group sync (rollup)", "delivery_state": "SPECIFIED", - "landed_in": [], + "landed_in": [ + "9fc4eae0f299" + ], "verified_by": [], "rollup_of": [ "GRAPHOS-IDENTITY-R010.2.1", @@ -347,9 +357,16 @@ { "id": "GRAPHOS-IDENTITY-R010.2.1", "title": "Group-DN-to-role mapping evaluation", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "9fc4eae0f299" + ], + "verified_by": [ + "tests/identity/test_ldap_group_roles.py:13", + "tests/identity/test_ldap_group_roles.py:19", + "tests/identity/test_ldap_group_roles.py:25", + "tests/identity/test_ldap_group_roles.py:32" + ] }, { "id": "GRAPHOS-IDENTITY-R010.2.2", @@ -428,8 +445,10 @@ { "id": "GRAPHOS-IDENTITY-R011.2.1", "title": "Typed SCIM User resource model", - "delivery_state": "SPECIFIED", - "landed_in": [], + "delivery_state": "LANDED", + "landed_in": [ + "e92d812eb39d" + ], "verified_by": [] }, { @@ -482,9 +501,17 @@ { "id": "GRAPHOS-IDENTITY-R012.2.1", "title": "Typed parsed-assertion model and condition checks", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "fdbaebdb795a" + ], + "verified_by": [ + "tests/identity/test_saml_assertion_conditions.py:37", + "tests/identity/test_saml_assertion_conditions.py:42", + "tests/identity/test_saml_assertion_conditions.py:64", + "tests/identity/test_saml_assertion_conditions.py:74", + "tests/identity/test_saml_assertion_conditions.py:89" + ] }, { "id": "GRAPHOS-IDENTITY-R012.2.2", diff --git a/specs/mcp-resource-publication-reconciliation/status.json b/specs/mcp-resource-publication-reconciliation/status.json index 0a174c93..ced6c97a 100644 --- a/specs/mcp-resource-publication-reconciliation/status.json +++ b/specs/mcp-resource-publication-reconciliation/status.json @@ -74,9 +74,16 @@ { "id": "GRAPHOS-MCP-RESOURCES-R004", "title": "Status docs are the gate's result, not hand-maintained prose", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "94ee72171294" + ], + "verified_by": [ + "tests/fleet/test_mcp_resource_status_docs.py:20", + "tests/fleet/test_mcp_resource_status_docs.py:25", + "tests/fleet/test_mcp_resource_status_docs.py:32", + "tests/fleet/test_mcp_resource_status_docs.py:44" + ] } ] } From 4ecc19a9f4e805c654ddb05138f656220a329097 Mon Sep 17 00:00:00 2001 From: Audel Rouhi Date: Sat, 10 Oct 2026 16:33:10 -0500 Subject: [PATCH 25/25] GRAPHOS-IDENTITY-R009.2.2: OIDC ID-token claim checks with typed refusal Adds check_id_token_claims (issuer, audience, expiry, nonce over decoded claims) raising OidcTokenRefused with an OidcRefusalReason; no signature handling. Spec: GRAPHOS-IDENTITY-R009.2.2 Co-Authored-By: Claude Opus 5.5 (1M context) --- graph_os/identity/oidc.py | 60 ++++++++++++++- tests/identity/test_oidc_id_token_claims.py | 85 +++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/identity/test_oidc_id_token_claims.py diff --git a/graph_os/identity/oidc.py b/graph_os/identity/oidc.py index f2936e55..6c1e06c4 100644 --- a/graph_os/identity/oidc.py +++ b/graph_os/identity/oidc.py @@ -2,13 +2,16 @@ Slice .1: the typed model and construction validation. Slice .2.1: pure ordered claim-to-rule evaluation (``select_mapping_rule``). -PKCE/state/nonce verification and the callback wiring are later slices. +Slice .2.2: ID-token claim checks (``check_id_token_claims``). +Callback wiring is a later slice. """ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import StrEnum from typing import Any from .engine import IdentityUnavailable @@ -76,3 +79,58 @@ def select_mapping_rule( if _claim_matches(rule.claim_match, claims): return rule raise IdentityUnavailable("no OIDC mapping rule matches the presented claims") + + +class OidcRefusalReason(StrEnum): + """Why decoded ID-token claims were refused.""" + + WRONG_ISSUER = "wrong_issuer" + WRONG_AUDIENCE = "wrong_audience" + EXPIRED = "expired" + MISSING_EXPIRY = "missing_expiry" + NONCE_MISMATCH = "nonce_mismatch" + + +class OidcTokenRefused(IdentityUnavailable): + """Decoded ID-token claims failed a check; carries the typed reason.""" + + def __init__(self, reason: OidcRefusalReason, message: str) -> None: + super().__init__(message) + self.reason = reason + + +def check_id_token_claims( + claims: Mapping[str, Any], + *, + issuer: str, + audience: str, + nonce: str, + now: datetime, + clock_skew: timedelta = timedelta(0), +) -> None: + """Refuse unless issuer, audience, expiry and nonce all match (no signature check).""" + if now.tzinfo is None: + raise IdentityUnavailable("OIDC check time must be timezone-aware") + if not issuer or not audience or not nonce: + raise IdentityUnavailable("OIDC check requires issuer, audience and nonce") + if claims.get("iss") != issuer: + raise OidcTokenRefused( + OidcRefusalReason.WRONG_ISSUER, "ID token issuer does not match" + ) + aud = claims.get("aud") + audiences = [aud] if isinstance(aud, str) else aud + if not isinstance(audiences, (list, tuple)) or audience not in audiences: + raise OidcTokenRefused( + OidcRefusalReason.WRONG_AUDIENCE, "ID token audience is not this client" + ) + exp = claims.get("exp") + if isinstance(exp, bool) or not isinstance(exp, (int, float)): + raise OidcTokenRefused( + OidcRefusalReason.MISSING_EXPIRY, "ID token has no usable expiry" + ) + if now.astimezone(UTC) - clock_skew >= datetime.fromtimestamp(exp, UTC): + raise OidcTokenRefused(OidcRefusalReason.EXPIRED, "ID token has expired") + if claims.get("nonce") != nonce: + raise OidcTokenRefused( + OidcRefusalReason.NONCE_MISMATCH, "ID token nonce does not match" + ) diff --git a/tests/identity/test_oidc_id_token_claims.py b/tests/identity/test_oidc_id_token_claims.py new file mode 100644 index 00000000..1e0394b7 --- /dev/null +++ b/tests/identity/test_oidc_id_token_claims.py @@ -0,0 +1,85 @@ +"""Spec-bound refusal tests for OIDC ID-token claim checks.""" + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest + +from graph_os.identity.engine import IdentityUnavailable +from graph_os.identity.oidc import ( + OidcRefusalReason, + OidcTokenRefused, + check_id_token_claims, +) + +NOW = datetime(2026, 10, 10, 12, 0, tzinfo=UTC) + + +def _claims(**over: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "iss": "https://idp", + "aud": "client", + "exp": NOW.timestamp() + 60, + "nonce": "n1", + } + base.update(over) + return base + + +def _check(claims: dict[str, Any], **kw: Any) -> None: + check_id_token_claims( + claims, issuer="https://idp", audience="client", nonce="n1", now=NOW, **kw + ) + + +def _reason(claims: dict[str, Any], **kw: Any) -> OidcRefusalReason: + with pytest.raises(OidcTokenRefused) as ei: + _check(claims, **kw) + return ei.value.reason + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_valid_claims_pass_with_string_or_list_audience() -> None: + _check(_claims()) + _check(_claims(aud=["other", "client"])) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_wrong_issuer_refused() -> None: + assert _reason(_claims(iss="https://evil")) is OidcRefusalReason.WRONG_ISSUER + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_wrong_or_missing_audience_refused() -> None: + assert _reason(_claims(aud="other")) is OidcRefusalReason.WRONG_AUDIENCE + assert _reason(_claims(aud=None)) is OidcRefusalReason.WRONG_AUDIENCE + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_expired_and_missing_expiry_refused() -> None: + past = _claims(exp=NOW.timestamp() - 1) + assert _reason(past) is OidcRefusalReason.EXPIRED + assert _reason(_claims(exp="soon")) is OidcRefusalReason.MISSING_EXPIRY + assert _reason(_claims(exp=True)) is OidcRefusalReason.MISSING_EXPIRY + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_clock_skew_tolerates_recent_expiry() -> None: + _check(_claims(exp=NOW.timestamp() - 5), clock_skew=timedelta(seconds=30)) + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_nonce_mismatch_refused() -> None: + assert _reason(_claims(nonce="x")) is OidcRefusalReason.NONCE_MISMATCH + + +@pytest.mark.spec("GRAPHOS-IDENTITY-R009.2.2") +def test_naive_time_refused() -> None: + with pytest.raises(IdentityUnavailable): + check_id_token_claims( + _claims(), + issuer="https://idp", + audience="client", + nonce="n1", + now=datetime(2026, 10, 10), + )