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" diff --git a/graph_os/api/ops/ingest.py b/graph_os/api/ops/ingest.py index 29ffa144..5860a963 100644 --- a/graph_os/api/ops/ingest.py +++ b/graph_os/api/ops/ingest.py @@ -25,8 +25,10 @@ Verb, ) from graph_os.ingest.service import ( + get_job_status, get_source_status, index_repository, + list_packs, list_sources, sync_source, ) @@ -38,8 +40,10 @@ #: (``graph_os.ingest.service``). __all__ = [ "operations", + "get_job_status", "get_source_status", "index_repository", + "list_packs", "list_sources", "sync_source", ] @@ -82,6 +86,22 @@ class IngestSourceStatusResult(_Params): value: dict[str, str] +class IngestPacksListParams(_Params): + pass + + +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( @@ -132,4 +152,26 @@ 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, + ), + 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/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/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/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/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/graph_os/ingest/service.py b/graph_os/ingest/service.py index 007c134c..4e47e904 100644 --- a/graph_os/ingest/service.py +++ b/graph_os/ingest/service.py @@ -66,6 +66,34 @@ 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, ...] + + +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. @@ -90,6 +118,10 @@ async def get_source_status( self, *, tenant: str, source_id: str ) -> IngestSourceRecord: ... + 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") @@ -142,3 +174,19 @@ 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")} + + +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/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/specs/fleet-catalog-and-tools/status.json b/specs/fleet-catalog-and-tools/status.json index 6f34649b..56729b2e 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" ] }, { @@ -360,9 +360,14 @@ { "id": "GRAPHOS-FLEET-R006.3.1", "title": "`_probe_protocol_families` harvest calls routed through the cutover helper", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "ddb10c07995e" + ], + "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/specs/graphos-a2a-002/status.json b/specs/graphos-a2a-002/status.json index faf243d9..c9a7e754 100644 --- a/specs/graphos-a2a-002/status.json +++ b/specs/graphos-a2a-002/status.json @@ -64,9 +64,13 @@ { "id": "GRAPHOS-A2A-002-R005", "title": "No advisory, cached, or partial substitute", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "63762147f0bc" + ], + "verified_by": [ + "tests/a2a/test_admission.py:52" + ] }, { "id": "GRAPHOS-A2A-002-R006", 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..9fb42234 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,76 @@ "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": "VERIFIED", + "landed_in": [ + "567a2b417ed7" + ], + "verified_by": [ + "tests/mcp_server/test_legacy_action_mapping_ingest_sync.py:11" + ] + }, + { + "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 diff --git a/specs/hosted-api-operations/requirements.md b/specs/hosted-api-operations/requirements.md index 0027e341..31dde67a 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. | @@ -41,6 +43,11 @@ | `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. | | `GRAPHOS-OPS-R022.2` | **Evolution loop, schedule, and proposal operations — evolution slice.** Implements the evolution portion of `GRAPHOS-OPS-R022`: `evolution.*` operations for loop status/run/pause, schedule, and proposal access, with the daemon acting only as a facade while durable loop state remains owned by the underlying service. | A test confirms run/pause requires the `loops:control` scope, reads require `loops:read`, and a repeated run stays idempotent. | +| `GRAPHOS-OPS-R022.2.1` | **`evolution.loops.status` op.** GraphOS exposes `evolution.loops.status` as a read operation through a typed evolution runner port, reporting one loop's state while durable loop state stays with the owner service. Child of `GRAPHOS-OPS-R022.2` (rollup of `.1` through `.5`). **Blocked:** the engine (epistemic-graph) scope registry (`contract/scopes.json`) must register `loops:read`; the registry factory rejects unregistered op scopes (`graph_os/api/ops/registry_factory.py:72-76`) and substituting `work:read` is not acceptable. | Tests confirm the op reaches the runner with the caller's tenant, declares the `loops:read` scope, and fails closed with `UNAVAILABLE` when the runner is absent. | +| `GRAPHOS-OPS-R022.2.2` | **`evolution.schedules.list` op.** Read operation listing this tenant's loop schedules through the evolution runner port. Depends on `GRAPHOS-OPS-R022.2.1` for the port. | Tests confirm the op reaches the runner with the caller's tenant and fails closed with `UNAVAILABLE`. | +| `GRAPHOS-OPS-R022.2.3` | **`evolution.proposals.list` op.** Read operation listing this tenant's evolution proposals through the evolution runner port. Depends on `GRAPHOS-OPS-R022.2.1`. | Tests confirm the op reaches the runner with the caller's tenant and fails closed with `UNAVAILABLE`. | +| `GRAPHOS-OPS-R022.2.4` | **`evolution.loops.run` op.** Write operation requesting one loop run, idempotency-key required, requiring `loops:control`. Depends on `GRAPHOS-OPS-R022.2.1`; may need the engine scope registry (epistemic-graph). | Tests confirm `loops:control` is required and a repeated run with the same key stays idempotent. | +| `GRAPHOS-OPS-R022.2.5` | **`evolution.loops.pause` op.** Write operation pausing one loop, requiring `loops:control`. Depends on `GRAPHOS-OPS-R022.2.4`. | Tests confirm `loops:control` is required and the pause is idempotent. | | `GRAPHOS-OPS-R023` | **Agent, browser, RLM, and fleet call operations.** GraphOS exposes agent, A2A task, RLM, browser, and fleet operations, including fleet.call, deriving each loaded tool's effect classification from its annotation so every call still passes through the invoke pipeline rather than bypassing it. | Integration tests confirm every loaded fleet tool call reaches the invoke pipeline and no direct child dispatch bypasses it. | | `GRAPHOS-OPS-R024` | **Telemetry, security, usage, and admin operations.** GraphOS exposes telemetry, security, operational (doctor, config, backup, restore, tenants), usage, memory, and graph operations, sourcing usage data from the engine and requiring destructive backup, restore, and tenant actions to pass console step-up with a durable audit record. | Integration tests confirm backup, restore, and tenant actions cannot complete from a plain confirmation and require step-up plus audit. | | `GRAPHOS-OPS-R024.1` | **Telemetry operations — telemetry slice.** Implements the telemetry portion of `GRAPHOS-OPS-R024`: `telemetry.*` operations bound to the engine's tenant-scoped telemetry method. | A test confirms a telemetry read is tenant-scoped and reaches the engine. | @@ -63,7 +70,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 9f2062be..16c582b0 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", @@ -43,6 +45,11 @@ "GRAPHOS-OPS-R022", "GRAPHOS-OPS-R022.1", "GRAPHOS-OPS-R022.2", + "GRAPHOS-OPS-R022.2.1", + "GRAPHOS-OPS-R022.2.2", + "GRAPHOS-OPS-R022.2.3", + "GRAPHOS-OPS-R022.2.4", + "GRAPHOS-OPS-R022.2.5", "GRAPHOS-OPS-R023", "GRAPHOS-OPS-R024", "GRAPHOS-OPS-R024.1", @@ -66,6 +73,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", @@ -325,7 +334,7 @@ { "id": "GRAPHOS-OPS-R020", "title": "Ingest operations through a typed SDK runner facade", - "delivery_state": "SPECIFIED", + "delivery_state": "LANDED", "landed_in": [ "e990e195ab42" ], @@ -350,8 +359,8 @@ "cc90ee37583f" ], "verified_by": [ - "tests/api/test_ingest_ops.py:80", - "tests/api/test_ingest_ops.py:99" + "tests/api/test_ingest_ops.py:116", + "tests/api/test_ingest_ops.py:97" ] }, { @@ -366,17 +375,17 @@ "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: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" ] }, { "id": "GRAPHOS-OPS-R020.3", "title": "Pack and job management operations", - "delivery_state": "SPECIFIED", + "delivery_state": "VERIFIED", "landed_in": [ "822f55d5835d" ], @@ -388,9 +397,41 @@ { "id": "GRAPHOS-OPS-R020.3.1", "title": "Missing `ingest.packs.*` and `ingest.jobs.*` ops", - "delivery_state": "SPECIFIED", - "landed_in": [], - "verified_by": [] + "delivery_state": "VERIFIED", + "landed_in": [ + "16c9b2bddda3" + ], + "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": "VERIFIED", + "landed_in": [ + "16c9b2bddda3" + ], + "verified_by": [ + "tests/api/test_ingest_ops.py:228", + "tests/api/test_ingest_ops.py:240", + "tests/api/test_ingest_ops.py:249" + ] + }, + { + "id": "GRAPHOS-OPS-R020.3.1.2", + "title": "`ingest.jobs.status` op", + "delivery_state": "VERIFIED", + "landed_in": [ + "634a344fade7" + ], + "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", @@ -472,6 +513,50 @@ "title": "Evolution loop, schedule, and proposal operations — evolution slice", "delivery_state": "SPECIFIED", "landed_in": [], + "verified_by": [], + "rollup_of": [ + "GRAPHOS-OPS-R022.2.1", + "GRAPHOS-OPS-R022.2.2", + "GRAPHOS-OPS-R022.2.3", + "GRAPHOS-OPS-R022.2.4", + "GRAPHOS-OPS-R022.2.5" + ] + }, + { + "id": "GRAPHOS-OPS-R022.2.1", + "title": "`evolution.loops.status` op", + "delivery_state": "LANDED", + "landed_in": [ + "364ef8b376e6" + ], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R022.2.2", + "title": "`evolution.schedules.list` op", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R022.2.3", + "title": "`evolution.proposals.list` op", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R022.2.4", + "title": "`evolution.loops.run` op", + "delivery_state": "SPECIFIED", + "landed_in": [], + "verified_by": [] + }, + { + "id": "GRAPHOS-OPS-R022.2.5", + "title": "`evolution.loops.pause` op", + "delivery_state": "SPECIFIED", + "landed_in": [], "verified_by": [] }, { @@ -704,9 +789,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", @@ -717,7 +808,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..587d84fd 100644 --- a/specs/hosted-api-operations/tasks.md +++ b/specs/hosted-api-operations/tasks.md @@ -49,9 +49,16 @@ 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 +- [ ] **GRAPHOS-OPS-R022.2.1:** `evolution.loops.status` op +- [ ] **GRAPHOS-OPS-R022.2.2:** `evolution.schedules.list` op +- [ ] **GRAPHOS-OPS-R022.2.3:** `evolution.proposals.list` op +- [ ] **GRAPHOS-OPS-R022.2.4:** `evolution.loops.run` op +- [ ] **GRAPHOS-OPS-R022.2.5:** `evolution.loops.pause` op - [ ] **GRAPHOS-OPS-R031:** Published tool-to-operation parity inventory - [ ] **GRAPHOS-OPS-R031.1:** Remaining scope of GRAPHOS-OPS-R031 (slice .1): Published tool-to-operation parity inventory - [ ] **GRAPHOS-OPS-R032:** Identity dependency inversion via ports @@ -60,6 +67,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 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", 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/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..0a174c93 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": [ @@ -29,7 +31,7 @@ "6a19f564d301" ], "verified_by": [ - "tests/fleet/test_mcp_resource_reconciliation.py:20" + "tests/fleet/test_mcp_resource_reconciliation.py:22" ] }, { @@ -41,7 +43,30 @@ }, { "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": "VERIFIED", + "landed_in": [ + "965d34390876" + ], + "verified_by": [ + "tests/fleet/test_mcp_resource_reconciliation.py:119", + "tests/fleet/test_mcp_resource_reconciliation.py:138" + ] + }, + { + "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 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 diff --git a/tests/api/test_ingest_ops.py b/tests/api/test_ingest_ops.py index d63ee292..b24701cb 100644 --- a/tests/api/test_ingest_ops.py +++ b/tests/api/test_ingest_ops.py @@ -12,14 +12,19 @@ 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, list_sources, operations, sync_source, ) from graph_os.ingest.service import ( IngestIndexReceipt, + IngestJobRecord, + IngestPackInventory, + IngestPackRecord, IngestSourceInventory, IngestSourceRecord, IngestSyncMode, @@ -33,6 +38,8 @@ 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] = [] + self.job_calls: list[tuple[str, str]] = [] async def sync_source( self, *, tenant: str, source_id: str, mode: IngestSyncMode, idempotency_key: str @@ -68,6 +75,16 @@ 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"),) + ) + + 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") @@ -206,3 +223,57 @@ 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" + + +@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" 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" 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 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"} 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") 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()}