diff --git a/ace/application/__init__.py b/ace/application/__init__.py index eb43f59..8bac5d9 100644 --- a/ace/application/__init__.py +++ b/ace/application/__init__.py @@ -334,11 +334,15 @@ from ace.application.intelligence_resource_plane import ( RESOURCE_QUERY_AUTHORITY, RESOURCE_QUERY_OPERATION, + IntelligenceResourceCursorV1Alpha1, + IntelligenceResourceKind, + IntelligenceResourcePageV1Alpha1, IntelligenceResourcePlaneAuthorizationPort, IntelligenceResourcePlaneError, IntelligenceResourcePlaneService, IntelligenceResourceProjectionBatch, IntelligenceResourceProjectionReader, + IntelligenceResourceQueryV1Alpha1, ) from ace.application.intelligence_resource_projection import ( IntelligenceLedgerProjectionError, @@ -441,11 +445,15 @@ __all__ = [ "RESOURCE_QUERY_AUTHORITY", "RESOURCE_QUERY_OPERATION", + "IntelligenceResourceCursorV1Alpha1", + "IntelligenceResourceKind", + "IntelligenceResourcePageV1Alpha1", "IntelligenceResourcePlaneAuthorizationPort", "IntelligenceResourcePlaneError", "IntelligenceResourcePlaneService", "IntelligenceResourceProjectionBatch", "IntelligenceResourceProjectionReader", + "IntelligenceResourceQueryV1Alpha1", "IntelligenceLedgerProjectionError", "IntelligenceLedgerResourceProjectionReader", "ASSERTION_DECISION_RECORD_KIND", diff --git a/ace/application/intelligence_resource_plane.py b/ace/application/intelligence_resource_plane.py index 97fc80d..defa27a 100644 --- a/ace/application/intelligence_resource_plane.py +++ b/ace/application/intelligence_resource_plane.py @@ -9,6 +9,7 @@ from ace.core.runtime_use import AuthorityUseReceiptV1Alpha1 from ace.intelligence.contracts.resource_plane import ( IntelligenceResourceCursorV1Alpha1, + IntelligenceResourceKind, IntelligenceResourcePageState, IntelligenceResourcePageV1Alpha1, IntelligenceResourceQueryV1Alpha1, @@ -217,6 +218,10 @@ async def query( "IntelligenceResourcePlaneAuthorizationPort", "IntelligenceResourcePlaneError", "IntelligenceResourcePlaneService", + "IntelligenceResourceCursorV1Alpha1", + "IntelligenceResourceKind", + "IntelligenceResourcePageV1Alpha1", + "IntelligenceResourceQueryV1Alpha1", "IntelligenceResourceProjectionBatch", "IntelligenceResourceProjectionReader", ] diff --git a/ace/intelligence/contracts/resource_plane.py b/ace/intelligence/contracts/resource_plane.py index 9fc5eae..2d5f13c 100644 --- a/ace/intelligence/contracts/resource_plane.py +++ b/ace/intelligence/contracts/resource_plane.py @@ -307,11 +307,11 @@ def validate_scope_time_and_identity(self) -> Self: raise ValueError("resource query crossed authenticated product scope") if self.available_at < self.as_of: raise ValueError("query available_at cannot precede as_of") - if not ( - self.authenticated_context.authenticated_at <= self.available_at < self.authenticated_context.expires_at - ): - raise ValueError("query availability cutoff must be inside the authenticated window") - material = self.model_dump(mode="json", exclude={"cursor", "query_id", "query_digest"}) + material = self.model_dump( + mode="json", + exclude={"authenticated_context", "cursor", "query_id", "query_digest"}, + ) + material["actor_ref"] = self.authenticated_context.actor_ref digest = canonical_hash(material) expected_id = f"resource_query:{digest[:32]}" expected_digest = f"sha256:{digest}" diff --git a/core/engine/api/intelligence_resources.py b/core/engine/api/intelligence_resources.py new file mode 100644 index 0000000..8be2790 --- /dev/null +++ b/core/engine/api/intelligence_resources.py @@ -0,0 +1,61 @@ +"""HTTP transport for the governed ACE Intelligence resource plane.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from core.engine.core.auth import get_current_user +from core.engine.core.intelligence_resource_plane import ( + IntelligenceResourceHttpContractConflict, + IntelligenceResourceHttpDenied, + IntelligenceResourceHttpQueryV1, + IntelligenceResourceHttpRuntime, + IntelligenceResourceHttpUnauthenticated, + IntelligenceResourceHttpUnavailable, + IntelligenceResourcePageV1Alpha1, + intelligence_resource_runtime, + query_intelligence_resource_page, +) + +router = APIRouter(prefix="/v1/intelligence/resources", tags=["intelligence-resources"]) + + +@router.post("/query", response_model=IntelligenceResourcePageV1Alpha1) +async def query_intelligence_resources( + selector: IntelligenceResourceHttpQueryV1, + user: dict = Depends(get_current_user), + runtime: IntelligenceResourceHttpRuntime = Depends(intelligence_resource_runtime), +) -> IntelligenceResourcePageV1Alpha1: + """Reauthenticate and reauthorize one point-in-time page; cursors grant no authority.""" + + try: + return await query_intelligence_resource_page( + selector=selector, + user=user, + runtime=runtime, + ) + except IntelligenceResourceHttpUnauthenticated as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Verified token lacks product scope" + ) from exc + except IntelligenceResourceHttpDenied as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Intelligence query denied") from exc + except IntelligenceResourceHttpUnavailable as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Intelligence authentication evidence is unavailable", + ) from exc + except IntelligenceResourceHttpContractConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Intelligence resource query could not preserve its exact contract", + ) from exc + + +__all__ = [ + "IntelligenceResourceHttpQueryV1", + "IntelligenceResourceHttpRuntime", + "intelligence_resource_runtime", + "query_intelligence_resources", + "router", +] diff --git a/core/engine/api/main.py b/core/engine/api/main.py index 8e4a38d..62e16f2 100644 --- a/core/engine/api/main.py +++ b/core/engine/api/main.py @@ -664,6 +664,7 @@ async def api_version(): from core.engine.api.extension_invocations import router as extension_invocations_router from core.engine.api.foresight import router as foresight_router from core.engine.api.intel import router as intel_router +from core.engine.api.intelligence_resources import router as intelligence_resources_router from core.engine.api.landscape import router as landscape_router from core.engine.api.product_state import router as product_state_router from core.engine.api.sentinels import router as sentinels_router @@ -673,6 +674,7 @@ async def api_version(): app.include_router(extension_invocations_router) app.include_router(capture_router) app.include_router(intel_router) +app.include_router(intelligence_resources_router) app.include_router(landscape_router) app.include_router(product_state_router) app.include_router(foresight_router) diff --git a/core/engine/core/intelligence_resource_plane.py b/core/engine/core/intelligence_resource_plane.py new file mode 100644 index 0000000..9da4524 --- /dev/null +++ b/core/engine/core/intelligence_resource_plane.py @@ -0,0 +1,143 @@ +"""Supported host composition for the governed Intelligence resource plane. + +This is the only legacy Core host edge into the public ACE Application layer. +The HTTP adapter depends on this host boundary and never imports public ACE +packages directly. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from pydantic import BaseModel, ConfigDict, Field + +from ace.application import ( + RESOURCE_QUERY_AUTHORITY, + IntelligenceLedgerResourceProjectionReader, + IntelligenceResourceCursorV1Alpha1, + IntelligenceResourceKind, + IntelligenceResourcePageV1Alpha1, + IntelligenceResourcePlaneAuthorizationPort, + IntelligenceResourcePlaneError, + IntelligenceResourcePlaneService, + IntelligenceResourceQueryV1Alpha1, +) +from ace.core import ImmutableRecordPersistenceError, ImmutableRecordStore +from core.engine.core.agent_composition_runtime import ( + GovernedCompositionAuthorityError, + GovernedStateRuntimeUseResolver, + persist_task_authentication_receipt, +) +from core.engine.core.db import pool +from core.engine.core.governed_state import SurrealGovernedStateStore +from core.engine.core.immutable_records import SurrealImmutableRecordStore + + +class IntelligenceResourceHttpQueryV1(BaseModel): + """HTTP selector; authenticated context comes only from verified claims.""" + + model_config = ConfigDict(extra="forbid") + + authority_grant_ref: str = Field(min_length=1, max_length=240) + resource_kinds: tuple[IntelligenceResourceKind, ...] = Field(min_length=1, max_length=32) + subject_refs: tuple[str, ...] = Field(default_factory=tuple, max_length=256) + as_of: datetime + available_at: datetime + page_size: int = Field(ge=1, le=200) + cursor: IntelligenceResourceCursorV1Alpha1 | None = None + + +@dataclass(frozen=True, slots=True) +class IntelligenceResourceHttpRuntime: + records: ImmutableRecordStore + authority: IntelligenceResourcePlaneAuthorizationPort + + +class IntelligenceResourceHttpDenied(RuntimeError): + """The verified principal or current Core grant denied the query.""" + + +class IntelligenceResourceHttpUnauthenticated(RuntimeError): + """The verified token did not contain a usable product-scoped principal.""" + + +class IntelligenceResourceHttpUnavailable(RuntimeError): + """Required authentication evidence could not be persisted.""" + + +class IntelligenceResourceHttpContractConflict(RuntimeError): + """The query could not preserve the exact resource-plane contract.""" + + +def intelligence_resource_runtime() -> IntelligenceResourceHttpRuntime: + records = SurrealImmutableRecordStore(pool) + governed_state = SurrealGovernedStateStore(pool) + return IntelligenceResourceHttpRuntime( + records=records, + authority=GovernedStateRuntimeUseResolver(governed_state=governed_state), + ) + + +def _verified_claims(user: dict) -> tuple[str, str]: + actor_ref = user.get("sub") + product_id = user.get("product") + authorities = user.get("authorities") + if not isinstance(actor_ref, str) or not actor_ref or not isinstance(product_id, str) or not product_id: + raise IntelligenceResourceHttpUnauthenticated("verified token lacks product scope") + if not isinstance(authorities, list) or RESOURCE_QUERY_AUTHORITY not in authorities: + raise IntelligenceResourceHttpDenied("intelligence read authority is required") + return actor_ref, product_id + + +async def query_intelligence_resource_page( + *, + selector: IntelligenceResourceHttpQueryV1, + user: dict, + runtime: IntelligenceResourceHttpRuntime, +) -> IntelligenceResourcePageV1Alpha1: + """Bind verified host context to one authorized public resource query.""" + + actor_ref, product_id = _verified_claims(user) + evaluated_at = datetime.now(UTC) + try: + authentication = await persist_task_authentication_receipt( + claims={**user, "sub": actor_ref, "product": product_id}, + verified_at=evaluated_at, + store=runtime.records, + verification_policy_ref="jwt_verification_policy:v1", + ) + request = IntelligenceResourceQueryV1Alpha1( + authenticated_context=authentication.runtime_context(), + product_id=product_id, + authority_grant_ref=selector.authority_grant_ref, + resource_kinds=selector.resource_kinds, + subject_refs=selector.subject_refs, + as_of=selector.as_of, + available_at=selector.available_at, + page_size=selector.page_size, + cursor=selector.cursor, + ) + return await IntelligenceResourcePlaneService( + reader=IntelligenceLedgerResourceProjectionReader(store=runtime.records), + authority=runtime.authority, + ).query(request, evaluated_at=evaluated_at) + except GovernedCompositionAuthorityError as exc: + raise IntelligenceResourceHttpDenied("current Core grant denied the query") from exc + except ImmutableRecordPersistenceError as exc: + raise IntelligenceResourceHttpUnavailable("authentication evidence is unavailable") from exc + except (IntelligenceResourcePlaneError, TypeError, ValueError) as exc: + raise IntelligenceResourceHttpContractConflict("resource query contract could not be preserved") from exc + + +__all__ = [ + "IntelligenceResourceHttpContractConflict", + "IntelligenceResourceHttpDenied", + "IntelligenceResourceHttpQueryV1", + "IntelligenceResourceHttpRuntime", + "IntelligenceResourceHttpUnauthenticated", + "IntelligenceResourceHttpUnavailable", + "IntelligenceResourcePageV1Alpha1", + "intelligence_resource_runtime", + "query_intelligence_resource_page", +] diff --git a/docs/design/intelligence-resource-plane-v0.8.0-work-packet-v1.md b/docs/design/intelligence-resource-plane-v0.8.0-work-packet-v1.md index 5725afa..3fbd297 100644 --- a/docs/design/intelligence-resource-plane-v0.8.0-work-packet-v1.md +++ b/docs/design/intelligence-resource-plane-v0.8.0-work-packet-v1.md @@ -1,8 +1,8 @@ # ACE 0.8.0 unified Intelligence resource plane work packet -Status: **active 0.8C packet; C1 public facade and C2 immutable-ledger projection implemented** +Status: **active 0.8C packet; C1 facade, C2 ledger projection, and C3 governed HTTP query implemented** Public milestone: [issue #40](https://github.com/augmented-cognition-engine/core/issues/40) -Accepted base: `main@bf4a75a` (0.8A architecture, AM4 lifecycle, completed 0.8B, and C1 facade) +Accepted base: `main@6b4d6b2` (0.8A architecture, AM4 lifecycle, completed 0.8B, C1 facade, and C2 projection) ## Outcome @@ -73,9 +73,16 @@ projects Entity Snapshots as public Entities, preserves exact lineage and payloa subject and cursor, and remains reproducible when reconstructed over the same store. Unsupported or unavailable buckets return explicit degradation while preserving available truth. -C3 must add governed-state projections for the remaining canonical families, expose the contracts -through one supported machine interface, and verify packaged schema/import integrity. C4 must prove -Atrium consumes that interface rather than privileged internal state. +C3 exposes `POST /v1/intelligence/resources/query` as the first machine interface. The host derives +the authenticated context from a verified bearer token, persists an opaque authentication receipt, +requires the token's `observe_read` authority, resolves the current Core grant, and returns the same +public page contract. Historical data cutoffs are independent from login time; every page is +reauthenticated and reauthorized, while query identity remains stable across authentication receipt +refreshes for the same actor and exact selector. + +C3 must still add governed-state projections for the remaining canonical families and complete +packaged schema/import integrity. C4 must prove Atrium consumes this interface rather than +privileged internal state. The 0.8C exit gate is one authorized query path that can reproduce the evidence-to-outcome resource chain after restart, report partial truth honestly, and remain identical for World and Market diff --git a/tests/intelligence/test_intelligence_resource_plane.py b/tests/intelligence/test_intelligence_resource_plane.py index 63a5705..cc9564b 100644 --- a/tests/intelligence/test_intelligence_resource_plane.py +++ b/tests/intelligence/test_intelligence_resource_plane.py @@ -33,13 +33,19 @@ NOW = datetime(2026, 8, 12, 20, 0, tzinfo=UTC) -def _context(*, product_id: str = PRODUCT) -> AuthenticatedRuntimeContextV1Alpha1: +def _context( + *, + product_id: str = PRODUCT, + actor_ref: str = "principal:analyst", + receipt_suffix: str = "resource-plane", + authenticated_at: datetime = NOW - timedelta(minutes=10), +) -> AuthenticatedRuntimeContextV1Alpha1: return AuthenticatedRuntimeContextV1Alpha1( product_id=product_id, - actor_ref="principal:analyst", - authentication_receipt_ref="authentication_receipt:resource-plane", + actor_ref=actor_ref, + authentication_receipt_ref=f"authentication_receipt:{receipt_suffix}", authentication_receipt_digest="sha256:" + "a" * 64, - authenticated_at=NOW - timedelta(minutes=10), + authenticated_at=authenticated_at, expires_at=NOW + timedelta(minutes=30), ) @@ -215,6 +221,50 @@ def test_query_identity_excludes_cursor_but_cursor_is_bound_to_the_exact_query() ) +def test_query_identity_survives_reauthentication_but_remains_actor_bound() -> None: + first = _query() + refreshed_context = _context( + receipt_suffix="resource-plane-refresh", + authenticated_at=NOW - timedelta(minutes=1), + ) + refreshed = IntelligenceResourceQueryV1Alpha1( + **{ + **first.model_dump( + mode="python", + exclude={"authenticated_context", "query_id", "query_digest"}, + ), + "authenticated_context": refreshed_context, + } + ) + assert refreshed.query_id == first.query_id + assert refreshed.query_digest == first.query_digest + + other_actor = IntelligenceResourceQueryV1Alpha1( + **{ + **first.model_dump( + mode="python", + exclude={"authenticated_context", "query_id", "query_digest"}, + ), + "authenticated_context": _context(actor_ref="principal:other"), + } + ) + assert other_actor.query_id != first.query_id + + +def test_historical_cutoff_is_authorized_at_request_time_not_authentication_time() -> None: + context = _context(authenticated_at=NOW) + query = IntelligenceResourceQueryV1Alpha1( + authenticated_context=context, + product_id=PRODUCT, + authority_grant_ref="authority_grant:resource-read", + resource_kinds=(IntelligenceResourceKind.BRIEF,), + as_of=NOW - timedelta(days=2), + available_at=NOW - timedelta(days=1), + page_size=10, + ) + assert query.available_at < context.authenticated_at + + def test_record_requires_exact_provenance_scope_and_revision_lineage() -> None: prior = _reference(IntelligenceResourceKind.ENTITY, "a", revision=1) current = _reference(IntelligenceResourceKind.ENTITY, "a", revision=2, available_at=NOW + timedelta(minutes=1)) diff --git a/tests/test_api_intelligence_resources.py b/tests/test_api_intelligence_resources.py new file mode 100644 index 0000000..fc24935 --- /dev/null +++ b/tests/test_api_intelligence_resources.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from ace.core import GovernedStateHeadPreconditionV1Alpha1 +from ace.core.runtime_use import AuthorityUseReceiptV1Alpha1 +from ace.testing import InMemoryImmutableRecordStore +from core.engine.api.intelligence_resources import ( + IntelligenceResourceHttpRuntime, + intelligence_resource_runtime, + router, +) +from core.engine.core.agent_composition_runtime import GovernedCompositionAuthorityError +from core.engine.core.auth import get_current_user + +pytestmark = pytest.mark.unit + +PRODUCT = "product:resource-http" +ACTOR = "principal:http-analyst" +GRANT = "authority_grant:resource-http-read" +NOW = datetime.now(UTC) + + +class _Authority: + def __init__(self, *, deny: bool = False) -> None: + self.deny = deny + self.calls: list[dict] = [] + + async def resolve_authority_use(self, **kwargs) -> AuthorityUseReceiptV1Alpha1: + self.calls.append(kwargs) + if self.deny: + raise GovernedCompositionAuthorityError("inactive grant") + return AuthorityUseReceiptV1Alpha1( + product_id=kwargs["context"].product_id, + actor_ref=kwargs["context"].actor_ref, + authenticated_context=kwargs["context"], + use_subject_ref=kwargs["use_subject_ref"], + use_subject_digest=kwargs["use_subject_digest"], + operation=kwargs["operation"], + authority=kwargs["authority"], + grant_ref=kwargs["grant_ref"], + grant_hash="b" * 64, + evaluated_at=kwargs["evaluated_at"], + expires_at=NOW + timedelta(hours=1), + state_head_precondition=GovernedStateHeadPreconditionV1Alpha1( + state_kind="authority_grant", + product_id=PRODUCT, + state_id=GRANT, + sequence=1, + revision_id="authority_revision:resource-http", + commit_receipt_id="authority_receipt:resource-http", + ), + ) + + +def _claims(*, authorities: list[str] | None = None) -> dict: + return { + "sub": ACTOR, + "product": PRODUCT, + "authorities": ["observe_read"] if authorities is None else authorities, + "exp": (NOW + timedelta(hours=1)).timestamp(), + } + + +def _body() -> dict: + return { + "authority_grant_ref": GRANT, + "resource_kinds": ["brief"], + "subject_refs": [], + "as_of": (NOW - timedelta(days=2)).isoformat(), + "available_at": (NOW - timedelta(days=1)).isoformat(), + "page_size": 20, + } + + +async def _request( + *, + claims: dict, + authority: _Authority, + records: InMemoryImmutableRecordStore | None = None, +): + app = FastAPI() + app.include_router(router) + records = records or InMemoryImmutableRecordStore() + app.dependency_overrides[get_current_user] = lambda: claims + app.dependency_overrides[intelligence_resource_runtime] = lambda: IntelligenceResourceHttpRuntime( + records=records, + authority=authority, + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/v1/intelligence/resources/query", json=_body()) + return response, records + + +@pytest.mark.asyncio +async def test_http_resource_query_derives_context_and_uses_current_core_authority() -> None: + authority = _Authority() + response, records = await _request(claims=_claims(), authority=authority) + + assert response.status_code == 200 + body = response.json() + assert body["product_id"] == PRODUCT + assert body["actor_ref"] == ACTOR + assert body["state"] == "complete" + assert body["items"] == [] + assert body["authority_use"]["authority"] == "observe_read" + assert body["authority_use"]["operation"] == "query_intelligence_resources" + assert authority.calls[0]["context"].authentication_receipt_ref.startswith("task_authentication_receipt:") + assert any(record.record_kind == "task_authentication" for record in records.records.values()) + + +@pytest.mark.asyncio +async def test_http_resource_query_requires_token_and_current_grant_authority() -> None: + response, records = await _request(claims=_claims(authorities=[]), authority=_Authority()) + assert response.status_code == 403 + assert records.records == {} + + denied, _ = await _request(claims=_claims(), authority=_Authority(deny=True)) + assert denied.status_code == 403 + assert denied.json()["detail"] == "Intelligence query denied" + + +@pytest.mark.asyncio +async def test_http_resource_query_rejects_verified_claims_without_product_scope() -> None: + claims = _claims() + claims.pop("product") + response, records = await _request(claims=claims, authority=_Authority()) + assert response.status_code == 401 + assert records.records == {} + + +@pytest.mark.asyncio +async def test_http_resource_query_reports_authentication_evidence_outage() -> None: + response, _ = await _request( + claims=_claims(), + authority=_Authority(), + records=InMemoryImmutableRecordStore(fail_after_records=1), + ) + assert response.status_code == 503 + assert response.json()["detail"] == "Intelligence authentication evidence is unavailable" + + +def test_http_resource_query_openapi_exposes_the_public_page_contract() -> None: + app = FastAPI() + app.include_router(router) + operation = app.openapi()["paths"]["/v1/intelligence/resources/query"]["post"] + response_schema = operation["responses"]["200"]["content"]["application/json"]["schema"] + assert response_schema["$ref"].endswith("IntelligenceResourcePageV1Alpha1") diff --git a/tests/test_public_core_boundaries.py b/tests/test_public_core_boundaries.py index 60a4c75..a6507ff 100644 --- a/tests/test_public_core_boundaries.py +++ b/tests/test_public_core_boundaries.py @@ -106,6 +106,7 @@ def test_host_adapters_are_the_only_core_engine_edge_into_public_ace() -> None: allowed = { "core/engine/core/governed_state.py", "core/engine/core/immutable_records.py", + "core/engine/core/intelligence_resource_plane.py", "core/engine/core/live_cognition.py", "core/engine/core/action_execution.py", "core/engine/core/agent_composition_runtime.py",