diff --git a/adapters/http/app.py b/adapters/http/app.py index 971765d8..72130240 100644 --- a/adapters/http/app.py +++ b/adapters/http/app.py @@ -88,6 +88,10 @@ ) from engine.runtime.actor import MembershipRejectionAuditReceipt from engine.runtime.budget import PackageBudgetRequest +from engine.runtime.citation import ( + PRIVATE_FILE_CITATION_OPEN_PROFILE, + CitationAuthorityUnavailable, +) from engine.runtime.construction import required_kernel_dependencies from engine.runtime.context_run import ContextRunPersistenceUnavailable from engine.runtime.contracts import ( @@ -221,6 +225,7 @@ def create_app( required_kernel_dependencies(), clock=clock, query_digest_keyring=query_digest_keyring, + citation_profile=PRIVATE_FILE_CITATION_OPEN_PROFILE, ) if runtime is not None and query_digest_keyring is not None: raise TypeError( @@ -486,11 +491,15 @@ def resolve_context( private_binding = authentication.private_delivery_binding if delivery_evidence_ref is None: if private_binding is not None: + if type(runtime_request) is OpenCitation: + return _citation_not_available_response(request_id) raise TransportAuthenticationFailed elif ( - type(runtime_request) is not Acquire + type(runtime_request) not in {Acquire, OpenCitation} or type(private_binding) is not VerifiedPrivateDeliveryBinding ): + if type(runtime_request) is OpenCitation: + return _citation_not_available_response(request_id) raise TransportAuthenticationFailed try: organization_verification = selected_organization_authority.verify_existing( @@ -509,6 +518,8 @@ def resolve_context( checked_at=received_at, ) except (OrganizationVerificationRejected, TypeError, ValueError): + if type(runtime_request) is OpenCitation: + return _citation_not_available_response(request_id) raise TransportAuthenticationFailed from None try: with selected_membership_authority.current_user_actor( @@ -651,6 +662,10 @@ def resolve_context( redemption_request, ) except DeliveryEvidenceNotAvailable: + if type(runtime_request) is OpenCitation: + return _citation_not_available_response( + invocation.request_id + ) raise TransportAuthenticationFailed from None except DeliveryEvidenceAuthorityUnavailable: raise TrustedAuthorityUnavailable from None @@ -690,6 +705,8 @@ def resolve_context( raise TrustedAuthorityUnavailable from None if membership_rejection_observer is not None: membership_rejection_observer(error.audit_receipt) + if type(runtime_request) is OpenCitation: + return _citation_not_available_response(request_id) raise TransportAuthenticationFailed from None except MembershipAuthorityUnavailable: raise TrustedAuthorityUnavailable from None @@ -699,6 +716,8 @@ def resolve_context( raise TrustedAuthorityUnavailable from None except EgressGrantIssuanceUnavailable: raise TrustedAuthorityUnavailable from None + except CitationAuthorityUnavailable: + raise TrustedAuthorityUnavailable from None except ScopeAuthorityUnavailable: raise TrustedAuthorityUnavailable from None except ActiveReleaseUnavailable: @@ -709,6 +728,19 @@ def resolve_context( return app +def _citation_not_available_response(request_id: str) -> JSONResponse: + """Return the one non-enumerating result after transport authentication.""" + + return JSONResponse( + {"kind": "citation_not_available"}, + status_code=200, + headers={ + "Cache-Control": "no-store", + "X-Context-Request-Id": request_id, + }, + ) + + def _package_budget_from_wire( body: AcquireWire | ContinueWire, ) -> PackageBudgetRequest | None: diff --git a/docs/decisions/0051-reauthorize-opaque-citation-opens.md b/docs/decisions/0051-reauthorize-opaque-citation-opens.md new file mode 100644 index 00000000..60452851 --- /dev/null +++ b/docs/decisions/0051-reauthorize-opaque-citation-opens.md @@ -0,0 +1,100 @@ +--- +name: adr-0051-reauthorize-opaque-citation-opens +version: "1.0.0" +description: > + Issue digest-only multi-use citation locators after authorized projection and + reauthorize every open through the sealed Runtime Kernel. +--- + +# 0051. Reauthorize every opaque citation open + +- Status: accepted +- Date: 2026-07-24 +- Refines: ADR-0012, ADR-0013, ADR-0023, ADR-0025, ADR-0028, ADR-0031, ADR-0045, ADR-0046, ADR-0048 + +## Context + +A citation must let a later caller request the exact prior Evidence target, but +the reference cannot preserve the prior caller's authorization. Membership, +Resource access, Source lifecycle, field rights, Policy Epoch, delivery +audience, and egress policy may all differ at open time. Treating the reference +as a bearer capability would bypass those current facts; returning a source URL +would expose both location and authority-sensitive metadata. + +The locator is also useful across retries. A denied open must therefore neither +consume it nor extend its lifetime, and denial must not reveal whether the +target once existed. + +## Decision + +An authorized File `Evidence` receives a server-issued `CitationOpenRef` only +after `CandidateRef -> AuthorizationKernel -> AuthorizedProjection` has +completed. The reference is opaque, type-separated from continuation and egress +capabilities, and included in Evidence integrity and the public Package digest. + +PostgreSQL stores only the SHA-256 locator digest, digest/profile and retention +metadata, prior Package/Evidence refs, and exact Resource/Revision/Fragment +location lineage. It stores no source URL, prior principal, Membership, +audience, purpose, Policy Epoch, authorization decision, or bearer. A dedicated +NOLOGIN definer owns three function-only operations. The Runtime login may +issue and redeem through two of them but has no locator-table privilege. A +restricted security-operator login may invoke only exact-Organization cleanup, +which uses database time and deletes digest lineage only after the fixed +profile `retain_until`. FORCE RLS and exact same-Organization foreign keys +remain mandatory. + +Redemption is multi-use and content-free. It returns at most one `CandidateRef` +plus prior Package/Evidence location lineage; it does not return content or an +authorization receipt. Database time decides issuance and expiry. Missing, +expired, forged, cross-kind, cross-Organization, disabled, tombstoned, or stale +location lineage maps to the same internal not-available condition without +mutation. + +Every active `OpenCitation` obtains a new current `UserActor` transaction and a +new trusted direct or private delivery context. For private delivery, the HTTP +metadata carries a new request-bound `DeliveryEvidenceRef` whose purpose is +`citation.open`; trusted audience facts never enter the body. Runtime computes +the current full trusted scope, feeds the redeemed `CandidateRef` through the +same sealed Kernel locator, scope, field projection, budget, provenance, final +epoch, and audit gates, and never calls candidate discovery. A successful open +produces a replacement audience-bound `ContextPackage`, fresh citation locator, +matching `EgressGrant`, and authorized `ContextRun`. The retained query digest +uses the fixed semantic value `citation.open`, never the locator bearer. + +If the locator or current authorization yields no Evidence, Runtime persists +only the existing generic delivered-empty ContextRun/DecisionAudit lineage and +returns `citation_not_available`. It issues no egress grant and exposes no +existence detail. A denied open does not consume, refresh, or otherwise mutate +the original locator, so a later authorized opener can succeed. + +Issue #69 activates only private/direct File citation issuance and opening over +the public HTTP v0 contract and generated TypeScript SDK. Group/public +`AudienceSnapshot`, non-File provider citation semantics, and Continue remain +`NOT_ACTIVE`. + +## Rationale + +Location is sufficient to restart authorization; carrying any previous +decision would create a second authorization system. Multi-use locators make +retries deterministic while current-transaction reauthorization makes every +open independently revocable. Including the locator in Package integrity +prevents substitution without turning it into authority. + +## Consequences + +- Reader A may issue and reopen a locator; reader B receives only the generic + unavailable outcome; reader A may still reopen it afterward. +- Every content-bearing open crosses `CandidateRef`, `AuthorizationKernel`, and + `AuthorizedProjection` before a replacement Package or grant exists. +- Locator database outages are service unavailability, while validly decided + misses and denials are `citation_not_available`. +- Rollback refuses while locator lineage remains; the dedicated security + operator cleans it only after the versioned citation retention window. +- Ordinary traces and public responses contain neither the locator bearer nor + prior trusted authorization facts. + +## Revisit trigger + +Revisit before activating group/public citation delivery, a non-File provider's +locator semantics, a different retention profile, or a public citation contract +that cannot preserve the same generic denial and sealed Kernel path. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8dcf47bb..ec11f24e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -46,8 +46,10 @@ kernel, capability separation, and publication visibility model. | File Source progress | [0043 — Separate acquisition and publication progress](0043-separate-file-acquisition-progress-from-publication-progress.md) | Append accepted changes separately from contiguous Runtime-visibility completion and expose them through an Organization/Source-scoped Control read | One ambiguous checkpoint, skipped publication gaps, Runtime authorization from watermarks, or false standard ProviderPort capability claims | | File Source offboarding | [0044 — Disable before cleanup](0044-disable-file-sources-before-cleanup.md) | One trusted Control transaction terminally disables the Source, advances its Organization Policy Epoch, cancels outstanding work, and records immutable pending cleanup lineage | Cleanup-defined revocation, bulk Resource deletion, application-only lifecycle checks, post-disable leases/tickets, or treating progress as authority | | Private delivery ingress | [0045 — Redeem private delivery evidence at ingress](0045-redeem-private-delivery-evidence-at-ingress.md) | One digest-only service/request/asker/audience/epoch-bound DeliveryEvidenceRef constructs private TrustedDeliveryContext inside the current UserActor transaction before content work | Raw trusted delivery facts on the wire, bearer persistence, application-role minting/table reads, alternate Runtime paths, or claiming later M2 carriers | +| Exact Package egress | [0046 — Bind egress to one exact Package hop](0046-bind-egress-to-one-exact-package-hop.md) | One digest-only grant binds one exact audience-bound Package to one model or channel preflight hop and redeems atomically | Treating Package construction as disclosure authority, arbitrary content at egress, cross-hop reuse, or bypassing final policy | | Public OpenAPI v0 | [0047 — Freeze OpenAPI v0 through one Runtime path](0047-freeze-openapi-v0-through-one-runtime-path.md) | One public `/v0/resolve` schema and a hidden provisional v1 bridge share the same sealed Runtime; Package release lineage is read-only from the Learning-published active manifest | Two authorization compositions, caller-authored release facts, Runtime publication/fallback, or in-place mutation of historical snapshots | | Generated TypeScript SDK | [0048 — Generate the TypeScript SDK behind a closed facade](0048-generate-typescript-sdk-behind-a-closed-facade.md) | OpenAPI v0 generates the semantic contract and internal fetch client; a package export map exposes only a metadata-safe facade and the contract checksum | Handwritten wire schemas, raw generated transport exports, arbitrary caller headers, floating generator/runtime versions, or claiming inactive capability redemption | +| Citation open | [0051 — Reauthorize opaque citation opens](0051-reauthorize-opaque-citation-opens.md) | Digest-only multi-use locators recover only content-free target lineage; every open uses a current UserActor and the sealed Kernel to produce a replacement Package | Bearer authority, prior-decision reuse, source URLs, locator consumption on denial, or content before exact reauthorization | Each baseline ADR is `accepted` and contains Context, Decision, Rationale, Consequences, and Revisit trigger sections. A revisit trigger permits review; it @@ -127,6 +129,7 @@ touched: - [0030 — Bound ticket audiences](0030-bound-ticket-audiences.md) - [0049 — Exact private ActionPlane prepare](0049-prepare-one-exact-private-effect.md) - [0050 — Exact private ActionPlane perform](0050-perform-one-exact-private-effect.md) +- [0051 — Reauthorize opaque citation opens](0051-reauthorize-opaque-citation-opens.md) - [0031 — Authorized-only ContextRun lineage](0031-persist-authorized-context-run-lineage.md) - [0032 — Membership-bound materialized fields](0032-bind-materialized-fields-to-membership-projection-rights.md) - [0033 — Organization release promotion owner](0033-promote-organization-releases-through-one-learning-owner.md) @@ -142,3 +145,4 @@ touched: - [0043 — Separate File acquisition and publication progress](0043-separate-file-acquisition-progress-from-publication-progress.md) - [0044 — Disable File sources before cleanup](0044-disable-file-sources-before-cleanup.md) - [0045 — Redeem private delivery evidence at ingress](0045-redeem-private-delivery-evidence-at-ingress.md) +- [0046 — Bind egress to one exact Package hop](0046-bind-egress-to-one-exact-package-hop.md) diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py index 665b612a..28c33a31 100644 --- a/engine/persistence/__init__.py +++ b/engine/persistence/__init__.py @@ -7,10 +7,12 @@ PostgreSQLAccessPolicyControl, ResourceAccessRevocation, ) +from engine.persistence.citation import PostgreSQLCitationOpenRetentionPort from engine.persistence.configuration import ( ACTION_EXECUTE_DEFINER_ROLE, ACTION_PREPARE_DEFINER_ROLE, ACTION_ROLE, + CITATION_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, EGRESS_ROLE, @@ -94,6 +96,7 @@ "DatabaseConfiguration", "ACTION_PREPARE_DEFINER_ROLE", "ACTION_ROLE", + "CITATION_DEFINER_ROLE", "AccessChangeRejected", "AccessPolicyControlUnavailable", "DatabaseConfigurationError", @@ -121,6 +124,7 @@ "ContextRunView", "OperatorAuthorizationProvenance", "PostgreSQLContextRunReader", + "PostgreSQLCitationOpenRetentionPort", "PostgreSQLControlStore", "PostgreSQLDeliveryEvidenceIssuerPort", "PostgreSQLDeliveryEvidenceRetentionPort", diff --git a/engine/persistence/citation.py b/engine/persistence/citation.py new file mode 100644 index 00000000..fe0f2fb5 --- /dev/null +++ b/engine/persistence/citation.py @@ -0,0 +1,36 @@ +"""Restricted PostgreSQL cleanup for retained citation-locator lineage.""" + +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import Engine, text +from sqlalchemy.exc import SQLAlchemyError + +from engine.persistence.role_guard import assert_security_operator_role +from engine.runtime.citation import CitationAuthorityUnavailable + + +class PostgreSQLCitationOpenRetentionPort: + """Delete retained digests through the dedicated security-operator login.""" + + def __init__(self, engine: Engine) -> None: + self._engine = engine + + def delete_expired_lineage(self, organization_id: UUID) -> int: + try: + with self._engine.begin() as connection: + assert_security_operator_role(connection) + deleted = connection.execute( + text( + "SELECT " + "context_security_delete_expired_citation_open_lineage(" + ":organization_id)" + ), + {"organization_id": organization_id}, + ).scalar_one() + except (AssertionError, SQLAlchemyError): + raise CitationAuthorityUnavailable from None + if type(deleted) is not int or deleted < 0: + raise CitationAuthorityUnavailable + return deleted diff --git a/engine/persistence/configuration.py b/engine/persistence/configuration.py index 05da772d..a1bf78e3 100644 --- a/engine/persistence/configuration.py +++ b/engine/persistence/configuration.py @@ -19,6 +19,7 @@ ACTION_EXECUTE_DEFINER_ROLE = "context_engine_action_execute_definer" EGRESS_GRANT_DEFINER_ROLE = "context_engine_egress_grant_definer" DELIVERY_EVIDENCE_DEFINER_ROLE = "context_engine_delivery_evidence_definer" +CITATION_DEFINER_ROLE = "context_engine_citation_definer" ACCESS_POLICY_DEFINER_ROLE = "context_engine_access_policy_definer" WORKER_LEASE_DEFINER_ROLE = "context_engine_worker_lease_definer" CONTEXT_RUN_READER_DEFINER_ROLE = "context_engine_context_run_reader_definer" diff --git a/engine/persistence/membership_context.py b/engine/persistence/membership_context.py index d23fe0bb..23c6b072 100644 --- a/engine/persistence/membership_context.py +++ b/engine/persistence/membership_context.py @@ -6,6 +6,7 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta +from hashlib import sha256 from typing import Protocol from uuid import UUID @@ -22,6 +23,18 @@ _construct_current_membership_verification, _open_membership_authority_scope, ) +from engine.runtime.citation import ( + CITATION_OPEN_DIGEST_PROFILE, + CitationAuthorityUnavailable, + CitationOpenIssue, + CitationOpenProfile, + CitationOpenRedemption, + CitationOpenTarget, + CitationOpenTargetLineage, + _close_citation_authority_scope, + _construct_citation_open_session, + _open_citation_authority_scope, +) from engine.runtime.context_run import ( ContextRunRecord, DecisionAuditRecord, @@ -760,6 +773,96 @@ def issue(self, request: EgressGrantIssue, grant_digest: bytes) -> bool: return accepted is True +class _PostgreSQLCitationOpenPort: + """Function-only citation issue/redemption on the current actor connection.""" + + def __init__(self, connection: Connection) -> None: + self._connection = connection + + def issue( + self, + *, + request: CitationOpenIssue, + locator_digest: bytes, + digest_profile: str, + profile: CitationOpenProfile, + retain_until: datetime, + ) -> bool: + if digest_profile != CITATION_OPEN_DIGEST_PROFILE: + return False + try: + result = self._connection.execute( + text( + """ + SELECT context_runtime_issue_citation_open_ref( + :organization_id, :locator_digest, :digest_profile, + :package_ref, :evidence_ref, :resource_ref, + :revision_id, :fragment_ref, :issued_at, :expires_at, + :profile_ref, :retention_policy_ref, :retain_until + ) + """ + ), + { + "organization_id": request.organization_id, + "locator_digest": locator_digest, + "digest_profile": digest_profile, + "package_ref": request.package_ref, + "evidence_ref": request.evidence_ref, + "resource_ref": request.resource_ref, + "revision_id": request.revision_id, + "fragment_ref": request.fragment_ref, + "issued_at": request.issued_at, + "expires_at": request.expires_at, + "profile_ref": profile.profile_ref, + "retention_policy_ref": profile.retention_policy_ref, + "retain_until": retain_until, + }, + ).scalar_one() + except SQLAlchemyError: + raise CitationAuthorityUnavailable from None + return result is True + + def redeem(self, request: CitationOpenRedemption) -> CitationOpenTarget | None: + try: + row = self._connection.execute( + text( + """ + SELECT source_ref, resource_ref, revision_id, fragment_ref, + package_ref, evidence_ref + FROM context_runtime_redeem_citation_open_ref( + :organization_id, :locator_digest, :digest_profile, + :opened_at + ) + """ + ), + { + "organization_id": request.organization_id, + "locator_digest": sha256( + request.citation_open_ref.value.encode("utf-8") + ).digest(), + "digest_profile": CITATION_OPEN_DIGEST_PROFILE, + "opened_at": request.opened_at, + }, + ).one_or_none() + except SQLAlchemyError: + raise CitationAuthorityUnavailable from None + if row is None: + return None + return CitationOpenTarget( + candidate_ref=CandidateRef( + organization_id=request.organization_id, + source_ref=row.source_ref, + resource_ref=row.resource_ref, + revision_ref=str(row.revision_id), + fragment_ref=row.fragment_ref, + ), + lineage=CitationOpenTargetLineage( + package_ref=row.package_ref, + evidence_ref=row.evidence_ref, + ), + ) + + class PostgreSQLMembershipAuthority: """Open and retain the exact UserActor transaction through Runtime work.""" @@ -907,6 +1010,7 @@ def _current_user_actor_transaction( context_run_scope = _open_context_run_persistence_scope() delivery_evidence_scope = _open_delivery_evidence_redemption_scope() egress_issuance_scope = _open_egress_grant_issuance_scope() + citation_scope = _open_citation_authority_scope() try: projection_session = _construct_materialized_projection_session( authority_scope=projection_scope, @@ -949,12 +1053,17 @@ def _current_user_actor_transaction( port=_PostgreSQLEgressGrantIssuancePort(connection), ) ), + citation_open_session=_construct_citation_open_session( + authority_scope=citation_scope, + port=_PostgreSQLCitationOpenPort(connection), + ), active_runtime_release=_observe_active_runtime_release( connection, identity.organization_id, ), ) finally: + _close_citation_authority_scope(citation_scope) _close_egress_grant_issuance_scope(egress_issuance_scope) _close_delivery_evidence_redemption_scope(delivery_evidence_scope) _close_context_run_persistence_scope(context_run_scope) diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index c4bc28f1..d16d7f99 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,5 +1,5 @@ { - "manifestVersion": "22.0.0", + "manifestVersion": "23.0.0", "controlOperations": [ { "name": "register_file_source", @@ -566,6 +566,14 @@ "context_engine_action_execute_definer" ], "using": "true" + }, + { + "name": "membership_citation_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_citation_definer" + ], + "using": "true" } ] }, @@ -588,6 +596,9 @@ ], "context_engine_action_execute_definer": [ "SELECT" + ], + "context_engine_citation_definer": [ + "SELECT" ] }, "partitions": [], @@ -603,6 +614,195 @@ "DB-010" ] }, + { + "name": "citation_open_locator", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-CITATION-AUTH-010", + "selector": { + "table": "citation_open_locator" + } + }, + "purpose": "Digest-only multi-use citation locator lineage that carries no authorization decision", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_citation_open_locator", + "kind": "primary_key", + "columns": [ + "organization_id", + "locator_digest" + ] + } + ], + "capabilityUniqueKeys": [ + { + "name": "uq_citation_open_locator_digest_global", + "kind": "unique", + "columns": [ + "locator_digest" + ], + "rationale": "one opaque locator digest names at most one Organization-bound prior Package/Evidence location" + } + ], + "foreignKeys": [ + { + "name": "fk_citation_open_locator_organization", + "columns": [ + "organization_id" + ], + "references": { + "table": "organization", + "columns": [ + "organization_id" + ] + }, + "onDelete": "RESTRICT" + }, + { + "name": "fk_citation_open_locator_fragment_lineage", + "columns": [ + "organization_id", + "resource_ref", + "revision_id", + "fragment_ref" + ], + "references": { + "table": "context_fragment", + "columns": [ + "organization_id", + "resource_ref", + "revision_id", + "fragment_ref" + ] + }, + "onDelete": "RESTRICT" + } + ], + "checkConstraints": [ + { + "name": "ck_citation_open_locator_digest_sha256", + "expression": "octet_length(locator_digest) = 32" + }, + { + "name": "ck_citation_open_locator_digest_profile", + "expression": "digest_profile = 'citation-open-ref-sha256-v1'" + }, + { + "name": "ck_citation_open_locator_package_ref", + "expression": "package_ref ~ '^pkg_[0-9a-f]{32}$'" + }, + { + "name": "ck_citation_open_locator_evidence_ref", + "expression": "evidence_ref ~ '^ev_[0-9a-f]{64}$'" + }, + { + "name": "ck_citation_open_locator_fragment_refs_nonblank", + "expression": "btrim(resource_ref) <> '' AND btrim(fragment_ref) <> ''" + }, + { + "name": "ck_citation_open_locator_profiles", + "expression": "profile_ref = 'private-citation-open-v1' AND retention_policy_ref = 'citation-locator-retention-v1'" + }, + { + "name": "ck_citation_open_locator_time_windows", + "expression": "expires_at > issued_at AND expires_at <= issued_at + interval '10 minutes' AND retain_until = issued_at + interval '30 days'" + } + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + { + "name": "citation_open_locator_migrator_administration", + "command": "ALL", + "roles": [ + "context_engine_migrator" + ], + "using": "true", + "withCheck": "true" + }, + { + "name": "citation_open_locator_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_citation_definer" + ], + "using": "true" + }, + { + "name": "citation_open_locator_definer_insert", + "command": "INSERT", + "roles": [ + "context_engine_citation_definer" + ], + "withCheck": "true" + }, + { + "name": "citation_open_locator_definer_delete", + "command": "DELETE", + "roles": [ + "context_engine_citation_definer" + ], + "using": "true" + } + ] + }, + "functionOnlyMutation": { + "databaseFunctions": [ + "context_runtime_issue_citation_open_ref", + "context_runtime_redeem_citation_open_ref", + "context_security_delete_expired_citation_open_lineage" + ], + "definerRole": "context_engine_citation_definer", + "directTableMutationAllowed": false + }, + "retention": { + "class": "restricted_citation_locator_lineage", + "bearerStored": false, + "priorAuthorizationStored": false, + "expirySource": "private-citation-open-v1 with database clock enforcement", + "cleanup": "context_security_delete_expired_citation_open_lineage deletes only one exact Organization's digest-only rows after database-clock retain_until; schema downgrade refuses while rows remain" + }, + "permittedOperations": { + "context_engine_runtime": [ + "EXECUTE context_runtime_issue_citation_open_ref", + "EXECUTE context_runtime_redeem_citation_open_ref" + ], + "context_engine_citation_definer": [ + "SELECT", + "INSERT", + "DELETE" + ], + "context_engine_control": [], + "context_engine_worker": [], + "context_engine_learning": [], + "context_engine_security_operator": [ + "EXECUTE context_security_delete_expired_citation_open_lineage" + ] + }, + "partitions": [], + "securityInvariantIds": [ + "TENANT-OWNERSHIP-001", + "TENANT-FK-002", + "RLS-FAIL-CLOSED-003", + "REVOCATION-006", + "NON-ENUMERATION-009", + "TRACE-REDACTION-012" + ], + "negativeTestIds": [ + "DB-001", + "DB-002", + "DB-004", + "DB-008", + "DB-009", + "DB-010", + "CITE-001", + "CITE-002", + "CITE-003", + "CITE-004" + ] + }, { "name": "delivery_evidence", "classification": "tenant_owned", @@ -1436,6 +1636,14 @@ ], "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid", "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "context_resource_citation_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_citation_definer" + ], + "using": "true" } ] }, @@ -1470,6 +1678,9 @@ "SELECT", "INSERT", "UPDATE" + ], + "context_engine_citation_definer": [ + "SELECT" ] }, "partitions": [], @@ -3379,6 +3590,14 @@ "context_engine_worker_lease_definer" ], "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "context_fragment_citation_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_citation_definer" + ], + "using": "true" } ] }, @@ -3406,6 +3625,9 @@ "context_engine_worker_lease_definer": [ "SELECT", "INSERT" + ], + "context_engine_citation_definer": [ + "SELECT" ] }, "partitions": [], diff --git a/engine/runtime/actor.py b/engine/runtime/actor.py index d1c49bb8..00cb0a05 100644 --- a/engine/runtime/actor.py +++ b/engine/runtime/actor.py @@ -6,6 +6,10 @@ from typing import Final, Literal, NoReturn from uuid import UUID +from engine.runtime.citation import ( + CitationOpenSession, + _require_active_citation_open_session, +) from engine.runtime.context_run import ( ContextRunPersistenceSession, _require_active_context_run_persistence_session, @@ -114,6 +118,7 @@ class CurrentMembershipVerification: field(repr=False) ) egress_grant_issuance_session: EgressGrantIssuanceSession | None = field(repr=False) + citation_open_session: CitationOpenSession | None = field(repr=False) active_runtime_release: ActiveRuntimeRelease | None = field(repr=False) construction_provenance: MembershipVerificationProvenance _authority_scope: _MembershipAuthorityScope = field(repr=False) @@ -146,6 +151,7 @@ def _construct_current_membership_verification( DeliveryEvidenceRedemptionSession | None ) = None, egress_grant_issuance_session: EgressGrantIssuanceSession | None = None, + citation_open_session: CitationOpenSession | None = None, active_runtime_release: ActiveRuntimeRelease | None = None, ) -> CurrentMembershipVerification: """Construct proof after the trusted authority verifies the durable row.""" @@ -197,6 +203,8 @@ def _construct_current_membership_verification( ) if egress_grant_issuance_session is not None: _require_active_egress_grant_issuance_session(egress_grant_issuance_session) + if citation_open_session is not None: + _require_active_citation_open_session(citation_open_session) if active_runtime_release is not None: if type(active_runtime_release) is not ActiveRuntimeRelease: raise TypeError("current Membership active release has the wrong type") @@ -249,6 +257,7 @@ def _construct_current_membership_verification( "egress_grant_issuance_session", egress_grant_issuance_session, ) + object.__setattr__(verification, "citation_open_session", citation_open_session) object.__setattr__( verification, "active_runtime_release", @@ -297,6 +306,8 @@ def _require_active_current_membership_verification( _require_active_egress_grant_issuance_session( verification.egress_grant_issuance_session ) + if verification.citation_open_session is not None: + _require_active_citation_open_session(verification.citation_open_session) _require_active_policy_epoch_verification(verification.policy_epoch_verification) if ( verification.policy_epoch != verification.policy_epoch_verification.policy_epoch @@ -332,6 +343,7 @@ class UserActor: field(repr=False) ) egress_grant_issuance_session: EgressGrantIssuanceSession | None = field(repr=False) + citation_open_session: CitationOpenSession | None = field(repr=False) active_runtime_release: ActiveRuntimeRelease | None = field(repr=False) current_membership_verification: CurrentMembershipVerification = field(repr=False) construction_provenance: UserActorConstructionProvenance @@ -385,6 +397,7 @@ def _construct_user_actor( "egress_grant_issuance_session", verification.egress_grant_issuance_session, ), + ("citation_open_session", verification.citation_open_session), ("active_runtime_release", verification.active_runtime_release), ("current_membership_verification", verification), ( @@ -427,6 +440,7 @@ def _require_active_user_actor(actor: UserActor) -> None: is not verification.delivery_evidence_redemption_session or actor.egress_grant_issuance_session is not verification.egress_grant_issuance_session + or actor.citation_open_session is not verification.citation_open_session or actor.active_runtime_release is not verification.active_runtime_release ): raise ValueError("UserActor does not match its current Membership proof") diff --git a/engine/runtime/capabilities.py b/engine/runtime/capabilities.py index 3b93fc35..404b93e3 100644 --- a/engine/runtime/capabilities.py +++ b/engine/runtime/capabilities.py @@ -75,10 +75,20 @@ class RuntimeCapabilityGate: __slots__ = () - def require_available(self, capability: RuntimeCapability) -> None: + def require_available( + self, + capability: RuntimeCapability, + *, + citation_open_active: bool = False, + ) -> None: if type(capability) is not RuntimeCapability: raise TypeError("capability must be RuntimeCapability") - if capability not in M0_RUNTIME_CAPABILITY_DECLARATION.available: + if type(citation_open_active) is not bool: + raise TypeError("citation_open_active must be bool") + available = M0_RUNTIME_CAPABILITY_DECLARATION.available + if citation_open_active: + available = available | frozenset({RuntimeCapability.OPEN_CITATION}) + if capability not in available: raise UnsupportedCapability diff --git a/engine/runtime/citation.py b/engine/runtime/citation.py new file mode 100644 index 00000000..0998ef30 --- /dev/null +++ b/engine/runtime/citation.py @@ -0,0 +1,338 @@ +"""Opaque multi-use citation locators that carry no authorization.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from hashlib import sha256 +from re import fullmatch +from secrets import token_hex +from typing import Final, NoReturn, Protocol +from uuid import UUID + +from engine.runtime.contracts import CitationOpenRef +from engine.runtime.evidence import CandidateRef + +CITATION_OPEN_REF_PREFIX: Final = "cor" +CITATION_OPEN_DIGEST_PROFILE: Final = "citation-open-ref-sha256-v1" +CITATION_OPEN_RETENTION_CLASS: Final = "restricted_citation_locator_lineage" + + +class CitationLocatorNotAvailable(Exception): + """The locator does not name currently usable content-free lineage.""" + + def __init__(self) -> None: + super().__init__("citation not available") + + +class CitationAuthorityUnavailable(RuntimeError): + """Citation persistence could not make a safe decision.""" + + +def _require_nonblank(field_name: str, value: object) -> str: + if type(value) is not str or not value or value.isspace(): + raise ValueError(f"citation {field_name} must be nonblank") + return value + + +def _require_utc(field_name: str, value: object) -> datetime: + if ( + type(value) is not datetime + or value.tzinfo is None + or value.utcoffset() != timedelta(0) + ): + raise ValueError(f"citation {field_name} must be aware UTC") + return value + + +def _is_citation_open_ref(value: object) -> bool: + return type(value) is str and fullmatch(r"cor_[0-9a-f]{64}", value) is not None + + +@dataclass(frozen=True, slots=True) +class CitationOpenProfile: + """Server-owned locator lifetime and digest-retention policy.""" + + profile_ref: str + retention_policy_ref: str + maximum_ttl: timedelta + retention_period: timedelta + retention_class: str = CITATION_OPEN_RETENTION_CLASS + + def __post_init__(self) -> None: + _require_nonblank("profile_ref", self.profile_ref) + _require_nonblank("retention_policy_ref", self.retention_policy_ref) + if type(self.maximum_ttl) is not timedelta or self.maximum_ttl <= timedelta(0): + raise ValueError("citation maximum_ttl must be positive") + if ( + type(self.retention_period) is not timedelta + or self.retention_period < self.maximum_ttl + ): + raise ValueError("citation retention must cover the locator lifetime") + if self.retention_class != CITATION_OPEN_RETENTION_CLASS: + raise ValueError("citation retention class is not active") + + +PRIVATE_FILE_CITATION_OPEN_PROFILE: Final = CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), +) + + +@dataclass(frozen=True, slots=True) +class CitationOpenIssue: + """Authorized Package/Evidence lineage eligible for locator issuance.""" + + organization_id: UUID = field(repr=False) + package_ref: str = field(repr=False) + evidence_ref: str = field(repr=False) + resource_ref: str = field(repr=False) + revision_id: UUID = field(repr=False) + fragment_ref: str = field(repr=False) + issued_at: datetime = field(repr=False) + expires_at: datetime = field(repr=False) + + def __post_init__(self) -> None: + if type(self.organization_id) is not UUID or type(self.revision_id) is not UUID: + raise TypeError("citation Organization and Revision must be UUID") + if fullmatch(r"pkg_[0-9a-f]{32}", self.package_ref) is None: + raise ValueError("citation package_ref must use the closed format") + if fullmatch(r"ev_[0-9a-f]{64}", self.evidence_ref) is None: + raise ValueError("citation evidence_ref must use the closed format") + _require_nonblank("resource_ref", self.resource_ref) + _require_nonblank("fragment_ref", self.fragment_ref) + _require_utc("issued_at", self.issued_at) + _require_utc("expires_at", self.expires_at) + if self.expires_at <= self.issued_at: + raise ValueError("citation expiry must follow issuance") + + +@dataclass(frozen=True, slots=True) +class CitationOpenRedemption: + """Current opener's content-free locator lookup request.""" + + citation_open_ref: CitationOpenRef = field(repr=False) + organization_id: UUID = field(repr=False) + opened_at: datetime = field(repr=False) + + def __post_init__(self) -> None: + if type(self.citation_open_ref) is not CitationOpenRef: + raise TypeError("citation redemption requires CitationOpenRef") + if type(self.organization_id) is not UUID: + raise TypeError("citation redemption Organization must be UUID") + _require_utc("opened_at", self.opened_at) + + +@dataclass(frozen=True, slots=True) +class CitationOpenTargetLineage: + """Prior Package/Evidence location only; never a prior authorization fact.""" + + package_ref: str + evidence_ref: str + + def __post_init__(self) -> None: + if fullmatch(r"pkg_[0-9a-f]{32}", self.package_ref) is None: + raise ValueError("citation target package_ref must use the closed format") + if fullmatch(r"ev_[0-9a-f]{64}", self.evidence_ref) is None: + raise ValueError("citation target evidence_ref must use the closed format") + + +@dataclass(frozen=True, slots=True) +class CitationOpenTarget: + """Content-free candidate lineage returned before exact reauthorization.""" + + candidate_ref: CandidateRef = field(repr=False) + lineage: CitationOpenTargetLineage + + def __post_init__(self) -> None: + if type(self.candidate_ref) is not CandidateRef: + raise TypeError("citation target requires CandidateRef") + if type(self.lineage) is not CitationOpenTargetLineage: + raise TypeError("citation target lineage has the wrong type") + + +class CitationOpenPort(Protocol): + """Digest-only issue and content-free multi-use lookup boundary.""" + + def issue( + self, + *, + request: CitationOpenIssue, + locator_digest: bytes, + digest_profile: str, + profile: CitationOpenProfile, + retain_until: datetime, + ) -> bool: ... + + def redeem(self, request: CitationOpenRedemption) -> CitationOpenTarget | None: ... + + +class CitationOpenRetentionPort(Protocol): + """Organization-scoped cleanup using authority-owned current time.""" + + def delete_expired_lineage(self, organization_id: UUID) -> int: ... + + +class CitationOpenRetention: + """Delete locator digests only after their versioned retention window.""" + + def __init__(self, port: CitationOpenRetentionPort) -> None: + if not callable(getattr(port, "delete_expired_lineage", None)): + raise TypeError("citation retention port is incomplete") + self._port = port + + def delete_expired(self, organization_id: UUID) -> int: + if type(organization_id) is not UUID: + raise TypeError("citation retention requires an Organization UUID") + try: + deleted = self._port.delete_expired_lineage(organization_id) + except CitationAuthorityUnavailable: + raise + except Exception as error: + raise CitationAuthorityUnavailable from error + if type(deleted) is not int or deleted < 0: + raise CitationAuthorityUnavailable + return deleted + + +class _CitationAuthorityScope: + __slots__ = ("_active", "_seal") + _active: bool + _seal: object + + def __init__(self) -> None: + raise TypeError("citation authority scopes are not constructible") + + def __reduce__(self) -> NoReturn: + raise TypeError("citation authority scopes are not serializable") + + +_CITATION_AUTHORITY_SCOPE_SEAL = object() + + +def _open_citation_authority_scope() -> _CitationAuthorityScope: + scope = object.__new__(_CitationAuthorityScope) + scope._active = True + scope._seal = _CITATION_AUTHORITY_SCOPE_SEAL + return scope + + +def _close_citation_authority_scope(scope: _CitationAuthorityScope) -> None: + if ( + type(scope) is not _CitationAuthorityScope + or getattr(scope, "_seal", None) is not _CITATION_AUTHORITY_SCOPE_SEAL + ): + raise TypeError("citation authority scope has the wrong nominal type") + scope._active = False + + +@dataclass(frozen=True, slots=True, init=False) +class CitationOpenSession: + """Current-UserActor citation authority valid only in its transaction.""" + + _authority_scope: _CitationAuthorityScope = field(repr=False) + _port: CitationOpenPort = field(repr=False) + + def __init__(self, *args: object, **kwargs: object) -> None: + raise TypeError("CitationOpenSession is authority-constructed") + + def __reduce__(self) -> NoReturn: + raise TypeError("CitationOpenSession is not serializable") + + +def _require_active_citation_open_session(session: CitationOpenSession) -> None: + if type(session) is not CitationOpenSession: + raise TypeError("citation session has the wrong nominal type") + scope = session._authority_scope + if ( + type(scope) is not _CitationAuthorityScope + or getattr(scope, "_seal", None) is not _CITATION_AUTHORITY_SCOPE_SEAL + or not getattr(scope, "_active", False) + ): + raise ValueError("citation session requires an active authority scope") + + +def _construct_citation_open_session( + *, + authority_scope: _CitationAuthorityScope, + port: CitationOpenPort, +) -> CitationOpenSession: + session = object.__new__(CitationOpenSession) + object.__setattr__(session, "_authority_scope", authority_scope) + object.__setattr__(session, "_port", port) + _require_active_citation_open_session(session) + if not callable(getattr(port, "issue", None)) or not callable( + getattr(port, "redeem", None) + ): + raise TypeError("citation port is incomplete") + return session + + +def issue_citation_open_ref( + session: CitationOpenSession, + request: CitationOpenIssue, + *, + profile: CitationOpenProfile, + reference_factory: Callable[[], str] = lambda: f"cor_{token_hex(32)}", +) -> CitationOpenRef: + """Issue one opaque locator after authorized Package assembly.""" + + _require_active_citation_open_session(session) + if ( + type(request) is not CitationOpenIssue + or type(profile) is not CitationOpenProfile + ): + raise TypeError("citation issuance requires exact request and profile types") + if request.expires_at - request.issued_at > profile.maximum_ttl: + raise CitationLocatorNotAvailable + try: + reference = reference_factory() + except Exception: + raise CitationAuthorityUnavailable("citation authority unavailable") from None + if not _is_citation_open_ref(reference): + raise CitationAuthorityUnavailable("citation authority unavailable") + locator = CitationOpenRef(reference) + try: + persisted = session._port.issue( + request=request, + locator_digest=sha256(reference.encode("utf-8")).digest(), + digest_profile=CITATION_OPEN_DIGEST_PROFILE, + profile=profile, + retain_until=request.issued_at + profile.retention_period, + ) + except CitationAuthorityUnavailable: + raise + except Exception: + raise CitationAuthorityUnavailable("citation authority unavailable") from None + if persisted is not True: + raise CitationAuthorityUnavailable("citation authority unavailable") + return locator + + +def redeem_citation_open_ref( + session: CitationOpenSession, + request: CitationOpenRedemption, +) -> CitationOpenTarget: + """Resolve content-free lineage without consuming or refreshing the locator.""" + + _require_active_citation_open_session(session) + if type(request) is not CitationOpenRedemption: + raise TypeError("citation redemption requires CitationOpenRedemption") + if not _is_citation_open_ref(request.citation_open_ref.value): + raise CitationLocatorNotAvailable + try: + target = session._port.redeem(request) + except CitationLocatorNotAvailable: + raise + except CitationAuthorityUnavailable: + raise + except Exception: + raise CitationAuthorityUnavailable("citation authority unavailable") from None + if type(target) is not CitationOpenTarget: + raise CitationLocatorNotAvailable + if target.candidate_ref.organization_id != request.organization_id: + raise CitationLocatorNotAvailable + return target diff --git a/engine/runtime/construction.py b/engine/runtime/construction.py index 773019ac..2806f2c9 100644 --- a/engine/runtime/construction.py +++ b/engine/runtime/construction.py @@ -22,6 +22,15 @@ UnsupportedCapabilityAuditReceipt, _required_capability_for_request, ) +from engine.runtime.citation import ( + CitationAuthorityUnavailable, + CitationLocatorNotAvailable, + CitationOpenIssue, + CitationOpenProfile, + CitationOpenRedemption, + issue_citation_open_ref, + redeem_citation_open_ref, +) from engine.runtime.content_io import ( CandidateIndex, RuntimeContentIo, @@ -74,6 +83,7 @@ CandidateRef, EvidenceLineage, PackageContent, + _attach_citation_open_refs, _candidate_sort_key, _close_authorization_kernel_scope, _construct_authorized_projection, @@ -232,6 +242,32 @@ def validate_unavailable( effective_scope=EffectiveScope(frozenset()), ) + def validate_open_citation( + self, + invocation: AuthenticatedInvocation, + delivery_context: TrustedDeliveryContext, + request: OpenCitation, + ) -> PolicyReceipt: + """Compute current full trusted scope; the locator contributes no authority.""" + + if type(request) is not OpenCitation: + raise TypeError("Runtime request must be OpenCitation") + _validate_trusted_invocation_and_delivery(invocation, delivery_context) + effective_scope = ( + compute_effective_scope( + _trusted_operands_from_snapshot(invocation.trusted_scope_snapshot), + OMITTED_REQUEST_NARROWING, + ) + if invocation.trusted_scope_snapshot.policy_epoch == invocation.policy_epoch + else EffectiveScope(frozenset()) + ) + return PolicyReceipt( + request_id=invocation.request_id, + purpose=delivery_context.purpose, + policy_epoch=invocation.policy_epoch, + effective_scope=effective_scope, + ) + @dataclass(frozen=True, slots=True) class PolicyEpochGate: @@ -256,9 +292,11 @@ class PackageBudgetGate: def intersect( self, server_budget: PackageBudget, - request: Acquire | Continue, + request: Acquire | Continue | OpenCitation, ) -> PackageBudget: - return effective_package_budget(server_budget, request.package_budget) + if isinstance(request, Acquire | Continue): + return effective_package_budget(server_budget, request.package_budget) + return effective_package_budget(server_budget, None) def preflight( self, @@ -610,6 +648,54 @@ def authorize_acquire( content=content, ) + def authorize_open_citation( + self, + invocation: AuthenticatedInvocation, + delivery_context: TrustedDeliveryContext, + request: OpenCitation, + *, + candidate: CandidateRef | None, + server_budget: PackageBudget, + as_of: datetime, + reference_issuer: _OpaqueReferenceIssuer, + projection_session: MaterializedProjectionSession | None, + ) -> AuthorizationDecision: + """Reauthorize one content-free locator target through the exact Kernel.""" + + policy_receipt = self._policy.validate_open_citation( + invocation, + delivery_context, + request, + ) + if not self._policy_epoch.is_current( + invocation.user_actor.policy_epoch_verification + ): + policy_receipt = replace( + policy_receipt, + effective_scope=EffectiveScope(frozenset()), + ) + effective_budget = self._budget.intersect(server_budget, request) + provenance_receipt = self._provenance.issue( + invocation, + policy_receipt, + as_of=as_of, + reference_issuer=reference_issuer, + ) + content = self._authorize_and_assemble( + invocation, + policy_receipt, + provenance_receipt, + effective_budget, + (candidate,) if candidate is not None else (), + projection_session, + ) + return AuthorizationDecision( + effective_budget=effective_budget, + policy_receipt=policy_receipt, + provenance_receipt=provenance_receipt, + content=content, + ) + def preflight_unavailable_request( self, invocation: AuthenticatedInvocation, @@ -766,7 +852,7 @@ def _authorize_and_assemble( candidates: tuple[CandidateRef, ...], projection_session: MaterializedProjectionSession | None, ) -> PackageContent: - if not candidates: + if not candidates or not policy_receipt.effective_scope.targets: return construct_package_content(()) if projection_session is None: raise RuntimeConfigurationError( @@ -929,6 +1015,7 @@ def __init__( clock: Callable[[], datetime] = _utc_now, query_digest_keyring: QueryDigestKeyring | None = None, egress_profile: EgressProfile = INTERNAL_ONLY_EGRESS_PROFILE, + citation_profile: CitationOpenProfile | None = None, ) -> None: validated = _validate_kernel_dependencies(dependencies) if type(package_ttl_seconds) is not int or package_ttl_seconds <= 0: @@ -990,6 +1077,14 @@ def __init__( "egress_profile must be one closed server-owned profile" ) self._egress_profile = egress_profile + if citation_profile is not None: + if type(citation_profile) is not CitationOpenProfile: + raise TypeError("citation_profile must be CitationOpenProfile or None") + if timedelta(seconds=package_ttl_seconds) > citation_profile.maximum_ttl: + raise RuntimeConfigurationError( + "citation profile lifetime must cover the Package TTL" + ) + self._citation_profile = citation_profile self._reference_issuer = _OpaqueReferenceIssuer() @overload @@ -1014,7 +1109,7 @@ def resolve( invocation: AuthenticatedInvocation, delivery_context: TrustedDeliveryContext, request: OpenCitation, - ) -> CitationNotAvailable: ... + ) -> Resolved | CitationNotAvailable: ... @overload def resolve( @@ -1047,7 +1142,10 @@ def resolve( "mandatory AuthorizationKernel is missing or invalid" ) try: - self._capability_gate.require_available(capability) + self._capability_gate.require_available( + capability, + citation_open_active=self._citation_profile is not None, + ) except UnsupportedCapability: self._kernel.preflight_unavailable_request( invocation, @@ -1061,21 +1159,22 @@ def resolve( return CitationNotAvailable() return RequestNotAvailable() - if capability is not RuntimeCapability.MATERIALIZED_ACQUIRE: + if capability not in { + RuntimeCapability.MATERIALIZED_ACQUIRE, + RuntimeCapability.OPEN_CITATION, + }: raise RuntimeConfigurationError( "available Acquire capability has no sealed implementation" ) - if request_type is not Acquire: + if request_type not in {Acquire, OpenCitation}: raise RuntimeConfigurationError( "available future Runtime carrier has no sealed implementation" ) - assert isinstance(request, Acquire) - acquire = request active_release = invocation.user_actor.active_runtime_release if active_release is None: raise ActiveReleaseUnavailable( - "Acquire requires one Learning-published active release" + "Runtime delivery requires one Learning-published active release" ) if active_release.organization_id != invocation.user_actor.organization_id: raise ActiveReleaseUnavailable( @@ -1083,23 +1182,85 @@ def resolve( ) as_of = _require_utc("Runtime clock", self._clock()) - decision = self._kernel.authorize_acquire( - invocation, - delivery_context, - acquire, - server_budget=self._server_budget, - as_of=as_of, - reference_issuer=self._reference_issuer, - candidate_index=( - self._content_io.index if self._candidate_discovery_enabled else None - ), - projection_session=(invocation.user_actor.materialized_projection_session), - ) + if request_type is Acquire: + assert isinstance(request, Acquire) + decision = self._kernel.authorize_acquire( + invocation, + delivery_context, + request, + server_budget=self._server_budget, + as_of=as_of, + reference_issuer=self._reference_issuer, + candidate_index=( + self._content_io.index + if self._candidate_discovery_enabled + else None + ), + projection_session=( + invocation.user_actor.materialized_projection_session + ), + ) + else: + assert isinstance(request, OpenCitation) + citation_session = invocation.user_actor.citation_open_session + if citation_session is None: + raise CitationAuthorityUnavailable("citation authority unavailable") + try: + target = redeem_citation_open_ref( + citation_session, + CitationOpenRedemption( + citation_open_ref=request.citation_open_ref, + organization_id=invocation.user_actor.organization_id, + opened_at=as_of, + ), + ) + except CitationLocatorNotAvailable: + target = None + decision = self._kernel.authorize_open_citation( + invocation, + delivery_context, + request, + candidate=(target.candidate_ref if target is not None else None), + server_budget=self._server_budget, + as_of=as_of, + reference_issuer=self._reference_issuer, + projection_session=( + invocation.user_actor.materialized_projection_session + ), + ) finalized = self._kernel.finalize_for_delivery(invocation, decision) policy_receipt = finalized.policy_receipt content = finalized.content audit_receipt = finalized.audit_receipt provenance = finalized.provenance_receipt + if self._citation_profile is not None and content.evidence: + citation_session = invocation.user_actor.citation_open_session + if citation_session is None: + raise CitationAuthorityUnavailable("citation authority unavailable") + citation_references = {} + for item in content.evidence: + try: + revision_id = UUID(item.revision_ref) + except ValueError: + raise CitationAuthorityUnavailable( + "citation authority unavailable" + ) from None + citation_references[item.evidence_ref] = issue_citation_open_ref( + citation_session, + CitationOpenIssue( + organization_id=invocation.user_actor.organization_id, + package_ref=provenance.package_id, + evidence_ref=item.evidence_ref, + resource_ref=item.resource_ref, + revision_id=revision_id, + fragment_ref=item.fragment_ref, + issued_at=provenance.as_of, + expires_at=provenance.as_of + + timedelta(seconds=self._package_ttl_seconds), + ), + profile=self._citation_profile, + ) + content = _attach_citation_open_refs(content, citation_references) audience_digest = delivery_context.audience_digest if audience_digest is None: audience_digest = direct_egress_audience_digest( @@ -1145,18 +1306,22 @@ def resolve( reason=audit_receipt.reason, ), ) - egress_grant = self._kernel.finalize_egress( - invocation=invocation, - delivery_context=delivery_context, - provenance=provenance, - package=package, - profile=self._egress_profile, - issued_at=as_of, + egress_grant = ( + self._kernel.finalize_egress( + invocation=invocation, + delivery_context=delivery_context, + provenance=provenance, + package=package, + profile=self._egress_profile, + issued_at=as_of, + ) + if request_type is Acquire or package.evidence + else None ) persistence_session = invocation.user_actor.context_run_persistence_session if persistence_session is None: raise ContextRunPersistenceUnavailable( - "Acquire requires durable ContextRun persistence" + "Runtime delivery requires durable ContextRun persistence" ) if self._query_digest_keyring is None: raise ContextRunPersistenceUnavailable( @@ -1164,7 +1329,7 @@ def resolve( ) run_record, decision_audit = build_context_run_records( invocation=invocation, - request=acquire, + request=request, provenance=provenance, package=package, final_effective_scope=policy_receipt.effective_scope, @@ -1176,6 +1341,8 @@ def resolve( run_record, decision_audit, ) + if request_type is OpenCitation and not package.evidence: + return CitationNotAvailable() return Resolved( package=package, effective_budget=decision.effective_budget, @@ -1201,7 +1368,10 @@ def _requires_active_scope_authority(self, request: RuntimeRequest) -> bool: capability = self._required_capability(request) gate = RuntimeCapabilityGate() try: - gate.require_available(capability) + gate.require_available( + capability, + citation_open_active=self._citation_profile is not None, + ) except UnsupportedCapability: return False return True diff --git a/engine/runtime/context_run.py b/engine/runtime/context_run.py index 1cf790d3..8c52093b 100644 --- a/engine/runtime/context_run.py +++ b/engine/runtime/context_run.py @@ -13,6 +13,7 @@ Acquire, ContextPackage, CoverageStatus, + OpenCitation, context_package_digest_document, ) from engine.runtime.package_digest import ( @@ -36,7 +37,7 @@ class ContextRunPersistenceUnavailable(RuntimeError): class ContextRunOutcome(StrEnum): - """Tenant-safe terminal outcomes active for Acquire.""" + """Tenant-safe terminal outcomes for active Runtime delivery carriers.""" DELIVERED_AUTHORIZED = "delivered_authorized" DELIVERED_EMPTY = "delivered_empty" @@ -76,7 +77,7 @@ def _require_utc(field_name: str, value: object) -> datetime: @dataclass(frozen=True, slots=True) class ContextRunRecord: - """Authorized-only final lineage for one successful Acquire delivery.""" + """Authorized-only final lineage for one completed Runtime delivery.""" organization_id: UUID = field(repr=False) run_ref: str @@ -384,7 +385,7 @@ def persist_context_run( def build_context_run_records( *, invocation: object, - request: Acquire, + request: Acquire | OpenCitation, provenance: object, package: ContextPackage, final_effective_scope: EffectiveScope, @@ -403,8 +404,8 @@ def build_context_run_records( if type(invocation) is not AuthenticatedInvocation: raise TypeError("ContextRun projection requires AuthenticatedInvocation") - if type(request) is not Acquire: - raise TypeError("ContextRun projection requires Acquire") + if type(request) not in {Acquire, OpenCitation}: + raise TypeError("ContextRun projection requires Acquire or OpenCitation") if type(package) is not ContextPackage: raise TypeError("ContextRun projection requires ContextPackage") active_release = invocation.user_actor.active_runtime_release @@ -444,9 +445,11 @@ def build_context_run_records( decision_provenance = cast(DecisionProvenance, provenance) authorized_scope = compute_effective_scope( _trusted_operands_from_snapshot(invocation.trusted_scope_snapshot), - request.narrowing - if request.narrowing is not None - else OMITTED_REQUEST_NARROWING, + ( + request.narrowing + if type(request) is Acquire and request.narrowing is not None + else OMITTED_REQUEST_NARROWING + ), ) if invocation.trusted_scope_snapshot.policy_epoch != invocation.policy_epoch: authorized_scope = EffectiveScope(frozenset()) @@ -501,7 +504,7 @@ def build_context_run_records( query_receipt = query_digest( keyring, invocation.user_actor.organization_id, - request.need.query, + request.need.query if type(request) is Acquire else "citation.open", ) outcome = ( ContextRunOutcome.DELIVERED_AUTHORIZED diff --git a/engine/runtime/contracts.py b/engine/runtime/contracts.py index ad4a2063..12ede34f 100644 --- a/engine/runtime/contracts.py +++ b/engine/runtime/contracts.py @@ -430,6 +430,11 @@ def context_package_digest_document(package: ContextPackage) -> dict[str, object "revisionRef": item.revision_ref, "fragmentRef": item.fragment_ref, "projectedFields": list(item.projected_field_refs), + "citationOpenRef": ( + item.citation_open_ref.value + if item.citation_open_ref is not None + else None + ), "runRef": item.lineage.run_ref, "purpose": item.lineage.purpose, "authorizationAsOf": _wire_datetime(item.lineage.as_of), @@ -470,7 +475,7 @@ def complete_context_package_nullable_fields( for item in evidence: if not isinstance(item, dict): raise TypeError("public ContextPackage Evidence must contain objects") - item["citationOpenRef"] = None + item.setdefault("citationOpenRef", None) document["continuation"] = None return document diff --git a/engine/runtime/evidence.py b/engine/runtime/evidence.py index 574f9472..fa3139c8 100644 --- a/engine/runtime/evidence.py +++ b/engine/runtime/evidence.py @@ -1,12 +1,18 @@ """Nominal exact-authorization and request-scoped Evidence contracts.""" +from __future__ import annotations + +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import StrEnum from hashlib import sha256 -from typing import Final, NoReturn +from typing import TYPE_CHECKING, Final, NoReturn from uuid import UUID +if TYPE_CHECKING: + from engine.runtime.contracts import CitationOpenRef + __all__ = [ "AuthorizedProjection", "CandidateRef", @@ -37,8 +43,7 @@ def _require_opaque_ref(field_name: str, value: object) -> str: or any(character.isspace() for character in value) ): raise ValueError( - f"{field_name} must be a non-empty bounded opaque string without " - "whitespace" + f"{field_name} must be a non-empty bounded opaque string without whitespace" ) return value @@ -60,9 +65,7 @@ def _require_utc_as_of(value: object) -> datetime: def _require_evidence_ref(value: object) -> str: - expected_length = ( - len(EVIDENCE_REF_PREFIX) + 1 + EVIDENCE_REF_ENTROPY_LENGTH - ) + expected_length = len(EVIDENCE_REF_PREFIX) + 1 + EVIDENCE_REF_ENTROPY_LENGTH if ( type(value) is not str or len(value) != expected_length @@ -371,6 +374,7 @@ class Evidence: fragment_ref: str projected_field_refs: tuple[str, ...] lineage: EvidenceLineage + citation_open_ref: CitationOpenRef | None = field(default=None, repr=False) _integrity_digest: str = field(init=False, repr=False) def __post_init__(self) -> None: @@ -387,6 +391,11 @@ def __post_init__(self) -> None: ) if type(self.lineage) is not EvidenceLineage: raise TypeError("Evidence lineage must be EvidenceLineage") + if self.citation_open_ref is not None: + from engine.runtime.contracts import CitationOpenRef + + if type(self.citation_open_ref) is not CitationOpenRef: + raise TypeError("Evidence citation_open_ref must be CitationOpenRef") validate_projected_field_refs(self.projected_field_refs) self.lineage.__post_init__() object.__setattr__(self, "_integrity_digest", _evidence_integrity_digest(self)) @@ -434,6 +443,11 @@ def _evidence_integrity_digest(evidence: Evidence) -> str: canonical += _encode_text(value) for field_ref in evidence.projected_field_refs: canonical += _encode_text(field_ref) + canonical += _encode_text( + evidence.citation_open_ref.value + if evidence.citation_open_ref is not None + else "" + ) canonical += _lineage_canonical_bytes(evidence.lineage) return sha256(canonical).hexdigest() @@ -540,6 +554,37 @@ def _construct_validated_package_content( return content +def _attach_citation_open_refs( + content: PackageContent, + references: Mapping[str, CitationOpenRef], +) -> PackageContent: + """Bind issued locators to already-authorized Evidence without reopening it.""" + + from engine.runtime.contracts import CitationOpenRef + + if type(content) is not PackageContent or not isinstance(references, Mapping): + raise TypeError("citation attachment requires PackageContent and a mapping") + expected = {item.evidence_ref for item in content.evidence} + if set(references) != expected or any( + type(reference) is not CitationOpenRef for reference in references.values() + ): + raise ValueError("citation references must cover exact authorized Evidence") + evidence = tuple( + Evidence( + evidence_ref=item.evidence_ref, + source_ref=item.source_ref, + resource_ref=item.resource_ref, + revision_ref=item.revision_ref, + fragment_ref=item.fragment_ref, + projected_field_refs=item.projected_field_refs, + lineage=item.lineage, + citation_open_ref=references[item.evidence_ref], + ) + for item in content.evidence + ) + return _construct_validated_package_content(content.blocks, evidence) + + def _candidate_sort_key(candidate_ref: CandidateRef) -> tuple[object, ...]: return ( candidate_ref.organization_id.bytes, diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index a5037e59..ac4060ae 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -84,9 +84,9 @@ {"id": "PROP-NON-ENUMERATION-009", "layer": "property", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_denied_cross_organization_and_missing_candidates_share_one_runtime_outcome"}, {"id": "PG-NON-ENUMERATION-009", "layer": "postgres", "selector": "tests/integration/test_runtime_non_enumeration_integration.py::test_real_postgres_http_denied_and_missing_are_externally_equivalent"}, {"id": "RUNTIME-NON-ENUMERATION-009", "layer": "runtime", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_runtime_canonical_empty_package_is_equal_for_every_internal_branch"}, - {"id": "PROP-CITATION-AUTH-010", "layer": "property", "selector": "tests/unit/test_runtime_unavailable_capabilities.py::test_capability_declarations_are_closed_and_m0_does_not_false_green_carriers"}, - {"id": "PG-CITATION-AUTH-010", "layer": "postgres", "selector": "tests/integration/test_m0_unavailable_security_carriers.py::test_unavailable_citation_and_real_provider_carriers_fail_closed"}, - {"id": "RUNTIME-CITATION-AUTH-010", "layer": "runtime", "selector": "tests/unit/test_http_unavailable_capabilities.py::test_accept_010_open_citation_is_generic_and_zero_io"}, + {"id": "PROP-CITATION-AUTH-010", "layer": "property", "selector": "tests/unit/test_citation_open.py::test_cross_kind_and_forged_locator_are_generic_not_available"}, + {"id": "PG-CITATION-AUTH-010", "layer": "postgres", "selector": "tests/integration/test_citation_open.py::test_citation_locator_is_digest_only_multi_use_and_function_only"}, + {"id": "RUNTIME-CITATION-AUTH-010", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_file_http_citation_is_not_consumed_by_denied_reader"}, {"id": "PROP-EGRESS-011", "layer": "property", "selector": "tests/unit/test_egress_grant.py::test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects"}, {"id": "PG-EGRESS-011", "layer": "postgres", "selector": "tests/integration/test_egress_grant.py::test_digest_only_grant_is_atomic_one_shot_and_audited"}, {"id": "RUNTIME-EGRESS-011", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_file_http_package_redeems_exact_model_grant_before_gateway_bytes"}, @@ -113,7 +113,7 @@ {"id": "FIXTURE-ACCEPT-007", "layer": "runtime", "selector": "tests/unit/test_http_trust_boundary.py::test_trusted_field_injection_is_closed_before_domain_execution"}, {"id": "FIXTURE-ACCEPT-008", "layer": "runtime", "selector": "tests/integration/test_worker_lease.py::test_valid_lease_completes_once_through_worker_application_and_replay_is_zero"}, {"id": "FIXTURE-ACCEPT-009", "layer": "runtime", "selector": "tests/unit/test_http_unavailable_capabilities.py::test_accept_009_server_owned_unavailable_source_paths_are_generic_and_zero_io"}, - {"id": "FIXTURE-ACCEPT-010", "layer": "runtime", "selector": "tests/unit/test_http_unavailable_capabilities.py::test_accept_010_open_citation_is_generic_and_zero_io"}, + {"id": "FIXTURE-ACCEPT-010", "layer": "runtime", "selector": "tests/integration/test_z_egress_grant_file.py::test_file_http_citation_is_not_consumed_by_denied_reader"}, {"id": "FIXTURE-ACCEPT-011", "layer": "runtime", "selector": "tests/integration/test_runtime_non_enumeration_integration.py::test_real_postgres_http_denied_and_missing_are_externally_equivalent"}, {"id": "FIXTURE-ACCEPT-012", "layer": "runtime", "selector": "tests/unit/test_ticket_audience_separation.py::test_accept_012_context_read_ticket_cannot_create_an_action_effect"} ], @@ -144,7 +144,7 @@ {"fixtureRef": "ACCEPT-007", "carrierStatusAtM0": "available", "m0Expectation": "active_fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-007"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-007"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-007"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-007"]}}, {"fixtureRef": "ACCEPT-008", "carrierStatusAtM0": "future", "m0Expectation": "fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-008"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-008"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-008"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-008"]}}, {"fixtureRef": "ACCEPT-009", "carrierStatusAtM0": "future", "m0Expectation": "fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-009"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-009"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-009"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-009"]}}, - {"fixtureRef": "ACCEPT-010", "carrierStatusAtM0": "future", "m0Expectation": "fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-010"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-010"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-010"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-010"]}}, + {"fixtureRef": "ACCEPT-010", "carrierStatusAtM0": "available", "m0Expectation": "active_fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-010"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-010"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-010"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-010"]}}, {"fixtureRef": "ACCEPT-011", "carrierStatusAtM0": "available", "m0Expectation": "active_fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-011"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-011"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-011"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-011"]}}, {"fixtureRef": "ACCEPT-012", "carrierStatusAtM0": "unavailable", "m0Expectation": "fail_closed", "evidenceRefs": ["FIXTURE-ACCEPT-012"], "hardOracleEvidenceRefs": {"unauthorizedEvidenceCount": ["FIXTURE-ACCEPT-012"], "wrongOrganizationEffectCount": ["FIXTURE-ACCEPT-012"], "missingContextFallbackCount": ["FIXTURE-ACCEPT-012"]}} ] diff --git a/eval/catalogs/security-catalog.schema.json b/eval/catalogs/security-catalog.schema.json index ae98b843..b84e5728 100644 --- a/eval/catalogs/security-catalog.schema.json +++ b/eval/catalogs/security-catalog.schema.json @@ -95,8 +95,8 @@ }, "activations": { "type": "array", - "minItems": 12, - "maxItems": 12, + "minItems": 13, + "maxItems": 13, "uniqueItems": true, "prefixItems": [ { @@ -147,7 +147,7 @@ "const": { "issueRef": "#16", "invariantRef": "INDEX-NOT-AUTHORITY-005", - "carrier": "ContextRuntime.resolve(Continue | OpenCitation | server-owned unavailable Acquire plan)", + "carrier": "ContextRuntime.resolve(Continue | profile-disabled OpenCitation | server-owned unavailable Acquire plan)", "status": "active_fail_closed", "policyEpochScope": "organization-v0", "controlBoundary": "RuntimeCapabilityGate.require_available(RuntimeCapability)", @@ -155,7 +155,7 @@ { "id": "RUN-UNAVAILABLE-016", "surface": "tests/unit/test_runtime_unavailable_capabilities.py", - "oracle": "Table-driven Runtime cases prove unavailable Continue, OpenCitation, and server-owned Acquire plans traverse the content-free sealed Kernel preflight and stop before Provider, index, or source I/O; the restricted mandatory audit retains only UNSUPPORTED_CAPABILITY." + "oracle": "Table-driven Runtime cases prove unavailable Continue, profile-disabled OpenCitation, and server-owned Acquire plans traverse the content-free sealed Kernel preflight and stop before Provider, index, or source I/O; the restricted mandatory audit retains only UNSUPPORTED_CAPABILITY." }, { "id": "HTTP-UNAVAILABLE-016", @@ -165,17 +165,16 @@ ], "deferredEvidence": [ "real-continuation-redemption", - "real-citation-redemption", "real-federated-source-native-authorization" ], "futureCarriers": [ "Continue", - "OpenCitation", - "federated/source-native ContextProvider" + "federated/source-native ContextProvider", + "group/public Runtime carrier" ], "notActive": [ "continuation issuance/redemption", - "citation locator redemption", + "profile-enabled OpenCitation in this issue-16 activation", "federated Provider/source-native ACL I/O", "File publication" ] @@ -314,7 +313,7 @@ "OBS-003 production debug-endpoint authorization" ], "futureCarriers": [ - "Continue and OpenCitation ContextRun lineage", + "Continue ContextRun lineage", "full retrieval candidate/ranking traces", "authorized feedback and golden-set extraction", "explicitly approved full-Package retention" @@ -361,7 +360,7 @@ "futureCarriers": [ "production ContextProvider native field projection", "File and Base ingestion field ACL", - "Continue and OpenCitation field projection", + "Continue field projection", "field-policy change independent of Membership version" ], "notActive": [ @@ -407,13 +406,11 @@ ], "deferredEvidence": [ "group AudienceSnapshot DeliveryEvidenceRef", - "generated TypeScript SDK carrier", "production BotDelivery caller" ], "futureCarriers": [ "public group DeliveryEvidenceRef", - "OpenCitation delivery evidence", - "generated TypeScript SDK", + "private group DeliveryEvidenceRef", "private BotDelivery application" ], "notActive": [ @@ -421,8 +418,7 @@ "AudienceSnapshot", "production ModelGateway", "ActionPlane", - "BotDelivery application", - "generated SDK" + "BotDelivery application" ] } }, @@ -467,8 +463,7 @@ "real Sender or channel write", "ActionTicket or external effect", "group AudienceSnapshot", - "BotDelivery application process", - "generated SDK consumer" + "BotDelivery application process" ] } }, @@ -503,23 +498,18 @@ } ], "deferredEvidence": [ - "generated TypeScript SDK conformance", "production BotDelivery generated-SDK caller", - "Continue and OpenCitation redemption" + "Continue redemption" ], "futureCarriers": [ - "generated TypeScript SDK", "MCP", "BotDelivery application", - "Continue redemption", - "OpenCitation redemption" + "Continue redemption" ], "notActive": [ - "generated SDK consumer", "MCP", "BotDelivery application process", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects" ] @@ -542,27 +532,25 @@ { "id": "SDK-LIVE-FILE-064", "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", - "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant, while generated Continue and OpenCitation calls return their generic inactive outcomes." + "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant. The generated OpenCitation call uses a second request-bound DeliveryEvidenceRef to reauthorize the acquired opaque locator into a distinct citation.open Package, fresh locator, and matching grant; generated Continue remains a generic inactive outcome." } ], "deferredEvidence": [ "signed package-registry publication provenance", "production BotDelivery generated-SDK caller", - "real Continue and OpenCitation issuance and redemption" + "real Continue issuance and redemption" ], "futureCarriers": [ "published package-registry SDK", "BotDelivery application", "MCP", - "Continue redemption", - "OpenCitation redemption" + "Continue redemption" ], "notActive": [ "external package publication", "production BotDelivery application process", "MCP", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects" ] @@ -641,6 +629,49 @@ "full ACCEPT-012 pass" ] } + }, + { + "const": { + "issueRef": "#69", + "invariantRef": "CITATION-AUTH-010", + "carrier": "private/direct File CitationOpenRef issuance and OpenCitation", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "digest-only locator -> current UserActor transaction -> CandidateRef -> AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage", + "testEvidence": [ + { + "id": "PG-CITATION-AUTH-010", + "surface": "tests/integration/test_citation_open.py", + "oracle": "Real PostgreSQL stores only a SHA-256 locator digest plus prior Package/Evidence and Fragment location lineage under FORCE RLS. Runtime can issue/redeem only through dedicated definer functions; the locator is multi-use, database-clock-expiring, cross-kind and cross-Organization probes are generic, and ordinary roles have no table access." + }, + { + "id": "RUNTIME-CITATION-AUTH-010", + "surface": "tests/integration/test_z_egress_grant_file.py::test_file_http_citation_is_not_consumed_by_denied_reader", + "oracle": "The public HTTP File carrier proves reader A receives an opaque locator, reader B reauthorizes to the same generic unavailable outcome without consuming or refreshing it, and reader A opens again through the sealed Kernel to a new audience-bound Package." + }, + { + "id": "SDK-LIVE-FILE-064", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant. The generated OpenCitation call uses a second request-bound DeliveryEvidenceRef to reauthorize the acquired opaque locator into a distinct citation.open Package, fresh locator, and matching grant; generated Continue remains a generic inactive outcome." + } + ], + "deferredEvidence": [ + "group/public AudienceSnapshot citation opening", + "Continue issuance or redemption", + "non-File provider citation lineage" + ], + "futureCarriers": [ + "group-public citation delivery", + "Continue", + "non-File ContextProvider citations" + ], + "notActive": [ + "group AudienceSnapshot", + "public-group citation package", + "Continue", + "raw source URL locator" + ] + } } ], "items": false @@ -919,7 +950,9 @@ "SDK-CONTRACT-064", "SDK-LIVE-FILE-064", "PG-ACTION-PREPARE-067", - "PG-ACTION-PERFORM-068" + "PG-ACTION-PERFORM-068", + "PG-CITATION-AUTH-010", + "RUNTIME-CITATION-AUTH-010" ] }, "surface": { @@ -959,7 +992,8 @@ "#66", "#64", "#67", - "#68" + "#68", + "#69" ] }, "invariantRef": { @@ -971,13 +1005,13 @@ "TRACE-REDACTION-012", "SCOPE-INTERSECTION-004", "TRANSPORT-UNTRUSTED-008", - "EGRESS-011" + "EGRESS-011", + "CITATION-AUTH-010" ] }, "carrier": { "enum": [ "ContextRuntime.resolve(Acquire)", - "ContextRuntime.resolve(Continue | OpenCitation | server-owned unavailable Acquire plan)", "signed one-shot persistent no-op durable-job WorkerLease", "signed synthetic ContextAccessTicket Provider read | signed synthetic ActionTicket no-op channel action", "ContextRuntime.resolve(Acquire) authorized-only ContextRun | restricted delivered-empty DecisionAudit", @@ -987,7 +1021,9 @@ "frozen public POST /v0/resolve OpenAPI contract", "packaged generated TypeScript POST /v0/resolve client", "ActionPlane.prepare private create-placeholder, finalize-reply, or follow-up ticket", - "ActionPlane.perform private deterministic Sender-twin effect and receipt reconciliation" + "ActionPlane.perform private deterministic Sender-twin effect and receipt reconciliation", + "private/direct File CitationOpenRef issuance and OpenCitation", + "ContextRuntime.resolve(Continue | profile-disabled OpenCitation | server-owned unavailable Acquire plan)" ] }, "status": { @@ -1012,7 +1048,8 @@ "authenticated HTTP metadata -> closed ResolveWire -> current UserActor and active release observation -> sealed ContextRuntime.resolve -> closed ResolutionOutcome", "immutable OpenAPI v0 -> pinned generator -> generated semantic types and internal fetch client -> closed package exports and metadata-safe facade -> authenticated HTTP ingress -> sealed ContextRuntime.resolve", "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> least-privilege PostgreSQL prepare function -> operation-specific audience-bound ActionTicket", - "operation-specific ActionTicket plus exact canonical payload -> least-privilege PostgreSQL begin/complete/reconcile state machine -> deterministic private Sender twin" + "operation-specific ActionTicket plus exact canonical payload -> least-privilege PostgreSQL begin/complete/reconcile state machine -> deterministic private Sender twin", + "digest-only locator -> current UserActor transaction -> CandidateRef -> AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage" ] }, "testEvidence": { diff --git a/eval/catalogs/security-invariants.yaml b/eval/catalogs/security-invariants.yaml index d7a068eb..16c50154 100644 --- a/eval/catalogs/security-invariants.yaml +++ b/eval/catalogs/security-invariants.yaml @@ -15,7 +15,8 @@ "#66", "#64", "#67", - "#68" + "#68", + "#69" ], "documentRefs": [ "README.md", @@ -35,9 +36,10 @@ "docs/decisions/0047-freeze-openapi-v0-through-one-runtime-path.md", "docs/decisions/0048-generate-typescript-sdk-behind-a-closed-facade.md", "docs/decisions/0049-prepare-one-exact-private-effect.md", - "docs/decisions/0050-perform-one-exact-private-effect.md" + "docs/decisions/0050-perform-one-exact-private-effect.md", + "docs/decisions/0051-reauthorize-opaque-citation-opens.md" ], - "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE. Issue #16 activates only the M0 refusal gate for unavailable Continue, OpenCitation, and server-owned unavailable Acquire plans: its real continuation, citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue/OpenCitation, and Issue #20 runner substitution remain future or NOT_ACTIVE. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, OpenCitation, production ModelGateway, ActionPlane, the BotDelivery application, frozen OpenAPI compatibility, and the generated TypeScript SDK remain future or NOT_ACTIVE. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, the BotDelivery application process, and a generated SDK consumer remain future or NOT_ACTIVE. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. Generated TypeScript SDK conformance, a production BotDelivery caller, Continue and OpenCitation redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant; generated Continue and OpenCitation calls remain generic unavailable outcomes. External package publication, production BotDelivery, MCP, group AudienceSnapshot, real Continue/OpenCitation redemption, and external effects remain future or NOT_ACTIVE. Issue #67 activates only private ActionPlane.prepare for create-placeholder, finalize-reply, and private-follow-up operation-specific tickets under ACTION-SEPARATION-014. PG-ACTION-PREPARE-067 proves exact current delivery, Organization, destination, audience, source, payload, approval, epoch, lifetime, and idempotency binding under a dedicated non-owner PostgreSQL role with digest-only FORCE-RLS persistence and zero effects. Issue #68 activates private ActionPlane.perform only through a deterministic Sender twin. PG-ACTION-PERFORM-068 proves one pre-Sender current-authority validation, one provider-attempt identity, immutable applied receipt replay, zero-effect ticket/payload mutation and stale-audience refusal, same-label cross-Organization isolation, and monotonic applied/rejected reconciliation including crash interleavings. Real provider or channel network effects, group AudienceSnapshot, compensation/delete, production BotDelivery orchestration, and the full ACCEPT-012 pass remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction." + "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: at that activation, PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE; later issue records are authoritative for subsequently activated carriers. Issue #16 activates only the M0 refusal gate for unavailable Continue, profile-disabled OpenCitation, and server-owned unavailable Acquire plans: at that activation its real continuation, profile-enabled citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O; Issue #69 later activates the private/direct File profile-enabled citation carrier. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue, and Issue #20 runner substitution remain future or NOT_ACTIVE; Issue #69 later activates private/direct File OpenCitation through the same field-projection gates. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, production ModelGateway, ActionPlane, and the BotDelivery application remain future or NOT_ACTIVE; Issues #64, #66, and #69 later activate the frozen OpenAPI, generated TypeScript SDK, and private/direct OpenCitation delivery-evidence carriers. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, and the BotDelivery application process remain future or NOT_ACTIVE; Issue #64 later activates the generated SDK consumer. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. A production BotDelivery caller, Continue redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE; Issues #64 and #69 later activate the generated TypeScript SDK and private/direct OpenCitation redemption through this frozen operation. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant. Issue #69 later extends SDK-LIVE-FILE-064 with a successful private/direct File OpenCitation through a second request-bound DeliveryEvidenceRef; generated Continue remains generic unavailable. External package publication, production BotDelivery, MCP, group AudienceSnapshot, real Continue redemption, and external effects remain future or NOT_ACTIVE. Issue #67 activates only private ActionPlane.prepare for create-placeholder, finalize-reply, and private-follow-up operation-specific tickets under ACTION-SEPARATION-014. PG-ACTION-PREPARE-067 proves exact current delivery, Organization, destination, audience, source, payload, approval, epoch, lifetime, and idempotency binding under a dedicated non-owner PostgreSQL role with digest-only FORCE-RLS persistence and zero effects. Issue #68 activates private ActionPlane.perform only through a deterministic Sender twin. PG-ACTION-PERFORM-068 proves one pre-Sender current-authority validation, one provider-attempt identity, immutable applied receipt replay, zero-effect ticket/payload mutation and stale-audience refusal, same-label cross-Organization isolation, and monotonic applied/rejected reconciliation including crash interleavings. Real provider or channel network effects, group AudienceSnapshot, compensation/delete, production BotDelivery orchestration, and the full ACCEPT-012 pass remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction. Issue #69 activates private/direct File CitationOpenRef issuance and OpenCitation under CITATION-AUTH-010: digest-only multi-use locators reveal only prior Package/Evidence and Fragment location lineage, every open obtains a current UserActor and trusted delivery context then traverses CandidateRef, AuthorizationKernel, AuthorizedProjection, a replacement ContextPackage, EgressGrant, ContextRun, and restricted DecisionAudit. PG-CITATION-AUTH-010, RUNTIME-CITATION-AUTH-010, and SDK-LIVE-FILE-064 prove A/B/A reauthorization, non-consumption on denial, database-clock expiry, cross-kind and cross-Organization opacity, and the generated SDK carrier. Group/public AudienceSnapshot, non-File providers, raw source URL locators, and Continue remain future or NOT_ACTIVE." }, "hardOracles": [ { @@ -102,7 +104,7 @@ { "issueRef": "#16", "invariantRef": "INDEX-NOT-AUTHORITY-005", - "carrier": "ContextRuntime.resolve(Continue | OpenCitation | server-owned unavailable Acquire plan)", + "carrier": "ContextRuntime.resolve(Continue | profile-disabled OpenCitation | server-owned unavailable Acquire plan)", "status": "active_fail_closed", "policyEpochScope": "organization-v0", "controlBoundary": "RuntimeCapabilityGate.require_available(RuntimeCapability)", @@ -110,7 +112,7 @@ { "id": "RUN-UNAVAILABLE-016", "surface": "tests/unit/test_runtime_unavailable_capabilities.py", - "oracle": "Table-driven Runtime cases prove unavailable Continue, OpenCitation, and server-owned Acquire plans traverse the content-free sealed Kernel preflight and stop before Provider, index, or source I/O; the restricted mandatory audit retains only UNSUPPORTED_CAPABILITY." + "oracle": "Table-driven Runtime cases prove unavailable Continue, profile-disabled OpenCitation, and server-owned Acquire plans traverse the content-free sealed Kernel preflight and stop before Provider, index, or source I/O; the restricted mandatory audit retains only UNSUPPORTED_CAPABILITY." }, { "id": "HTTP-UNAVAILABLE-016", @@ -120,17 +122,16 @@ ], "deferredEvidence": [ "real-continuation-redemption", - "real-citation-redemption", "real-federated-source-native-authorization" ], "futureCarriers": [ "Continue", - "OpenCitation", - "federated/source-native ContextProvider" + "federated/source-native ContextProvider", + "group/public Runtime carrier" ], "notActive": [ "continuation issuance/redemption", - "citation locator redemption", + "profile-enabled OpenCitation in this issue-16 activation", "federated Provider/source-native ACL I/O", "File publication" ] @@ -263,7 +264,7 @@ "OBS-003 production debug-endpoint authorization" ], "futureCarriers": [ - "Continue and OpenCitation ContextRun lineage", + "Continue ContextRun lineage", "full retrieval candidate/ranking traces", "authorized feedback and golden-set extraction", "explicitly approved full-Package retention" @@ -308,7 +309,7 @@ "futureCarriers": [ "production ContextProvider native field projection", "File and Base ingestion field ACL", - "Continue and OpenCitation field projection", + "Continue field projection", "field-policy change independent of Membership version" ], "notActive": [ @@ -352,13 +353,11 @@ ], "deferredEvidence": [ "group AudienceSnapshot DeliveryEvidenceRef", - "generated TypeScript SDK carrier", "production BotDelivery caller" ], "futureCarriers": [ "public group DeliveryEvidenceRef", - "OpenCitation delivery evidence", - "generated TypeScript SDK", + "private group DeliveryEvidenceRef", "private BotDelivery application" ], "notActive": [ @@ -366,8 +365,7 @@ "AudienceSnapshot", "production ModelGateway", "ActionPlane", - "BotDelivery application", - "generated SDK" + "BotDelivery application" ] }, { @@ -410,8 +408,7 @@ "real Sender or channel write", "ActionTicket or external effect", "group AudienceSnapshot", - "BotDelivery application process", - "generated SDK consumer" + "BotDelivery application process" ] }, { @@ -444,23 +441,18 @@ } ], "deferredEvidence": [ - "generated TypeScript SDK conformance", "production BotDelivery generated-SDK caller", - "Continue and OpenCitation redemption" + "Continue redemption" ], "futureCarriers": [ - "generated TypeScript SDK", "MCP", "BotDelivery application", - "Continue redemption", - "OpenCitation redemption" + "Continue redemption" ], "notActive": [ - "generated SDK consumer", "MCP", "BotDelivery application process", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects" ] @@ -481,27 +473,25 @@ { "id": "SDK-LIVE-FILE-064", "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", - "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant, while generated Continue and OpenCitation calls return their generic inactive outcomes." + "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant. The generated OpenCitation call uses a second request-bound DeliveryEvidenceRef to reauthorize the acquired opaque locator into a distinct citation.open Package, fresh locator, and matching grant; generated Continue remains a generic inactive outcome." } ], "deferredEvidence": [ "signed package-registry publication provenance", "production BotDelivery generated-SDK caller", - "real Continue and OpenCitation issuance and redemption" + "real Continue issuance and redemption" ], "futureCarriers": [ "published package-registry SDK", "BotDelivery application", "MCP", - "Continue redemption", - "OpenCitation redemption" + "Continue redemption" ], "notActive": [ "external package publication", "production BotDelivery application process", "MCP", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects" ] @@ -575,6 +565,47 @@ "production BotDelivery orchestration", "full ACCEPT-012 pass" ] + }, + { + "issueRef": "#69", + "invariantRef": "CITATION-AUTH-010", + "carrier": "private/direct File CitationOpenRef issuance and OpenCitation", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "digest-only locator -> current UserActor transaction -> CandidateRef -> AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage", + "testEvidence": [ + { + "id": "PG-CITATION-AUTH-010", + "surface": "tests/integration/test_citation_open.py", + "oracle": "Real PostgreSQL stores only a SHA-256 locator digest plus prior Package/Evidence and Fragment location lineage under FORCE RLS. Runtime can issue/redeem only through dedicated definer functions; the locator is multi-use, database-clock-expiring, cross-kind and cross-Organization probes are generic, and ordinary roles have no table access." + }, + { + "id": "RUNTIME-CITATION-AUTH-010", + "surface": "tests/integration/test_z_egress_grant_file.py::test_file_http_citation_is_not_consumed_by_denied_reader", + "oracle": "The public HTTP File carrier proves reader A receives an opaque locator, reader B reauthorizes to the same generic unavailable outcome without consuming or refreshing it, and reader A opens again through the sealed Kernel to a new audience-bound Package." + }, + { + "id": "SDK-LIVE-FILE-064", + "surface": "tests/integration/test_z_egress_grant_file.py::test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http", + "oracle": "A tarball installed into a temporary TypeScript consumer calls a real local POST /v0/resolve server with only authentication, request id, and opaque DeliveryEvidenceRef metadata; real PostgreSQL redemption and File acquisition prove CandidateRef through the sealed AuthorizationKernel to AuthorizedProjection, an audience-bound ContextPackage, and an opaque model egress grant. The generated OpenCitation call uses a second request-bound DeliveryEvidenceRef to reauthorize the acquired opaque locator into a distinct citation.open Package, fresh locator, and matching grant; generated Continue remains a generic inactive outcome." + } + ], + "deferredEvidence": [ + "group/public AudienceSnapshot citation opening", + "Continue issuance or redemption", + "non-File provider citation lineage" + ], + "futureCarriers": [ + "group-public citation delivery", + "Continue", + "non-File ContextProvider citations" + ], + "notActive": [ + "group AudienceSnapshot", + "public-group citation package", + "Continue", + "raw source URL locator" + ] } ], "invariants": [ @@ -1097,7 +1128,8 @@ "CITE-001", "CITE-002", "CITE-003", - "CITE-004" + "CITE-004", + "RUNTIME-CITATION-AUTH-010" ] }, "authorityRefs": [ @@ -2691,18 +2723,18 @@ }, { "id": "ACCEPT-010", - "title": "Unavailable citation carrier denies revoked citation open at M0", + "title": "Current-opener citation reauthorization is fail closed and multi-use", "decisionStatus": "accepted", "carrier": { - "statusAtM0": "future", - "m0Expectation": "fail_closed", - "upgradeTrigger": "The owning M2 OpenCitation implementation issue upgrades this fixture only after current-opener authorization and distinct token variants exist at the public HTTP seam." + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", + "upgradeTrigger": "Issue #69 activates private/direct File citation opens through the public HTTP and generated SDK seams; extend only when group/public AudienceSnapshot and Continue carriers activate." }, "setup": { "preconditions": [ - "citation-ref-1 is a locator recorded in an earlier Package for member-a.", - "member-a's Resource grant has since been revoked.", - "M0 has no active OpenCitation carrier and cannot redeem citation or continuation wire variants." + "reader-a acquired one authorized File Evidence with an opaque CitationOpenRef.", + "reader-b has a current Membership but no current Resource authorization.", + "the locator stores only its digest and prior Package/Evidence plus Fragment location lineage." ], "trustedIdentity": { "organizationRef": "org-a", @@ -2718,9 +2750,9 @@ "attemptedAlternateUse": "ContinuationToken" }, "operation": { - "interface": "ContextRuntime.resolve", - "request": "OpenCitation citation-ref-1", - "phase": "inactive-capability gate before source or blob I/O" + "interface": "POST /v0/resolve through generated SDK and ContextRuntime.resolve", + "request": "OpenCitation with the acquired opaque CitationOpenRef", + "phase": "current UserActor and delivery binding, locator redemption, then sealed exact reauthorization" }, "expected": { "externalResponse": { @@ -2732,8 +2764,8 @@ "packageOrError": { "kind": "citation_not_available", "citationFieldsReturned": 0, - "capabilityStatus": "unavailable", - "capabilityReportedAsPass": false + "capabilityStatus": "available", + "capabilityReportedAsPass": true }, "evidence": { "unauthorizedEvidenceCount": 0, @@ -2762,7 +2794,8 @@ "docs/design/2026-07-18-context-engine-implementation-design.md#53-tokens-and-locators", "docs/security/安全负向测试清单.md#6-runtimeassemblycitation-与-egress", "docs/decisions/0019-security-catalog-normalization.md#decision", - "docs/decisions/0028-fail-closed-unavailable-runtime-capabilities.md#decision" + "docs/decisions/0028-fail-closed-unavailable-runtime-capabilities.md#decision", + "docs/decisions/0051-reauthorize-opaque-citation-opens.md#decision" ] }, { diff --git a/migrations/versions/20260724_0024_citation_open.py b/migrations/versions/20260724_0024_citation_open.py new file mode 100644 index 00000000..cb8b5cc7 --- /dev/null +++ b/migrations/versions/20260724_0024_citation_open.py @@ -0,0 +1,354 @@ +"""Persist digest-only multi-use CitationOpenRef lineage. + +Revision ID: 20260724_0024 +Revises: 20260724_0023 +Create Date: 2026-07-24 +""" + +# ruff: noqa: E501 + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260724_0024" +down_revision: str | None = "20260724_0023" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TABLE = "citation_open_locator" +_MIGRATOR = "context_engine_migrator" +_RUNTIME = "context_engine_runtime" +_OPERATOR = "context_engine_security_operator" +_DEFINER = "context_engine_citation_definer" +_ISSUE = "context_runtime_issue_citation_open_ref" +_REDEEM = "context_runtime_redeem_citation_open_ref" +_DELETE_EXPIRED = "context_security_delete_expired_citation_open_lineage" +_ISSUE_SIGNATURE = "(uuid, bytea, text, text, text, text, uuid, text, timestamptz, timestamptz, text, text, timestamptz)" +_REDEEM_SIGNATURE = "(uuid, bytea, text, timestamptz)" +_DELETE_EXPIRED_SIGNATURE = "(uuid)" + + +def upgrade() -> None: + """Create a function-only content-free citation locator boundary.""" + + op.create_table( + _TABLE, + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("locator_digest", postgresql.BYTEA(), nullable=False), + sa.Column("digest_profile", sa.Text(), nullable=False), + sa.Column("package_ref", sa.Text(), nullable=False), + sa.Column("evidence_ref", sa.Text(), nullable=False), + sa.Column("resource_ref", sa.Text(), nullable=False), + sa.Column("revision_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("fragment_ref", sa.Text(), nullable=False), + sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("profile_ref", sa.Text(), nullable=False), + sa.Column("retention_policy_ref", sa.Text(), nullable=False), + sa.Column("retain_until", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", "locator_digest", name="pk_citation_open_locator" + ), + sa.UniqueConstraint( + "locator_digest", name="uq_citation_open_locator_digest_global" + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.organization_id"], + name="fk_citation_open_locator_organization", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["organization_id", "resource_ref", "revision_id", "fragment_ref"], + [ + "context_fragment.organization_id", + "context_fragment.resource_ref", + "context_fragment.revision_id", + "context_fragment.fragment_ref", + ], + name="fk_citation_open_locator_fragment_lineage", + ondelete="RESTRICT", + ), + sa.CheckConstraint( + "octet_length(locator_digest) = 32", + name="ck_citation_open_locator_digest_sha256", + ), + sa.CheckConstraint( + "digest_profile = 'citation-open-ref-sha256-v1'", + name="ck_citation_open_locator_digest_profile", + ), + sa.CheckConstraint( + "package_ref ~ '^pkg_[0-9a-f]{32}$'", + name="ck_citation_open_locator_package_ref", + ), + sa.CheckConstraint( + "evidence_ref ~ '^ev_[0-9a-f]{64}$'", + name="ck_citation_open_locator_evidence_ref", + ), + sa.CheckConstraint( + "btrim(resource_ref) <> '' AND btrim(fragment_ref) <> ''", + name="ck_citation_open_locator_fragment_refs_nonblank", + ), + sa.CheckConstraint( + "profile_ref = 'private-citation-open-v1' AND retention_policy_ref = 'citation-locator-retention-v1'", + name="ck_citation_open_locator_profiles", + ), + sa.CheckConstraint( + "expires_at > issued_at AND expires_at <= issued_at + interval '10 minutes' AND retain_until = issued_at + interval '30 days'", + name="ck_citation_open_locator_time_windows", + ), + ) + for role in ("PUBLIC", _RUNTIME, _OPERATOR, _DEFINER): + op.execute(f"REVOKE ALL ON TABLE {_TABLE} FROM {role}") + op.execute(f"ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY citation_open_locator_migrator_administration ON {_TABLE} FOR ALL TO {_MIGRATOR} USING (true) WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY citation_open_locator_definer_select ON {_TABLE} FOR SELECT TO {_DEFINER} USING (true)" + ) + op.execute( + f"CREATE POLICY citation_open_locator_definer_insert ON {_TABLE} FOR INSERT TO {_DEFINER} WITH CHECK (true)" + ) + op.execute( + f"CREATE POLICY citation_open_locator_definer_delete ON {_TABLE} FOR DELETE TO {_DEFINER} USING (true)" + ) + op.execute(f"GRANT SELECT, INSERT, DELETE ON TABLE {_TABLE} TO {_DEFINER}") + op.execute(f"GRANT SELECT ON TABLE context_resource TO {_DEFINER}") + op.execute(f"GRANT SELECT ON TABLE context_fragment TO {_DEFINER}") + op.execute(f"GRANT SELECT ON TABLE membership TO {_DEFINER}") + op.execute( + "CREATE POLICY context_resource_citation_definer_select ON context_resource FOR SELECT TO context_engine_citation_definer USING (true)" + ) + op.execute( + "CREATE POLICY context_fragment_citation_definer_select ON context_fragment FOR SELECT TO context_engine_citation_definer USING (true)" + ) + op.execute( + "CREATE POLICY membership_citation_definer_select ON membership FOR SELECT TO context_engine_citation_definer USING (true)" + ) + + op.execute( + f""" + CREATE FUNCTION public.{_ISSUE}( + requested_organization_id uuid, requested_locator_digest bytea, + requested_digest_profile text, requested_package_ref text, + requested_evidence_ref text, requested_resource_ref text, + requested_revision_id uuid, requested_fragment_ref text, + requested_issued_at timestamptz, requested_expires_at timestamptz, + requested_profile_ref text, requested_retention_policy_ref text, + requested_retain_until timestamptz + ) RETURNS boolean + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE authority_now timestamptz := pg_catalog.clock_timestamp(); + BEGIN + IF SESSION_USER <> '{_RUNTIME}' + OR requested_organization_id IS DISTINCT FROM NULLIF( + current_setting('app.organization_id', true), '' + )::uuid + OR current_setting('app.actor_kind', true) <> 'user' + OR requested_locator_digest IS NULL + OR octet_length(requested_locator_digest) <> 32 + OR requested_digest_profile <> 'citation-open-ref-sha256-v1' + OR requested_profile_ref <> 'private-citation-open-v1' + OR requested_retention_policy_ref <> 'citation-locator-retention-v1' + OR requested_issued_at > authority_now + interval '5 seconds' + OR requested_expires_at <= authority_now + OR requested_expires_at > requested_issued_at + interval '10 minutes' + OR requested_retain_until <> requested_issued_at + interval '30 days' + OR NOT EXISTS ( + SELECT 1 FROM public.membership AS actor_membership + WHERE actor_membership.organization_id = requested_organization_id + AND actor_membership.user_id = NULLIF( + current_setting('app.user_id', true), '' + )::uuid + AND actor_membership.membership_id = NULLIF( + current_setting('app.membership_id', true), '' + )::uuid + AND actor_membership.membership_version = NULLIF( + current_setting('app.membership_version', true), '' + )::bigint + AND actor_membership.status = 'active' + AND actor_membership.valid_from <= authority_now + AND (actor_membership.valid_until IS NULL OR actor_membership.valid_until > authority_now) + ) + OR NOT EXISTS ( + SELECT 1 FROM public.context_resource AS resource + WHERE resource.organization_id = requested_organization_id + AND resource.resource_ref = requested_resource_ref + AND resource.active_revision_id = requested_revision_id + AND resource.tombstoned IS FALSE + ) + OR NOT EXISTS ( + SELECT 1 FROM public.context_fragment AS fragment + WHERE fragment.organization_id = requested_organization_id + AND fragment.resource_ref = requested_resource_ref + AND fragment.revision_id = requested_revision_id + AND fragment.fragment_ref = requested_fragment_ref + ) + THEN RETURN false; END IF; + INSERT INTO public.{_TABLE} ( + organization_id, locator_digest, digest_profile, package_ref, + evidence_ref, resource_ref, revision_id, fragment_ref, + issued_at, expires_at, profile_ref, retention_policy_ref, + retain_until + ) VALUES ( + requested_organization_id, requested_locator_digest, + requested_digest_profile, requested_package_ref, + requested_evidence_ref, requested_resource_ref, + requested_revision_id, requested_fragment_ref, + requested_issued_at, requested_expires_at, + requested_profile_ref, requested_retention_policy_ref, + requested_retain_until + ) ON CONFLICT DO NOTHING; + RETURN FOUND; + END; + $function$ + """ + ) + op.execute( + f""" + CREATE FUNCTION public.{_DELETE_EXPIRED}( + requested_organization_id uuid + ) RETURNS bigint + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE deleted_count bigint; + BEGIN + IF SESSION_USER <> '{_OPERATOR}' + OR requested_organization_id IS NULL + THEN RETURN 0; END IF; + DELETE FROM public.{_TABLE} AS locator + WHERE locator.organization_id = requested_organization_id + AND locator.retain_until <= pg_catalog.clock_timestamp(); + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; + END; + $function$ + """ + ) + op.execute( + f""" + CREATE FUNCTION public.{_REDEEM}( + requested_organization_id uuid, requested_locator_digest bytea, + requested_digest_profile text, requested_opened_at timestamptz + ) RETURNS TABLE ( + source_ref text, resource_ref text, revision_id uuid, + fragment_ref text, package_ref text, evidence_ref text + ) + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE authority_now timestamptz := pg_catalog.clock_timestamp(); + BEGIN + IF SESSION_USER <> '{_RUNTIME}' + OR requested_organization_id IS DISTINCT FROM NULLIF( + current_setting('app.organization_id', true), '' + )::uuid + OR current_setting('app.actor_kind', true) <> 'user' + OR requested_locator_digest IS NULL + OR octet_length(requested_locator_digest) <> 32 + OR requested_digest_profile <> 'citation-open-ref-sha256-v1' + OR requested_opened_at IS NULL + OR requested_opened_at > authority_now + interval '5 seconds' + OR NOT EXISTS ( + SELECT 1 FROM public.membership AS actor_membership + WHERE actor_membership.organization_id = requested_organization_id + AND actor_membership.user_id = NULLIF( + current_setting('app.user_id', true), '' + )::uuid + AND actor_membership.membership_id = NULLIF( + current_setting('app.membership_id', true), '' + )::uuid + AND actor_membership.membership_version = NULLIF( + current_setting('app.membership_version', true), '' + )::bigint + AND actor_membership.status = 'active' + AND actor_membership.valid_from <= authority_now + AND (actor_membership.valid_until IS NULL OR actor_membership.valid_until > authority_now) + ) + THEN RETURN; END IF; + RETURN QUERY + SELECT resource.source_ref, locator.resource_ref, + locator.revision_id, locator.fragment_ref, + locator.package_ref, locator.evidence_ref + FROM public.{_TABLE} AS locator + JOIN public.context_resource AS resource + ON resource.organization_id = locator.organization_id + AND resource.resource_ref = locator.resource_ref + AND resource.active_revision_id = locator.revision_id + AND resource.tombstoned IS FALSE + JOIN public.context_fragment AS fragment + ON fragment.organization_id = locator.organization_id + AND fragment.resource_ref = locator.resource_ref + AND fragment.revision_id = locator.revision_id + AND fragment.fragment_ref = locator.fragment_ref + WHERE locator.organization_id = requested_organization_id + AND locator.locator_digest = requested_locator_digest + AND locator.digest_profile = requested_digest_profile + AND locator.issued_at <= authority_now + AND authority_now < locator.expires_at; + END; + $function$ + """ + ) + for function_name, signature in ( + (_ISSUE, _ISSUE_SIGNATURE), + (_DELETE_EXPIRED, _DELETE_EXPIRED_SIGNATURE), + (_REDEEM, _REDEEM_SIGNATURE), + ): + op.execute( + f"REVOKE ALL ON FUNCTION public.{function_name}{signature} FROM PUBLIC" + ) + op.execute(f"GRANT CREATE ON SCHEMA public TO {_DEFINER}") + op.execute( + f"ALTER FUNCTION public.{function_name}{signature} OWNER TO {_DEFINER}" + ) + op.execute(f"REVOKE CREATE ON SCHEMA public FROM {_DEFINER}") + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute( + f"GRANT EXECUTE ON FUNCTION public.{_ISSUE}{_ISSUE_SIGNATURE} TO {_RUNTIME}" + ) + op.execute( + f"GRANT EXECUTE ON FUNCTION public.{_REDEEM}{_REDEEM_SIGNATURE} TO {_RUNTIME}" + ) + op.execute( + f"GRANT EXECUTE ON FUNCTION public.{_DELETE_EXPIRED}{_DELETE_EXPIRED_SIGNATURE} TO {_OPERATOR}" + ) + op.execute("RESET ROLE") + + +def downgrade() -> None: + """Remove the carrier only when no locator lineage remains.""" + + op.execute( + f""" + DO $block$ BEGIN + IF EXISTS (SELECT 1 FROM public.{_TABLE}) + THEN RAISE EXCEPTION USING ERRCODE = '55000', + MESSAGE = 'cannot downgrade with citation locator rows'; + END IF; + END; $block$ + """ + ) + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"DROP FUNCTION public.{_REDEEM}{_REDEEM_SIGNATURE}") + op.execute(f"DROP FUNCTION public.{_DELETE_EXPIRED}{_DELETE_EXPIRED_SIGNATURE}") + op.execute(f"DROP FUNCTION public.{_ISSUE}{_ISSUE_SIGNATURE}") + op.execute("RESET ROLE") + op.execute("DROP POLICY context_fragment_citation_definer_select ON context_fragment") + op.execute("DROP POLICY context_resource_citation_definer_select ON context_resource") + op.execute("DROP POLICY membership_citation_definer_select ON membership") + op.execute(f"REVOKE SELECT ON TABLE membership FROM {_DEFINER}") + op.execute(f"REVOKE SELECT ON TABLE context_fragment FROM {_DEFINER}") + op.execute(f"REVOKE SELECT ON TABLE context_resource FROM {_DEFINER}") + op.drop_table(_TABLE) diff --git a/scripts/provision_database_roles.py b/scripts/provision_database_roles.py index 85fc1750..e0500f68 100644 --- a/scripts/provision_database_roles.py +++ b/scripts/provision_database_roles.py @@ -18,6 +18,7 @@ ACTION_EXECUTE_DEFINER_ROLE, ACTION_PREPARE_DEFINER_ROLE, ACTION_ROLE, + CITATION_DEFINER_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, @@ -60,6 +61,7 @@ class RoleProvisioningContract: context_run_reader_definer_role: str release_definer_role: str delivery_evidence_definer_role: str + citation_definer_role: str egress_grant_definer_role: str action_prepare_definer_role: str action_execute_definer_role: str @@ -80,6 +82,7 @@ def __post_init__(self) -> None: "context_run_reader_definer_role", "release_definer_role", "delivery_evidence_definer_role", + "citation_definer_role", "egress_grant_definer_role", "action_prepare_definer_role", "action_execute_definer_role", @@ -100,11 +103,12 @@ def __post_init__(self) -> None: self.context_run_reader_definer_role, self.release_definer_role, self.delivery_evidence_definer_role, + self.citation_definer_role, self.egress_grant_definer_role, self.action_prepare_definer_role, self.action_execute_definer_role, } - if len(security_roles) != 15: + if len(security_roles) != 16: raise ValueError("provisioned database roles must be distinct") if type(self.postgres_port) is not int or not 1 <= self.postgres_port <= 65535: raise ValueError("postgres_port must be a valid TCP port") @@ -199,6 +203,7 @@ def _contract_from_environment( context_run_reader_definer_role=CONTEXT_RUN_READER_DEFINER_ROLE, release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, + citation_definer_role=CITATION_DEFINER_ROLE, egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, action_prepare_definer_role=ACTION_PREPARE_DEFINER_ROLE, action_execute_definer_role=ACTION_EXECUTE_DEFINER_ROLE, @@ -318,6 +323,7 @@ def provision_security_roles( _create_role_if_missing(connection, contract.context_run_reader_definer_role) _create_role_if_missing(connection, contract.release_definer_role) _create_role_if_missing(connection, contract.delivery_evidence_definer_role) + _create_role_if_missing(connection, contract.citation_definer_role) _create_role_if_missing(connection, contract.egress_grant_definer_role) _create_role_if_missing(connection, contract.action_prepare_definer_role) _create_role_if_missing(connection, contract.action_execute_definer_role) @@ -397,6 +403,12 @@ def provision_security_roles( "NOINHERIT NOREPLICATION NOBYPASSRLS" ).format(sql.Identifier(contract.delivery_evidence_definer_role)) ) + connection.execute( + sql.SQL( + "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + "NOINHERIT NOREPLICATION NOBYPASSRLS" + ).format(sql.Identifier(contract.citation_definer_role)) + ) connection.execute( sql.SQL( "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " @@ -451,6 +463,7 @@ def provision_security_roles( _revoke_roles_granted_to(connection, contract.context_run_reader_definer_role) _revoke_roles_granted_to(connection, contract.release_definer_role) _revoke_roles_granted_to(connection, contract.delivery_evidence_definer_role) + _revoke_roles_granted_to(connection, contract.citation_definer_role) _revoke_roles_granted_to(connection, contract.egress_grant_definer_role) _revoke_roles_granted_to(connection, contract.action_prepare_definer_role) _revoke_roles_granted_to(connection, contract.action_execute_definer_role) @@ -465,6 +478,7 @@ def provision_security_roles( _revoke_members_of(connection, contract.context_run_reader_definer_role) _revoke_members_of(connection, contract.release_definer_role) _revoke_members_of(connection, contract.delivery_evidence_definer_role) + _revoke_members_of(connection, contract.citation_definer_role) _revoke_members_of(connection, contract.egress_grant_definer_role) _revoke_members_of(connection, contract.action_prepare_definer_role) _revoke_members_of(connection, contract.action_execute_definer_role) @@ -510,6 +524,12 @@ def provision_security_roles( sql.Identifier(contract.migrator_role), ) ) + connection.execute( + sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( + sql.Identifier(contract.citation_definer_role), + sql.Identifier(contract.migrator_role), + ) + ) connection.execute( sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( sql.Identifier(contract.release_definer_role), @@ -529,6 +549,7 @@ def provision_security_roles( contract.context_run_reader_definer_role, contract.release_definer_role, contract.delivery_evidence_definer_role, + contract.citation_definer_role, contract.egress_grant_definer_role, contract.action_prepare_definer_role, contract.action_execute_definer_role, diff --git a/scripts/security_gate/rls.py b/scripts/security_gate/rls.py index 9277161c..ee3fda53 100644 --- a/scripts/security_gate/rls.py +++ b/scripts/security_gate/rls.py @@ -23,6 +23,7 @@ "action_receipt": "PG-ACTION-PERFORM-068", "action_reconciliation": "PG-ACTION-PERFORM-068", "action_ticket": "PG-ACTION-PREPARE-067", + "citation_open_locator": "PG-CITATION-AUTH-010", "context_source": "PG-FILE-SOURCE-RLS-021", "delivery_evidence": "PG-DELIVERY-EVIDENCE-063", "egress_grant": "PG-EGRESS-011", diff --git a/scripts/validate_security_catalog.py b/scripts/validate_security_catalog.py index f737d39b..5b1afa3f 100644 --- a/scripts/validate_security_catalog.py +++ b/scripts/validate_security_catalog.py @@ -405,6 +405,9 @@ "PG-ACTION-PREPARE-067", "PG-ACTION-PERFORM-068", ) +REQUIRED_PROPERTY_EVIDENCE["CITATION-AUTH-010"] = ("PROP-CITATION-AUTH-010",) +REQUIRED_POSTGRES_EVIDENCE["CITATION-AUTH-010"] = ("PG-CITATION-AUTH-010",) +REQUIRED_RUNTIME_EVIDENCE["CITATION-AUTH-010"] = ("RUNTIME-CITATION-AUTH-010",) ACCEPT_002_ACTIVE_CARRIER: dict[str, str] = { "statusAtM0": "available", @@ -438,12 +441,12 @@ } ACCEPT_010_FUTURE_CARRIER: dict[str, str] = { - "statusAtM0": "future", - "m0Expectation": "fail_closed", + "statusAtM0": "available", + "m0Expectation": "active_fail_closed", "upgradeTrigger": ( - "The owning M2 OpenCitation implementation issue upgrades this " - "fixture only after current-opener authorization and distinct token " - "variants exist at the public HTTP seam." + "Issue #69 activates private/direct File citation opens through the " + "public HTTP and generated SDK seams; extend only when group/public " + "AudienceSnapshot and Continue carriers activate." ), } @@ -543,8 +546,8 @@ "issueRef": "#16", "invariantRef": "INDEX-NOT-AUTHORITY-005", "carrier": ( - "ContextRuntime.resolve(Continue | OpenCitation | server-owned " - "unavailable Acquire plan)" + "ContextRuntime.resolve(Continue | profile-disabled OpenCitation | " + "server-owned unavailable Acquire plan)" ), "status": "active_fail_closed", "policyEpochScope": "organization-v0", @@ -555,7 +558,8 @@ "surface": "tests/unit/test_runtime_unavailable_capabilities.py", "oracle": ( "Table-driven Runtime cases prove unavailable Continue, " - "OpenCitation, and server-owned Acquire plans traverse the " + "profile-disabled OpenCitation, and server-owned Acquire plans " + "traverse the " "content-free sealed Kernel preflight and stop before Provider, " "index, or source I/O; the restricted mandatory audit retains " "only UNSUPPORTED_CAPABILITY." @@ -575,17 +579,16 @@ ], "deferredEvidence": [ "real-continuation-redemption", - "real-citation-redemption", "real-federated-source-native-authorization", ], "futureCarriers": [ "Continue", - "OpenCitation", "federated/source-native ContextProvider", + "group/public Runtime carrier", ], "notActive": [ "continuation issuance/redemption", - "citation locator redemption", + "profile-enabled OpenCitation in this issue-16 activation", "federated Provider/source-native ACL I/O", "File publication", ], @@ -812,7 +815,7 @@ "OBS-003 production debug-endpoint authorization", ], "futureCarriers": [ - "Continue and OpenCitation ContextRun lineage", + "Continue ContextRun lineage", "full retrieval candidate/ranking traces", "authorized feedback and golden-set extraction", "explicitly approved full-Package retention", @@ -921,7 +924,7 @@ "futureCarriers": [ "production ContextProvider native field projection", "File and Base ingestion field ACL", - "Continue and OpenCitation field projection", + "Continue field projection", "field-policy change independent of Membership version", ], "notActive": [ @@ -1037,13 +1040,11 @@ ], "deferredEvidence": [ "group AudienceSnapshot DeliveryEvidenceRef", - "generated TypeScript SDK carrier", "production BotDelivery caller", ], "futureCarriers": [ "public group DeliveryEvidenceRef", - "OpenCitation delivery evidence", - "generated TypeScript SDK", + "private group DeliveryEvidenceRef", "private BotDelivery application", ], "notActive": [ @@ -1052,7 +1053,6 @@ "production ModelGateway", "ActionPlane", "BotDelivery application", - "generated SDK", ], } @@ -1132,7 +1132,6 @@ "ActionTicket or external effect", "group AudienceSnapshot", "BotDelivery application process", - "generated SDK consumer", ], } @@ -1210,28 +1209,44 @@ }, ], "deferredEvidence": [ - "generated TypeScript SDK conformance", "production BotDelivery generated-SDK caller", - "Continue and OpenCitation redemption", + "Continue redemption", ], "futureCarriers": [ - "generated TypeScript SDK", "MCP", "BotDelivery application", "Continue redemption", - "OpenCitation redemption", ], "notActive": [ - "generated SDK consumer", "MCP", "BotDelivery application process", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects", ], } +SDK_LIVE_FILE_064_TEST_EVIDENCE: dict[str, object] = { + "id": "SDK-LIVE-FILE-064", + "surface": ( + "tests/integration/test_z_egress_grant_file.py::" + "test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http" + ), + "oracle": ( + "A tarball installed into a temporary TypeScript consumer calls " + "a real local POST /v0/resolve server with only authentication, " + "request id, and opaque DeliveryEvidenceRef metadata; real " + "PostgreSQL redemption and File acquisition prove CandidateRef " + "through the sealed AuthorizationKernel to AuthorizedProjection, " + "an audience-bound ContextPackage, and an opaque model egress " + "grant. The generated OpenCitation call uses a second request-bound " + "DeliveryEvidenceRef to reauthorize the acquired opaque locator into " + "a distinct citation.open Package, fresh locator, and matching grant; " + "generated Continue remains a generic inactive outcome." + ), +} + + CANONICAL_TYPESCRIPT_SDK_ACTIVATION: dict[str, object] = { "issueRef": "#64", "invariantRef": "TRANSPORT-UNTRUSTED-008", @@ -1258,43 +1273,24 @@ "checksum and the generated tree retains a SHA-256 digest." ), }, - { - "id": "SDK-LIVE-FILE-064", - "surface": ( - "tests/integration/test_z_egress_grant_file.py::" - "test_packed_typescript_sdk_resolves_authorized_file_package_" - "over_live_http" - ), - "oracle": ( - "A tarball installed into a temporary TypeScript consumer calls " - "a real local POST /v0/resolve server with only authentication, " - "request id, and opaque DeliveryEvidenceRef metadata; real " - "PostgreSQL redemption and File acquisition prove CandidateRef " - "through the sealed AuthorizationKernel to AuthorizedProjection, " - "an audience-bound ContextPackage, and an opaque model egress " - "grant, while generated Continue and OpenCitation calls return " - "their generic inactive outcomes." - ), - }, + SDK_LIVE_FILE_064_TEST_EVIDENCE, ], "deferredEvidence": [ "signed package-registry publication provenance", "production BotDelivery generated-SDK caller", - "real Continue and OpenCitation issuance and redemption", + "real Continue issuance and redemption", ], "futureCarriers": [ "published package-registry SDK", "BotDelivery application", "MCP", "Continue redemption", - "OpenCitation redemption", ], "notActive": [ "external package publication", "production BotDelivery application process", "MCP", "continuation issuance or redemption", - "citation persistence or redemption", "group AudienceSnapshot", "external effects", ], @@ -1419,6 +1415,62 @@ ], } +CANONICAL_CITATION_OPEN_ACTIVATION: dict[str, object] = { + "issueRef": "#69", + "invariantRef": "CITATION-AUTH-010", + "carrier": "private/direct File CitationOpenRef issuance and OpenCitation", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": ( + "digest-only locator -> current UserActor transaction -> CandidateRef -> " + "AuthorizationKernel -> AuthorizedProjection -> replacement ContextPackage" + ), + "testEvidence": [ + { + "id": "PG-CITATION-AUTH-010", + "surface": "tests/integration/test_citation_open.py", + "oracle": ( + "Real PostgreSQL stores only a SHA-256 locator digest plus prior " + "Package/Evidence and Fragment location lineage under FORCE RLS. " + "Runtime can issue/redeem only through dedicated definer functions; " + "the locator is multi-use, database-clock-expiring, cross-kind and " + "cross-Organization probes are generic, and ordinary roles have no " + "table access." + ), + }, + { + "id": "RUNTIME-CITATION-AUTH-010", + "surface": ( + "tests/integration/test_z_egress_grant_file.py::" + "test_file_http_citation_is_not_consumed_by_denied_reader" + ), + "oracle": ( + "The public HTTP File carrier proves reader A receives an opaque " + "locator, reader B reauthorizes to the same generic unavailable " + "outcome without consuming or refreshing it, and reader A opens " + "again through the sealed Kernel to a new audience-bound Package." + ), + }, + SDK_LIVE_FILE_064_TEST_EVIDENCE, + ], + "deferredEvidence": [ + "group/public AudienceSnapshot citation opening", + "Continue issuance or redemption", + "non-File provider citation lineage", + ], + "futureCarriers": [ + "group-public citation delivery", + "Continue", + "non-File ContextProvider citations", + ], + "notActive": [ + "group AudienceSnapshot", + "public-group citation package", + "Continue", + "raw source URL locator", + ], +} + CANONICAL_ACTIVATIONS: list[dict[str, object]] = [ CANONICAL_REVOCATION_ACTIVATION, CANONICAL_UNAVAILABLE_CAPABILITY_ACTIVATION, @@ -1432,6 +1484,7 @@ CANONICAL_TYPESCRIPT_SDK_ACTIVATION, CANONICAL_ACTION_PREPARE_ACTIVATION, CANONICAL_ACTION_PERFORM_ACTIVATION, + CANONICAL_CITATION_OPEN_ACTIVATION, ] CANONICAL_ACTIVATION_ISSUE_LIST = ", ".join( f"Issue {activation['issueRef']}" for activation in CANONICAL_ACTIVATIONS diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index e4abaed2..fbcea402 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -51,12 +51,20 @@ export interface AcquireContextOptions extends ResolveContextOptionsBase { readonly request: Extract; } +export interface DeliveryBoundContextOptions extends ResolveContextOptionsBase { + readonly deliveryEvidenceRef?: string; + readonly request: Extract; +} + export interface DirectContextOptions extends ResolveContextOptionsBase { readonly deliveryEvidenceRef?: never; - readonly request: Exclude; + readonly request: Extract; } -export type ResolveContextOptions = AcquireContextOptions | DirectContextOptions; +export type ResolveContextOptions = + | AcquireContextOptions + | DeliveryBoundContextOptions + | DirectContextOptions; export class ContextEngineHttpError extends Error { readonly body: ResolveContextV0Error; diff --git a/sdk/typescript/test/live-consumer.mjs b/sdk/typescript/test/live-consumer.mjs index be66ac64..344d01c6 100644 --- a/sdk/typescript/test/live-consumer.mjs +++ b/sdk/typescript/test/live-consumer.mjs @@ -3,6 +3,7 @@ import { ContextEngineResolveClient } from "@context-engine/resolve-sdk"; const requiredEnvironment = [ "CONTEXT_ENGINE_SDK_BASE_URL", "CONTEXT_ENGINE_SDK_DELIVERY_EVIDENCE_REF", + "CONTEXT_ENGINE_SDK_CITATION_DELIVERY_EVIDENCE_REF", "CONTEXT_ENGINE_SDK_REQUEST_ID", "CONTEXT_ENGINE_SDK_TEST_AUTHENTICATION", "CONTEXT_ENGINE_SDK_TEST_DIRECT_AUTHENTICATION", @@ -40,12 +41,13 @@ const continuation = await directClient.resolve({ }, requestId: process.env.CONTEXT_ENGINE_SDK_REQUEST_ID, }); -const citation = await directClient.resolve({ +const citation = await client.resolve({ + deliveryEvidenceRef: process.env.CONTEXT_ENGINE_SDK_CITATION_DELIVERY_EVIDENCE_REF, request: { - citationOpenRef: "citation_sdk_live_inactive", + citationOpenRef: acquire.package.evidence[0].citationOpenRef, kind: "open_citation", }, - requestId: process.env.CONTEXT_ENGINE_SDK_REQUEST_ID, + requestId: `${process.env.CONTEXT_ENGINE_SDK_REQUEST_ID}-citation`, }); process.stdout.write(`${JSON.stringify({ acquire, citation, continuation })}\n`); diff --git a/sdk/typescript/test/package-consumer/contract.ts b/sdk/typescript/test/package-consumer/contract.ts index 9e620b37..53aceab0 100644 --- a/sdk/typescript/test/package-consumer/contract.ts +++ b/sdk/typescript/test/package-consumer/contract.ts @@ -1,5 +1,6 @@ import { ContextEngineResolveClient, + type DeliveryBoundContextOptions, type DirectContextOptions, type ResolutionOutcomeWire, type ResolveWire, @@ -22,6 +23,16 @@ const outcome: Promise = client.resolve({ }); void outcome; +const citationDelivery: DeliveryBoundContextOptions = { + deliveryEvidenceRef: "deliv_citation_opaque", + request: { + citationOpenRef: "cor_opaque", + kind: "open_citation", + }, + requestId: "sdk-citation-contract", +}; +void citationDelivery; + client.resolve({ requestId: "forbidden-body-field", request: { @@ -49,7 +60,7 @@ client.resolve({ }); const forbiddenContinueEvidence: DirectContextOptions = { - // @ts-expect-error delivery evidence is currently valid only for Acquire + // @ts-expect-error delivery evidence is valid for Acquire/OpenCitation only deliveryEvidenceRef: "deliv_forbidden_for_continue", request: { continuationToken: "continuation_inactive", diff --git a/tests/catalog/test_validate_m0_security_evidence.py b/tests/catalog/test_validate_m0_security_evidence.py index f0b69633..0e5d17bd 100644 --- a/tests/catalog/test_validate_m0_security_evidence.py +++ b/tests/catalog/test_validate_m0_security_evidence.py @@ -126,11 +126,10 @@ def test_m0_registry_uses_activated_egress_and_honest_learning_evidence() -> Non registry, _, _ = _documents() evidence = {entry["id"]: entry["selector"] for entry in registry["evidence"]} - unavailable_carrier = ( - "tests/integration/test_m0_unavailable_security_carriers.py::" - "test_unavailable_citation_and_real_provider_carriers_fail_closed" + assert evidence["PG-CITATION-AUTH-010"] == ( + "tests/integration/test_citation_open.py::" + "test_citation_locator_is_digest_only_multi_use_and_function_only" ) - assert evidence["PG-CITATION-AUTH-010"] == unavailable_carrier assert evidence["PROP-EGRESS-011"] == ( "tests/unit/test_egress_grant.py::" "test_each_egress_binding_mutation_and_cross_kind_emits_zero_bytes_effects" @@ -188,9 +187,7 @@ def test_registry_requires_every_invariant_evidence_layer() -> None: error = _validation_error(broken) assert any( - message.startswith( - "registry.invariantMappings[0].evidenceRefs.postgres: " - ) + message.startswith("registry.invariantMappings[0].evidenceRefs.postgres: ") for message in error.errors ) @@ -242,9 +239,7 @@ def test_registry_rejects_wrong_layer_unknown_refs_and_empty_selectors() -> None ) unknown_ref = copy.deepcopy(registry) - unknown_ref["fixtureMappings"][0]["evidenceRefs"] = [ - "UNKNOWN-EVIDENCE-020" - ] + unknown_ref["fixtureMappings"][0]["evidenceRefs"] = ["UNKNOWN-EVIDENCE-020"] assert any( "unknown evidence ref 'UNKNOWN-EVIDENCE-020'" in message for message in _validation_error(unknown_ref).errors @@ -279,7 +274,8 @@ def test_registry_hard_oracle_adapters_match_catalog_vetoes() -> None: "missingContextFallbackCount", ] assert all( - adapter["observation"] == { + adapter["observation"] + == { "source": "pytest-user-property", "reducer": "sum", } @@ -308,12 +304,12 @@ def test_registry_selector_paths_and_functions_exist() -> None: "marker IDs differ from registry IDs", ), ( - '@pytest.mark.security_evidence(' + "@pytest.mark.security_evidence(" 'id="PROP-TENANT-OWNERSHIP-001", layer="runtime")\n', "marker layer", ), ( - '@pytest.mark.security_evidence(' + "@pytest.mark.security_evidence(" '"PROP-TENANT-OWNERSHIP-001", layer="property")\n', "must use exact id= and layer= string arguments", ), @@ -326,11 +322,7 @@ def test_registry_rejects_missing_wrong_or_malformed_evidence_markers( ) -> None: registry, schema, catalog, repository = _temporary_marker_repository( tmp_path, - test_source=( - f"{marker}" - "def test_registered() -> None:\n" - " pass\n\n" - ), + test_source=(f"{marker}def test_registered() -> None:\n pass\n\n"), ) with pytest.raises(CatalogValidationError) as raised: diff --git a/tests/catalog/test_validate_security_catalog.py b/tests/catalog/test_validate_security_catalog.py index a2b421bb..5684da4e 100644 --- a/tests/catalog/test_validate_security_catalog.py +++ b/tests/catalog/test_validate_security_catalog.py @@ -25,6 +25,7 @@ CANONICAL_ACTION_PREPARE_ACTIVATION, CANONICAL_ACTIVATION_ISSUE_LIST, CANONICAL_ACTIVATIONS, + CANONICAL_CITATION_OPEN_ACTIVATION, CANONICAL_CONTEXT_RUN_ACTIVATION, CANONICAL_EGRESS_GRANT_ACTIVATION, CANONICAL_FAIL_CLOSED_OUTCOMES, @@ -571,6 +572,7 @@ def make_catalog() -> dict[str, object]: copy.deepcopy(CANONICAL_TYPESCRIPT_SDK_ACTIVATION), copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION), copy.deepcopy(CANONICAL_ACTION_PERFORM_ACTIVATION), + copy.deepcopy(CANONICAL_CITATION_OPEN_ACTIVATION), ], "invariants": invariants, "fixtures": fixtures, @@ -665,6 +667,7 @@ def make_schema() -> dict[str, object]: {"const": copy.deepcopy(CANONICAL_TYPESCRIPT_SDK_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_ACTION_PERFORM_ACTIVATION)}, + {"const": copy.deepcopy(CANONICAL_CITATION_OPEN_ACTIVATION)}, ], "items": False, }, @@ -1796,7 +1799,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( self.assertEqual(catalog["catalogVersion"], "1.3.0") self.assertEqual( - issue_refs[-12:], + issue_refs[-13:], [ "#15", "#16", @@ -1810,6 +1813,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( "#64", "#67", "#68", + "#69", ], ) self.assertIn( @@ -1820,6 +1824,10 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( "docs/decisions/0050-perform-one-exact-private-effect.md", document_refs, ) + self.assertIn( + "docs/decisions/0051-reauthorize-opaque-citation-opens.md", + document_refs, + ) for boundary in ( "Issue #19 activates only the current Acquire authorized-only ContextRun", "DIGEST-019", diff --git a/tests/integration/test_citation_open.py b/tests/integration/test_citation_open.py new file mode 100644 index 00000000..5c1925b2 --- /dev/null +++ b/tests/integration/test_citation_open.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from pathlib import Path +from time import sleep +from uuid import UUID + +import pytest +from sqlalchemy import Engine, text +from sqlalchemy.exc import ProgrammingError + +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLCitationOpenRetentionPort, + create_database_engine, +) +from engine.persistence.file_imports import PublishedFileImport +from engine.persistence.membership_context import ( + MembershipIdentity, + PostgreSQLMembershipAuthority, +) +from engine.runtime.citation import ( + CITATION_OPEN_DIGEST_PROFILE, + CitationLocatorNotAvailable, + CitationOpenIssue, + CitationOpenProfile, + CitationOpenRedemption, + CitationOpenRetention, + issue_citation_open_ref, + redeem_citation_open_ref, +) +from engine.runtime.contracts import CitationOpenRef +from tests.integration.test_file_import_tracer import ( + _FileImportScenario, + _prepare_file_import_scenario, + _run_file_import, +) + +pytestmark = pytest.mark.integration +REFERENCE = "cor_" + "7" * 64 + + +@pytest.fixture +def citation_file_scenario( + tmp_path: Path, + migration_configuration: DatabaseConfiguration, + guarded_control_engine: Engine, + guarded_worker_engine: Engine, +) -> Iterator[tuple[_FileImportScenario, PublishedFileImport, UUID, Engine]]: + scenario = _prepare_file_import_scenario( + tmp_path, + migration_configuration, + guarded_control_engine, + ) + assert scenario.token is not None + published = _run_file_import( + scenario, + scenario.prepared, + scenario.token, + guarded_worker_engine, + ) + migration_engine = create_database_engine(migration_configuration) + try: + with migration_engine.connect() as connection: + user_id = connection.execute( + text( + "SELECT user_id FROM membership " + "WHERE organization_id = :org AND membership_id = :membership" + ), + { + "org": scenario.organization_id, + "membership": scenario.membership_id, + }, + ).scalar_one() + yield scenario, published, user_id, migration_engine + finally: + with migration_engine.begin() as connection: + connection.execute( + text("DELETE FROM citation_open_locator WHERE organization_id = :org"), + {"org": scenario.organization_id}, + ) + migration_engine.dispose() + + +def _identity( + scenario: _FileImportScenario, + user_id: UUID, + *, + request_id: str, + checked_at: datetime, +) -> MembershipIdentity: + return MembershipIdentity( + organization_id=scenario.organization_id, + user_id=user_id, + membership_id=scenario.membership_id, + membership_version=1, + principal_ref="principal:file-tracer", + request_id=request_id, + authentication_binding_ref="binding:file-tracer", + checked_at=checked_at, + ) + + +@pytest.mark.security_evidence(id="PG-CITATION-AUTH-010", layer="postgres") +def test_citation_locator_is_digest_only_multi_use_and_function_only( + citation_file_scenario: tuple[ + _FileImportScenario, PublishedFileImport, UUID, Engine + ], + guarded_runtime_engine: Engine, +) -> None: + scenario, published, user_id, migration_engine = citation_file_scenario + candidate = published.candidate_ref + now = datetime.now(UTC) + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + profile = CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ) + issue = CitationOpenIssue( + organization_id=scenario.organization_id, + package_ref="pkg_" + "1" * 32, + evidence_ref="ev_" + "2" * 64, + resource_ref=candidate.resource_ref, + revision_id=UUID(candidate.revision_ref), + fragment_ref=candidate.fragment_ref, + issued_at=now, + expires_at=now + timedelta(minutes=5), + ) + + with authority.current_user_actor( + _identity(scenario, user_id, request_id="citation-issue", checked_at=now) + ) as verification: + assert verification.citation_open_session is not None + issued = issue_citation_open_ref( + verification.citation_open_session, + issue, + profile=profile, + reference_factory=lambda: REFERENCE, + ) + + with authority.current_user_actor( + _identity( + scenario, + user_id, + request_id="citation-open-1", + checked_at=now + timedelta(seconds=1), + ) + ) as verification: + assert verification.citation_open_session is not None + first = redeem_citation_open_ref( + verification.citation_open_session, + CitationOpenRedemption( + citation_open_ref=issued, + organization_id=scenario.organization_id, + opened_at=now + timedelta(seconds=1), + ), + ) + with authority.current_user_actor( + _identity( + scenario, + user_id, + request_id="citation-open-2", + checked_at=now + timedelta(seconds=2), + ) + ) as verification: + assert verification.citation_open_session is not None + second = redeem_citation_open_ref( + verification.citation_open_session, + CitationOpenRedemption( + citation_open_ref=issued, + organization_id=scenario.organization_id, + opened_at=now + timedelta(seconds=2), + ), + ) + + assert first == second + assert first.candidate_ref == candidate + assert first.lineage.package_ref == issue.package_ref + assert first.lineage.evidence_ref == issue.evidence_ref + + with migration_engine.connect() as connection: + row = connection.execute( + text( + "SELECT locator_digest, digest_profile, package_ref, evidence_ref, " + "resource_ref, revision_id, fragment_ref, issued_at, expires_at, " + "profile_ref, retention_policy_ref, retain_until " + "FROM citation_open_locator WHERE organization_id = :org" + ), + {"org": scenario.organization_id}, + ).one() + columns = { + str(item.name) + for item in connection.execute( + text( + "SELECT column_name AS name FROM information_schema.columns " + "WHERE table_schema = 'public' " + "AND table_name = 'citation_open_locator'" + ) + ) + } + assert bytes(row.locator_digest) == sha256(REFERENCE.encode()).digest() + assert row.digest_profile == CITATION_OPEN_DIGEST_PROFILE + assert REFERENCE not in repr(row) + prior_authorization_columns = { + "principal_ref", + "membership_id", + "audience_digest", + "purpose", + "decision_ref", + } + assert prior_authorization_columns.isdisjoint(columns) + assert "first_redeemed_at" not in columns + assert "consumed_at" not in columns + with ( + guarded_runtime_engine.connect() as connection, + pytest.raises(ProgrammingError), + ): + connection.execute(text("SELECT * FROM citation_open_locator")) + + +def test_citation_locator_lineage_is_retained_then_cleaned_by_operator_clock( + citation_file_scenario: tuple[ + _FileImportScenario, PublishedFileImport, UUID, Engine + ], + guarded_runtime_engine: Engine, + guarded_operator_engine: Engine, +) -> None: + scenario, published, user_id, migration_engine = citation_file_scenario + candidate = published.candidate_ref + now = datetime.now(UTC) + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + with authority.current_user_actor( + _identity(scenario, user_id, request_id="citation-retained", checked_at=now) + ) as verification: + assert verification.citation_open_session is not None + current_ref = issue_citation_open_ref( + verification.citation_open_session, + CitationOpenIssue( + organization_id=scenario.organization_id, + package_ref="pkg_" + "5" * 32, + evidence_ref="ev_" + "6" * 64, + resource_ref=candidate.resource_ref, + revision_id=UUID(candidate.revision_ref), + fragment_ref=candidate.fragment_ref, + issued_at=now, + expires_at=now + timedelta(minutes=5), + ), + profile=CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ), + reference_factory=lambda: "cor_" + "9" * 64, + ) + expired_digest = sha256(b"expired-citation-lineage").digest() + with migration_engine.begin() as connection: + connection.execute( + text( + "INSERT INTO citation_open_locator (organization_id, " + "locator_digest, digest_profile, package_ref, evidence_ref, " + "resource_ref, revision_id, fragment_ref, issued_at, expires_at, " + "profile_ref, retention_policy_ref, retain_until) VALUES (" + ":org, :digest, 'citation-open-ref-sha256-v1', :package, " + ":evidence, :resource, :revision, :fragment, :issued, :expires, " + "'private-citation-open-v1', " + "'citation-locator-retention-v1', :retain_until)" + ), + { + "org": scenario.organization_id, + "digest": expired_digest, + "package": "pkg_" + "7" * 32, + "evidence": "ev_" + "8" * 64, + "resource": candidate.resource_ref, + "revision": UUID(candidate.revision_ref), + "fragment": candidate.fragment_ref, + "issued": now - timedelta(days=31), + "expires": now - timedelta(days=31) + timedelta(minutes=5), + "retain_until": now - timedelta(days=1), + }, + ) + + retention = CitationOpenRetention( + PostgreSQLCitationOpenRetentionPort(guarded_operator_engine) + ) + + assert retention.delete_expired(scenario.organization_id) == 1 + with migration_engine.connect() as connection: + digests = { + bytes(item) + for item in connection.execute( + text( + "SELECT locator_digest FROM citation_open_locator " + "WHERE organization_id = :org" + ), + {"org": scenario.organization_id}, + ).scalars() + } + assert expired_digest not in digests + assert sha256(current_ref.value.encode()).digest() in digests + + +@pytest.mark.parametrize("mutation", ["reference", "organization", "expiry"]) +def test_citation_locator_mutations_are_identical_not_available( + mutation: str, + citation_file_scenario: tuple[ + _FileImportScenario, PublishedFileImport, UUID, Engine + ], + guarded_runtime_engine: Engine, +) -> None: + scenario, published, user_id, _ = citation_file_scenario + now = datetime.now(UTC) + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + with authority.current_user_actor( + _identity( + scenario, + user_id, + request_id=f"citation-{mutation}-issue", + checked_at=now, + ) + ) as verification: + assert verification.citation_open_session is not None + issued = issue_citation_open_ref( + verification.citation_open_session, + CitationOpenIssue( + organization_id=scenario.organization_id, + package_ref="pkg_" + "3" * 32, + evidence_ref="ev_" + "4" * 64, + resource_ref=published.candidate_ref.resource_ref, + revision_id=UUID(published.candidate_ref.revision_ref), + fragment_ref=published.candidate_ref.fragment_ref, + issued_at=now, + expires_at=now + + ( + timedelta(milliseconds=300) + if mutation == "expiry" + else timedelta(seconds=2) + ), + ), + profile=CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ), + reference_factory=lambda: REFERENCE, + ) + values = { + "citation_open_ref": issued, + "organization_id": scenario.organization_id, + "opened_at": now + timedelta(seconds=1), + } + if mutation == "reference": + values["citation_open_ref"] = CitationOpenRef("cor_" + "8" * 64) + elif mutation == "organization": + values["organization_id"] = UUID("7e74ff30-a3d5-4655-b70d-c792beb874be") + else: + sleep(0.4) + values["opened_at"] = datetime.now(UTC) + + with authority.current_user_actor( + _identity( + scenario, + user_id, + request_id=f"citation-{mutation}-open", + checked_at=now + timedelta(seconds=1), + ) + ) as verification: + assert verification.citation_open_session is not None + with pytest.raises(CitationLocatorNotAvailable): + redeem_citation_open_ref( + verification.citation_open_session, + CitationOpenRedemption(**values), # type: ignore[arg-type] + ) diff --git a/tests/integration/test_file_import_tracer.py b/tests/integration/test_file_import_tracer.py index c7863f57..bc44b904 100644 --- a/tests/integration/test_file_import_tracer.py +++ b/tests/integration/test_file_import_tracer.py @@ -1582,7 +1582,7 @@ def _assert_structural_file_import_returns_coherent_authorized_units_over_http( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260724_0023" + == "20260724_0024" ) diff --git a/tests/integration/test_m0_security_gate_rls.py b/tests/integration/test_m0_security_gate_rls.py index 92294d03..75f2a4cf 100644 --- a/tests/integration/test_m0_security_gate_rls.py +++ b/tests/integration/test_m0_security_gate_rls.py @@ -30,7 +30,7 @@ def _manifest() -> dict[str, object]: def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( guarded_runtime_engine: Engine, ) -> None: - """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 47/47.""" + """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 48/48.""" with guarded_runtime_engine.connect() as connection: report = audit_live_rls( @@ -41,13 +41,13 @@ def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( assert report["passed"] is True assert report["denominator"] == { - "allTables": 50, - "tenantOwned": 47, + "allTables": 51, + "tenantOwned": 48, "global": 3, } assert report["coverage"] == { - "numerator": 47, - "denominator": 47, + "numerator": 48, + "denominator": 48, "percent": 100.0, } assert report["failures"] == [] @@ -86,9 +86,9 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( assert mutated["passed"] is False assert mutated["coverage"] == { - "numerator": 46, - "denominator": 47, - "percent": 97.87, + "numerator": 47, + "denominator": 48, + "percent": 97.92, } tenant_tables = cast(list[dict[str, Any]], mutated["tenantTables"]) organization_record = next( @@ -111,8 +111,8 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( ) assert restored["passed"] is True assert restored["coverage"] == { - "numerator": 47, - "denominator": 47, + "numerator": 48, + "denominator": 48, "percent": 100.0, } diff --git a/tests/integration/test_m0_unavailable_security_carriers.py b/tests/integration/test_m0_unavailable_security_carriers.py index 9a13418c..df3c1423 100644 --- a/tests/integration/test_m0_unavailable_security_carriers.py +++ b/tests/integration/test_m0_unavailable_security_carriers.py @@ -10,12 +10,10 @@ pytestmark = pytest.mark.integration UNAVAILABLE_M0_TABLE_STEMS = ( - "citation", "model_gateway", "model_input", ) UNAVAILABLE_M0_FUNCTION_STEMS = ( - "citation", "model_gateway", "model_input", ) @@ -61,11 +59,10 @@ def _public_application_objects(engine: Engine) -> tuple[set[str], set[str]]: return tables, functions -@pytest.mark.security_evidence(id="PG-CITATION-AUTH-010", layer="postgres") -def test_unavailable_citation_and_real_provider_carriers_fail_closed( +def test_unavailable_real_provider_carriers_fail_closed( guarded_runtime_engine: Engine, ) -> None: - """Activated egress state does not activate citation or real-provider state.""" + """Activated egress/citation state does not activate real-provider state.""" tables, functions = _public_application_objects(guarded_runtime_engine) with guarded_runtime_engine.connect() as connection: @@ -91,9 +88,10 @@ def test_learning_persistence_is_organization_bound( with guarded_learning_engine.connect() as connection: assert_learning_role(connection) - rows = connection.execute( - text( - """ + rows = ( + connection.execute( + text( + """ SELECT table_record.relname AS table_name, table_record.relrowsecurity, @@ -132,9 +130,12 @@ def test_learning_persistence_is_organization_bound( GROUP BY table_record.oid, table_record.relname ORDER BY table_record.relname """ - ), - {"table_names": sorted(ORGANIZATION_BOUND_LEARNING_TABLES)}, - ).mappings().all() + ), + {"table_names": sorted(ORGANIZATION_BOUND_LEARNING_TABLES)}, + ) + .mappings() + .all() + ) observed = {cast(str, row["table_name"]): row for row in rows} assert set(observed) == ORGANIZATION_BOUND_LEARNING_TABLES diff --git a/tests/integration/test_membership_schema.py b/tests/integration/test_membership_schema.py index ff04647c..0966cc09 100644 --- a/tests/integration/test_membership_schema.py +++ b/tests/integration/test_membership_schema.py @@ -15,6 +15,7 @@ from engine.persistence.configuration import ( ACTION_EXECUTE_DEFINER_ROLE, ACTION_PREPARE_DEFINER_ROLE, + CITATION_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, RUNTIME_ROLE, @@ -466,12 +467,13 @@ def test_runtime_worker_and_public_grants_are_least_privilege( FROM information_schema.table_privileges WHERE table_schema = 'public' AND table_name IN ('user_account', 'membership') - AND grantee IN ( - 'PUBLIC', :runtime_role, :worker_role, - :delivery_evidence_definer_role, - :egress_grant_definer_role, - :action_prepare_definer_role, - :action_execute_definer_role + AND grantee IN ( + 'PUBLIC', :runtime_role, :worker_role, + :delivery_evidence_definer_role, + :egress_grant_definer_role, + :action_prepare_definer_role, + :action_execute_definer_role, + :citation_definer_role ) """ ), @@ -484,6 +486,7 @@ def test_runtime_worker_and_public_grants_are_least_privilege( "egress_grant_definer_role": EGRESS_GRANT_DEFINER_ROLE, "action_prepare_definer_role": ACTION_PREPARE_DEFINER_ROLE, "action_execute_definer_role": ACTION_EXECUTE_DEFINER_ROLE, + "citation_definer_role": CITATION_DEFINER_ROLE, }, ) } @@ -535,6 +538,7 @@ def test_runtime_worker_and_public_grants_are_least_privilege( (EGRESS_GRANT_DEFINER_ROLE, "membership", "SELECT"), (ACTION_PREPARE_DEFINER_ROLE, "membership", "SELECT"), (ACTION_EXECUTE_DEFINER_ROLE, "membership", "SELECT"), + (CITATION_DEFINER_ROLE, "membership", "SELECT"), } assert security == (True, True) assert set(policies) == { @@ -544,6 +548,7 @@ def test_runtime_worker_and_public_grants_are_least_privilege( "membership_action_prepare_definer_select", "membership_action_execute_definer_select", "membership_file_import_definer_select", + "membership_citation_definer_select", "membership_migrator_administration", } runtime_policy = policies["membership_current_user_actor"] diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 67db0340..621be17f 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -4,7 +4,7 @@ from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor from dataclasses import replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from time import monotonic, sleep from uuid import UUID, uuid4 @@ -21,6 +21,15 @@ PostgreSQLWorkerLeaseIssuer, create_database_engine, ) +from engine.persistence.membership_context import ( + MembershipIdentity, + PostgreSQLMembershipAuthority, +) +from engine.runtime.citation import ( + CitationOpenIssue, + CitationOpenProfile, + issue_citation_open_ref, +) from engine.supply import ( MarkdownCompilerConfig, ParsedDocument, @@ -43,7 +52,7 @@ pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] -_HEAD_REVISION = "20260724_0023" +_HEAD_REVISION = "20260724_0024" HEAD_TABLES = [ "action_delivery_attempt", "action_perform_audit", @@ -54,6 +63,7 @@ "action_ticket", "active_release_manifest", "alembic_version", + "citation_open_locator", "context_fragment", "context_fragment_field", "context_resource", @@ -716,9 +726,7 @@ def test_file_source_offboarding_revision_downgrades_and_reapplies_cleanly( command.upgrade(alembic_configuration, "head") assert _revision_rows(migration_configuration) == [_HEAD_REVISION] - assert "file_source_cleanup_intent" in _application_tables( - migration_configuration - ) + assert "file_source_cleanup_intent" in _application_tables(migration_configuration) engine = create_database_engine(migration_configuration) try: with engine.connect() as connection: @@ -756,10 +764,7 @@ def test_file_source_offboarding_revision_downgrades_and_reapplies_cleanly( ) ).all() assert len(privileges) == 2 - assert all( - tuple(row)[1:] == (False, False, False, False) - for row in privileges - ) + assert all(tuple(row)[1:] == (False, False, False, False) for row in privileges) finally: engine.dispose() @@ -779,14 +784,127 @@ def test_delivery_evidence_revision_downgrades_only_while_empty( try: command.downgrade(alembic_configuration, "20260723_0018") assert _revision_rows(migration_configuration) == ["20260723_0018"] - assert "delivery_evidence" not in _application_tables( + assert "delivery_evidence" not in _application_tables(migration_configuration) + finally: + command.upgrade(alembic_configuration, "head") + + assert _revision_rows(migration_configuration) == [_HEAD_REVISION] + assert "delivery_evidence" in _application_tables(migration_configuration) + + +def test_citation_open_revision_downgrades_only_while_empty( + migration_configuration: DatabaseConfiguration, +) -> None: + """Issue #69 carrier can be removed only before locator lineage exists.""" + + alembic_configuration = Config(ROOT / "alembic.ini") + engine = create_database_engine(migration_configuration) + try: + with engine.begin() as connection: + connection.execute(text("DELETE FROM citation_open_locator")) + finally: + engine.dispose() + try: + command.downgrade(alembic_configuration, "20260724_0023") + assert _revision_rows(migration_configuration) == ["20260724_0023"] + assert "citation_open_locator" not in _application_tables( migration_configuration ) finally: command.upgrade(alembic_configuration, "head") assert _revision_rows(migration_configuration) == [_HEAD_REVISION] - assert "delivery_evidence" in _application_tables(migration_configuration) + assert "citation_open_locator" in _application_tables(migration_configuration) + + +def test_citation_open_revision_refuses_downgrade_with_retained_lineage( + tmp_path: Path, + migration_configuration: DatabaseConfiguration, + guarded_control_engine: Engine, + guarded_worker_engine: Engine, + guarded_runtime_engine: Engine, +) -> None: + """Issue #69 rollback retains digest lineage until profile cleanup.""" + + scenario = _prepare_file_import_scenario( + tmp_path, + migration_configuration, + guarded_control_engine, + ) + assert scenario.token is not None + published = _run_file_import( + scenario, + scenario.prepared, + scenario.token, + guarded_worker_engine, + ) + engine = create_database_engine(migration_configuration) + try: + with engine.connect() as connection: + user_id = connection.execute( + text( + "SELECT user_id FROM membership WHERE organization_id = :org " + "AND membership_id = :membership" + ), + {"org": scenario.organization_id, "membership": scenario.membership_id}, + ).scalar_one() + now = datetime.now(UTC) + authority = PostgreSQLMembershipAuthority(guarded_runtime_engine) + with authority.current_user_actor( + MembershipIdentity( + organization_id=scenario.organization_id, + user_id=user_id, + membership_id=scenario.membership_id, + membership_version=1, + principal_ref="principal:file-tracer", + request_id="migration-citation-lineage", + authentication_binding_ref="binding:file-tracer", + checked_at=now, + ) + ) as verification: + assert verification.citation_open_session is not None + issue_citation_open_ref( + verification.citation_open_session, + CitationOpenIssue( + organization_id=scenario.organization_id, + package_ref="pkg_" + "a" * 32, + evidence_ref="ev_" + "b" * 64, + resource_ref=published.candidate_ref.resource_ref, + revision_id=UUID(published.candidate_ref.revision_ref), + fragment_ref=published.candidate_ref.fragment_ref, + issued_at=now, + expires_at=now + timedelta(minutes=5), + ), + profile=CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ), + ) + + with pytest.raises(SQLAlchemyError): + command.downgrade(Config(ROOT / "alembic.ini"), "20260724_0023") + assert _revision_rows(migration_configuration) == [_HEAD_REVISION] + with engine.connect() as connection: + assert connection.execute( + text( + "SELECT count(*) FROM citation_open_locator " + "WHERE organization_id = :org" + ), + {"org": scenario.organization_id}, + ).scalar_one() == 1 + finally: + with engine.begin() as connection: + connection.execute( + text("DELETE FROM citation_open_locator WHERE organization_id = :org"), + {"org": scenario.organization_id}, + ) + engine.dispose() + _delete_issue_27_upgrade_fixture( + migration_configuration, + scenario.organization_id, + ) def test_file_source_offboarding_refuses_downgrade_with_committed_intent( diff --git a/tests/integration/test_postgres_harness.py b/tests/integration/test_postgres_harness.py index c7de4cf2..72db5aa3 100644 --- a/tests/integration/test_postgres_harness.py +++ b/tests/integration/test_postgres_harness.py @@ -21,6 +21,7 @@ ACTION_EXECUTE_DEFINER_ROLE, ACTION_PREPARE_DEFINER_ROLE, ACTION_ROLE, + CITATION_DEFINER_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, @@ -182,6 +183,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( context_run_reader_definer_role=CONTEXT_RUN_READER_DEFINER_ROLE, release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, + citation_definer_role=CITATION_DEFINER_ROLE, egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, action_prepare_definer_role=ACTION_PREPARE_DEFINER_ROLE, action_execute_definer_role=ACTION_EXECUTE_DEFINER_ROLE, @@ -215,6 +217,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( WORKER_LEASE_DEFINER_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, + CITATION_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, ACTION_PREPARE_DEFINER_ROLE, ACTION_EXECUTE_DEFINER_ROLE, @@ -234,7 +237,8 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( SELECT count(*) FROM pg_roles WHERE rolname IN ( - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s ) """, ( @@ -252,6 +256,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( ACTION_ROLE, ACTION_PREPARE_DEFINER_ROLE, ACTION_EXECUTE_DEFINER_ROLE, + CITATION_DEFINER_ROLE, ), ).fetchone() assert missing_roles == (0,) @@ -313,6 +318,26 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( FROM pg_auth_members AS reader_members WHERE reader_members.roleid = reader_definer.oid ), + citation_definer.rolcanlogin, + citation_definer.rolsuper, + citation_definer.rolcreaterole, + citation_definer.rolcreatedb, + citation_definer.rolinherit, + citation_definer.rolreplication, + citation_definer.rolbypassrls, + citation_membership.admin_option, + citation_membership.inherit_option, + citation_membership.set_option, + NOT EXISTS ( + SELECT 1 + FROM pg_auth_members AS granted_to_citation + WHERE granted_to_citation.member = citation_definer.oid + ), + ( + SELECT count(*) + FROM pg_auth_members AS citation_members + WHERE citation_members.roleid = citation_definer.oid + ), action_execute_definer.rolcanlogin, action_execute_definer.rolsuper, action_execute_definer.rolcreaterole, @@ -340,6 +365,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( CROSS JOIN pg_roles AS definer CROSS JOIN pg_roles AS worker_definer CROSS JOIN pg_roles AS reader_definer + CROSS JOIN pg_roles AS citation_definer CROSS JOIN pg_roles AS action_execute_definer JOIN pg_auth_members AS access_membership ON access_membership.roleid = definer.oid @@ -351,6 +377,9 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( JOIN pg_auth_members AS reader_membership ON reader_membership.roleid = reader_definer.oid AND reader_membership.member = migrator.oid + JOIN pg_auth_members AS citation_membership + ON citation_membership.roleid = citation_definer.oid + AND citation_membership.member = migrator.oid JOIN pg_auth_members AS action_execute_membership ON action_execute_membership.roleid = action_execute_definer.oid AND action_execute_membership.member = migrator.oid @@ -359,6 +388,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( AND definer.rolname = %s AND worker_definer.rolname = %s AND reader_definer.rolname = %s + AND citation_definer.rolname = %s AND action_execute_definer.rolname = %s AND migrator.rolname = %s """, @@ -368,6 +398,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( ACCESS_POLICY_DEFINER_ROLE, WORKER_LEASE_DEFINER_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, + CITATION_DEFINER_ROLE, ACTION_EXECUTE_DEFINER_ROLE, MIGRATOR_ROLE, ), @@ -416,6 +447,18 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( True, True, 1, + False, + False, + False, + False, + False, + False, + False, + False, + False, + True, + True, + 1, ) learning_facts = bootstrap_connection.execute( """ @@ -1056,9 +1099,7 @@ def test_learning_and_release_definer_roles_have_exact_authority( False, True, ) - assert memberships == [ - (RELEASE_DEFINER_ROLE, MIGRATOR_ROLE, False, False, True) - ] + assert memberships == [(RELEASE_DEFINER_ROLE, MIGRATOR_ROLE, False, False, True)] assert not [row for row in owned_objects if row[0] == LEARNING_ROLE] assert promote_owner == RELEASE_DEFINER_ROLE assert any( @@ -1066,6 +1107,7 @@ def test_learning_and_release_definer_roles_have_exact_authority( for owner, catalog, _object_id in owned_objects ) + def test_security_operator_role_guard_passes_operator_and_rejects_runtime( guarded_operator_engine: Engine, guarded_runtime_engine: Engine, diff --git a/tests/integration/test_z_egress_grant_file.py b/tests/integration/test_z_egress_grant_file.py index 6e5d3d77..645577ff 100644 --- a/tests/integration/test_z_egress_grant_file.py +++ b/tests/integration/test_z_egress_grant_file.py @@ -5,13 +5,13 @@ import socket import subprocess import time -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import closing from datetime import UTC, datetime, timedelta from hashlib import sha256 from pathlib import Path from threading import Thread -from uuid import UUID +from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient @@ -29,12 +29,15 @@ ) from engine.persistence import ( DatabaseConfiguration, + PostgreSQLAccessPolicyControl, PostgreSQLDeliveryEvidenceIssuerPort, PostgreSQLEgressGrantRedemptionAuthority, PostgreSQLMembershipAuthority, PublishedFileImport, + ResourceAccessRevocation, create_database_engine, ) +from engine.runtime.citation import CitationOpenProfile from engine.runtime.construction import Runtime, required_kernel_dependencies from engine.runtime.contracts import Resolved from engine.runtime.delivery_evidence import ( @@ -58,10 +61,13 @@ _run_file_import, _RuntimeAuthenticator, ) +from tests.integration.test_zz_file_resource_tombstone import _tombstone +from tests.integration.test_zz_file_source_offboarding import _offboard from tests.support.releases import ( clear_test_runtime_release, ensure_test_runtime_release, ) +from tests.support.security_gate import record_security_oracles pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] @@ -101,13 +107,14 @@ def _assert_sdk_transport_headers( *, authentication: bytes, delivery_evidence_ref: bytes | None, + request_id: bytes = b"file-egress-sdk-http", ) -> None: observed: dict[bytes, list[bytes]] = {} for name, value in headers: observed.setdefault(name.lower(), []).append(value) expected_context_headers = { - b"x-context-request-id": [b"file-egress-sdk-http"], + b"x-context-request-id": [request_id], } if delivery_evidence_ref is not None: expected_context_headers[b"x-context-delivery-evidence-ref"] = [ @@ -120,9 +127,7 @@ def _assert_sdk_transport_headers( if name.startswith(b"x-context-") } == expected_context_headers assert set(observed) <= ( - SDK_STANDARD_HTTP_HEADERS - | {b"authorization"} - | set(expected_context_headers) + SDK_STANDARD_HTTP_HEADERS | {b"authorization"} | set(expected_context_headers) ) @@ -149,6 +154,30 @@ def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: ).authenticate(opaque_credential) +class _TwoReaderAuthenticator: + def __init__( + self, + identities: dict[str, tuple[UUID, UUID, UUID]], + *, + principal_refs: dict[str, str] | None = None, + ) -> None: + self.identities = identities + self.principal_refs = principal_refs or {} + + def authenticate(self, opaque_credential: str) -> VerifiedAuthenticationContext: + organization_id, user_id, membership_id = self.identities[opaque_credential] + context = _RuntimeAuthenticator( + organization_id, + user_id, + membership_id, + token=opaque_credential, + ).authenticate(opaque_credential) + principal_ref = self.principal_refs.get(opaque_credential) + if principal_ref is not None: + object.__setattr__(context, "principal_ref", principal_ref) + return context + + def _unused_port() -> int: with closing(socket.socket()) as listener: listener.bind(("127.0.0.1", 0)) @@ -244,6 +273,7 @@ def _run_installed_live_consumer( *, base_url: str, delivery_evidence_ref: str, + citation_delivery_evidence_ref: str, ) -> dict[str, object]: result = _run_sdk_process( ["node", "live-consumer.mjs"], @@ -252,11 +282,12 @@ def _run_installed_live_consumer( **os.environ, "CONTEXT_ENGINE_SDK_BASE_URL": base_url, "CONTEXT_ENGINE_SDK_DELIVERY_EVIDENCE_REF": delivery_evidence_ref, + "CONTEXT_ENGINE_SDK_CITATION_DELIVERY_EVIDENCE_REF": ( + citation_delivery_evidence_ref + ), "CONTEXT_ENGINE_SDK_REQUEST_ID": "file-egress-sdk-http", "CONTEXT_ENGINE_SDK_TEST_AUTHENTICATION": "runtime-secret", - "CONTEXT_ENGINE_SDK_TEST_DIRECT_AUTHENTICATION": ( - "runtime-direct-secret" - ), + "CONTEXT_ENGINE_SDK_TEST_DIRECT_AUTHENTICATION": ("runtime-direct-secret"), }, ) document = json.loads(result.stdout) @@ -278,6 +309,15 @@ def _file_model_profile() -> ModelEgressProfile: ) +def _file_citation_profile() -> CitationOpenProfile: + return CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ) + + @pytest.fixture def _published_file_scenario( tmp_path: Path, @@ -306,18 +346,47 @@ def _published_file_scenario( yield scenario, published, migration_engine finally: clear_test_runtime_release(scenario.organization_id) + cleanup_triggers = ( + ( + "file_source_publish_watermark", + "file_source_publish_watermark_immutable", + ), + ( + "file_source_acquisition_checkpoint", + "file_source_acquisition_checkpoint_immutable", + ), + ("file_resource_cleanup_intent", "file_resource_cleanup_intent_immutable"), + ("file_source_cleanup_intent", "file_source_cleanup_intent_immutable"), + ) with migration_engine.begin() as connection: - for table in ( - "decision_audit", - "context_run", - "egress_audit", - "egress_grant", - "delivery_evidence", - ): + for table, trigger in cleanup_triggers: connection.execute( - text(f"DELETE FROM {table} WHERE organization_id = :org"), - {"org": scenario.organization_id}, + text(f"ALTER TABLE {table} DISABLE TRIGGER {trigger}") ) + try: + with migration_engine.begin() as connection: + for table in ( + "citation_open_locator", + "decision_audit", + "context_run", + "egress_audit", + "egress_grant", + "delivery_evidence", + "file_source_publish_watermark", + "file_source_acquisition_checkpoint", + "file_resource_cleanup_intent", + "file_source_cleanup_intent", + ): + connection.execute( + text(f"DELETE FROM {table} WHERE organization_id = :org"), + {"org": scenario.organization_id}, + ) + finally: + with migration_engine.begin() as connection: + for table, trigger in reversed(cleanup_triggers): + connection.execute( + text(f"ALTER TABLE {table} ENABLE TRIGGER {trigger}") + ) migration_engine.dispose() @@ -422,6 +491,300 @@ def test_file_http_package_redeems_exact_model_grant_before_gateway_bytes( egress_engine.dispose() +@pytest.mark.security_evidence(id="RUNTIME-CITATION-AUTH-010", layer="runtime") +@pytest.mark.security_evidence(id="FIXTURE-ACCEPT-010", layer="runtime") +@pytest.mark.parametrize("denied_principal", (False, True)) +def test_file_http_citation_is_not_consumed_by_denied_reader( + denied_principal: bool, + _published_file_scenario: tuple[_FileImportScenario, PublishedFileImport, Engine], + guarded_runtime_engine: Engine, + query_digest_keyring: QueryDigestKeyring, + caplog: pytest.LogCaptureFixture, + record_property: Callable[[str, object], None], +) -> None: + scenario, published, migration_engine = _published_file_scenario + denied_user_id = uuid4() + denied_membership_id = uuid4() + request_now = datetime.now(UTC) + with migration_engine.begin() as connection: + authorized_user_id = connection.execute( + text( + "SELECT user_id FROM membership " + "WHERE organization_id = :organization_id " + "AND membership_id = :membership_id" + ), + { + "organization_id": scenario.organization_id, + "membership_id": scenario.membership_id, + }, + ).scalar_one() + connection.execute( + text("INSERT INTO user_account (user_id) VALUES (:user_id)"), + {"user_id": denied_user_id}, + ) + connection.execute( + text( + "INSERT INTO membership (organization_id, membership_id, user_id, " + "status, membership_version, valid_from) VALUES " + "(:org, :membership, :user_id, 'active', 1, :valid_from)" + ), + { + "org": scenario.organization_id, + "membership": denied_membership_id, + "user_id": denied_user_id, + "valid_from": request_now - timedelta(days=1), + }, + ) + + observed: list[Resolved] = [] + client = TestClient( + create_app( + authenticator=_TwoReaderAuthenticator( + { + "reader-a": ( + scenario.organization_id, + authorized_user_id, + scenario.membership_id, + ), + "reader-b": ( + scenario.organization_id, + denied_user_id, + denied_membership_id, + ), + }, + principal_refs=( + {"reader-b": "principal:file-denied-reader"} + if denied_principal + else None + ), + ), + organization_authority=_OrganizationAuthority(), + membership_authority=PostgreSQLMembershipAuthority(guarded_runtime_engine), + scope_authority=_ExactScopeAuthority( + published.candidate_ref.source_ref, + published.candidate_ref.resource_ref, + ), + runtime=Runtime( + required_kernel_dependencies(), + candidate_index=PostgreSQLExactPhraseCandidateIndex(), + egress_profile=_file_model_profile(), + citation_profile=_file_citation_profile(), + clock=lambda: request_now, + query_digest_keyring=query_digest_keyring, + ), + resolution_observer=observed.append, + clock=lambda: request_now, + ) + ) + + acquired = client.post( + "/v0/resolve", + headers={ + "Authorization": "Bearer reader-a", + "X-Context-Request-Id": "citation-reader-a-acquire", + }, + json={ + "kind": "acquire", + "need": {"query": "ContextEngine delivers context."}, + }, + ) + assert acquired.status_code == 200 + citation_ref = acquired.json()["package"]["evidence"][0]["citationOpenRef"] + assert isinstance(citation_ref, str) and citation_ref.startswith("cor_") + + with migration_engine.connect() as connection: + before = connection.execute( + text( + "SELECT expires_at, retain_until FROM citation_open_locator " + "WHERE organization_id = :org AND locator_digest = :digest" + ), + { + "org": scenario.organization_id, + "digest": sha256(citation_ref.encode()).digest(), + }, + ).one() + + denied = client.post( + "/v0/resolve", + headers={ + "Authorization": "Bearer reader-b", + "X-Context-Request-Id": "citation-reader-b-denied", + }, + json={"kind": "open_citation", "citationOpenRef": citation_ref}, + ) + assert denied.status_code == 200 + assert denied.json() == {"kind": "citation_not_available"} + + with migration_engine.connect() as connection: + after = connection.execute( + text( + "SELECT expires_at, retain_until FROM citation_open_locator " + "WHERE organization_id = :org AND locator_digest = :digest" + ), + { + "org": scenario.organization_id, + "digest": sha256(citation_ref.encode()).digest(), + }, + ).one() + assert after == before + + reopened = client.post( + "/v0/resolve", + headers={ + "Authorization": "Bearer reader-a", + "X-Context-Request-Id": "citation-reader-a-reopen", + }, + json={"kind": "open_citation", "citationOpenRef": citation_ref}, + ) + assert reopened.status_code == 200 + assert reopened.json()["kind"] == "resolved" + assert reopened.json()["package"]["blocks"][0]["text"] == ( + "ContextEngine delivers context." + ) + assert citation_ref not in json.dumps(reopened.json()) + assert citation_ref not in repr(observed) + assert citation_ref not in "".join( + record.getMessage() for record in caplog.records + ) + assert "sourceUrl" not in json.dumps(reopened.json()) + assert len(observed) == 2 + with migration_engine.connect() as connection: + denied_run = connection.execute( + text( + "SELECT run_ref, decision_ref, outcome, purpose, " + "authorized_evidence_refs, query_digest FROM context_run " + "WHERE organization_id = :org AND request_id = :request_id" + ), + { + "org": scenario.organization_id, + "request_id": "citation-reader-b-denied", + }, + ).one() + denied_audit = connection.execute( + text( + "SELECT category FROM decision_audit " + "WHERE organization_id = :org AND run_ref = :run_ref " + "AND decision_ref = :decision_ref" + ), + { + "org": scenario.organization_id, + "run_ref": denied_run.run_ref, + "decision_ref": denied_run.decision_ref, + }, + ).scalar_one() + assert denied_run.outcome == "delivered_empty" + assert denied_run.purpose == "citation.open" + assert denied_run.authorized_evidence_refs == [] + assert denied_run.query_digest != sha256(citation_ref.encode()).hexdigest() + assert denied_audit == "no_authorized_evidence" + record_security_oracles( + record_property, + fixture_ref="ACCEPT-010", + unauthorized_evidence_count=len( + denied.json().get("package", {}).get("evidence", []) + ), + wrong_organization_effect_count=0, + missing_context_fallback_count=0, + ) + + +@pytest.mark.parametrize("target_state", ["revoked", "source_offboarded", "tombstoned"]) +def test_file_http_citation_reauthorizes_unavailable_target( + target_state: str, + _published_file_scenario: tuple[_FileImportScenario, PublishedFileImport, Engine], + guarded_runtime_engine: Engine, + guarded_control_engine: Engine, + query_digest_keyring: QueryDigestKeyring, +) -> None: + scenario, published, migration_engine = _published_file_scenario + request_now = datetime.now(UTC) + with migration_engine.connect() as connection: + user_id = connection.execute( + text( + "SELECT user_id FROM membership " + "WHERE organization_id = :org AND membership_id = :membership" + ), + {"org": scenario.organization_id, "membership": scenario.membership_id}, + ).scalar_one() + client = TestClient( + create_app( + authenticator=_RuntimeAuthenticator( + scenario.organization_id, user_id, scenario.membership_id + ), + organization_authority=_OrganizationAuthority(), + membership_authority=PostgreSQLMembershipAuthority( + guarded_runtime_engine + ), + scope_authority=_ExactScopeAuthority( + published.candidate_ref.source_ref, + published.candidate_ref.resource_ref, + ), + runtime=Runtime( + required_kernel_dependencies(), + candidate_index=PostgreSQLExactPhraseCandidateIndex(), + egress_profile=_file_model_profile(), + citation_profile=_file_citation_profile(), + clock=lambda: request_now, + query_digest_keyring=query_digest_keyring, + ), + clock=lambda: request_now, + ) + ) + acquired = client.post( + "/v0/resolve", + headers={ + "Authorization": "Bearer runtime-secret", + "X-Context-Request-Id": f"citation-{target_state}-acquire", + }, + json={"kind": "acquire", "need": {"query": "ContextEngine delivers context."}}, + ) + citation_ref = acquired.json()["package"]["evidence"][0]["citationOpenRef"] + + if target_state == "revoked": + PostgreSQLAccessPolicyControl(guarded_control_engine).change_access( + ResourceAccessRevocation( + organization_id=scenario.organization_id, + resource_ref=published.candidate_ref.resource_ref, + principal_ref="principal:file-reader", + expected_access_version=1, + ) + ) + elif target_state == "tombstoned": + _tombstone( + scenario, + guarded_control_engine, + resource_ref=published.candidate_ref.resource_ref, + event_ref=f"citation-{target_state}", + event_sequence=2, + ) + else: + _offboard(scenario, guarded_control_engine) + + opened = client.post( + "/v0/resolve", + headers={ + "Authorization": "Bearer runtime-secret", + "X-Context-Request-Id": f"citation-{target_state}-open", + }, + json={"kind": "open_citation", "citationOpenRef": citation_ref}, + ) + + assert opened.status_code == 200 + assert opened.content == b'{"kind":"citation_not_available"}' + with migration_engine.connect() as connection: + retained = connection.execute( + text( + "SELECT count(*) FROM citation_open_locator " + "WHERE organization_id = :org AND locator_digest = :digest" + ), + { + "org": scenario.organization_id, + "digest": sha256(citation_ref.encode()).digest(), + }, + ).scalar_one() + assert retained == 1 + + @pytest.mark.security_evidence(id="SDK-LIVE-FILE-064", layer="runtime") def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( _published_file_scenario: tuple[_FileImportScenario, PublishedFileImport, Engine], @@ -453,7 +816,7 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( _pack_and_install_sdk(consumer_root) request_now = datetime.now(UTC).replace(microsecond=0) - evidence_ref = PrivateDeliveryEvidenceIssuer( + evidence_issuer = PrivateDeliveryEvidenceIssuer( PostgreSQLDeliveryEvidenceIssuerPort(identity_engine), profile=DeliveryEvidenceProfile( profile_ref="private-delivery-evidence-v1", @@ -463,7 +826,8 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( + sha256(scenario.organization_id.bytes + b"sdk-http").hexdigest(), resolution_ref_factory=lambda: "dlr_" + sha256(scenario.organization_id.bytes + b"sdk-result").hexdigest()[:32], - ).issue_private( + ) + evidence_ref = evidence_issuer.issue_private( PrivateDeliveryEvidenceIssue( organization_id=scenario.organization_id, user_id=user_id, @@ -480,6 +844,35 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( expires_at=request_now + timedelta(minutes=10), ) ) + citation_evidence_ref = PrivateDeliveryEvidenceIssuer( + PostgreSQLDeliveryEvidenceIssuerPort(identity_engine), + profile=DeliveryEvidenceProfile( + profile_ref="private-delivery-evidence-v1", + maximum_ttl=timedelta(minutes=15), + ), + reference_factory=lambda: "der_" + + sha256(scenario.organization_id.bytes + b"sdk-citation-http").hexdigest(), + resolution_ref_factory=lambda: "dlr_" + + sha256( + scenario.organization_id.bytes + b"sdk-citation-result" + ).hexdigest()[:32], + ).issue_private( + PrivateDeliveryEvidenceIssue( + organization_id=scenario.organization_id, + user_id=user_id, + membership_id=scenario.membership_id, + membership_version=1, + authenticated_service_ref="application:file-tracer", + authentication_binding_ref="binding:file-tracer", + request_id="file-egress-sdk-http-citation", + destination_ref="private-chat:file-tracer", + consumer_ref="consumer:file-tracer", + purpose="citation.open", + policy_epoch=1, + issued_at=request_now - timedelta(seconds=1), + expires_at=request_now + timedelta(minutes=10), + ) + ) observed: list[Resolved] = [] transport_observer = _SdkTransportObserver( @@ -501,6 +894,7 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( required_kernel_dependencies(), candidate_index=PostgreSQLExactPhraseCandidateIndex(), egress_profile=_file_model_profile(), + citation_profile=_file_citation_profile(), clock=lambda: request_now, query_digest_keyring=query_digest_keyring, ), @@ -526,6 +920,7 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( consumer_root, base_url=f"http://127.0.0.1:{port}", delivery_evidence_ref=evidence_ref.evidence_ref, + citation_delivery_evidence_ref=(citation_evidence_ref.evidence_ref), ) acquire = result["acquire"] @@ -552,20 +947,44 @@ def test_packed_typescript_sdk_resolves_authorized_file_package_over_live_http( "kind": "request_not_available", "retryable": False, } - assert result["citation"] == {"kind": "citation_not_available"} + citation = result["citation"] + assert isinstance(citation, dict) + assert citation["kind"] == "resolved" + citation_package = citation["package"] + assert isinstance(citation_package, dict) + assert citation_package["purpose"] == "citation.open" + assert citation_package["packageId"] != package["packageId"] + assert citation_package["blocks"][0]["text"] == ( + "ContextEngine delivers context." + ) + assert ( + citation_package["evidence"][0]["citationOpenRef"] + != (evidence[0]["citationOpenRef"]) + ) + citation_grant = citation["egressGrant"] + assert isinstance(citation_grant, dict) + assert citation_grant["kind"] == "model" + assert isinstance(citation_grant["value"], str) + assert citation_grant["value"] + assert citation_grant["value"] != grant["value"] assert len(transport_observer.requests) == 3 _assert_sdk_transport_headers( transport_observer.requests[0], authentication=b"Bearer runtime-secret", delivery_evidence_ref=evidence_ref.evidence_ref.encode("ascii"), ) - for direct_request_headers in transport_observer.requests[1:]: - _assert_sdk_transport_headers( - direct_request_headers, - authentication=b"Bearer runtime-direct-secret", - delivery_evidence_ref=None, - ) - assert len(observed) == 1 + _assert_sdk_transport_headers( + transport_observer.requests[1], + authentication=b"Bearer runtime-direct-secret", + delivery_evidence_ref=None, + ) + _assert_sdk_transport_headers( + transport_observer.requests[2], + authentication=b"Bearer runtime-secret", + delivery_evidence_ref=(citation_evidence_ref.evidence_ref.encode("ascii")), + request_id=b"file-egress-sdk-http-citation", + ) + assert len(observed) == 2 assert observed[0].package.decision_ref == package["decisionRef"] finally: if server is not None: diff --git a/tests/integration/test_zz_file_content_noop.py b/tests/integration/test_zz_file_content_noop.py index c0a2977b..21fd5b06 100644 --- a/tests/integration/test_zz_file_content_noop.py +++ b/tests/integration/test_zz_file_content_noop.py @@ -278,7 +278,7 @@ def test_repeated_canonically_identical_file_import_is_an_auditable_noop( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260724_0023" + == "20260724_0024" ) diff --git a/tests/unit/test_citation_open.py b/tests/unit/test_citation_open.py new file mode 100644 index 00000000..e6514925 --- /dev/null +++ b/tests/unit/test_citation_open.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from contextlib import nullcontext +from dataclasses import fields +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from typing import cast +from uuid import UUID + +import pytest +from sqlalchemy import Engine + +import engine.persistence.citation as persistence_citation +from engine.persistence.citation import PostgreSQLCitationOpenRetentionPort +from engine.runtime.citation import ( + CITATION_OPEN_DIGEST_PROFILE, + CITATION_OPEN_RETENTION_CLASS, + CitationAuthorityUnavailable, + CitationLocatorNotAvailable, + CitationOpenIssue, + CitationOpenProfile, + CitationOpenRedemption, + CitationOpenRetention, + CitationOpenTarget, + CitationOpenTargetLineage, + _close_citation_authority_scope, + _construct_citation_open_session, + _open_citation_authority_scope, + issue_citation_open_ref, + redeem_citation_open_ref, +) +from engine.runtime.contracts import CitationOpenRef +from engine.runtime.evidence import CandidateRef + +NOW = datetime(2026, 7, 24, 10, 0, tzinfo=UTC) +ORGANIZATION_ID = UUID("7e74ff30-a3d5-4655-b70d-c792beb874be") +REVISION_ID = UUID("841caaf5-8898-4894-843f-ad8982dc710a") +REFERENCE = "cor_" + "a" * 64 + + +def _profile() -> CitationOpenProfile: + return CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), + ) + + +def _issue() -> CitationOpenIssue: + return CitationOpenIssue( + organization_id=ORGANIZATION_ID, + package_ref="pkg_" + "1" * 32, + evidence_ref="ev_" + "2" * 64, + resource_ref="resource:file:handbook", + revision_id=REVISION_ID, + fragment_ref="fragment:paragraph:1", + issued_at=NOW, + expires_at=NOW + timedelta(minutes=5), + ) + + +class RecordingCitationPort: + def __init__(self) -> None: + self.issue_calls: list[dict[str, object]] = [] + self.redemption_calls: list[CitationOpenRedemption] = [] + self.target: CitationOpenTarget | None = CitationOpenTarget( + candidate_ref=CandidateRef( + organization_id=ORGANIZATION_ID, + source_ref="7cc3242f-53fa-46dc-a861-82b8bd85103c", + resource_ref="resource:file:handbook", + revision_ref=str(REVISION_ID), + fragment_ref="fragment:paragraph:1", + ), + lineage=CitationOpenTargetLineage( + package_ref="pkg_" + "1" * 32, + evidence_ref="ev_" + "2" * 64, + ), + ) + + def issue( + self, + *, + request: CitationOpenIssue, + locator_digest: bytes, + digest_profile: str, + profile: CitationOpenProfile, + retain_until: datetime, + ) -> bool: + self.issue_calls.append( + { + "request": request, + "locator_digest": locator_digest, + "digest_profile": digest_profile, + "profile": profile, + "retain_until": retain_until, + } + ) + return True + + def redeem( + self, + request: CitationOpenRedemption, + ) -> CitationOpenTarget | None: + self.redemption_calls.append(request) + return self.target + + +class RecordingCitationRetentionPort: + def __init__(self) -> None: + self.organization_ids: list[UUID] = [] + + def delete_expired_lineage(self, organization_id: UUID) -> int: + self.organization_ids.append(organization_id) + return 3 + + +class RoleGuardFailureEngine: + def begin(self) -> object: + return nullcontext(object()) + + +def test_issue_returns_opaque_locator_and_persists_digest_only() -> None: + port = RecordingCitationPort() + scope = _open_citation_authority_scope() + try: + issued = issue_citation_open_ref( + _construct_citation_open_session(authority_scope=scope, port=port), + _issue(), + profile=_profile(), + reference_factory=lambda: REFERENCE, + ) + finally: + _close_citation_authority_scope(scope) + + assert issued == CitationOpenRef(REFERENCE) + assert port.issue_calls == [ + { + "request": _issue(), + "locator_digest": sha256(REFERENCE.encode()).digest(), + "digest_profile": CITATION_OPEN_DIGEST_PROFILE, + "profile": _profile(), + "retain_until": NOW + timedelta(days=30), + } + ] + assert REFERENCE not in repr(port.issue_calls[0]) + + +def test_locator_is_multi_use_and_redemption_carries_no_prior_authorization() -> None: + port = RecordingCitationPort() + request = CitationOpenRedemption( + citation_open_ref=CitationOpenRef(REFERENCE), + organization_id=ORGANIZATION_ID, + opened_at=NOW + timedelta(seconds=1), + ) + + scope = _open_citation_authority_scope() + try: + session = _construct_citation_open_session(authority_scope=scope, port=port) + first = redeem_citation_open_ref(session, request) + second = redeem_citation_open_ref(session, request) + finally: + _close_citation_authority_scope(scope) + + assert first == second == port.target + assert len(port.redemption_calls) == 2 + assert first is not None + lineage_document = { + item.name: getattr(first.lineage, item.name) for item in fields(first.lineage) + } + assert lineage_document == { + "package_ref": "pkg_" + "1" * 32, + "evidence_ref": "ev_" + "2" * 64, + } + protected_old_decision_facts = ( + "principal_ref", + "membership_id", + "audience_digest", + "purpose", + "decision_ref", + "policy_epoch", + ) + for field_name in protected_old_decision_facts: + assert not hasattr(first.lineage, field_name) + + +def test_retention_deletes_only_lineage_past_its_profile_window() -> None: + port = RecordingCitationRetentionPort() + + deleted = CitationOpenRetention(port).delete_expired(ORGANIZATION_ID) + + assert deleted == 3 + assert port.organization_ids == [ORGANIZATION_ID] + + +def test_postgres_operator_role_guard_failure_is_an_opaque_authority_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_operator_role(connection: object) -> None: + del connection + raise AssertionError("private operator facts") + + monkeypatch.setattr( + persistence_citation, + "assert_security_operator_role", + reject_operator_role, + ) + retention = PostgreSQLCitationOpenRetentionPort( + cast(Engine, RoleGuardFailureEngine()) + ) + + with pytest.raises(CitationAuthorityUnavailable) as error: + retention.delete_expired_lineage(ORGANIZATION_ID) + + assert "private operator facts" not in str(error.value) + + +@pytest.mark.parametrize( + "reference", + [ + "egrm_" + "a" * 64, + "der_" + "a" * 64, + "cor_" + "a" * 63, + "cor_" + "A" * 64, + ], +) +@pytest.mark.security_evidence(id="PROP-CITATION-AUTH-010", layer="property") +def test_cross_kind_and_forged_locator_are_generic_not_available( + reference: str, +) -> None: + port = RecordingCitationPort() + port.target = None + scope = _open_citation_authority_scope() + try: + session = _construct_citation_open_session(authority_scope=scope, port=port) + with pytest.raises(CitationLocatorNotAvailable, match="not available"): + redeem_citation_open_ref( + session, + CitationOpenRedemption( + citation_open_ref=CitationOpenRef(reference), + organization_id=ORGANIZATION_ID, + opened_at=NOW, + ), + ) + finally: + _close_citation_authority_scope(scope) + + +def test_issuer_rejects_invalid_profile_lifetime_without_calling_port() -> None: + port = RecordingCitationPort() + issue = CitationOpenIssue( + organization_id=ORGANIZATION_ID, + package_ref="pkg_" + "1" * 32, + evidence_ref="ev_" + "2" * 64, + resource_ref="resource:file:handbook", + revision_id=REVISION_ID, + fragment_ref="fragment:paragraph:1", + issued_at=NOW, + expires_at=NOW + timedelta(minutes=11), + ) + + scope = _open_citation_authority_scope() + try: + session = _construct_citation_open_session(authority_scope=scope, port=port) + with pytest.raises(CitationLocatorNotAvailable): + issue_citation_open_ref( + session, + issue, + profile=_profile(), + reference_factory=lambda: REFERENCE, + ) + finally: + _close_citation_authority_scope(scope) + + assert port.issue_calls == [] + assert _profile().retention_class == CITATION_OPEN_RETENTION_CLASS + + +def test_port_failures_are_opaque_authority_failures() -> None: + class FailingPort(RecordingCitationPort): + def issue(self, **values: object) -> bool: + del values + raise RuntimeError("private database detail") + + def redeem( + self, + request: CitationOpenRedemption, + ) -> CitationOpenTarget | None: + del request + raise RuntimeError("private database detail") + + port = FailingPort() + scope = _open_citation_authority_scope() + try: + session = _construct_citation_open_session(authority_scope=scope, port=port) + with pytest.raises(CitationAuthorityUnavailable) as issue_error: + issue_citation_open_ref( + session, + _issue(), + profile=_profile(), + reference_factory=lambda: REFERENCE, + ) + with pytest.raises(CitationAuthorityUnavailable) as redemption_error: + redeem_citation_open_ref( + session, + CitationOpenRedemption( + citation_open_ref=CitationOpenRef(REFERENCE), + organization_id=ORGANIZATION_ID, + opened_at=NOW, + ), + ) + finally: + _close_citation_authority_scope(scope) + assert "private database detail" not in str(issue_error.value) + assert "private database detail" not in str(redemption_error.value) diff --git a/tests/unit/test_database_harness_contract.py b/tests/unit/test_database_harness_contract.py index 7dfb0c95..89ab82ed 100644 --- a/tests/unit/test_database_harness_contract.py +++ b/tests/unit/test_database_harness_contract.py @@ -159,6 +159,7 @@ def test_harness_provisions_post_init_roles_before_readiness() -> None: assert "contract.identity_role" in provisioner assert "contract.identity_password" in provisioner assert "DELIVERY_EVIDENCE_DEFINER_ROLE" in provisioner + assert "CITATION_DEFINER_ROLE" in provisioner assert "WORKER_LEASE_DEFINER_ROLE" in provisioner assert "CONTEXT_RUN_READER_DEFINER_ROLE" in provisioner assert "contract.context_run_reader_definer_role" in provisioner diff --git a/tests/unit/test_http_trust_boundary.py b/tests/unit/test_http_trust_boundary.py index ac7d4759..81390636 100644 --- a/tests/unit/test_http_trust_boundary.py +++ b/tests/unit/test_http_trust_boundary.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Iterator from contextlib import AbstractContextManager, contextmanager -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, replace from datetime import UTC, datetime, timedelta from typing import Any, cast from uuid import UUID @@ -366,6 +366,25 @@ def redeem_private( ) +class MismatchedCitationDeliveryEvidencePort(ExactPrivateDeliveryEvidencePort): + def __init__(self, mismatch: str) -> None: + super().__init__(evidence_digest=PRIVATE_EVIDENCE_DIGEST) + self.mismatch = mismatch + + def redeem_private( + self, + request: PrivateDeliveryEvidenceRedemption, + ) -> RedeemedPrivateDeliveryEvidence | None: + redeemed = super().redeem_private(request) + assert redeemed is not None + redeemed = replace(redeemed, purpose="citation.open") + if self.mismatch == "purpose": + return replace(redeemed, purpose="context.answer") + if self.mismatch == "destination": + return replace(redeemed, destination_ref="chat:private:wrong") + return replace(redeemed, audience_digest="f" * 64) + + class DeterministicOrganizationAuthority: """Test twin that recognizes the one registered conformance Organization.""" @@ -811,6 +830,155 @@ def test_delivery_evidence_is_rejected_for_inactive_non_acquire_carriers() -> No assert content_io.total_calls == 0 +@pytest.mark.parametrize("mismatch", ("purpose", "destination", "audience")) +def test_open_citation_private_binding_mismatch_is_generic_before_content_io( + mismatch: str, + caplog: pytest.LogCaptureFixture, +) -> None: + port = MismatchedCitationDeliveryEvidencePort(mismatch) + content_io = DownstreamContentIoSpy() + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator( + private_destination_ref="chat:private:42" + ), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(port), + runtime=Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=content_io, + provider=content_io, + source_content=content_io, + ), + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + clock=lambda: RECEIVED_AT, + ), + clock=lambda: RECEIVED_AT, + ) + ) + + response = client.post( + "/v0/resolve", + headers={ + "Authorization": f"Bearer {VALID_TOKEN}", + "X-Context-Request-Id": "private-request", + "X-Context-Delivery-Evidence-Ref": PRIVATE_EVIDENCE_REF, + }, + json={ + "kind": "open_citation", + "citationOpenRef": "cor_" + "a" * 64, + }, + ) + + assert response.status_code == 200 + assert response.content == b'{"kind":"citation_not_available"}' + assert len(port.requests) == 1 + assert port.requests[0].purpose == "citation.open" + assert content_io.total_calls == 0 + assert PRIVATE_EVIDENCE_REF not in response.text + assert PRIVATE_EVIDENCE_REF not in "".join( + record.getMessage() for record in caplog.records + ) + + +@pytest.mark.parametrize("rejection", ("organization", "membership")) +def test_authenticated_open_citation_wrong_tenant_actor_is_generic( + rejection: str, +) -> None: + content_io = DownstreamContentIoSpy() + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator(), + organization_authority=( + RejectingTestOrganizationAuthority() + if rejection == "organization" + else DeterministicOrganizationAuthority() + ), + membership_authority=( + RejectingTestMembershipAuthority() + if rejection == "membership" + else DeterministicMembershipAuthority() + ), + runtime=Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=content_io, + provider=content_io, + source_content=content_io, + ), + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + clock=lambda: RECEIVED_AT, + ), + clock=lambda: RECEIVED_AT, + ) + ) + + response = client.post( + "/v0/resolve", + headers={ + "Authorization": f"Bearer {VALID_TOKEN}", + "X-Context-Request-Id": f"citation-wrong-{rejection}", + }, + json={ + "kind": "open_citation", + "citationOpenRef": "cor_" + "b" * 64, + }, + ) + + assert response.status_code == 200 + assert response.content == b'{"kind":"citation_not_available"}' + assert content_io.total_calls == 0 + + +@pytest.mark.parametrize("private_evidence_present", (False, True)) +def test_open_citation_missing_private_transport_half_is_generic( + private_evidence_present: bool, +) -> None: + content_io = DownstreamContentIoSpy() + client = TestClient( + create_app( + authenticator=DeterministicAuthenticator( + private_destination_ref=( + None if private_evidence_present else "chat:private:42" + ) + ), + organization_authority=DeterministicOrganizationAuthority(), + membership_authority=DeterministicMembershipAuthority(), + runtime=Runtime( + required_kernel_dependencies(), + content_io=RuntimeContentIo( + index=content_io, + provider=content_io, + source_content=content_io, + ), + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + clock=lambda: RECEIVED_AT, + ), + clock=lambda: RECEIVED_AT, + ) + ) + headers = { + "Authorization": f"Bearer {VALID_TOKEN}", + "X-Context-Request-Id": "citation-private-half", + } + if private_evidence_present: + headers["X-Context-Delivery-Evidence-Ref"] = PRIVATE_EVIDENCE_REF + + response = client.post( + "/v0/resolve", + headers=headers, + json={ + "kind": "open_citation", + "citationOpenRef": "cor_" + "c" * 64, + }, + ) + + assert response.status_code == 200 + assert response.content == b'{"kind":"citation_not_available"}' + assert content_io.total_calls == 0 + + def test_valid_auth_constructs_exact_trusted_invocation_once() -> None: authenticator = DeterministicAuthenticator() spy = InvocationSpy() diff --git a/tests/unit/test_http_unavailable_capabilities.py b/tests/unit/test_http_unavailable_capabilities.py index 037a146e..293d69b3 100644 --- a/tests/unit/test_http_unavailable_capabilities.py +++ b/tests/unit/test_http_unavailable_capabilities.py @@ -206,8 +206,6 @@ def test_accept_005_continue_is_generic_non_retryable_and_zero_io( ) -@pytest.mark.security_evidence(id="RUNTIME-CITATION-AUTH-010", layer="runtime") -@pytest.mark.security_evidence(id="FIXTURE-ACCEPT-010", layer="runtime") @pytest.mark.parametrize( "locator", ("citation-revoked", "citation-missing", "continuation-shaped-value"), diff --git a/tests/unit/test_m0_rls_inventory.py b/tests/unit/test_m0_rls_inventory.py index ab366f9e..27300dcf 100644 --- a/tests/unit/test_m0_rls_inventory.py +++ b/tests/unit/test_m0_rls_inventory.py @@ -22,6 +22,7 @@ "action_receipt", "action_reconciliation", "action_ticket", + "citation_open_locator", "context_fragment", "context_fragment_field", "context_resource", @@ -144,7 +145,7 @@ def test_manifest_declares_exact_live_table_denominator_and_rls_evidence() -> No assert global_tables == GLOBAL_TABLES assert tenant_tables == TENANT_TABLES - assert len(tables) == 50 + assert len(tables) == 51 for name in sorted(GLOBAL_TABLES): rationale = tables[name]["classificationRationale"] @@ -167,8 +168,8 @@ def test_rls_auditor_requires_every_live_control_and_non_owner_evidence() -> Non assert report["passed"] is True assert report["coverage"] == { - "numerator": 47, - "denominator": 47, + "numerator": 48, + "denominator": 48, "percent": 100.0, } inventory = cast(dict[str, object], report["inventory"]) @@ -193,7 +194,7 @@ def test_rls_auditor_does_not_count_force_rls_or_evidence_gaps() -> None: assert report["passed"] is False assert report["coverage"] == { "numerator": 0, - "denominator": 47, + "denominator": 48, "percent": 0.0, } tenant_reports = cast(list[dict[str, Any]], report["tenantTables"]) diff --git a/tests/unit/test_runtime_authorized_evidence.py b/tests/unit/test_runtime_authorized_evidence.py index 4e471a26..622f7cdb 100644 --- a/tests/unit/test_runtime_authorized_evidence.py +++ b/tests/unit/test_runtime_authorized_evidence.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import fields, replace -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from itertools import permutations from typing import cast from uuid import UUID @@ -16,6 +16,16 @@ _open_membership_authority_scope, ) from engine.runtime.budget import PackageBudgetRequest +from engine.runtime.citation import ( + CitationOpenIssue, + CitationOpenProfile, + CitationOpenRedemption, + CitationOpenTarget, + CitationOpenTargetLineage, + _close_citation_authority_scope, + _construct_citation_open_session, + _open_citation_authority_scope, +) from engine.runtime.construction import ( DEFAULT_SERVER_PACKAGE_BUDGET, AuthorizationDecision, @@ -29,8 +39,11 @@ from engine.runtime.content_io import CandidateIndex from engine.runtime.contracts import ( Acquire, + CitationNotAvailable, + CitationOpenRef, ContextNeed, ContextPackage, + OpenCitation, RequestNarrowing, Resolved, ) @@ -212,6 +225,55 @@ def read_current_epoch(self, organization_id: UUID) -> object: return self._epochs[0] +class RecordingCitationPort: + def __init__(self) -> None: + self.issue_calls: list[tuple[CitationOpenIssue, bytes]] = [] + self.redemption_calls: list[CitationOpenRedemption] = [] + + def issue( + self, + *, + request: CitationOpenIssue, + locator_digest: bytes, + digest_profile: str, + profile: CitationOpenProfile, + retain_until: datetime, + ) -> bool: + del digest_profile, profile, retain_until + self.issue_calls.append((request, locator_digest)) + return True + + def redeem(self, request: CitationOpenRedemption) -> CitationOpenTarget | None: + from hashlib import sha256 + + self.redemption_calls.append(request) + digest = sha256(request.citation_open_ref.value.encode("utf-8")).digest() + for issue, stored_digest in self.issue_calls: + if digest == stored_digest: + return CitationOpenTarget( + candidate_ref=CandidateRef( + organization_id=issue.organization_id, + source_ref=AUTHORIZED.source_ref, + resource_ref=issue.resource_ref, + revision_ref=str(issue.revision_id), + fragment_ref=issue.fragment_ref, + ), + lineage=CitationOpenTargetLineage( + package_ref=issue.package_ref, + evidence_ref=issue.evidence_ref, + ), + ) + return None + + +CITATION_PROFILE = CitationOpenProfile( + profile_ref="private-citation-open-v1", + retention_policy_ref="citation-locator-retention-v1", + maximum_ttl=timedelta(minutes=10), + retention_period=timedelta(days=30), +) + + class MismatchedLocatorPort(RecordingMaterializedPort): def locate( self, @@ -279,6 +341,8 @@ def scope_for(*candidates: CandidateRef) -> ScopeSet: def trusted_operands( port: RecordingMaterializedPort, *, + purpose: str = "context.answer", + citation_port: RecordingCitationPort | None = None, policy_epoch_port: SequencedPolicyEpochPort | None = None, scope_policy_epoch: int | None = None, context_run_port: RecordingContextRunPort | None = None, @@ -287,6 +351,7 @@ def trusted_operands( materialized_scope = _open_materialized_projection_scope() scope_authority_scope = _open_scope_authority_scope() policy_epoch_scope = _open_policy_epoch_authority_scope() + citation_scope = _open_citation_authority_scope() try: selected_epoch_port = policy_epoch_port or SequencedPolicyEpochPort(7) policy_epoch_verification = _observe_current_policy_epoch( @@ -338,6 +403,14 @@ def trusted_operands( ), materialized_projection_session=projection_session, context_run_persistence_session=persistence_session, + citation_open_session=( + _construct_citation_open_session( + authority_scope=citation_scope, + port=citation_port, + ) + if citation_port is not None + else None + ), ) scope_snapshot = _construct_trusted_scope_snapshot( authority_scope=scope_authority_scope, @@ -352,7 +425,7 @@ def trusted_operands( ), principal_ref="principal-authorized-evidence", agent_version_ref="agent-version-authorized-evidence", - purpose="context.answer", + purpose=purpose, request_id="request-authorized-evidence", authentication_binding_ref="binding-authorized-evidence", checked_at=AS_OF, @@ -376,18 +449,19 @@ def trusted_operands( agent_version_ref="agent-version-authorized-evidence", authenticated_application_ref="application-authorized-evidence", authentication_binding_ref="binding-authorized-evidence", - trusted_purpose="context.answer", + trusted_purpose=purpose, received_at=AS_OF, trusted_scope_snapshot=scope_snapshot, ) delivery = _construct_direct_delivery_context( - purpose="context.answer", + purpose=purpose, authenticated_application_ref="application-authorized-evidence", delivery_binding_ref="binding-authorized-evidence", established_at=AS_OF, ) yield invocation, delivery finally: + _close_citation_authority_scope(citation_scope) _close_policy_epoch_authority_scope(policy_epoch_scope) _close_scope_authority_scope(scope_authority_scope) _close_materialized_projection_scope(materialized_scope) @@ -477,6 +551,169 @@ def test_hostile_candidate_order_delivers_only_exact_authorized_evidence( ) +def test_citation_open_redeems_only_lineage_then_reauthorizes_exact_candidate() -> None: + index = HostileCandidateIndex((AUTHORIZED,)) + materialized = RecordingMaterializedPort() + citation = RecordingCitationPort() + run_port = RecordingContextRunPort() + runtime = Runtime( + required_kernel_dependencies(), + candidate_index=cast(CandidateIndex, index), + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + citation_profile=CITATION_PROFILE, + ) + + with trusted_operands( + materialized, + citation_port=citation, + context_run_port=run_port, + ) as (invocation, delivery): + acquired = runtime.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="issue citation")), + ) + + assert type(acquired) is Resolved + citation_ref = acquired.package.evidence[0].citation_open_ref + assert type(citation_ref) is CitationOpenRef + + with trusted_operands( + materialized, + purpose="citation.open", + citation_port=citation, + context_run_port=run_port, + ) as (invocation, delivery): + opened = runtime.resolve( + invocation, + delivery, + OpenCitation(citation_open_ref=citation_ref), + ) + + assert type(opened) is Resolved + assert opened.package.package_id != acquired.package.package_id + assert opened.package.purpose == "citation.open" + assert opened.package.blocks[0].body == "A-safe" + assert type(opened.package.evidence[0].citation_open_ref) is CitationOpenRef + assert opened.package.evidence[0].citation_open_ref != citation_ref + assert index.calls == 1 + assert citation.redemption_calls[0].citation_open_ref == citation_ref + assert materialized.locator_calls == [AUTHORIZED, AUTHORIZED] + assert materialized.body_calls == [locator(AUTHORIZED), locator(AUTHORIZED)] + assert len(run_port.calls) == 2 + + +def test_citation_open_denial_is_generic_and_does_not_consume_locator() -> None: + index = HostileCandidateIndex((AUTHORIZED,)) + materialized = RecordingMaterializedPort() + citation = RecordingCitationPort() + runtime = Runtime( + required_kernel_dependencies(), + candidate_index=cast(CandidateIndex, index), + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + citation_profile=CITATION_PROFILE, + ) + + with trusted_operands(materialized, citation_port=citation) as ( + invocation, + delivery, + ): + acquired = runtime.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="issue reusable citation")), + ) + assert type(acquired) is Resolved + citation_ref = acquired.package.evidence[0].citation_open_ref + assert type(citation_ref) is CitationOpenRef + + with trusted_operands( + materialized, + purpose="citation.open", + citation_port=citation, + ) as (invocation, delivery): + for operand_name in ( + "organization_boundary", + "membership_rights", + "principal_grants", + "agent_ceiling", + "source_native_acl", + "resource_acl", + "purpose_policy", + ): + object.__setattr__( + invocation.trusted_scope_snapshot, + operand_name, + ScopeSet(frozenset()), + ) + denied = runtime.resolve( + invocation, + delivery, + OpenCitation(citation_open_ref=citation_ref), + ) + + with trusted_operands( + materialized, + purpose="citation.open", + citation_port=citation, + ) as (invocation, delivery): + reopened = runtime.resolve( + invocation, + delivery, + OpenCitation(citation_open_ref=citation_ref), + ) + + assert denied == CitationNotAvailable() + assert type(reopened) is Resolved + assert len(citation.redemption_calls) == 2 + + +def test_citation_open_stale_scope_epoch_is_generic_without_target_io() -> None: + index = HostileCandidateIndex((AUTHORIZED,)) + materialized = RecordingMaterializedPort() + citation = RecordingCitationPort() + runtime = Runtime( + required_kernel_dependencies(), + candidate_index=cast(CandidateIndex, index), + clock=lambda: AS_OF, + query_digest_keyring=TEST_QUERY_DIGEST_KEYRING, + citation_profile=CITATION_PROFILE, + ) + with trusted_operands(materialized, citation_port=citation) as ( + invocation, + delivery, + ): + acquired = runtime.resolve( + invocation, + delivery, + Acquire(need=ContextNeed(query="issue stale-scope citation")), + ) + assert type(acquired) is Resolved + citation_ref = acquired.package.evidence[0].citation_open_ref + assert type(citation_ref) is CitationOpenRef + locator_calls_before = tuple(materialized.locator_calls) + body_calls_before = tuple(materialized.body_calls) + + with trusted_operands( + materialized, + purpose="citation.open", + citation_port=citation, + scope_policy_epoch=6, + ) as (invocation, delivery): + denied = runtime.resolve( + invocation, + delivery, + OpenCitation(citation_open_ref=citation_ref), + ) + + assert denied == CitationNotAvailable() + assert len(citation.redemption_calls) == 1 + assert tuple(materialized.locator_calls) == locator_calls_before + assert tuple(materialized.body_calls) == body_calls_before + + def test_stale_scope_epoch_stops_before_candidate_or_body_io() -> None: index = HostileCandidateIndex((AUTHORIZED,)) port = RecordingMaterializedPort() diff --git a/tests/unit/test_runtime_unavailable_capabilities.py b/tests/unit/test_runtime_unavailable_capabilities.py index c02f4f61..2a6e67e2 100644 --- a/tests/unit/test_runtime_unavailable_capabilities.py +++ b/tests/unit/test_runtime_unavailable_capabilities.py @@ -465,7 +465,6 @@ def test_replacing_the_mandatory_capability_gate_is_rejected_before_io() -> None assert twin.calls == (0, 0, 0) -@pytest.mark.security_evidence(id="PROP-CITATION-AUTH-010", layer="property") def test_capability_declarations_are_closed_and_m0_does_not_false_green_carriers() -> ( None ): diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index fb536dbe..401d28ea 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -32,7 +32,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "22.0.0" + assert document["manifestVersion"] == "23.0.0" assert set(tables) == { "active_release_manifest", "action_delivery_attempt", @@ -43,6 +43,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: "action_reconciliation", "action_ticket", "alembic_version", + "citation_open_locator", "context_fragment", "context_fragment_field", "context_resource", @@ -89,6 +90,31 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: assert tables["organization"]["classification"] == "global" assert tables["user_account"]["classification"] == "global" assert tables["membership"]["classification"] == "tenant_owned" + assert tables["citation_open_locator"]["classification"] == "tenant_owned" + citation = tables["citation_open_locator"] + assert citation["functionOnlyMutation"] == { + "databaseFunctions": [ + "context_runtime_issue_citation_open_ref", + "context_runtime_redeem_citation_open_ref", + "context_security_delete_expired_citation_open_lineage", + ], + "definerRole": "context_engine_citation_definer", + "directTableMutationAllowed": False, + } + assert citation["permittedOperations"]["context_engine_runtime"] == [ + "EXECUTE context_runtime_issue_citation_open_ref", + "EXECUTE context_runtime_redeem_citation_open_ref", + ] + assert citation["permittedOperations"]["context_engine_security_operator"] == [ + "EXECUTE context_security_delete_expired_citation_open_lineage" + ] + assert citation["permittedOperations"]["context_engine_citation_definer"] == [ + "SELECT", + "INSERT", + "DELETE", + ] + assert citation["retention"]["bearerStored"] is False + assert citation["retention"]["priorAuthorizationStored"] is False assert tables["organization_record"]["classification"] == "tenant_owned" assert tables["organization_policy_epoch"]["classification"] == "tenant_owned" assert tables["resource_access_policy"]["classification"] == "tenant_owned" @@ -126,8 +152,7 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: "context_engine_action_prepare_definer" ] == ["SELECT", "INSERT"] action_ticket_constraints = { - constraint["name"] - for constraint in tables["action_ticket"]["checkConstraints"] + constraint["name"] for constraint in tables["action_ticket"]["checkConstraints"] } assert "ck_action_ticket_bearer_digest" in action_ticket_constraints assert tables["action_ticket"]["retention"]["bearerStored"] is False @@ -1245,6 +1270,7 @@ def test_membership_manifest_requires_exact_user_actor_and_read_only_runtime() - "context_engine_egress_grant_definer": ["SELECT"], "context_engine_action_prepare_definer": ["SELECT"], "context_engine_action_execute_definer": ["SELECT"], + "context_engine_citation_definer": ["SELECT"], } rls = entry["rowLevelSecurity"] @@ -1503,6 +1529,8 @@ def test_content_manifest_preserves_lineage_visibility_and_immutability() -> Non expected_operations["context_engine_control"] = [ "EXECUTE context_control_tombstone_file_resource" ] + if entry["name"] in {"context_resource", "context_fragment"}: + expected_operations["context_engine_citation_definer"] = ["SELECT"] assert entry["permittedOperations"] == expected_operations rls = entry["rowLevelSecurity"] assert rls["enabled"] is True