diff --git a/.config/dupehound-distinct.toml b/.config/dupehound-distinct.toml index aa1e4fe5..9a8b557d 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: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-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/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/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/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/graph_os/identity/oidc.py b/graph_os/identity/oidc.py index babbc137..6c1e06c4 100644 --- a/graph_os/identity/oidc.py +++ b/graph_os/identity/oidc.py @@ -1,12 +1,18 @@ """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``). +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 @@ -37,3 +43,94 @@ 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") + + +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/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/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/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..7cad44e9 100644 --- a/specs/hosted-api-operations/requirements.md +++ b/specs/hosted-api-operations/requirements.md @@ -31,12 +31,16 @@ | `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. | | `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. | @@ -63,7 +67,11 @@ | `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 (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 9f2062be..fb334e64 100644 --- a/specs/hosted-api-operations/status.json +++ b/specs/hosted-api-operations/status.json @@ -34,11 +34,15 @@ "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", "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", @@ -66,6 +70,10 @@ "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.3.2.1", + "GRAPHOS-OPS-R032.2.3.2.2", "GRAPHOS-OPS-R032.2.4", "GRAPHOS-OPS-R033", "GRAPHOS-OPS-R034", @@ -325,7 +333,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 +358,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 +374,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 +396,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", @@ -435,6 +475,31 @@ "id": "GRAPHOS-OPS-R021.2", "title": "Retrieval, context, and freshness read operations — retrieval slice", "delivery_state": "SPECIFIED", + "landed_in": [ + "e78a5e5b701e" + ], + "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": "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", + "title": "`retrieval.freshness` op", + "delivery_state": "SPECIFIED", "landed_in": [], "verified_by": [] }, @@ -704,9 +769,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 +788,43 @@ }, { "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 (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 6ce0892b..5615c66c 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`. @@ -49,6 +51,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 @@ -60,6 +64,10 @@ 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.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 diff --git a/specs/identity-access/requirements.md b/specs/identity-access/requirements.md index ed4247c0..ecbb9a10 100644 --- a/specs/identity-access/requirements.md +++ b/specs/identity-access/requirements.md @@ -21,15 +21,31 @@ | `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-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`. | -| `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-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 65db4532..251fdd91 100644 --- a/specs/identity-access/status.json +++ b/specs/identity-access/status.json @@ -24,14 +24,30 @@ "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-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", "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-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", @@ -183,7 +199,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 +252,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", @@ -256,7 +279,49 @@ }, { "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": "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", + "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": [] @@ -276,7 +341,50 @@ }, { "id": "GRAPHOS-IDENTITY-R010.2", - "title": "Directory bind and group sync", + "title": "Directory bind and group sync (rollup)", + "delivery_state": "SPECIFIED", + "landed_in": [ + "9fc4eae0f299" + ], + "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": "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", + "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": [] @@ -323,7 +431,43 @@ }, { "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": "LANDED", + "landed_in": [ + "e92d812eb39d" + ], + "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": [] @@ -343,7 +487,49 @@ }, { "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": "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", + "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 397669f1..453c3574 100644 --- a/specs/identity-access/tasks.md +++ b/specs/identity-access/tasks.md @@ -51,18 +51,34 @@ 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 +- [ ] **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 +- [ ] **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-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 +- [ ] **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/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..ced6c97a 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,17 +43,47 @@ }, { "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": [] + "verified_by": [], + "rollup_of": [ + "GRAPHOS-MCP-RESOURCES-R003.1", + "GRAPHOS-MCP-RESOURCES-R003.2" + ] }, { - "id": "GRAPHOS-MCP-RESOURCES-R004", - "title": "Status docs are the gate's result, not hand-maintained prose", + "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": [] + }, + { + "id": "GRAPHOS-MCP-RESOURCES-R004", + "title": "Status docs are the gate's result, not hand-maintained prose", + "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" + ] } ] } 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/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" 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/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() 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/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()}) 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), + ) 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"}) 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)) 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="") 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()}